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