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: branches:
- main - main
- master - master
default: '2.3'
jobs: jobs:
build-and-push: build-and-push:

View file

@ -8,6 +8,7 @@ from datetime import datetime, timedelta
from utils.logging_config import get_logger from utils.logging_config import get_logger
from database.music_database import MusicDatabase from database.music_database import MusicDatabase
from core.audiodb_client import AudioDBClient from core.audiodb_client import AudioDBClient
from core.worker_utils import interruptible_sleep
logger = get_logger("audiodb_worker") logger = get_logger("audiodb_worker")
@ -24,6 +25,7 @@ class AudioDBWorker:
self.paused = False self.paused = False
self.should_stop = False self.should_stop = False
self.thread = None self.thread = None
self._stop_event = threading.Event()
# Current item being processed (for UI tooltip) # Current item being processed (for UI tooltip)
self.current_item = None self.current_item = None
@ -52,6 +54,7 @@ class AudioDBWorker:
self.running = True self.running = True
self.should_stop = False self.should_stop = False
self._stop_event.clear()
self.thread = threading.Thread(target=self._run, daemon=True) self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start() self.thread.start()
logger.info("AudioDB background worker started") logger.info("AudioDB background worker started")
@ -64,9 +67,10 @@ class AudioDBWorker:
logger.info("Stopping AudioDB worker...") logger.info("Stopping AudioDB worker...")
self.should_stop = True self.should_stop = True
self.running = False self.running = False
self._stop_event.set()
if self.thread: if self.thread:
self.thread.join(timeout=5) self.thread.join(timeout=1)
logger.info("AudioDB worker stopped") logger.info("AudioDB worker stopped")
@ -115,7 +119,7 @@ class AudioDBWorker:
while not self.should_stop: while not self.should_stop:
try: try:
if self.paused: if self.paused:
time.sleep(1) interruptible_sleep(self._stop_event, 1)
continue continue
self.current_item = None self.current_item = None
@ -124,7 +128,7 @@ class AudioDBWorker:
if not item: if not item:
logger.debug("No pending items, sleeping...") logger.debug("No pending items, sleeping...")
time.sleep(10) interruptible_sleep(self._stop_event, 10)
continue continue
self.current_item = item self.current_item = item
@ -143,11 +147,11 @@ class AudioDBWorker:
self._process_item(item) self._process_item(item)
time.sleep(2) interruptible_sleep(self._stop_event, 2)
except Exception as e: except Exception as e:
logger.error(f"Error in worker loop: {e}") logger.error(f"Error in worker loop: {e}")
time.sleep(5) interruptible_sleep(self._stop_event, 5)
logger.info("AudioDB worker thread finished") logger.info("AudioDB worker thread finished")

View file

