From 0b8bfa1e6bdf1d886e04d10c84c157301864e4ce Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:23:03 -0700 Subject: [PATCH] Scope automation-triggered watchlist scans to the calling profile & Fix watchlist scan silently skipping all albums due to metadata cache returning incomplete data --- core/automation_engine.py | 15 +++++++--- core/spotify_client.py | 22 ++++++++++---- web_server.py | 63 +++++++++++++++++++++++++++++++-------- 3 files changed, 77 insertions(+), 23 deletions(-) diff --git a/core/automation_engine.py b/core/automation_engine.py index dbec0cfe..1fca397b 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -518,7 +518,7 @@ class AutomationEngine: # --- Schedule Execution (timer-based) --- - def run_automation(self, automation_id, skip_delay=False): + def run_automation(self, automation_id, skip_delay=False, profile_id=None): """Execute: check guard → run action → send notification → update stats → reschedule.""" if not self._running: return @@ -553,6 +553,8 @@ class AutomationEngine: # Inject automation identity for progress tracking action_config['_automation_id'] = automation_id action_config['_automation_name'] = auto.get('name', '') + if profile_id is not None: + action_config['_profile_id'] = profile_id # Action delay (skipped for manual run_now) delay_minutes = action_config.get('delay', 0) @@ -657,16 +659,21 @@ class AutomationEngine: if self._running: self.schedule_automation(automation_id) - def run_now(self, automation_id): + def run_now(self, automation_id, profile_id=None): """Manual trigger — run immediately in a background thread. - Always uses run_automation (skips condition checks and action delay).""" + Always uses run_automation (skips condition checks and action delay). + + Args: + automation_id: ID of automation to run + profile_id: If provided, scopes the run to this profile (manual trigger from UI) + """ auto = self.db.get_automation(automation_id) if not auto: return False thread = threading.Thread( target=self.run_automation, - args=(automation_id, True), + args=(automation_id, True, profile_id), daemon=True, name=f'automation-run-{automation_id}' ) diff --git a/core/spotify_client.py b/core/spotify_client.py index 227f11a8..e1a4399b 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -1075,9 +1075,14 @@ class SpotifyClient: cached = cache.get_entity(source, 'track', track_id) if cached: if source == 'spotify': - return self._build_enhanced_track(cached) - # iTunes cache hit — delegate to iTunesClient which reconstructs enhanced format - return self._itunes.get_track_details(track_id) + # Validate cache has full track data (not simplified from get_album_tracks) + if 'album' in cached: + return self._build_enhanced_track(cached) + # Simplified track cached by get_album_tracks — treat as cache miss + logger.debug(f"Cache hit for track {track_id} lacks album data, fetching full data") + else: + # iTunes cache hit — delegate to iTunesClient which reconstructs enhanced format + return self._itunes.get_track_details(track_id) if self.is_spotify_authenticated(): try: @@ -1159,9 +1164,14 @@ class SpotifyClient: cached = cache.get_entity(source, 'album', album_id) if cached: if source == 'spotify': - return cached # Spotify raw format is the expected format - # iTunes cache hit — delegate to iTunesClient which reconstructs Spotify-compatible format - return self._itunes.get_album(album_id) + # Validate cache has full album data (not simplified from artist_albums) + if 'tracks' in cached: + return cached + # Simplified album cached by get_artist_albums — treat as cache miss + logger.debug(f"Cache hit for album {album_id} lacks tracks, fetching full data") + else: + # iTunes cache hit — delegate to iTunesClient which reconstructs Spotify-compatible format + return self._itunes.get_album(album_id) if self.is_spotify_authenticated(): try: diff --git a/web_server.py b/web_server.py index a5cdbc61..52b7c3e6 100644 --- a/web_server.py +++ b/web_server.py @@ -366,7 +366,10 @@ def _register_automation_handlers(): def _auto_scan_watchlist(config): try: pre_state_id = id(watchlist_scan_state) - _process_watchlist_scan_automatically(automation_id=config.get('_automation_id')) + _process_watchlist_scan_automatically( + automation_id=config.get('_automation_id'), + profile_id=config.get('_profile_id') + ) # Only report stats if a fresh scan actually ran (state dict was reassigned) if id(watchlist_scan_state) != pre_state_id: summary = watchlist_scan_state.get('summary', {}) @@ -4752,7 +4755,7 @@ def run_automation_endpoint(automation_id): try: if not automation_engine: return jsonify({"error": "Automation engine not available"}), 500 - success = automation_engine.run_now(automation_id) + success = automation_engine.run_now(automation_id, profile_id=get_current_profile_id()) if not success: return jsonify({"error": "Automation not found"}), 404 return jsonify({"success": True}) @@ -28788,6 +28791,7 @@ def start_watchlist_scan(): # Get album tracks using provider-aware method album_data = scanner.metadata_service.get_album(album.id) if not album_data or 'tracks' not in album_data: + logger.debug(f"Skipping album {album.name} (id={album.id}): no track data returned") continue tracks = album_data['tracks']['items'] @@ -29565,11 +29569,18 @@ watchlist_scan_state = { 'error': None } -def _process_watchlist_scan_automatically(automation_id=None): - """Main automatic scanning logic that runs in background thread.""" +def _process_watchlist_scan_automatically(automation_id=None, profile_id=None): + """Main automatic scanning logic that runs in background thread. + + Args: + automation_id: ID of the automation triggering this scan + profile_id: If provided, only scan this profile's watchlist (manual trigger). + If None, scan all profiles (scheduled automation). + """ global watchlist_auto_scanning, watchlist_auto_scanning_timestamp, watchlist_scan_state - print("🤖 [Auto-Watchlist] Timer triggered - starting automatic watchlist scan...") + scope_label = f"profile {profile_id}" if profile_id else "all profiles" + print(f"🤖 [Auto-Watchlist] Timer triggered - starting automatic watchlist scan ({scope_label})...") _ew_state = {} @@ -29597,12 +29608,19 @@ def _process_watchlist_scan_automatically(automation_id=None): from core.watchlist_scanner import get_watchlist_scanner from database.music_database import get_database - # Check if we have artists to scan across all profiles database = get_database() - # Auto-scan covers all profiles - all_profiles = database.get_all_profiles() - watchlist_count = sum(database.get_watchlist_count(profile_id=p['id']) for p in all_profiles) - print(f"🔍 [Auto-Watchlist] Watchlist count check: {watchlist_count} artists found across {len(all_profiles)} profiles") + + # Determine which profiles to scan + if profile_id: + # Manual trigger — scan only the triggering profile + scan_profiles = [{'id': profile_id}] + else: + # Scheduled automation — scan all profiles + scan_profiles = database.get_all_profiles() + + watchlist_count = sum(database.get_watchlist_count(profile_id=p['id']) for p in scan_profiles) + profile_label = f"profile {profile_id}" if profile_id else f"{len(scan_profiles)} profiles" + print(f"🔍 [Auto-Watchlist] Watchlist count check: {watchlist_count} artists found ({profile_label})") if watchlist_count == 0: print("ℹ️ [Auto-Watchlist] No artists in watchlist for auto-scanning.") @@ -29620,13 +29638,14 @@ def _process_watchlist_scan_automatically(automation_id=None): print(f"👁️ [Auto-Watchlist] Found {watchlist_count} artists in watchlist, starting automatic scan...") _update_automation_progress(automation_id, progress=5, phase='Loading watchlist', - log_line=f'{watchlist_count} artists across {len(all_profiles)} profiles', log_type='info') + log_line=f'{watchlist_count} artists ({profile_label})', log_type='info') - # Get list of artists to scan (all profiles combined for auto-scan) + # Get list of artists to scan watchlist_artists = [] - for p in all_profiles: + for p in scan_profiles: watchlist_artists.extend(database.get_watchlist_artists(profile_id=p['id'])) scanner = get_watchlist_scanner(spotify_client) + all_profiles = scan_profiles # Used later for discovery pool population # Apply global overrides if enabled _apply_watchlist_global_overrides(watchlist_artists) @@ -29745,6 +29764,7 @@ def _process_watchlist_scan_automatically(automation_id=None): # Get album tracks using provider-aware method album_data = scanner.metadata_service.get_album(album.id) if not album_data or 'tracks' not in album_data: + logger.debug(f"Skipping album {album.name} (id={album.id}): no track data returned") continue tracks = album_data['tracks']['items'] @@ -29840,6 +29860,23 @@ def _process_watchlist_scan_automatically(automation_id=None): except Exception: pass + # Fetch similar artists for discovery feature (per-profile) + try: + watchlist_scan_state['current_phase'] = 'fetching_similar_artists' + source_artist_id = artist.spotify_artist_id or artist.itunes_artist_id or str(artist.id) + artist_profile_id = getattr(artist, 'profile_id', 1) + + spotify_authenticated = spotify_client and spotify_client.is_spotify_authenticated() + if database.has_fresh_similar_artists(source_artist_id, days_threshold=30, require_spotify=spotify_authenticated, profile_id=artist_profile_id): + print(f" Similar artists for {artist.artist_name} are cached and fresh (profile {artist_profile_id})") + scanner._backfill_similar_artists_itunes_ids(source_artist_id, profile_id=artist_profile_id) + else: + print(f" Fetching similar artists for {artist.artist_name} (profile {artist_profile_id})...") + scanner.update_similar_artists(artist, profile_id=artist_profile_id) + print(f" Similar artists updated for {artist.artist_name}") + except Exception as similar_error: + print(f" ⚠️ Failed to update similar artists for {artist.artist_name}: {similar_error}") + # Delay between artists if i < len(watchlist_artists) - 1: watchlist_scan_state['current_phase'] = 'rate_limiting'