From 5f94352b408d3830be2e862876e0454db9562a0d Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 8 Mar 2026 18:12:37 -0700 Subject: [PATCH] Add cancellation support to all discovery workers --- services/sync_service.py | 17 ++++++++++++++--- web_server.py | 27 +++++++++++++++++++++++++++ webui/static/script.js | 5 ++++- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/services/sync_service.py b/services/sync_service.py index 74e9095f..bf87f5bc 100644 --- a/services/sync_service.py +++ b/services/sync_service.py @@ -238,9 +238,20 @@ class PlaylistSyncService: logger.warning(f"❌ Track {i+1} invalid for playlist: {track} (type: {type(track)}, has ratingKey: {hasattr(track, 'ratingKey') if track else 'N/A'})") logger.info(f"Playlist validation: {len(valid_tracks)}/{len(media_tracks)} tracks are valid {server_type.title()} objects with ratingKeys") - - # Use the validated tracks for the sync - plex_tracks = valid_tracks # Keep variable name for compatibility with the rest of the function + + # Deduplicate by ratingKey — media servers silently drop duplicates, + # so count should reflect what actually ends up in the playlist + seen_keys = set() + deduped_tracks = [] + for t in valid_tracks: + if t.ratingKey not in seen_keys: + seen_keys.add(t.ratingKey) + deduped_tracks.append(t) + if len(deduped_tracks) < len(valid_tracks): + logger.info(f"Deduplicated {len(valid_tracks) - len(deduped_tracks)} duplicate ratingKeys ({len(valid_tracks)} → {len(deduped_tracks)} tracks)") + + # Use the deduplicated tracks for the sync + plex_tracks = deduped_tracks # Use active media server for playlist sync media_client, server_type = self._get_active_media_client() diff --git a/web_server.py b/web_server.py index 5c4e3afe..5026c2c1 100644 --- a/web_server.py +++ b/web_server.py @@ -20852,6 +20852,8 @@ def update_tidal_playlist_phase(playlist_id): return jsonify({"error": str(e)}), 500 +_playlist_discovery_cancelled = set() # Set of automation_ids that have been cancelled + def _run_playlist_discovery_worker(playlists, automation_id=None): """Background worker that discovers Spotify/iTunes metadata for undiscovered mirrored playlist tracks. Stores results in extra_data for use by sync.""" @@ -20931,6 +20933,16 @@ def _run_playlist_discovery_worker(playlists, automation_id=None): log_line=f'{len(undiscovered_tracks)} tracks to discover', log_type='info') for i, track in enumerate(undiscovered_tracks): + # Check for cancellation + if automation_id and automation_id in _playlist_discovery_cancelled: + _playlist_discovery_cancelled.discard(automation_id) + print(f"🛑 Playlist discovery cancelled (automation {automation_id})") + _update_automation_progress(automation_id, status='finished', progress=100, + phase='Discovery cancelled', + log_line=f'Cancelled: {total_discovered} discovered, {total_failed} failed', + log_type='info') + return + total_tracks += 1 track_id = track['id'] track_name = track.get('track_name', '') @@ -21995,6 +22007,11 @@ def _run_youtube_discovery_worker(url_hash): # Process each track for discovery for i, track in enumerate(tracks): try: + # Check for cancellation (phase changed by reset/delete/close) + if state.get('phase') != 'discovering': + print(f"🛑 Discovery cancelled for {url_hash} (phase changed to '{state.get('phase')}')") + return + # Update progress state['discovery_progress'] = int((i / len(tracks)) * 100) @@ -22297,6 +22314,11 @@ def _run_listenbrainz_discovery_worker(playlist_mbid): # Process each track for discovery for i, track in enumerate(tracks): try: + # Check for cancellation + if state.get('phase') != 'discovering': + print(f"🛑 ListenBrainz discovery cancelled (phase changed to '{state.get('phase')}')") + return + # Update progress state['discovery_progress'] = int((i / len(tracks)) * 100) @@ -29417,6 +29439,11 @@ def _run_beatport_discovery_worker(url_hash): # Process each track for discovery for i, track in enumerate(tracks): try: + # Check for cancellation + if state.get('phase') != 'discovering': + print(f"🛑 Beatport discovery cancelled (phase changed to '{state.get('phase')}')") + return + # Update progress state['discovery_progress'] = int((i / len(tracks)) * 100) diff --git a/webui/static/script.js b/webui/static/script.js index 73a37ab3..0d6c064d 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -43112,8 +43112,11 @@ async function clearMirroredDiscovery(playlistId, name) { const data = await res.json(); if (data.success) { showToast(`Cleared discovery for ${name} (${data.cleared} tracks)`, 'success'); - // Also clear the discovery state and remove stale modal DOM + // Signal cancellation to any running worker, then clear state const hash = `mirrored_${playlistId}`; + if (youtubePlaylistStates[hash]) { + youtubePlaylistStates[hash].phase = 'cancelled'; + } delete youtubePlaylistStates[hash]; const staleModal = document.getElementById(`youtube-discovery-modal-${hash}`); if (staleModal) staleModal.remove();