@ -72,9 +72,21 @@ class Track:
if isinstance(album_data, dict): 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') 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_data = track_data.get('artist', {})
artist_name = artist_data.get('name', 'Unknown Artist') if isinstance(artist_data, dict) else 'Unknown 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 # Get album name
album_name = '' album_name = ''
@ -102,7 +114,7 @@ class Track:
return cls( return cls(
id=str(track_data.get('id', '')), id=str(track_data.get('id', '')),
name=track_data.get('title', ''), name=track_data.get('title', ''),
artists=[artist_name], artists=all_artists,
album=album_name, album=album_name,
duration_ms=track_data.get('duration', 0) * 1000, # Deezer returns seconds duration_ms=track_data.get('duration', 0) * 1000, # Deezer returns seconds
popularity=track_data.get('rank', 0), 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_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 '' 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 { return {
'id': str(track_data.get('id', '')), 'id': str(track_data.get('id', '')),
'name': track_data.get('title', ''), 'name': track_data.get('title', ''),
@ -423,7 +444,7 @@ class DeezerClient:
'disc_number': track_data.get('disk_number', 1), 'disc_number': track_data.get('disk_number', 1),
'duration_ms': track_data.get('duration', 0) * 1000, 'duration_ms': track_data.get('duration', 0) * 1000,
'explicit': track_data.get('explicit_lyrics', False), 'explicit': track_data.get('explicit_lyrics', False),
'artists': [artist_name], 'artists': all_artists,
'primary_artist': artist_name, 'primary_artist': artist_name,
'album': { 'album': {
'id': album_id, 'id': album_id,

View file

@ -8,6 +8,7 @@ from datetime import datetime, timedelta
from utils.logging_config import get_logger from utils.logging_config import get_logger
from database.music_database import MusicDatabase from database.music_database import MusicDatabase
from core.deezer_client import DeezerClient from core.deezer_client import DeezerClient
from core.worker_utils import interruptible_sleep
logger = get_logger("deezer_worker") logger = get_logger("deezer_worker")
@ -24,6 +25,7 @@ class DeezerWorker:
self.paused = False self.paused = False
self.should_stop = False self.should_stop = False
self.thread = None self.thread = None
self._stop_event = threading.Event()
# Current item being processed (for UI tooltip) # Current item being processed (for UI tooltip)
self.current_item = None self.current_item = None
@ -52,6 +54,7 @@ class DeezerWorker:
self.running = True self.running = True
self.should_stop = False self.should_stop = False
self._stop_event.clear()
self.thread = threading.Thread(target=self._run, daemon=True) self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start() self.thread.start()
logger.info("Deezer background worker started") logger.info("Deezer background worker started")
@ -64,9 +67,10 @@ class DeezerWorker:
logger.info("Stopping Deezer worker...") logger.info("Stopping Deezer worker...")
self.should_stop = True self.should_stop = True
self.running = False self.running = False
self._stop_event.set()
if self.thread: if self.thread:
self.thread.join(timeout=5) self.thread.join(timeout=1)
logger.info("Deezer worker stopped") logger.info("Deezer worker stopped")
@ -115,7 +119,7 @@ class DeezerWorker:
while not self.should_stop: while not self.should_stop:
try: try:
if self.paused: if self.paused:
time.sleep(1) interruptible_sleep(self._stop_event, 1)
continue continue
self.current_item = None self.current_item = None
@ -124,7 +128,7 @@ class DeezerWorker:
if not item: if not item:
logger.debug("No pending items, sleeping...") logger.debug("No pending items, sleeping...")
time.sleep(10) interruptible_sleep(self._stop_event, 10)
continue continue
self.current_item = item self.current_item = item
@ -143,11 +147,11 @@ class DeezerWorker:
self._process_item(item) self._process_item(item)
time.sleep(2) interruptible_sleep(self._stop_event, 2)
except Exception as e: except Exception as e:
logger.error(f"Error in worker loop: {e}") logger.error(f"Error in worker loop: {e}")
time.sleep(5) interruptible_sleep(self._stop_event, 5)
logger.info("Deezer worker thread finished") 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 utils.logging_config import get_logger
from database.music_database import MusicDatabase from database.music_database import MusicDatabase
from core.discogs_client import DiscogsClient from core.discogs_client import DiscogsClient
from core.worker_utils import interruptible_sleep
logger = get_logger("discogs_worker") logger = get_logger("discogs_worker")
@ -34,6 +35,7 @@ class DiscogsWorker:
self.paused = False self.paused = False
self.should_stop = False self.should_stop = False
self.thread = None self.thread = None
self._stop_event = threading.Event()
# Current item being processed (for UI tooltip) # Current item being processed (for UI tooltip)
self.current_item = None self.current_item = None
@ -62,6 +64,7 @@ class DiscogsWorker:
self.running = True self.running = True
self.should_stop = False self.should_stop = False
self._stop_event.clear()
self.thread = threading.Thread(target=self._run, daemon=True) self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start() self.thread.start()
logger.info("Discogs background worker started") logger.info("Discogs background worker started")
@ -73,8 +76,9 @@ class DiscogsWorker:
logger.info("Stopping Discogs worker...") logger.info("Stopping Discogs worker...")
self.should_stop = True self.should_stop = True
self.running = False self.running = False
self._stop_event.set()
if self.thread: if self.thread:
self.thread.join(timeout=5) self.thread.join(timeout=1)
logger.info("Discogs worker stopped") logger.info("Discogs worker stopped")
def pause(self): def pause(self):
@ -113,14 +117,14 @@ class DiscogsWorker:
while not self.should_stop: while not self.should_stop:
try: try:
if self.paused: if self.paused:
time.sleep(1) interruptible_sleep(self._stop_event, 1)
continue continue
self.current_item = None self.current_item = None
item = self._get_next_item() item = self._get_next_item()
if not item: if not item:
time.sleep(10) interruptible_sleep(self._stop_event, 10)
continue continue
self.current_item = item.get('name', '') self.current_item = item.get('name', '')
@ -132,11 +136,11 @@ class DiscogsWorker:
continue continue
self._process_item(item) self._process_item(item)
time.sleep(2) interruptible_sleep(self._stop_event, 2)
except Exception as e: except Exception as e:
logger.error(f"Error in Discogs worker loop: {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") 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 database.music_database import MusicDatabase
from core.genius_client import GeniusClient from core.genius_client import GeniusClient
from config.settings import config_manager from config.settings import config_manager
from core.worker_utils import interruptible_sleep
logger = get_logger("genius_worker") logger = get_logger("genius_worker")
@ -31,6 +32,7 @@ class GeniusWorker:
self.paused = False self.paused = False
self.should_stop = False self.should_stop = False
self.thread = None self.thread = None
self._stop_event = threading.Event()
# Current item being processed (for UI tooltip) # Current item being processed (for UI tooltip)
self.current_item = None self.current_item = None
@ -64,6 +66,7 @@ class GeniusWorker:
self.running = True self.running = True
self.should_stop = False self.should_stop = False
self._stop_event.clear()
self.thread = threading.Thread(target=self._run, daemon=True) self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start() self.thread.start()
logger.info("Genius background worker started") logger.info("Genius background worker started")
@ -76,9 +79,10 @@ class GeniusWorker:
logger.info("Stopping Genius worker...") logger.info("Stopping Genius worker...")
self.should_stop = True self.should_stop = True
self.running = False self.running = False
self._stop_event.set()
if self.thread: if self.thread:
self.thread.join(timeout=5) self.thread.join(timeout=1)
logger.info("Genius worker stopped") logger.info("Genius worker stopped")
@ -123,14 +127,14 @@ class GeniusWorker:
while not self.should_stop: while not self.should_stop:
try: try:
if self.paused: if self.paused:
time.sleep(1) interruptible_sleep(self._stop_event, 1)
continue continue
# Check if access token is configured # Check if access token is configured
if not self.client.access_token: if not self.client.access_token:
self._init_client() self._init_client()
if not self.client.access_token: if not self.client.access_token:
time.sleep(30) interruptible_sleep(self._stop_event, 30)
continue continue
self.current_item = None self.current_item = None
@ -138,7 +142,7 @@ class GeniusWorker:
if not item: if not item:
logger.debug("No pending items, sleeping...") logger.debug("No pending items, sleeping...")
time.sleep(10) interruptible_sleep(self._stop_event, 10)
continue continue
self.current_item = item self.current_item = item
@ -157,11 +161,11 @@ class GeniusWorker:
self._process_item(item) self._process_item(item)
# Genius rate limiting is conservative (500ms per call) + lyrics scraping # Genius rate limiting is conservative (500ms per call) + lyrics scraping
time.sleep(1) interruptible_sleep(self._stop_event, 1)
except Exception as e: except Exception as e:
logger.error(f"Error in worker loop: {e}") logger.error(f"Error in worker loop: {e}")
time.sleep(5) interruptible_sleep(self._stop_event, 5)
logger.info("Genius worker thread finished") logger.info("Genius worker thread finished")

View file

@ -15,6 +15,7 @@ import time
import uuid import uuid
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from core.worker_utils import interruptible_sleep
class HydrabaseWorker: class HydrabaseWorker:
@ -31,6 +32,7 @@ class HydrabaseWorker:
self.paused = False self.paused = False
self.should_stop = False self.should_stop = False
self.thread = None self.thread = None
self._stop_event = threading.Event()
# Queue with cap # Queue with cap
self.queue = queue.Queue(maxsize=1000) self.queue = queue.Queue(maxsize=1000)
@ -47,6 +49,7 @@ class HydrabaseWorker:
return return
self.running = True self.running = True
self.should_stop = False self.should_stop = False
self._stop_event.clear()
self.thread = threading.Thread(target=self._run, daemon=True) self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start() self.thread.start()
logger.info("Hydrabase P2P mirror worker started") logger.info("Hydrabase P2P mirror worker started")
@ -56,8 +59,9 @@ class HydrabaseWorker:
return return
self.should_stop = True self.should_stop = True
self.running = False self.running = False
self._stop_event.set()
if self.thread: if self.thread:
self.thread.join(timeout=5) self.thread.join(timeout=1)
logger.info("Hydrabase P2P mirror worker stopped") logger.info("Hydrabase P2P mirror worker stopped")
def pause(self): def pause(self):
@ -102,7 +106,7 @@ class HydrabaseWorker:
while not self.should_stop: while not self.should_stop:
try: try:
if self.paused: if self.paused:
time.sleep(1) interruptible_sleep(self._stop_event, 1)
continue continue
# Non-blocking dequeue with timeout # Non-blocking dequeue with timeout
@ -112,12 +116,12 @@ class HydrabaseWorker:
continue continue
self._process_item(item) self._process_item(item)
time.sleep(0.5) # Rate limit interruptible_sleep(self._stop_event, 0.5) # Rate limit
except Exception as e: except Exception as e:
logger.error(f"Error in Hydrabase worker loop: {e}") logger.error(f"Error in Hydrabase worker loop: {e}")
self.stats['errors'] += 1 self.stats['errors'] += 1
time.sleep(2) interruptible_sleep(self._stop_event, 2)
def _process_item(self, item): def _process_item(self, item):
ws, lock = self.get_ws_and_lock() 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 utils.logging_config import get_logger
from database.music_database import MusicDatabase from database.music_database import MusicDatabase
from core.itunes_client import iTunesClient from core.itunes_client import iTunesClient
from core.worker_utils import interruptible_sleep
logger = get_logger("itunes_worker") logger = get_logger("itunes_worker")
@ -34,6 +35,7 @@ class iTunesWorker:
self.paused = False self.paused = False
self.should_stop = False self.should_stop = False
self.thread = None self.thread = None
self._stop_event = threading.Event()
# Current item being processed (for UI tooltip) # Current item being processed (for UI tooltip)
self.current_item = None self.current_item = None
@ -66,6 +68,7 @@ class iTunesWorker:
return return
self.running = True self.running = True
self.should_stop = False self.should_stop = False
self._stop_event.clear()
self.thread = threading.Thread(target=self._run, daemon=True) self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start() self.thread.start()
logger.info("iTunes background worker started") logger.info("iTunes background worker started")
@ -76,8 +79,9 @@ class iTunesWorker:
logger.info("Stopping iTunes worker...") logger.info("Stopping iTunes worker...")
self.should_stop = True self.should_stop = True
self.running = False self.running = False
self._stop_event.set()
if self.thread: if self.thread:
self.thread.join(timeout=5) self.thread.join(timeout=1)
logger.info("iTunes worker stopped") logger.info("iTunes worker stopped")
def pause(self): def pause(self):
@ -116,7 +120,7 @@ class iTunesWorker:
while not self.should_stop: while not self.should_stop:
try: try:
if self.paused: if self.paused:
time.sleep(1) interruptible_sleep(self._stop_event, 1)
continue continue
# No auth check needed — iTunes API requires no authentication # No auth check needed — iTunes API requires no authentication
@ -126,7 +130,7 @@ class iTunesWorker:
if not item: if not item:
logger.debug("No pending items, sleeping...") logger.debug("No pending items, sleeping...")
time.sleep(10) interruptible_sleep(self._stop_event, 10)
continue continue
self.current_item = item self.current_item = item
@ -147,13 +151,13 @@ class iTunesWorker:
# Sleep depends on item type — search items need more delay # Sleep depends on item type — search items need more delay
item_type = item.get('type', '') item_type = item.get('type', '')
if item_type in ('album_batch', 'track_batch'): 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: else:
time.sleep(self.inter_item_sleep) interruptible_sleep(self._stop_event, self.inter_item_sleep)
except Exception as e: except Exception as e:
logger.error(f"Error in worker loop: {e}") logger.error(f"Error in worker loop: {e}")
time.sleep(5) interruptible_sleep(self._stop_event, 5)
self.current_item = None self.current_item = None
logger.info("iTunes worker thread finished") logger.info("iTunes worker thread finished")
@ -444,7 +448,7 @@ class iTunesWorker:
self._mark_status('album', db_id, 'not_found') self._mark_status('album', db_id, 'not_found')
self.stats['not_found'] += 1 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") 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._mark_status('track', db_id, 'not_found')
self.stats['not_found'] += 1 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") 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 database.music_database import MusicDatabase
from core.lastfm_client import LastFMClient from core.lastfm_client import LastFMClient
from config.settings import config_manager from config.settings import config_manager
from core.worker_utils import interruptible_sleep
logger = get_logger("lastfm_worker") logger = get_logger("lastfm_worker")
@ -31,6 +32,7 @@ class LastFMWorker:
self.paused = False self.paused = False
self.should_stop = False self.should_stop = False
self.thread = None self.thread = None
self._stop_event = threading.Event()
# Current item being processed (for UI tooltip) # Current item being processed (for UI tooltip)
self.current_item = None self.current_item = None
@ -65,6 +67,7 @@ class LastFMWorker:
self.running = True self.running = True
self.should_stop = False self.should_stop = False
self._stop_event.clear()
self.thread = threading.Thread(target=self._run, daemon=True) self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start() self.thread.start()
logger.info("Last.fm background worker started") logger.info("Last.fm background worker started")
@ -77,9 +80,10 @@ class LastFMWorker:
logger.info("Stopping Last.fm worker...") logger.info("Stopping Last.fm worker...")
self.should_stop = True self.should_stop = True
self.running = False self.running = False
self._stop_event.set()
if self.thread: if self.thread:
self.thread.join(timeout=5) self.thread.join(timeout=1)
logger.info("Last.fm worker stopped") logger.info("Last.fm worker stopped")
@ -124,14 +128,14 @@ class LastFMWorker:
while not self.should_stop: while not self.should_stop:
try: try:
if self.paused: if self.paused:
time.sleep(1) interruptible_sleep(self._stop_event, 1)
continue continue
# Check if API key is configured # Check if API key is configured
if not self.client.api_key: if not self.client.api_key:
self._init_client() self._init_client()
if not self.client.api_key: if not self.client.api_key:
time.sleep(30) interruptible_sleep(self._stop_event, 30)
continue continue
self.current_item = None self.current_item = None
@ -139,7 +143,7 @@ class LastFMWorker:
if not item: if not item:
logger.debug("No pending items, sleeping...") logger.debug("No pending items, sleeping...")
time.sleep(10) interruptible_sleep(self._stop_event, 10)
continue continue
self.current_item = item self.current_item = item
@ -158,11 +162,11 @@ class LastFMWorker:
self._process_item(item) self._process_item(item)
# Last.fm allows 5 req/sec but we use multiple calls per 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: except Exception as e:
logger.error(f"Error in worker loop: {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") logger.info("Last.fm worker thread finished")

View file

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

View file

@ -657,22 +657,30 @@ class MusicMatchingEngine:
# --- Source Type --- # --- Source Type ---
is_youtube = slskd_track.username == 'youtube' is_youtube = slskd_track.username == 'youtube'
# 4b. Album Bonus: Prefer results from the correct album folder # 4b. Album Bonus/Penalty: Prefer results from the correct album folder.
# This ensures "Dark Side of the Moon" sources rank above "Greatest Hits" # Uses full-string similarity to prevent "Paradise" matching "Club Paradise".
# when both have the same song. Binary check — album words in path or not. # The old subset check said "paradise" ⊂ {"club", "paradise"} = True, which was wrong.
album_bonus = 0.0 album_bonus = 0.0
album_name = getattr(spotify_track, 'album', None) album_name = getattr(spotify_track, 'album', None)
if album_name and not is_youtube: if album_name and not is_youtube:
album_words = set(self.clean_album_name(album_name).split()) album_cleaned = self.clean_album_name(album_name)
if album_words: if album_cleaned:
best_album_sim = 0.0
path_segments = re.split(r'[/\\]', slskd_track.filename) path_segments = re.split(r'[/\\]', slskd_track.filename)
for segment in path_segments: for segment in path_segments:
if not segment: if not segment:
continue continue
seg_words = set(self.normalize_string(segment).split()) seg_cleaned = self.normalize_string(segment)
if album_words.issubset(seg_words): if not seg_cleaned:
album_bonus = 0.10 continue
break 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) # 5. Special handling for short titles (high false positive risk)
# Titles like "Run", "Love", "Girls", "Stay" need stricter artist matching # 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_timer = None # Timer for 5-minute periodic updates
self._periodic_update_interval = 300 # 5 minutes in seconds self._periodic_update_interval = 300 # 5 minutes in seconds
self._is_doing_periodic_updates = False # Track if we're in periodic update mode 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") 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}") logger.info(f"DEBUG: Media scan requested - reason: {reason}")
with self._lock: with self._lock:
if self._shutting_down:
logger.debug("Media scan request ignored during shutdown")
return
if self._scan_in_progress: if self._scan_in_progress:
# Server is currently scanning - mark that we need another scan later # Server is currently scanning - mark that we need another scan later
self._downloads_during_scan = True self._downloads_during_scan = True
@ -134,6 +138,7 @@ class MediaScanManager:
# Start the debounce timer # Start the debounce timer
self._timer = threading.Timer(self.delay, self._execute_scan) self._timer = threading.Timer(self.delay, self._execute_scan)
self._timer.daemon = True
self._timer.start() self._timer.start()
def add_scan_completion_callback(self, callback): def add_scan_completion_callback(self, callback):
@ -164,6 +169,9 @@ class MediaScanManager:
def _execute_scan(self): def _execute_scan(self):
"""Execute the actual media library scan""" """Execute the actual media library scan"""
with self._lock: with self._lock:
if self._shutting_down:
logger.debug("Media scan execution skipped during shutdown")
return
if self._scan_in_progress: if self._scan_in_progress:
logger.warning("Scan already in progress - skipping duplicate execution") logger.warning("Scan already in progress - skipping duplicate execution")
return return
@ -211,6 +219,7 @@ class MediaScanManager:
# Schedule first periodic update after 5 minutes # Schedule first periodic update after 5 minutes
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update) self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
self._periodic_update_timer.daemon = True
self._periodic_update_timer.start() self._periodic_update_timer.start()
except Exception as e: except Exception as e:
@ -247,15 +256,22 @@ class MediaScanManager:
if is_scanning: if is_scanning:
# Still scanning - trigger database update and continue periodic updates # Still scanning - trigger database update and continue periodic updates
logger.info(f"{server_type.upper()} still scanning - triggering database update") logger.info(f"{server_type.upper()} still scanning - triggering database update")
if self._shutting_down:
return
self._call_completion_callbacks() self._call_completion_callbacks()
# Schedule next periodic update # Schedule next periodic update
if self._shutting_down:
return
logger.info(f"Scheduling next periodic update in {self._periodic_update_interval//60} minutes") 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 = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
self._periodic_update_timer.daemon = True
self._periodic_update_timer.start() self._periodic_update_timer.start()
else: else:
# Scanning stopped - final update and cleanup # Scanning stopped - final update and cleanup
logger.info(f"{server_type.upper()} scanning completed - doing final database update") logger.info(f"{server_type.upper()} scanning completed - doing final database update")
if self._shutting_down:
return
self._call_completion_callbacks() self._call_completion_callbacks()
self._stop_periodic_updates() self._stop_periodic_updates()
@ -360,6 +376,7 @@ class MediaScanManager:
def shutdown(self): def shutdown(self):
"""Clean shutdown - cancel any pending timers""" """Clean shutdown - cancel any pending timers"""
with self._lock: with self._lock:
self._shutting_down = True
if self._timer: if self._timer:
self._timer.cancel() self._timer.cancel()
self._timer = None self._timer = None
@ -369,4 +386,4 @@ class MediaScanManager:
self._periodic_update_timer = None self._periodic_update_timer = None
self._is_doing_periodic_updates = False 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: def _is_junk_entity(self, fields: dict) -> bool:
"""Check if extracted fields represent junk/placeholder data.""" """Check if extracted fields represent junk/placeholder data."""
name = (fields.get('name') or '').strip().lower() 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: 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.""" """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 utils.logging_config import get_logger
from database.music_database import MusicDatabase from database.music_database import MusicDatabase
from core.musicbrainz_service import MusicBrainzService from core.musicbrainz_service import MusicBrainzService
from core.worker_utils import interruptible_sleep
logger = get_logger("musicbrainz_worker") logger = get_logger("musicbrainz_worker")
@ -20,6 +21,7 @@ class MusicBrainzWorker:
self.paused = False self.paused = False
self.should_stop = False self.should_stop = False
self.thread = None self.thread = None
self._stop_event = threading.Event()
# Current item being processed (for UI tooltip) # Current item being processed (for UI tooltip)
self.current_item = None self.current_item = None
@ -45,6 +47,7 @@ class MusicBrainzWorker:
self.running = True self.running = True
self.should_stop = False self.should_stop = False
self._stop_event.clear()
self.thread = threading.Thread(target=self._run, daemon=True) self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start() self.thread.start()
logger.info("MusicBrainz background worker started") logger.info("MusicBrainz background worker started")
@ -57,9 +60,10 @@ class MusicBrainzWorker:
logger.info("Stopping MusicBrainz worker...") logger.info("Stopping MusicBrainz worker...")
self.should_stop = True self.should_stop = True
self.running = False self.running = False
self._stop_event.set()
if self.thread: if self.thread:
self.thread.join(timeout=5) self.thread.join(timeout=1)
logger.info("Music Brainz worker stopped") logger.info("Music Brainz worker stopped")
@ -112,7 +116,7 @@ class MusicBrainzWorker:
try: try:
# Check if paused # Check if paused
if self.paused: if self.paused:
time.sleep(1) interruptible_sleep(self._stop_event, 1)
continue continue
# Clear previous item before getting next # Clear previous item before getting next
@ -124,7 +128,7 @@ class MusicBrainzWorker:
if not item: if not item:
# No more items - sleep for a bit # No more items - sleep for a bit
logger.debug("No pending items, sleeping...") logger.debug("No pending items, sleeping...")
time.sleep(10) interruptible_sleep(self._stop_event, 10)
continue continue
# Set current item for UI tracking # 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 # Keep current_item set during sleep so UI can see what was just processed
# Rate limit: 1 request per second # Rate limit: 1 request per second
time.sleep(1) interruptible_sleep(self._stop_event, 1)
except Exception as e: except Exception as e:
logger.error(f"Error in worker loop: {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") logger.info("MusicBrainz worker thread finished")

View file

@ -277,13 +277,13 @@ class PlexClient:
try: try:
for playlist in self.server.playlists(): for playlist in self.server.playlists():
if playlist.playlistType == 'audio': if getattr(playlist, 'playlistType', None) == 'audio':
playlist_info = PlexPlaylistInfo.from_plex_playlist(playlist) playlist_info = PlexPlaylistInfo.from_plex_playlist(playlist)
playlists.append(playlist_info) playlists.append(playlist_info)
logger.info(f"Retrieved {len(playlists)} audio playlists") logger.info(f"Retrieved {len(playlists)} audio playlists")
return playlists return playlists
except Exception as e: except Exception as e:
logger.error(f"Error fetching playlists: {e}") logger.error(f"Error fetching playlists: {e}")
return [] return []
@ -294,7 +294,7 @@ class PlexClient:
try: try:
playlist = self.server.playlist(name) playlist = self.server.playlist(name)
if playlist.playlistType == 'audio': if getattr(playlist, 'playlistType', None) == 'audio':
return PlexPlaylistInfo.from_plex_playlist(playlist) return PlexPlaylistInfo.from_plex_playlist(playlist)
return None return None

View file

@ -39,6 +39,7 @@ class PlexScanManager:
self._periodic_update_timer = None # Timer for 5-minute periodic updates self._periodic_update_timer = None # Timer for 5-minute periodic updates
self._periodic_update_interval = 300 # 5 minutes in seconds self._periodic_update_interval = 300 # 5 minutes in seconds
self._is_doing_periodic_updates = False # Track if we're in periodic update mode 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") 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}") logger.info(f"DEBUG: Plex scan requested - reason: {reason}")
with self._lock: with self._lock:
if self._shutting_down:
logger.debug("Plex scan request ignored during shutdown")
return
if self._scan_in_progress: if self._scan_in_progress:
# Plex is currently scanning - mark that we need another scan later # Plex is currently scanning - mark that we need another scan later
self._downloads_during_scan = True self._downloads_during_scan = True
@ -66,6 +70,7 @@ class PlexScanManager:
# Start the debounce timer # Start the debounce timer
self._timer = threading.Timer(self.delay, self._execute_scan) self._timer = threading.Timer(self.delay, self._execute_scan)
self._timer.daemon = True
self._timer.start() self._timer.start()
def add_scan_completion_callback(self, callback): def add_scan_completion_callback(self, callback):
@ -96,6 +101,9 @@ class PlexScanManager:
def _execute_scan(self): def _execute_scan(self):
"""Execute the actual Plex library scan""" """Execute the actual Plex library scan"""
with self._lock: with self._lock:
if self._shutting_down:
logger.debug("Plex scan execution skipped during shutdown")
return
if self._scan_in_progress: if self._scan_in_progress:
logger.warning("Scan already in progress - skipping duplicate execution") logger.warning("Scan already in progress - skipping duplicate execution")
return return
@ -136,6 +144,7 @@ class PlexScanManager:
# Schedule first periodic update after 5 minutes # Schedule first periodic update after 5 minutes
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update) self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
self._periodic_update_timer.daemon = True
self._periodic_update_timer.start() self._periodic_update_timer.start()
except Exception as e: except Exception as e:
@ -165,15 +174,22 @@ class PlexScanManager:
if is_scanning: if is_scanning:
# Still scanning - trigger database update and continue periodic updates # Still scanning - trigger database update and continue periodic updates
logger.info("Plex still scanning - triggering database update") logger.info("Plex still scanning - triggering database update")
if self._shutting_down:
return
self._call_completion_callbacks() self._call_completion_callbacks()
# Schedule next periodic update # Schedule next periodic update
if self._shutting_down:
return
logger.info(f"Scheduling next periodic update in {self._periodic_update_interval//60} minutes") 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 = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
self._periodic_update_timer.daemon = True
self._periodic_update_timer.start() self._periodic_update_timer.start()
else: else:
# Scanning stopped - final update and cleanup # Scanning stopped - final update and cleanup
logger.info("Plex scanning completed - doing final database update") logger.info("Plex scanning completed - doing final database update")
if self._shutting_down:
return
self._call_completion_callbacks() self._call_completion_callbacks()
self._stop_periodic_updates() self._stop_periodic_updates()
@ -218,7 +234,9 @@ class PlexScanManager:
if is_scanning: if is_scanning:
# Still scanning, poll again in 30 seconds # Still scanning, poll again in 30 seconds
logger.info("DEBUG: Plex library still scanning, will check 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: else:
# Scan completed! # Scan completed!
elapsed_time = time.time() - self._scan_start_time if self._scan_start_time else 0 elapsed_time = time.time() - self._scan_start_time if self._scan_start_time else 0
@ -311,6 +329,7 @@ class PlexScanManager:
def shutdown(self): def shutdown(self):
"""Clean shutdown - cancel any pending timers""" """Clean shutdown - cancel any pending timers"""
with self._lock: with self._lock:
self._shutting_down = True
if self._timer: if self._timer:
self._timer.cancel() self._timer.cancel()
self._timer = None self._timer = None
@ -320,4 +339,4 @@ class PlexScanManager:
self._periodic_update_timer = None self._periodic_update_timer = None
self._is_doing_periodic_updates = False 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 utils.logging_config import get_logger
from database.music_database import MusicDatabase from database.music_database import MusicDatabase
from core.qobuz_client import _qobuz_is_rate_limited from core.qobuz_client import _qobuz_is_rate_limited
from core.worker_utils import interruptible_sleep
logger = get_logger("qobuz_worker") logger = get_logger("qobuz_worker")
@ -24,6 +25,7 @@ class QobuzWorker:
self.paused = False self.paused = False
self.should_stop = False self.should_stop = False
self.thread = None self.thread = None
self._stop_event = threading.Event()
# Current item being processed (for UI tooltip) # Current item being processed (for UI tooltip)
self.current_item = None self.current_item = None
@ -52,6 +54,7 @@ class QobuzWorker:
self.running = True self.running = True
self.should_stop = False self.should_stop = False
self._stop_event.clear()
self.thread = threading.Thread(target=self._run, daemon=True) self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start() self.thread.start()
logger.info("Qobuz background worker started") logger.info("Qobuz background worker started")
@ -64,9 +67,10 @@ class QobuzWorker:
logger.info("Stopping Qobuz worker...") logger.info("Stopping Qobuz worker...")
self.should_stop = True self.should_stop = True
self.running = False self.running = False
self._stop_event.set()
if self.thread: if self.thread:
self.thread.join(timeout=5) self.thread.join(timeout=1)
logger.info("Qobuz worker stopped") logger.info("Qobuz worker stopped")
@ -120,24 +124,24 @@ class QobuzWorker:
while not self.should_stop: while not self.should_stop:
try: try:
if self.paused: if self.paused:
time.sleep(1) interruptible_sleep(self._stop_event, 1)
continue continue
# Auth guard: sleep if not authenticated # Auth guard: sleep if not authenticated
try: try:
if not self.client or not self.client.is_authenticated(): if not self.client or not self.client.is_authenticated():
self.current_item = None self.current_item = None
time.sleep(30) interruptible_sleep(self._stop_event, 30)
continue continue
except Exception: except Exception:
time.sleep(30) interruptible_sleep(self._stop_event, 30)
continue continue
# Rate limit guard: back off if globally rate limited # Rate limit guard: back off if globally rate limited
if _qobuz_is_rate_limited(): if _qobuz_is_rate_limited():
self.current_item = None self.current_item = None
logger.debug("Qobuz rate limited, backing off...") logger.debug("Qobuz rate limited, backing off...")
time.sleep(10) interruptible_sleep(self._stop_event, 10)
continue continue
self.current_item = None self.current_item = None
@ -146,7 +150,7 @@ class QobuzWorker:
if not item: if not item:
logger.debug("No pending items, sleeping...") logger.debug("No pending items, sleeping...")
time.sleep(10) interruptible_sleep(self._stop_event, 10)
continue continue
self.current_item = item self.current_item = item
@ -166,11 +170,11 @@ class QobuzWorker:
self._process_item(item) self._process_item(item)
# Throttle between API calls # Throttle between API calls
time.sleep(2) interruptible_sleep(self._stop_event, 2)
except Exception as e: except Exception as e:
logger.error(f"Error in worker loop: {e}") logger.error(f"Error in worker loop: {e}")
time.sleep(5) interruptible_sleep(self._stop_event, 5)
logger.info("Qobuz worker thread finished") logger.info("Qobuz worker thread finished")
@ -351,7 +355,7 @@ class QobuzWorker:
error_str = str(e).lower() error_str = str(e).lower()
if '429' in error_str or 'rate limit' in error_str: 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") logger.warning(f"Rate limited while processing {item['type']} #{item['id']}, backing off 30s")
time.sleep(30) interruptible_sleep(self._stop_event, 30)
return return
logger.error(f"Error processing {item['type']} #{item['id']}: {e}") logger.error(f"Error processing {item['type']} #{item['id']}: {e}")
self.stats['errors'] += 1 self.stats['errors'] += 1

View file

@ -141,7 +141,8 @@ class AcoustIDScannerJob(RepairJob):
if batch_count >= batch_size: if batch_count >= batch_size:
batch_count = 0 batch_count = 0
self._save_checkpoint(context, fpath) 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: if context.update_progress and (i + 1) % 10 == 0:
context.update_progress(i + 1, total) context.update_progress(i + 1, total)

View file

@ -2,6 +2,7 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass, field
import threading
from typing import Any, Callable, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional
@ -29,6 +30,7 @@ class JobContext:
mb_client: Any = None mb_client: Any = None
acoustid_client: Any = None acoustid_client: Any = None
metadata_cache: Any = None metadata_cache: Any = None
stop_event: Optional[threading.Event] = None
# Callbacks # Callbacks
create_finding: Optional[Callable] = None create_finding: Optional[Callable] = None
@ -39,6 +41,8 @@ class JobContext:
def check_stop(self) -> bool: def check_stop(self) -> bool:
"""Return True if the worker should stop.""" """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 return self.should_stop() if self.should_stop else False
def is_spotify_rate_limited(self) -> bool: def is_spotify_rate_limited(self) -> bool:
@ -55,11 +59,31 @@ class JobContext:
def wait_if_paused(self): def wait_if_paused(self):
"""Block until unpaused or stopped. Returns True if should stop.""" """Block until unpaused or stopped. Returns True if should stop."""
import time
while self.is_paused and self.is_paused(): while self.is_paused and self.is_paused():
if self.check_stop(): if self.check_stop():
return True 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() return self.check_stop()

View file

@ -821,7 +821,8 @@ class LibraryReorganizeJob(RepairJob):
years[key] = year_str years[key] = year_str
break break
import time import time
time.sleep(0.1) # Rate limit courtesy if context.sleep_or_stop(0.1): # Rate limit courtesy
break
except Exception as e: except Exception as e:
logger.debug("API year lookup failed for %s - %s: %s", artist, album, e) logger.debug("API year lookup failed for %s - %s: %s", artist, album, e)

View file

@ -294,7 +294,8 @@ class MbidMismatchDetectorJob(RepairJob):
try: try:
# Rate limit: MusicBrainz allows ~1 req/sec # 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']) recording = mb_client.get_recording(mbid, includes=['artist-credits'])
if not recording: if not recording:

View file

@ -173,7 +173,8 @@ class MetadataGapFillerJob(RepairJob):
# Rate limit API calls # Rate limit API calls
if spotify_track_id: 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: if context.update_progress and (i + 1) % 10 == 0:
context.update_progress(i + 1, total) context.update_progress(i + 1, total)

View file

@ -337,7 +337,8 @@ class UnknownArtistFixerJob(RepairJob):
except Exception as e: except Exception as e:
logger.debug(f"Title search failed for '{title}': {e}") logger.debug(f"Title search failed for '{title}': {e}")
# Rate limit courtesy # Rate limit courtesy
time.sleep(0.2) if context.sleep_or_stop(0.2):
return None
return None return None

View file

@ -67,6 +67,7 @@ class RepairWorker:
self.running = False self.running = False
self.enabled = False # Master toggle (replaces 'paused') self.enabled = False # Master toggle (replaces 'paused')
self.should_stop = False self.should_stop = False
self._stop_event = threading.Event()
self.thread = None self.thread = None
# Current job being executed # Current job being executed
@ -293,6 +294,7 @@ class RepairWorker:
return return
self.running = True self.running = True
self.should_stop = False self.should_stop = False
self._stop_event.clear()
self.thread = threading.Thread(target=self._run, daemon=True) self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start() self.thread.start()
logger.info("Repair worker started") logger.info("Repair worker started")
@ -303,8 +305,9 @@ class RepairWorker:
logger.info("Stopping repair worker...") logger.info("Stopping repair worker...")
self.should_stop = True self.should_stop = True
self.running = False self.running = False
self._stop_event.set()
if self.thread: if self.thread:
self.thread.join(timeout=5) self.thread.join(timeout=2)
logger.info("Repair worker stopped") logger.info("Repair worker stopped")
def toggle(self) -> bool: def toggle(self) -> bool:
@ -418,7 +421,7 @@ class RepairWorker:
logger.info("Repair worker thread started") logger.info("Repair worker thread started")
self._ensure_jobs_loaded() self._ensure_jobs_loaded()
while not self.should_stop: while not self._stop_event.is_set():
try: try:
# Check force-run queue even when disabled (user explicitly requested) # Check force-run queue even when disabled (user explicitly requested)
forced_job = None forced_job = None
@ -428,13 +431,15 @@ class RepairWorker:
if forced_job: if forced_job:
self._run_job(forced_job) self._run_job(forced_job)
time.sleep(2) if self._sleep_or_stop(2):
break
continue continue
if not self.enabled: if not self.enabled:
self._current_job_id = None self._current_job_id = None
self._current_job_name = None self._current_job_name = None
time.sleep(2) if self._sleep_or_stop(2):
break
continue continue
# Find the next job to run based on staleness # Find the next job to run based on staleness
@ -444,20 +449,23 @@ class RepairWorker:
# Nothing due — sleep and re-check # Nothing due — sleep and re-check
self._current_job_id = None self._current_job_id = None
self._current_job_name = None self._current_job_name = None
time.sleep(10) if self._sleep_or_stop(10):
break
continue continue
# Run the selected job # Run the selected job
self._run_job(next_job) self._run_job(next_job)
# Brief pause between jobs # Brief pause between jobs
time.sleep(5) if self._sleep_or_stop(5):
break
except Exception as e: except Exception as e:
logger.error("Error in repair worker loop: %s", e, exc_info=True) logger.error("Error in repair worker loop: %s", e, exc_info=True)
self._current_job_id = None self._current_job_id = None
self._current_job_name = None self._current_job_name = None
time.sleep(30) if self._sleep_or_stop(30):
break
logger.info("Repair worker thread finished") logger.info("Repair worker thread finished")
@ -552,6 +560,7 @@ class RepairWorker:
metadata_cache=self.metadata_cache, metadata_cache=self.metadata_cache,
create_finding=self._create_finding, create_finding=self._create_finding,
should_stop=lambda: self.should_stop, should_stop=lambda: self.should_stop,
stop_event=self._stop_event,
is_paused=lambda: not self.enabled, is_paused=lambda: not self.enabled,
update_progress=self._update_progress, update_progress=self._update_progress,
report_progress=_report_progress, report_progress=_report_progress,
@ -595,6 +604,17 @@ class RepairWorker:
self._current_job_name = None self._current_job_name = None
self._current_progress = {'scanned': 0, 'total': 0, 'percent': 0} 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): def run_job_now(self, job_id: str):
"""Queue a job for immediate execution by the main worker loop. """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 typing import Dict, Any, List, Optional
from utils.logging_config import get_logger from utils.logging_config import get_logger
from core.worker_utils import interruptible_sleep
logger = get_logger("soulid_worker") logger = get_logger("soulid_worker")
@ -79,6 +80,7 @@ class SoulIDWorker:
self.should_stop = False self.should_stop = False
self.thread = None self.thread = None
self.current_item = None self.current_item = None
self._stop_event = threading.Event()
# API clients (lazy-initialized) # API clients (lazy-initialized)
self._itunes_client = None self._itunes_client = None
@ -135,6 +137,7 @@ class SoulIDWorker:
return return
self.running = True self.running = True
self.should_stop = False self.should_stop = False
self._stop_event.clear()
self.thread = threading.Thread(target=self._run, daemon=True) self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start() self.thread.start()
logger.info("SoulID worker started") logger.info("SoulID worker started")
@ -144,8 +147,9 @@ class SoulIDWorker:
return return
self.should_stop = True self.should_stop = True
self.running = False self.running = False
self._stop_event.set()
if self.thread: if self.thread:
self.thread.join(timeout=5) self.thread.join(timeout=1)
logger.info("SoulID worker stopped") logger.info("SoulID worker stopped")
def pause(self): def pause(self):
@ -178,7 +182,7 @@ class SoulIDWorker:
while not self.should_stop: while not self.should_stop:
try: try:
if self.paused: if self.paused:
time.sleep(1) interruptible_sleep(self._stop_event, 1)
continue continue
processed = 0 processed = 0
@ -188,16 +192,16 @@ class SoulIDWorker:
if processed == 0: if processed == 0:
self.current_item = None self.current_item = None
time.sleep(self.idle_sleep) interruptible_sleep(self._stop_event, self.idle_sleep)
else: else:
# Albums/tracks get inter_batch_sleep, artists get their # Albums/tracks get inter_batch_sleep, artists get their
# own sleep inside _process_next_artist # 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: except Exception as e:
logger.error(f"Error in SoulID worker loop: {e}", exc_info=True) logger.error(f"Error in SoulID worker loop: {e}", exc_info=True)
self.stats['errors'] += 1 self.stats['errors'] += 1
time.sleep(5) interruptible_sleep(self._stop_event, 5)
self.current_item = None self.current_item = None
logger.info("SoulID worker thread finished") 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 "")) logger.info(f"Generated soul ID for artist: {name}" + (f" (canonical id: {canonical_id})" if canonical_id else ""))
# Rate limit courtesy for API calls # Rate limit courtesy for API calls
time.sleep(self.artist_sleep) interruptible_sleep(self._stop_event, self.artist_sleep)
return 1 return 1
except Exception as e: except Exception as e:
@ -324,7 +328,7 @@ class SoulIDWorker:
deezer_artist_id = int(raw_id) deezer_artist_id = int(raw_id)
logger.debug(f"Deezer artist ID for '{artist_name}': {deezer_artist_id}") logger.debug(f"Deezer artist ID for '{artist_name}': {deezer_artist_id}")
break break
time.sleep(0.3) interruptible_sleep(self._stop_event, 0.3)
except Exception as e: except Exception as e:
logger.debug(f"Deezer track search failed for '{artist_name}': {e}") logger.debug(f"Deezer track search failed for '{artist_name}': {e}")
@ -344,7 +348,7 @@ class SoulIDWorker:
itunes_artist_id = int(raw_id) itunes_artist_id = int(raw_id)
logger.debug(f"iTunes artist ID for '{artist_name}': {itunes_artist_id}") logger.debug(f"iTunes artist ID for '{artist_name}': {itunes_artist_id}")
break break
time.sleep(0.3) interruptible_sleep(self._stop_event, 0.3)
except Exception as e: except Exception as e:
logger.debug(f"iTunes track search failed for '{artist_name}': {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 utils.logging_config import get_logger
from database.music_database import MusicDatabase from database.music_database import MusicDatabase
from core.spotify_client import SpotifyClient, SpotifyRateLimitError from core.spotify_client import SpotifyClient, SpotifyRateLimitError
from core.worker_utils import interruptible_sleep
logger = get_logger("spotify_worker") logger = get_logger("spotify_worker")
@ -31,6 +32,7 @@ class SpotifyWorker:
self.paused = False self.paused = False
self.should_stop = False self.should_stop = False
self.thread = None self.thread = None
self._stop_event = threading.Event()
# Current item being processed (for UI tooltip) # Current item being processed (for UI tooltip)
self.current_item = None self.current_item = None
@ -66,6 +68,7 @@ class SpotifyWorker:
return return
self.running = True self.running = True
self.should_stop = False self.should_stop = False
self._stop_event.clear()
self.thread = threading.Thread(target=self._run, daemon=True) self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start() self.thread.start()
logger.info("Spotify background worker started") logger.info("Spotify background worker started")
@ -76,8 +79,9 @@ class SpotifyWorker:
logger.info("Stopping Spotify worker...") logger.info("Stopping Spotify worker...")
self.should_stop = True self.should_stop = True
self.running = False self.running = False
self._stop_event.set()
if self.thread: if self.thread:
self.thread.join(timeout=5) self.thread.join(timeout=1)
logger.info("Spotify worker stopped") logger.info("Spotify worker stopped")
def pause(self): def pause(self):
@ -167,7 +171,7 @@ class SpotifyWorker:
while not self.should_stop: while not self.should_stop:
try: try:
if self.paused: if self.paused:
time.sleep(1) interruptible_sleep(self._stop_event, 1)
continue continue
# Rate limit guard — if globally rate limited, sleep until ban expires # Rate limit guard — if globally rate limited, sleep until ban expires
@ -175,7 +179,7 @@ class SpotifyWorker:
info = self.client.get_rate_limit_info() info = self.client.get_rate_limit_info()
remaining = info['remaining_seconds'] if info else 60 remaining = info['remaining_seconds'] if info else 60
logger.debug(f"Spotify globally rate limited, sleeping {remaining}s...") 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 continue
# Daily budget guard — worker-only cap to avoid saturating Spotify rate limits # Daily budget guard — worker-only cap to avoid saturating Spotify rate limits
@ -184,7 +188,7 @@ class SpotifyWorker:
resets_in = budget['resets_in_seconds'] resets_in = budget['resets_in_seconds']
logger.info(f"Daily enrichment budget exhausted ({budget['used']}/{budget['limit']}), " logger.info(f"Daily enrichment budget exhausted ({budget['used']}/{budget['limit']}), "
f"resets in {resets_in // 3600}h {(resets_in % 3600) // 60}m") 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 continue
# Post-ban cooldown guard — after ban expires, wait before resuming # Post-ban cooldown guard — after ban expires, wait before resuming
@ -192,7 +196,7 @@ class SpotifyWorker:
cooldown = self.client.get_post_ban_cooldown_remaining() cooldown = self.client.get_post_ban_cooldown_remaining()
if cooldown > 0: if cooldown > 0:
logger.debug(f"Post-ban cooldown active ({cooldown}s left), sleeping...") 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 continue
# Auth guard — check if Spotify client is configured (no API call). # Auth guard — check if Spotify client is configured (no API call).
@ -203,7 +207,7 @@ class SpotifyWorker:
self.client.reload_config() self.client.reload_config()
if not self.client.is_spotify_authenticated(): if not self.client.is_spotify_authenticated():
logger.debug("Spotify not authenticated, sleeping 30s...") logger.debug("Spotify not authenticated, sleeping 30s...")
time.sleep(30) interruptible_sleep(self._stop_event, 30)
continue continue
self.current_item = None self.current_item = None
@ -211,7 +215,7 @@ class SpotifyWorker:
if not item: if not item:
logger.debug("No pending items, sleeping...") logger.debug("No pending items, sleeping...")
time.sleep(10) interruptible_sleep(self._stop_event, 10)
continue continue
self.current_item = item self.current_item = item
@ -229,14 +233,14 @@ class SpotifyWorker:
self._process_item(item) self._process_item(item)
self._increment_daily_budget() self._increment_daily_budget()
time.sleep(self.inter_item_sleep) interruptible_sleep(self._stop_event, self.inter_item_sleep)
except SpotifyRateLimitError: except SpotifyRateLimitError:
logger.debug("Spotify rate limit hit in worker loop, will retry after ban expires") 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: except Exception as e:
logger.error(f"Error in worker loop: {e}") logger.error(f"Error in worker loop: {e}")
time.sleep(5) interruptible_sleep(self._stop_event, 5)
self.current_item = None self.current_item = None
logger.info("Spotify worker thread finished") logger.info("Spotify worker thread finished")
@ -542,7 +546,7 @@ class SpotifyWorker:
self._mark_status('album', db_id, 'not_found') self._mark_status('album', db_id, 'not_found')
self.stats['not_found'] += 1 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") 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._mark_status('track', db_id, 'not_found')
self.stats['not_found'] += 1 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") 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 utils.logging_config import get_logger
from database.music_database import MusicDatabase from database.music_database import MusicDatabase
from core.tidal_client import TidalClient from core.tidal_client import TidalClient
from core.worker_utils import interruptible_sleep
logger = get_logger("tidal_worker") logger = get_logger("tidal_worker")
@ -45,6 +46,7 @@ class TidalWorker:
self.paused = False self.paused = False
self.should_stop = False self.should_stop = False
self.thread = None self.thread = None
self._stop_event = threading.Event()
# Current item being processed (for UI tooltip) # Current item being processed (for UI tooltip)
self.current_item = None self.current_item = None
@ -73,6 +75,7 @@ class TidalWorker:
self.running = True self.running = True
self.should_stop = False self.should_stop = False
self._stop_event.clear()
self.thread = threading.Thread(target=self._run, daemon=True) self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start() self.thread.start()
logger.info("Tidal background worker started") logger.info("Tidal background worker started")
@ -85,9 +88,10 @@ class TidalWorker:
logger.info("Stopping Tidal worker...") logger.info("Stopping Tidal worker...")
self.should_stop = True self.should_stop = True
self.running = False self.running = False
self._stop_event.set()
if self.thread: if self.thread:
self.thread.join(timeout=5) self.thread.join(timeout=1)
logger.info("Tidal worker stopped") logger.info("Tidal worker stopped")
@ -140,17 +144,17 @@ class TidalWorker:
while not self.should_stop: while not self.should_stop:
try: try:
if self.paused: if self.paused:
time.sleep(1) interruptible_sleep(self._stop_event, 1)
continue continue
# Auth guard: sleep if not authenticated # Auth guard: sleep if not authenticated
try: try:
if not self.client.is_authenticated(): if not self.client.is_authenticated():
self.current_item = None self.current_item = None
time.sleep(30) interruptible_sleep(self._stop_event, 30)
continue continue
except Exception: except Exception:
time.sleep(30) interruptible_sleep(self._stop_event, 30)
continue continue
self.current_item = None self.current_item = None
@ -159,7 +163,7 @@ class TidalWorker:
if not item: if not item:
logger.debug("No pending items, sleeping...") logger.debug("No pending items, sleeping...")
time.sleep(10) interruptible_sleep(self._stop_event, 10)
continue continue
self.current_item = item self.current_item = item
@ -178,11 +182,11 @@ class TidalWorker:
self._process_item(item) self._process_item(item)
time.sleep(2) interruptible_sleep(self._stop_event, 2)
except Exception as e: except Exception as e:
logger.error(f"Error in worker loop: {e}") logger.error(f"Error in worker loop: {e}")
time.sleep(5) interruptible_sleep(self._stop_event, 5)
logger.info("Tidal worker thread finished") logger.info("Tidal worker thread finished")
@ -364,7 +368,7 @@ class TidalWorker:
if '429' in error_str or 'rate limit' in error_str: if '429' in error_str or 'rate limit' in error_str:
# Rate limit — don't mark as error, back off then retry # 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") logger.warning(f"Rate limited while processing {item['type']} #{item['id']}, backing off 30s")
time.sleep(30) interruptible_sleep(self._stop_event, 30)
return return
logger.error(f"Error processing {item['type']} #{item['id']}: {e}") logger.error(f"Error processing {item['type']} #{item['id']}: {e}")
self.stats['errors'] += 1 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") logger.debug(f"Skipping {release_type}: {album_data.get('name', 'Unknown')} - user preference")
continue 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 # Check each track
for track in tracks: for track in tracks:
# Check content type filters (live, remix, acoustic, compilation) # Check content type filters (live, remix, acoustic, compilation)
@ -1349,6 +1355,22 @@ class WatchlistScanner:
except Exception: except Exception:
return False # Error — assume released 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: def _should_include_release(self, track_count: int, watchlist_artist: WatchlistArtist) -> bool:
""" """
Check if a release should be included based on user's preferences. 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)) unique_title_variations = list(dict.fromkeys(title_variations))
# Search for each artist with each title variation # 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 artist_name in artists_to_search:
for query_title in unique_title_variations: for query_title in unique_title_variations:
# Use same database check as modals with server awareness # When allow_duplicates is on, skip album hint so we get title+artist matches only
from config.settings import config_manager search_album = None if allow_duplicates else album_name
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=search_album)
db_track, confidence = self.database.check_track_exists(query_title, artist_name, confidence_threshold=0.7, server_source=active_server, album=album_name)
if db_track and confidence >= 0.7: 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})") logger.debug(f"Track found in library: '{original_title}' by '{artist_name}' (confidence: {confidence:.2f})")
return False # Track exists in library 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)") 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) # Determine if this is a new release (within last 30 days)
is_new = False is_new = False
try: try:

View file

@ -38,6 +38,8 @@ class WebScanManager:
self._max_scan_time = 1800 # 30 minutes maximum self._max_scan_time = 1800 # 30 minutes maximum
self._current_server_type = None self._current_server_type = None
self._scan_progress = {} self._scan_progress = {}
self._completion_check_timer = None
self._shutting_down = False
logger.info(f"WebScanManager initialized with {delay_seconds}s debounce delay") 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}") logger.info(f"Web scan requested - reason: {reason}")
with self._lock: 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 # Add callback if provided
if callback and callback not in self._scan_completion_callbacks: if callback and callback not in self._scan_completion_callbacks:
self._scan_completion_callbacks.append(callback) self._scan_completion_callbacks.append(callback)
@ -107,6 +117,7 @@ class WebScanManager:
# Start the debounce timer # Start the debounce timer
self._timer = threading.Timer(self.delay, self._execute_scan) self._timer = threading.Timer(self.delay, self._execute_scan)
self._timer.daemon = True
self._timer.start() self._timer.start()
return { return {
@ -169,6 +180,9 @@ class WebScanManager:
def _execute_scan(self): def _execute_scan(self):
"""Execute the actual media library scan""" """Execute the actual media library scan"""
with self._lock: with self._lock:
if self._shutting_down:
logger.debug("Web scan execution skipped during shutdown")
return
if self._scan_in_progress: if self._scan_in_progress:
logger.warning("Web scan already in progress - skipping duplicate execution") logger.warning("Web scan already in progress - skipping duplicate execution")
return return
@ -231,6 +245,9 @@ class WebScanManager:
"""Start periodic checking for scan completion""" """Start periodic checking for scan completion"""
def check_completion(): def check_completion():
try: try:
if self._shutting_down:
logger.debug("Web scan completion check aborted during shutdown")
return
# Check for timeout # Check for timeout
if self._scan_start_time and (time.time() - self._scan_start_time) > self._max_scan_time: 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") logger.warning(f"Web scan timed out after {self._max_scan_time} seconds")
@ -254,23 +271,38 @@ class WebScanManager:
self._handle_scan_completion() self._handle_scan_completion()
else: else:
# Continue checking # 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: except Exception as e:
logger.error(f"Error during web scan completion check: {e}") logger.error(f"Error during web scan completion check: {e}")
self._reset_scan_state() self._reset_scan_state()
# Start first check after 30 seconds # 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): def _handle_scan_completion(self):
"""Handle scan completion and trigger callbacks""" """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: with self._lock:
if self._shutting_down:
return
server_type = self._current_server_type
callbacks_to_call = self._scan_completion_callbacks.copy() 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: for callback in callbacks_to_call:
try: try:
@ -283,10 +315,9 @@ class WebScanManager:
self._reset_scan_state() self._reset_scan_state()
# Check if we need another scan due to downloads during this scan # Check if we need another scan due to downloads during this scan
with self._lock: if downloads_during_scan:
if self._downloads_during_scan: logger.info("Web scan follow-up needed for 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")
self.request_scan("Follow-up scan for downloads during previous scan")
def _reset_scan_state(self): def _reset_scan_state(self):
"""Reset internal scan state""" """Reset internal scan state"""
@ -295,4 +326,24 @@ class WebScanManager:
self._current_server_type = None self._current_server_type = None
self._scan_start_time = None self._scan_start_time = None
self._scan_progress = {} 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") logger = get_logger("music_database")
_database_initialized_paths = set() _database_initialized_paths = set()
_database_sidecar_warnings = set()
_database_initialization_lock = threading.Lock() _database_initialization_lock = threading.Lock()
# Import matching engine for enhanced similarity logic # Import matching engine for enhanced similarity logic
@ -170,10 +171,57 @@ class MusicDatabase:
database_path = os.environ.get('DATABASE_PATH', 'database/music_library.db') database_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
self.database_path = Path(database_path) self.database_path = Path(database_path)
self.database_path.parent.mkdir(parents=True, exist_ok=True) self.database_path.parent.mkdir(parents=True, exist_ok=True)
self._warn_about_stale_sqlite_sidecars()
# Initialize database once per process for this path # Initialize database once per process for this path
self._initialize_database_once() 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): def _initialize_database_once(self):
"""Run schema setup and migrations once per database path per process.""" """Run schema setup and migrations once per database path per process."""
db_key = str(self.database_path.resolve()) db_key = str(self.database_path.resolve())
@ -612,6 +660,23 @@ class MusicDatabase:
except Exception: except Exception:
pass 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() conn.commit()
logger.info("Database initialized successfully") logger.info("Database initialized successfully")
@ -977,12 +1042,15 @@ class MusicDatabase:
cursor.execute("ALTER TABLE discovery_pool ADD COLUMN artist_genres TEXT") cursor.execute("ALTER TABLE discovery_pool ADD COLUMN artist_genres TEXT")
logger.info("Added artist_genres column to discovery_pool table") 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 # Migration: Add iTunes columns to discovery_pool for dual-source discovery
if 'itunes_track_id' not in discovery_pool_columns: 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_track_id TEXT")
cursor.execute("ALTER TABLE discovery_pool ADD COLUMN itunes_album_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 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") logger.info("Added iTunes columns to discovery_pool table for dual-source discovery")
# Migration: Add Deezer columns to discovery_pool for tri-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)") cursor.execute("PRAGMA table_info(recent_releases)")
recent_releases_columns = [column[1] for column in cursor.fetchall()] 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: 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 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") logger.info("Added iTunes columns to recent_releases table for dual-source discovery")
# Migration: Add Deezer column to recent_releases for tri-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)") cursor.execute("PRAGMA table_info(discovery_recent_albums)")
discovery_recent_albums_columns = [column[1] for column in cursor.fetchall()] 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: 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 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 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") 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 # 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_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)") 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: except Exception as e:
logger.error(f"Error creating discovery tables: {e}") logger.error(f"Error creating discovery tables: {e}")
@ -1450,6 +1524,8 @@ class MusicDatabase:
include_remixes INTEGER DEFAULT 0, include_remixes INTEGER DEFAULT 0,
include_acoustic INTEGER DEFAULT 0, include_acoustic INTEGER DEFAULT 0,
include_compilations INTEGER DEFAULT 0, include_compilations INTEGER DEFAULT 0,
include_instrumentals INTEGER DEFAULT 0,
lookback_days INTEGER DEFAULT NULL,
itunes_artist_id TEXT, itunes_artist_id TEXT,
deezer_artist_id TEXT, deezer_artist_id TEXT,
discogs_artist_id TEXT, discogs_artist_id TEXT,
@ -1476,6 +1552,8 @@ class MusicDatabase:
include_remixes INTEGER DEFAULT 0, include_remixes INTEGER DEFAULT 0,
include_acoustic INTEGER DEFAULT 0, include_acoustic INTEGER DEFAULT 0,
include_compilations INTEGER DEFAULT 0, include_compilations INTEGER DEFAULT 0,
include_instrumentals INTEGER DEFAULT 0,
lookback_days INTEGER DEFAULT NULL,
itunes_artist_id TEXT, itunes_artist_id TEXT,
deezer_artist_id TEXT, deezer_artist_id TEXT,
discogs_artist_id TEXT discogs_artist_id TEXT
@ -1489,6 +1567,7 @@ class MusicDatabase:
'last_scan_timestamp', 'created_at', 'updated_at', 'image_url', 'last_scan_timestamp', 'created_at', 'updated_at', 'image_url',
'include_albums', 'include_eps', 'include_singles', 'include_live', 'include_albums', 'include_eps', 'include_singles', 'include_live',
'include_remixes', 'include_acoustic', 'include_compilations', 'include_remixes', 'include_acoustic', 'include_compilations',
'include_instrumentals', 'lookback_days',
'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id', 'profile_id'] 'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id', 'profile_id']
shared_cols = [c for c in new_cols if c in old_cols] shared_cols = [c for c in new_cols if c in old_cols]
cols_str = ', '.join(shared_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_id ON albums (discogs_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_discogs_status ON albums (discogs_match_status)") 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: except Exception as e:
logger.error(f"Error adding Discogs columns: {e}") logger.error(f"Error adding Discogs columns: {e}")
@ -2217,6 +2296,8 @@ class MusicDatabase:
include_remixes INTEGER DEFAULT 0, include_remixes INTEGER DEFAULT 0,
include_acoustic INTEGER DEFAULT 0, include_acoustic INTEGER DEFAULT 0,
include_compilations INTEGER DEFAULT 0, include_compilations INTEGER DEFAULT 0,
include_instrumentals INTEGER DEFAULT 0,
lookback_days INTEGER DEFAULT NULL,
itunes_artist_id TEXT, itunes_artist_id TEXT,
deezer_artist_id TEXT, deezer_artist_id TEXT,
discogs_artist_id TEXT, discogs_artist_id TEXT,
@ -2231,6 +2312,7 @@ class MusicDatabase:
'last_scan_timestamp', 'created_at', 'updated_at', 'image_url', 'last_scan_timestamp', 'created_at', 'updated_at', 'image_url',
'include_albums', 'include_eps', 'include_singles', 'include_live', 'include_albums', 'include_eps', 'include_singles', 'include_live',
'include_remixes', 'include_acoustic', 'include_compilations', 'include_remixes', 'include_acoustic', 'include_compilations',
'include_instrumentals', 'lookback_days',
'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id', 'profile_id'] 'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id', 'profile_id']
shared_cols = [c for c in new_cols if c in col_names] shared_cols = [c for c in new_cols if c in col_names]
cols_str = ', '.join(shared_cols) cols_str = ', '.join(shared_cols)
@ -2393,6 +2475,9 @@ class MusicDatabase:
itunes_track_id TEXT, itunes_track_id TEXT,
itunes_album_id TEXT, itunes_album_id TEXT,
itunes_artist_id TEXT, itunes_artist_id TEXT,
deezer_track_id TEXT,
deezer_album_id TEXT,
deezer_artist_id TEXT,
source TEXT NOT NULL DEFAULT 'spotify', source TEXT NOT NULL DEFAULT 'spotify',
track_name TEXT NOT NULL, track_name TEXT NOT NULL,
artist_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', new_cols = ['id', 'spotify_track_id', 'spotify_album_id', 'spotify_artist_id',
'itunes_track_id', 'itunes_album_id', 'itunes_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', 'source', 'track_name', 'artist_name', 'album_name', 'album_cover_url',
'duration_ms', 'popularity', 'release_date', 'is_new_release', 'duration_ms', 'popularity', 'release_date', 'is_new_release',
'track_data_json', 'artist_genres', 'added_date', 'profile_id'] 'track_data_json', 'artist_genres', 'added_date', 'profile_id']
@ -2486,6 +2572,7 @@ class MusicDatabase:
watchlist_artist_id INTEGER NOT NULL, watchlist_artist_id INTEGER NOT NULL,
album_spotify_id TEXT, album_spotify_id TEXT,
album_itunes_id TEXT, album_itunes_id TEXT,
album_deezer_id TEXT,
source TEXT NOT NULL DEFAULT 'spotify', source TEXT NOT NULL DEFAULT 'spotify',
album_name TEXT NOT NULL, album_name TEXT NOT NULL,
release_date 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', new_cols = ['id', 'watchlist_artist_id', 'album_spotify_id', 'album_itunes_id',
'source', 'album_name', 'release_date', 'album_cover_url', 'album_deezer_id', 'source', 'album_name', 'release_date',
'track_count', 'added_date', 'profile_id'] 'album_cover_url', 'track_count', 'added_date', 'profile_id']
shared_cols = [c for c in new_cols if c in old_cols] shared_cols = [c for c in new_cols if c in old_cols]
cols_str = ', '.join(shared_cols) cols_str = ', '.join(shared_cols)
@ -2557,10 +2644,15 @@ class MusicDatabase:
source_artist_id TEXT NOT NULL, source_artist_id TEXT NOT NULL,
similar_artist_spotify_id TEXT, similar_artist_spotify_id TEXT,
similar_artist_itunes_id TEXT, similar_artist_itunes_id TEXT,
similar_artist_deezer_id TEXT,
similar_artist_name TEXT NOT NULL, similar_artist_name TEXT NOT NULL,
similarity_rank INTEGER DEFAULT 1, similarity_rank INTEGER DEFAULT 1,
occurrence_count INTEGER DEFAULT 1, occurrence_count INTEGER DEFAULT 1,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
image_url TEXT,
genres TEXT,
popularity INTEGER DEFAULT 0,
metadata_updated_at TIMESTAMP,
last_featured TIMESTAMP, last_featured TIMESTAMP,
profile_id INTEGER DEFAULT 1, profile_id INTEGER DEFAULT 1,
UNIQUE(profile_id, source_artist_id, similar_artist_name) 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', new_cols = ['id', 'source_artist_id', 'similar_artist_spotify_id',
'similar_artist_itunes_id', 'similar_artist_name', 'similar_artist_itunes_id', 'similar_artist_deezer_id',
'similarity_rank', 'occurrence_count', 'last_updated', 'similar_artist_name', 'similarity_rank', 'occurrence_count',
'last_featured', 'profile_id'] '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] shared_cols = [c for c in new_cols if c in old_cols]
cols_str = ', '.join(shared_cols) cols_str = ', '.join(shared_cols)
@ -4812,25 +4905,27 @@ class MusicDatabase:
return [] return []
def search_artists(self, query: str, limit: int = 50, server_source: str = None) -> List[DatabaseArtist]: 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: try:
conn = self._get_connection() conn = self._get_connection()
cursor = conn.cursor() cursor = conn.cursor()
norm_query = f"%{self._normalize_for_comparison(query)}%"
if server_source: if server_source:
cursor.execute(""" cursor.execute("""
SELECT * FROM artists SELECT * FROM artists
WHERE name LIKE ? AND server_source = ? WHERE unidecode_lower(name) LIKE ? AND server_source = ?
ORDER BY name ORDER BY name
LIMIT ? LIMIT ?
""", (f"%{query}%", server_source, limit)) """, (norm_query, server_source, limit))
else: else:
cursor.execute(""" cursor.execute("""
SELECT * FROM artists SELECT * FROM artists
WHERE name LIKE ? WHERE unidecode_lower(name) LIKE ?
ORDER BY name ORDER BY name
LIMIT ? LIMIT ?
""", (f"%{query}%", limit)) """, (norm_query, limit))
rows = cursor.fetchall() rows = cursor.fetchall()
@ -6391,12 +6486,33 @@ class MusicDatabase:
spotify_json = json.dumps(spotify_track_data) spotify_json = json.dumps(spotify_track_data)
source_json = json.dumps(source_info or {}) 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(""" cursor.execute("""
INSERT OR REPLACE INTO wishlist_tracks INSERT OR REPLACE INTO wishlist_tracks
(spotify_track_id, spotify_data, failure_reason, source_type, source_info, date_added, profile_id) (spotify_track_id, spotify_data, failure_reason, source_type, source_info, date_added, profile_id)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?) 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() 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 concurrent.futures import ThreadPoolExecutor, as_completed
from flask import Flask, render_template, request, jsonify, redirect, send_file, Response, session, g 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 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 from utils.async_helpers import run_async
# --- Core Application Imports --- # --- Core Application Imports ---
# Import the same core clients and config manager used by the GUI app # Import the same core clients and config manager used by the GUI app
from config.settings import config_manager from config.settings import config_manager
# Initialize logger # Setup logging early to avoid any import-time logs from being swallowed
logger = get_logger("web_server") _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. # 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 # Dedicated source reuse logger — writes to logs/source_reuse.log
import logging as _logging import logging as _logging
@ -2517,27 +2519,41 @@ class WebUIDownloadMonitor:
self.monitoring = False self.monitoring = False
self.monitor_thread = None self.monitor_thread = None
self.monitored_batches = set() self.monitored_batches = set()
self._lock = threading.Lock()
def start_monitoring(self, batch_id): def start_monitoring(self, batch_id):
"""Start monitoring a download batch""" """Start monitoring a download batch"""
self.monitored_batches.add(batch_id) with self._lock:
if not self.monitoring: self.monitored_batches.add(batch_id)
self.monitoring = True if not self.monitoring:
self.monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True) self.monitoring = True
self.monitor_thread.start() self.monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True)
print(f"Started download monitor for batch {batch_id}") self.monitor_thread.start()
print(f"Started download monitor for batch {batch_id}")
def stop_monitoring(self, batch_id): def stop_monitoring(self, batch_id):
"""Stop monitoring a specific batch""" """Stop monitoring a specific batch"""
self.monitored_batches.discard(batch_id) with self._lock:
if not self.monitored_batches: 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 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): def _monitor_loop(self):
"""Main monitoring loop - checks downloads every 1 second for responsive web UX""" """Main monitoring loop - checks downloads every 1 second for responsive web UX"""
while self.monitoring and self.monitored_batches: while self.monitoring and self.monitored_batches:
try: try:
if globals().get('IS_SHUTTING_DOWN', False):
self.monitoring = False
break
self._check_all_downloads() self._check_all_downloads()
time.sleep(1) # 1-second polling for fast web UI updates time.sleep(1) # 1-second polling for fast web UI updates
except Exception as e: except Exception as e:
@ -2624,6 +2640,8 @@ class WebUIDownloadMonitor:
completed_tasks.append((batch_id, task_id)) completed_tasks.append((batch_id, task_id))
# ---- All work below runs WITHOUT tasks_lock held ---- # ---- 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) # Execute deferred operations from _should_retry_task (network calls, nested locks)
for op in deferred_ops: 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. This is the server-side equivalent of the frontend's worker count validation.
""" """
try: try:
if globals().get('IS_SHUTTING_DOWN', False):
return
import time import time
current_time = time.time() current_time = time.time()
@ -3310,15 +3330,41 @@ def validate_and_heal_batch_states():
# Start periodic batch healing (every 30 seconds) # Start periodic batch healing (every 30 seconds)
import threading 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(): def start_batch_healing_timer():
"""Start periodic batch state validation and healing""" """Start periodic batch state validation and healing"""
try: try:
if globals().get('IS_SHUTTING_DOWN', False):
return
validate_and_heal_batch_states() validate_and_heal_batch_states()
except Exception as e: except Exception as e:
print(f"[Batch Healing Timer] Error: {e}") print(f"[Batch Healing Timer] Error: {e}")
finally: finally:
# Schedule next healing cycle # 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 the healing timer when the server starts
start_batch_healing_timer() start_batch_healing_timer()
@ -3332,35 +3378,85 @@ def cleanup_monitor():
"""Clean up background monitor on shutdown""" """Clean up background monitor on shutdown"""
if download_monitor.monitoring: if download_monitor.monitoring:
print("Flask shutdown detected, stopping download monitor...") print("Flask shutdown detected, stopping download monitor...")
download_monitor.monitoring = False download_monitor.shutdown()
download_monitor.monitored_batches.clear()
# Give the thread a moment to exit cleanly # Give the thread a moment to exit cleanly
time.sleep(0.5) time.sleep(0.5)
# Clean up batch locks to prevent memory leaks # Clean up batch locks to prevent memory leaks
with tasks_lock: try:
batch_locks.clear() acquired = tasks_lock.acquire(timeout=1.0)
print("Cleaned up batch locks") 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 # Global shutdown flag
IS_SHUTTING_DOWN = False IS_SHUTTING_DOWN = False
def signal_handler(signum, frame): def _shutdown_executor(executor, name):
"""Handle SIGINT (Ctrl+C) and SIGTERM""" """Shut down a ThreadPoolExecutor without waiting for long-running tasks."""
global IS_SHUTTING_DOWN if executor is None:
print(f"Signal {signum} received, cleaning up...") return
IS_SHUTTING_DOWN = True
cleanup_monitor()
# Stop automation engine
try: try:
if automation_engine: print(f"Shutting down {name}...")
print("Stopping automation engine...") executor.shutdown(wait=False, cancel_futures=True)
automation_engine.stop()
except Exception as e: 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: try:
from core.api_call_tracker import api_call_tracker from core.api_call_tracker import api_call_tracker
api_call_tracker.save() api_call_tracker.save()
@ -3368,13 +3464,57 @@ def signal_handler(signum, frame):
except Exception as e: except Exception as e:
print(f"Error saving API call history: {e}") print(f"Error saving API call history: {e}")
# Shutdown executor to prevent new tasks # Stop the active DB update worker before tearing down the executor it runs on.
try: # This lets an in-flight update observe should_stop and exit cleanly.
print("Shutting down missing_download_executor...") _stop_component(db_update_worker, "db update worker")
missing_download_executor.shutdown(wait=False, cancel_futures=True) _stop_component(metadata_update_runtime_worker, "metadata update worker")
except Exception as e:
print(f"Error shutting down executor: {e}")
# Stop long-lived worker components in parallel so shutdown waits for the
# slowest worker instead of serially burning the timeout for each one.
_stop_components_parallel([
(mb_worker, "musicbrainz worker"),
(audiodb_worker, "audiodb worker"),
(discogs_worker, "discogs worker"),
(deezer_worker, "deezer worker"),
(spotify_enrichment_worker, "spotify enrichment worker"),
(itunes_enrichment_worker, "itunes enrichment worker"),
(lastfm_worker, "lastfm worker"),
(genius_worker, "genius worker"),
(tidal_enrichment_worker, "tidal enrichment worker"),
(qobuz_enrichment_worker, "qobuz enrichment worker"),
(hydrabase_worker, "hydrabase worker"),
(soulid_worker, "soulid worker"),
(listening_stats_worker, "listening stats worker"),
(repair_worker, "repair worker"),
])
# Shut down executor pools so their worker threads stop keeping the process alive.
for executor, name in [
(stream_executor, "stream executor"),
(db_update_executor, "db update executor"),
(quality_scanner_executor, "quality scanner executor"),
(duplicate_cleaner_executor, "duplicate cleaner executor"),
(retag_executor, "retag executor"),
(sync_executor, "sync executor"),
(missing_download_executor, "missing download executor"),
(tidal_discovery_executor, "tidal discovery executor"),
(deezer_discovery_executor, "deezer discovery executor"),
(spotify_public_discovery_executor, "spotify public discovery executor"),
(youtube_discovery_executor, "youtube discovery executor"),
(beatport_discovery_executor, "beatport discovery executor"),
(listenbrainz_discovery_executor, "listenbrainz discovery executor"),
(similar_artists_executor, "similar artists executor"),
(metadata_update_executor, "metadata update executor"),
]:
_shutdown_executor(executor, name)
# Give daemon cleanup threads a moment to observe the shutdown flag.
time.sleep(0.2)
def signal_handler(signum, frame):
"""Handle SIGINT (Ctrl+C) and SIGTERM"""
print(f"Signal {signum} received, cleaning up...")
_shutdown_runtime_components()
sys.exit(0) sys.exit(0)
# Register cleanup handlers # Register cleanup handlers
@ -3385,7 +3525,14 @@ def _atexit_save_history():
except Exception: except Exception:
pass pass
def _atexit_shutdown():
try:
_shutdown_runtime_components()
except Exception:
pass
atexit.register(_atexit_save_history) atexit.register(_atexit_save_history)
atexit.register(_atexit_shutdown)
atexit.register(cleanup_monitor) atexit.register(cleanup_monitor)
signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, 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_album_name = metadata.get('album', '')
_pp_artist_name = metadata.get('album_artist', '') or metadata.get('artist', '') _pp_artist_name = metadata.get('album_artist', '') or metadata.get('artist', '')
if _pp_album_name and _pp_artist_name: if _pp_album_name and _pp_artist_name:
conn = database._get_connection() conn = get_database()._get_connection()
try: try:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" 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.") print(f"Post-processing failed: Missing spotify_artist context.")
return 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) # Check if playlist folder mode is enabled (sync page playlists only)
track_info = context.get("track_info", {}) track_info = context.get("track_info", {})
playlist_folder_mode = track_info.get("_playlist_folder_mode", False) playlist_folder_mode = track_info.get("_playlist_folder_mode", False)
@ -21332,180 +21534,120 @@ def get_version_info():
"sections": [ "sections": [
{ {
"title": "Centralized Downloads Page", "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": [ "features": [
"• New Downloads page in sidebar — shows all tracks from Sync, Discover, Artists, Search, and Wishlist", "• New Downloads page in sidebar with live-updating list and nav badge",
"• Live-updating list with filter pills: All, Active, Queued, Completed, Failed", "• Filter pills: All, Active, Queued, Completed, Failed",
"• Section headers group downloads by status with track counts", "• Track position (3 of 19), album art, batch context, and error details per row",
"• Track position display (3 of 19) for album and playlist batches",
"• Album art, artist/album metadata, batch context, and error messages per row",
"• Clear Completed button removes finished items from the tracker", "• 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", "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": [ "features": [
"• 7-step wizard: Welcome, Metadata Source, Download Source, Paths & Media Server, Add Artists, First Download, Done", "• 7-step wizard: Welcome, Metadata Source, Download Source, Paths, Media Server, Add Artists, First Download",
"• All 6 download sources available: Soulseek, YouTube, HiFi, Tidal, Qobuz, Deezer — with inline config and test buttons", "• All 6 download sources with inline config and test buttons",
"• Path fields default to /app/downloads and /app/Transfer with lock/unlock for Docker users", "• Locked path defaults for Docker users (/app/downloads, /app/Transfer)",
"• Media server connection (Plex/Jellyfin/Navidrome) with inline test", "• Add artists to watchlist with live search and remove in place",
"• Add artists to watchlist with live search — shows watchlist status, add/remove in place", "• First download goes through the full matched download pipeline with metadata",
"• First download step searches metadata, finds best match, and downloads through the full pipeline", "• Auto-shows on fresh installs, skippable, all settings save to DB",
"• All settings save to DB identically to the Settings page — no difference in behavior", "• 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", "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": [ "features": [
"• Music Videos pill tab alongside Spotify/Deezer/iTunes/Discogs in both search bars", "• Video cards with thumbnails, duration, channel name, view count",
"• YouTube search returns video cards with 16:9 thumbnails, duration, channel name, view count", "• Click to download with progress ring — metadata matched before saving",
"• Click any video to download — circular progress ring on thumbnail, green checkmark on completion", "• Saves to configurable Music Videos directory (Plex format)",
"• 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"
], ],
"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)", "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": [ "features": [
"• 7th download source alongside Soulseek, YouTube, Tidal, Qobuz, HiFi, and Deezer", "• SoulSync handles discovery and matching, Lidarr handles downloading",
"• SoulSync handles discovery and matching, Lidarr handles downloading via its indexers", "• Configure with URL + API key in Settings > Downloads",
"• Configure with just URL + API key in Settings > Downloads",
"• Available as standalone source or in Hybrid mode priority order", "• 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", "title": "Matching & Quality Improvements",
"description": "Major fix for tracks downloading as 'Unknown Artist' and Spotify being used when Deezer/iTunes was selected", "description": "Better album matching, per-track artist support, and placeholder detection",
"features": [ "features": [
"• Fixed playlist pipeline (discover → sync → wishlist → download) losing artist, track number, and album year data", "• Album matching uses full similarity instead of word subset — 'Paradise' no longer matches 'Club Paradise'",
"• All discovery workers now respect your configured primary metadata source instead of always using Spotify", "• Artist gate prevents wrong-artist downloads (Soulseek < 0.25, YouTube < 0.15 threshold)",
"• Centralized metadata source selection in core/metadata_service.py — one source of truth for all features", "• Word boundary matching for artists — 'muse' no longer matches 'museum'",
"• Fixed Deezer metadata cache returning incomplete data (missing track_number, release_date) from search result cache", "• Watchlist scanner skips albums with placeholder tracks ('Track 1', 'Track 2') from unreleased tracklists",
"• Sync completion toast now shows which specific tracks failed to match (not just a count)", "• Per-track artist column for compilations and DJ mixes — tag writer uses track artist, not album artist",
"• New 'Fix Unknown Artists' maintenance job — scans library for Unknown Artist tracks and corrects metadata, tags, and file paths", "• Centralized metadata source selection — all features respect your configured source",
"• One-time migration purges stale discovery and Deezer cache entries on first startup after update"
], ],
"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", "title": "Fixes & Improvements",
"description": "Bug fixes, quality of life improvements, and new settings", "description": "60+ commits of bug fixes, UX improvements, and infrastructure",
"features": [ "features": [
"• Dismissed maintenance findings no longer reappear on next scan — dedup check now includes dismissed status", "• Plex playlists crash on Tag objects fixed — safe attribute access for non-playlist items",
"• Orphan file detector: increased path matching depth to 4 segments + filename parsing fallback for unreadable tags", "• Music library paths now auto-save when added/removed on settings page",
"• Media player: rapid play clicks no longer create duplicate audio streams requiring browser refresh", "• M3U files no longer created for single track downloads — only playlists",
"• Logs directory auto-created on startup — prevents crash for non-Docker installations", "• M3U [object Object] artist bug fixed — handles all artist format variations",
"• Stale discovery data re-processed in automation pipeline — tracks with missing metadata get re-enriched", "• Album year update 'database not defined' error fixed in post-processing",
"• Artist names no longer stored as lowercase — fixed static method shadowing instance method. Run a database update to fix existing names.", "• Sync tab content scrolling fixed — long lists no longer clipped",
"• Watchlist scanner skips future/unreleased albums — no more garbage downloads from albums not yet out", "• Hybrid download status shows green when any serverless source is in the order",
"• Playlist sync tracks now tagged with correct track numbers instead of always 01", "• Serverless sources (YouTube, HiFi, Qobuz) always show green in service status",
"• Emby playlist sync fixed — integer IDs now accepted alongside Jellyfin GUIDs", "• Repeated slskd 401 errors suppressed after first warning",
"• Discovery fix search now tries all metadata sources (Spotify → Deezer → iTunes) with automatic fallback", "• Download clients reload paths when settings change (no restart needed)",
"• Album completeness scanner skips zero-track albums to prevent auto-fill errors", "• watchlist_artists table migrations include all provider ID columns in rebuilds",
"• Global search string escaping fixed — albums with newlines in metadata no longer crash", "• Emojis removed from all Python log and print statements",
"• Download history timestamps fixed — no longer always showing 'Just now'", "• Interactive help coverage expanded with 30+ new entries",
"• Discogs added to enrichment service whitelist — Enrich button now works for Discogs", "• Docker compose includes optional slskd service block",
"• Settings Connections tab redesigned with collapsible accordion services and brand-colored dots", "• Multi-stage Docker build reduces image size",
"• Metadata source filter on Library page — filter artists by matched/unmatched to any service", "• MusicBrainz recording ID backfilled from Navidrome during scan",
"• 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"
], ],
"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", "title": "Wing It — Download or Sync Without Discovery",
"description": "Bypass metadata discovery and use raw track names directly", "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" "• Wishlist process API endpoint for external apps"
] ]
} }
] """ # end of _OLD_V22_NOTES
}
return jsonify(version_data)
_OLD_V2_NOTES = r""" _OLD_V2_NOTES = r"""
"features": [ "features": [
@ -22726,7 +22866,7 @@ def _simple_monitor_task():
Search cleanup and download cleanup are now handled by system automations.""" Search cleanup and download cleanup are now handled by system automations."""
print("Simple background monitor started") print("Simple background monitor started")
while True: while not globals().get('IS_SHUTTING_DOWN', False):
try: try:
with matched_context_lock: with matched_context_lock:
pending_count = len(matched_downloads_context) pending_count = len(matched_downloads_context)
@ -22753,6 +22893,8 @@ def _simple_monitor_task():
print(f"Simple monitor error: {e}") print(f"Simple monitor error: {e}")
time.sleep(10) time.sleep(10)
print("Simple background monitor stopped")
def start_simple_background_monitor(): def start_simple_background_monitor():
"""Starts the simple background monitor thread.""" """Starts the simple background monitor thread."""
monitor_thread = threading.Thread(target=_simple_monitor_task) monitor_thread = threading.Thread(target=_simple_monitor_task)
@ -30419,7 +30561,7 @@ def get_server_playlists():
raw_playlists = plex_client.server.playlists() raw_playlists = plex_client.server.playlists()
logger.info(f"[ServerPlaylists] Plex returned {len(raw_playlists)} total playlists") logger.info(f"[ServerPlaylists] Plex returned {len(raw_playlists)} total playlists")
for playlist in raw_playlists: for playlist in raw_playlists:
if playlist.playlistType == 'audio': if getattr(playlist, 'playlistType', None) == 'audio':
playlists_data.append({ playlists_data.append({
'id': str(playlist.ratingKey), 'id': str(playlist.ratingKey),
'name': playlist.title, 'name': playlist.title,
@ -40717,6 +40859,7 @@ metadata_update_state = {
} }
metadata_update_worker = None metadata_update_worker = None
metadata_update_runtime_worker = None
metadata_update_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="metadata_update") 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']) @app.route('/api/metadata/start', methods=['POST'])
def start_metadata_update(): def start_metadata_update():
"""Start the metadata update process - EXACT copy of dashboard.py logic""" """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: try:
# Check if already running # Check if already running
@ -44899,6 +45042,7 @@ def start_metadata_update():
# Start the metadata update worker - EXACTLY like dashboard.py # Start the metadata update worker - EXACTLY like dashboard.py
def run_metadata_update(): def run_metadata_update():
global metadata_update_runtime_worker
try: try:
metadata_worker = WebMetadataUpdateWorker( metadata_worker = WebMetadataUpdateWorker(
None, # Artists will be loaded in the worker thread - EXACTLY like dashboard.py None, # Artists will be loaded in the worker thread - EXACTLY like dashboard.py
@ -44907,12 +45051,15 @@ def start_metadata_update():
active_server, active_server,
refresh_interval_days refresh_interval_days
) )
metadata_update_runtime_worker = metadata_worker
metadata_worker.run() metadata_worker.run()
except Exception as e: except Exception as e:
print(f"Error in metadata update worker: {e}") print(f"Error in metadata update worker: {e}")
metadata_update_state['status'] = 'error' metadata_update_state['status'] = 'error'
metadata_update_state['error'] = str(e) metadata_update_state['error'] = str(e)
add_activity_item("", "Metadata Error", str(e), "Now") add_activity_item("", "Metadata Error", str(e), "Now")
finally:
metadata_update_runtime_worker = None
metadata_update_worker = metadata_update_executor.submit(run_metadata_update) metadata_update_worker = metadata_update_executor.submit(run_metadata_update)
@ -52046,7 +52193,7 @@ def _hydrabase_reconnect_loop():
global _hydrabase_ws global _hydrabase_ws
_consecutive_failures = 0 _consecutive_failures = 0
while True: while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(30) socketio.sleep(30)
try: try:
# Only attempt reconnect if auto_connect is enabled # Only attempt reconnect if auto_connect is enabled
@ -52096,7 +52243,7 @@ def _hydrabase_reconnect_loop():
def _emit_service_status_loop(): def _emit_service_status_loop():
"""Background thread that pushes service status every 5 seconds.""" """Background thread that pushes service status every 5 seconds."""
while True: while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(5) socketio.sleep(5)
try: try:
socketio.emit('status:update', _build_status_payload()) socketio.emit('status:update', _build_status_payload())
@ -52105,7 +52252,7 @@ def _emit_service_status_loop():
def _emit_watchlist_count_loop(): def _emit_watchlist_count_loop():
"""Background thread that pushes watchlist count every 10 seconds to each profile room.""" """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) socketio.sleep(10)
try: try:
database = get_database() database = get_database()
@ -52118,7 +52265,7 @@ def _emit_watchlist_count_loop():
def _emit_download_status_loop(): def _emit_download_status_loop():
"""Background thread that pushes download batch status every 2 seconds to subscribed rooms.""" """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) socketio.sleep(2)
try: try:
live_transfers_lookup = get_cached_transfer_data() live_transfers_lookup = get_cached_transfer_data()
@ -52179,7 +52326,7 @@ def handle_profile_join(data):
def _emit_system_stats_loop(): def _emit_system_stats_loop():
"""Background thread that pushes system stats every 10 seconds.""" """Background thread that pushes system stats every 10 seconds."""
while True: while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(10) socketio.sleep(10)
try: try:
socketio.emit('dashboard:stats', _build_system_stats()) socketio.emit('dashboard:stats', _build_system_stats())
@ -52188,7 +52335,7 @@ def _emit_system_stats_loop():
def _emit_activity_feed_loop(): def _emit_activity_feed_loop():
"""Background thread that pushes activity feed every 2 seconds.""" """Background thread that pushes activity feed every 2 seconds."""
while True: while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(2) socketio.sleep(2)
try: try:
with activity_feed_lock: with activity_feed_lock:
@ -52199,7 +52346,7 @@ def _emit_activity_feed_loop():
def _emit_db_stats_loop(): def _emit_db_stats_loop():
"""Background thread that pushes database stats every 10 seconds.""" """Background thread that pushes database stats every 10 seconds."""
while True: while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(10) socketio.sleep(10)
try: try:
db = get_database() db = get_database()
@ -52210,7 +52357,7 @@ def _emit_db_stats_loop():
def _emit_wishlist_count_loop(): def _emit_wishlist_count_loop():
"""Background thread that pushes wishlist count every 10 seconds to each profile room.""" """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) socketio.sleep(10)
try: try:
from core.wishlist_service import get_wishlist_service from core.wishlist_service import get_wishlist_service
@ -52256,7 +52403,7 @@ def _emit_rate_monitor_loop():
'tidal': 'tidal_enrichment', 'qobuz': 'qobuz_enrichment', 'tidal': 'tidal_enrichment', 'qobuz': 'qobuz_enrichment',
} }
while True: while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(1) socketio.sleep(1)
try: try:
from core.api_call_tracker import api_call_tracker from core.api_call_tracker import api_call_tracker
@ -52326,7 +52473,7 @@ def _emit_enrichment_status_loop():
'genius-enrichment': lambda: genius_worker, 'genius-enrichment': lambda: genius_worker,
} }
while True: while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(2) socketio.sleep(2)
# Auto-pause/resume rate-limited workers during downloads # Auto-pause/resume rate-limited workers during downloads
@ -52364,7 +52511,7 @@ def _emit_enrichment_status_loop():
def _emit_tool_progress_loop(): def _emit_tool_progress_loop():
"""Background thread that pushes all tool progress statuses every 1 second.""" """Background thread that pushes all tool progress statuses every 1 second."""
while True: while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(1) socketio.sleep(1)
# Stream status # Stream status
try: try:
@ -52452,7 +52599,7 @@ def handle_discovery_unsubscribe(data):
def _emit_sync_progress_loop(): def _emit_sync_progress_loop():
"""Push sync progress to subscribed rooms every 1 second.""" """Push sync progress to subscribed rooms every 1 second."""
while True: while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(1) socketio.sleep(1)
try: try:
with sync_lock: with sync_lock:
@ -52476,7 +52623,7 @@ def _emit_discovery_progress_loop():
'listenbrainz': lambda: listenbrainz_playlist_states, 'listenbrainz': lambda: listenbrainz_playlist_states,
'spotify_public': lambda: spotify_public_discovery_states, 'spotify_public': lambda: spotify_public_discovery_states,
} }
while True: while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(1) socketio.sleep(1)
for platform, get_states in platform_states.items(): for platform, get_states in platform_states.items():
try: try:
@ -52506,7 +52653,7 @@ def _emit_discovery_progress_loop():
def _emit_scan_status_loop(): def _emit_scan_status_loop():
"""Push watchlist and media scan status every 2 seconds.""" """Push watchlist and media scan status every 2 seconds."""
while True: while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(2) socketio.sleep(2)
# Watchlist scan # Watchlist scan
try: try:
@ -52538,7 +52685,7 @@ def _emit_scan_status_loop():
def _emit_automation_progress_loop(): def _emit_automation_progress_loop():
"""Push automation:progress events every 1 second for running automations.""" """Push automation:progress events every 1 second for running automations."""
while True: while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(1) socketio.sleep(1)
try: try:
with automation_progress_lock: with automation_progress_lock:
@ -52582,7 +52729,7 @@ def _emit_automation_progress_loop():
def _emit_repair_progress_loop(): def _emit_repair_progress_loop():
"""Push repair:progress events every 1 second for running repair jobs.""" """Push repair:progress events every 1 second for running repair jobs."""
while True: while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(1) socketio.sleep(1)
try: try:
if repair_worker is None: if repair_worker is None:
@ -52626,12 +52773,6 @@ def _emit_repair_progress_loop():
if __name__ == '__main__': 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("Starting SoulSync Web UI Server...")
print("Open your browser and navigate to http://127.0.0.1:8008") print("Open your browser and navigate to http://127.0.0.1:8008")

View file

@ -329,7 +329,7 @@
<!-- Version Section --> <!-- Version Section -->
<div class="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> </div>
<!-- Status Section --> <!-- Status Section -->
@ -6597,7 +6597,7 @@
<!-- Header --> <!-- Header -->
<div class="version-modal-header"> <div class="version-modal-header">
<h2 class="version-modal-title">What's New in SoulSync</h2> <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> </div>
<!-- Content Area with Scroll --> <!-- Content Area with Scroll -->

View file

@ -100,6 +100,24 @@ const HELPER_CONTENT = {
], ],
docsId: 'library' 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"]': { '.nav-button[data-page="stats"]': {
title: 'Library Statistics', title: 'Library Statistics',
description: 'Detailed analytics — genre breakdowns, format distribution, quality analysis, collection growth, and enrichment coverage across all metadata services.', 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'], 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' 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 page header & history
'.sync-history-btn': { '.sync-history-btn': {
@ -2092,6 +2124,81 @@ const HELPER_CONTENT = {
description: 'Save all settings changes. Some changes take effect immediately; others require a restart.', 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 ───────────────────────────────────
'#dashboard-activity-feed': { '#dashboard-activity-feed': {
@ -2103,6 +2210,92 @@ const HELPER_CONTENT = {
'The feed persists across page navigation within the session' '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 ─────────────────────────────────────────────── // ── Docs Navigation Helper ───────────────────────────────────────────────

View file

@ -3043,6 +3043,10 @@ function initializeMediaPlayer() {
const stopButton = document.getElementById('stop-button'); const stopButton = document.getElementById('stop-button');
const volumeSlider = document.getElementById('volume-slider'); 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 // Initialize HTML5 audio player
audioPlayer = document.getElementById('audio-player'); audioPlayer = document.getElementById('audio-player');
if (audioPlayer) { if (audioPlayer) {
@ -3129,8 +3133,9 @@ function setTrackInfo(track) {
document.getElementById('play-button').disabled = false; document.getElementById('play-button').disabled = false;
document.getElementById('stop-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('no-track-message').classList.add('hidden');
document.getElementById('media-player').classList.remove('idle');
// Sync expanded player and media session // Sync expanded player and media session
updateNpTrackInfo(); updateNpTrackInfo();
@ -3205,8 +3210,9 @@ function clearTrack() {
// Hide loading animation // Hide loading animation
hideLoadingAnimation(); hideLoadingAnimation();
// Show no track message // Show no track message and collapse player
document.getElementById('no-track-message').classList.remove('hidden'); document.getElementById('no-track-message').classList.remove('hidden');
document.getElementById('media-player').classList.add('idle');
// Reset queue state // Reset queue state
npQueue = []; npQueue = [];
@ -6704,9 +6710,13 @@ function renderMusicPaths(paths) {
container.innerHTML = paths.map((p, i) => ` container.innerHTML = paths.map((p, i) => `
<div class="form-group music-path-row" style="margin-bottom: 4px;"> <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;"> <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> </div>
`).join(''); `).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() { function addMusicPathRow() {
@ -6720,10 +6730,19 @@ function addMusicPathRow() {
row.style.marginBottom = '4px'; row.style.marginBottom = '4px';
row.innerHTML = ` row.innerHTML = `
<input type="text" class="music-path-input" value="" placeholder="/music or C:\\Music" style="flex:1;"> <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); 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() { function collectMusicPaths() {
@ -12108,9 +12127,13 @@ async function autoSavePlaylistM3U(playlistId) {
const m3uContent = generateM3UContent(playlistId); const m3uContent = generateM3UContent(playlistId);
if (!m3uContent) return; if (!m3uContent) return;
// Skip M3U for albums — albums are already naturally grouped in media servers // Skip M3U for non-playlist downloads — albums, singles, redownloads, etc.
const albumPrefixes = ['artist_album_', 'discover_album_', 'enhanced_search_album_', 'seasonal_album_', 'spotify_library_', 'beatport_release_', 'discover_cache_']; const nonPlaylistPrefixes = [
if (albumPrefixes.some(p => playlistId.startsWith(p))) return; '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 playlistName = process.playlist?.name || process.playlistName || 'Playlist';
const artistName = process.artist?.name || ''; const artistName = process.artist?.name || '';
@ -12166,7 +12189,14 @@ function generateM3UContent(playlistId) {
tracks.forEach((track, index) => { tracks.forEach((track, index) => {
const durationSeconds = track.duration_ms ? Math.floor(track.duration_ms / 1000) : -1; 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 // Check library match status from the modal UI
const matchEl = document.getElementById(`match-${playlistId}-${index}`); const matchEl = document.getElementById(`match-${playlistId}-${index}`);
@ -15561,6 +15591,11 @@ function processModalStatusUpdate(playlistId, data) {
const completionMessage = `Download complete! ${completionParts.join(', ')}.`; const completionMessage = `Download complete! ${completionParts.join(', ')}.`;
showToast(completionMessage, 'success'); 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) // Auto-close wishlist modal when completed (for auto-processing)
if (playlistId === 'wishlist') { if (playlistId === 'wishlist') {
console.log('🔄 [Auto-Wishlist] Auto-closing completed wishlist modal to enable next cycle'); 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.14) 0%,
rgba(var(--accent-rgb), 0.08) 30%, rgba(var(--accent-rgb), 0.08) 30%,
rgba(var(--accent-rgb), 0.03) 70%, 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-bottom: 1px solid rgba(255, 255, 255, 0.08);
border-top-right-radius: 20px; border-top-right-radius: 20px;
padding: 20px 24px; padding: 20px 24px;
@ -267,8 +267,11 @@ body {
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
position: relative; position: sticky;
top: 0;
z-index: 10;
overflow: hidden; overflow: hidden;
flex-shrink: 0;
/* Subtle inner glow */ /* Subtle inner glow */
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1); 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); 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 */ /* Top progress bar - thin line across full width */
.player-top-progress { .player-top-progress {
padding: 0 14px; padding: 0 14px;
@ -11894,7 +11922,8 @@ body.helper-mode-active #dashboard-activity-feed:hover {
flex-direction: column; flex-direction: column;
gap: 15px; gap: 15px;
flex-grow: 1; flex-grow: 1;
overflow: hidden; overflow-y: auto;
overflow-x: hidden;
} }
.sync-tab-content.active { .sync-tab-content.active {