diff --git a/api/__init__.py b/api/__init__.py index acca117..ece4b75 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -18,7 +18,7 @@ logger = get_logger("api_v1") # --------------------------------------------------------------------------- limiter = Limiter( key_func=get_remote_address, - default_limits=["60 per minute"], + default_limits=[], # No global default — limits are applied per-blueprint storage_uri="memory://", ) @@ -43,6 +43,9 @@ def create_api_blueprint(): from .listenbrainz import register_routes as reg_listenbrainz from .cache import register_routes as reg_cache + # ---- rate-limit only /api/v1 routes (not the whole app) ---- + limiter.limit("60 per minute")(bp) + reg_library(bp) reg_system(bp) reg_search(bp) diff --git a/web_server.py b/web_server.py index 9c50030..0e24ac3 100644 --- a/web_server.py +++ b/web_server.py @@ -20371,7 +20371,7 @@ def update_tidal_discovery_match(): result['status'] = '✅ Found' result['status_class'] = 'found' result['spotify_track'] = spotify_track['name'] - result['spotify_artist'] = ', '.join(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else spotify_track['artists'] + result['spotify_artist'] = _join_artist_names(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else _extract_artist_name(spotify_track['artists']) result['spotify_album'] = spotify_track['album'] result['spotify_id'] = spotify_track['id'] @@ -20812,10 +20812,24 @@ def _run_playlist_discovery_worker(playlists, automation_id=None): log_line=f'Error: {str(e)}', log_type='error') +def _extract_artist_name(artist): + """Extract artist name string from either a string or dict ({"name": "..."}) format.""" + if isinstance(artist, dict): + return artist.get('name', '') + return artist or '' + +def _extract_artist_names(artists): + """Extract a list of artist name strings from a list that may contain dicts or strings.""" + return [_extract_artist_name(a) for a in (artists or [])] + +def _join_artist_names(artists): + """Join artist names from a list that may contain dicts or strings.""" + return ', '.join(_extract_artist_names(artists)) + def _get_discovery_cache_key(title, artist): """Normalize title/artist for discovery cache lookup using matching_engine.""" norm_title = matching_engine.clean_title(title) - norm_artist = matching_engine.clean_artist(artist) + norm_artist = matching_engine.clean_artist(_extract_artist_name(artist)) return (norm_title, norm_artist) @@ -20835,6 +20849,11 @@ def _validate_discovery_cache_artist(source_artist, cached_match): for cand_artist in cached_artists: if not cand_artist: continue + # Handle both string artists and dict artists ({"name": "..."}) + if isinstance(cand_artist, dict): + cand_artist = cand_artist.get('name', '') + if not cand_artist: + continue cand_normalized = matching_engine.normalize_string(cand_artist) if source_artist_cleaned in cand_normalized: return True @@ -21585,7 +21604,7 @@ def update_youtube_discovery_match(): result['status'] = '✅ Found' result['status_class'] = 'found' result['spotify_track'] = spotify_track['name'] - result['spotify_artist'] = ', '.join(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else spotify_track['artists'] + result['spotify_artist'] = _join_artist_names(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else _extract_artist_name(spotify_track['artists']) result['spotify_album'] = spotify_track['album'] result['spotify_id'] = spotify_track['id'] @@ -21669,7 +21688,7 @@ def _run_youtube_discovery_worker(url_hash): 'status': '✅ Found', 'status_class': 'found', 'spotify_track': cached_match.get('name', ''), - 'spotify_artist': cached_match.get('artists', [''])[0] if cached_match.get('artists') else '', + 'spotify_artist': _extract_artist_name(cached_match.get('artists', [''])[0]) if cached_match.get('artists') else '', 'spotify_album': cached_match.get('album', {}).get('name', '') if isinstance(cached_match.get('album'), dict) else cached_match.get('album', ''), 'duration': f"{track['duration_ms'] // 60000}:{(track['duration_ms'] % 60000) // 1000:02d}" if track['duration_ms'] else '0:00', 'discovery_source': discovery_source, @@ -21807,7 +21826,7 @@ def _run_youtube_discovery_worker(url_hash): 'status': '✅ Found' if matched_track else '❌ Not Found', 'status_class': 'found' if matched_track else 'not-found', 'spotify_track': matched_track.name if matched_track else '', - 'spotify_artist': matched_track.artists[0] if matched_track else '', + 'spotify_artist': _extract_artist_name(matched_track.artists[0]) if matched_track else '', 'spotify_album': matched_track.album if matched_track else '', 'duration': f"{track['duration_ms'] // 60000}:{(track['duration_ms'] % 60000) // 1000:02d}" if track['duration_ms'] else '0:00', 'discovery_source': discovery_source, @@ -21932,7 +21951,7 @@ def _run_listenbrainz_discovery_worker(playlist_mbid): 'status': '✅ Found', 'status_class': 'found', 'spotify_track': cached_match.get('name', ''), - 'spotify_artist': cached_match.get('artists', [''])[0] if cached_match.get('artists') else '', + 'spotify_artist': _extract_artist_name(cached_match.get('artists', [''])[0]) if cached_match.get('artists') else '', 'spotify_album': cached_match.get('album', {}).get('name', '') if isinstance(cached_match.get('album'), dict) else cached_match.get('album', ''), 'duration': f"{duration_ms // 60000}:{(duration_ms % 60000) // 1000:02d}" if duration_ms else '0:00', 'discovery_source': discovery_source, @@ -22069,7 +22088,7 @@ def _run_listenbrainz_discovery_worker(playlist_mbid): 'status': '✅ Found' if matched_track else '❌ Not Found', 'status_class': 'found' if matched_track else 'not-found', 'spotify_track': matched_track.name if matched_track else '', - 'spotify_artist': matched_track.artists[0] if matched_track else '', + 'spotify_artist': _extract_artist_name(matched_track.artists[0]) if matched_track else '', 'spotify_album': matched_track.album if matched_track else '', 'duration': f"{duration_ms // 60000}:{(duration_ms % 60000) // 1000:02d}" if duration_ms else '0:00', 'discovery_source': discovery_source, @@ -27033,7 +27052,7 @@ def update_listenbrainz_discovery_match(): result['spotify_track'] = spotify_track.get('name', '') if spotify_track else '' # Join all artists (matching YouTube/Tidal/Beatport format) artists = spotify_track.get('artists', []) if spotify_track else [] - result['spotify_artist'] = ', '.join(artists) if isinstance(artists, list) else artists + result['spotify_artist'] = _join_artist_names(artists) if isinstance(artists, list) else _extract_artist_name(artists) # Album comes as a string from the frontend fix modal album = spotify_track.get('album', '') if spotify_track else '' result['spotify_album'] = album if isinstance(album, str) else album.get('name', '') if isinstance(album, dict) else '' @@ -28944,7 +28963,7 @@ def update_beatport_discovery_match(): result['status'] = '✅ Found' result['status_class'] = 'found' result['spotify_track'] = spotify_track['name'] - result['spotify_artist'] = ', '.join(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else spotify_track['artists'] + result['spotify_artist'] = _join_artist_names(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else _extract_artist_name(spotify_track['artists']) result['spotify_album'] = spotify_track['album'] result['spotify_id'] = spotify_track['id'] diff --git a/webui/static/script.js b/webui/static/script.js index b1c1194..ee61b27 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -22292,6 +22292,12 @@ async function startYouTubeDiscovery(urlHash) { return; } + // Update frontend phase to match backend + const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash]; + if (state) { + state.phase = 'discovering'; + } + // Start polling for progress startYouTubeDiscoveryPolling(urlHash); @@ -22570,9 +22576,15 @@ function openYouTubeDiscoveryModal(urlHash) { // Set initial progress if we have discovery results if (state.discoveryResults && state.discoveryResults.length > 0) { + // Compute progress from results if discoveryProgress is missing/zero + let progress = state.discoveryProgress || 0; + const matches = state.spotifyMatches || 0; + if (progress === 0 && state.discoveryResults.length > 0 && state.playlist.tracks.length > 0) { + progress = Math.min(100, Math.round((state.discoveryResults.length / state.playlist.tracks.length) * 100)); + } const progressData = { - progress: state.discoveryProgress || 0, - spotify_matches: state.spotifyMatches || 0, + progress: progress, + spotify_matches: matches || state.discoveryResults.filter(r => r.status_class === 'found').length, spotify_total: state.playlist.tracks.length, results: state.discoveryResults }; @@ -42422,7 +42434,9 @@ function renderMirroredCard(p, container) { `; card.addEventListener('click', () => { const st = youtubePlaylistStates[hash]; - if (st && st.phase && st.phase !== 'fresh') { + // Treat as non-fresh if phase is set, or if a poller/discovery modal exists + const hasActiveDiscovery = activeYouTubePollers[hash] || document.getElementById(`youtube-discovery-modal-${hash}`); + if (st && ((st.phase && st.phase !== 'fresh') || hasActiveDiscovery)) { if (st.phase === 'downloading' || st.phase === 'download_complete') { // Open download modal directly (follows Tidal/YouTube card click pattern) const spotifyPlaylistId = st.convertedSpotifyPlaylistId; @@ -42775,9 +42789,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 so the card goes back to fresh + // Also clear the discovery state and remove stale modal DOM const hash = `mirrored_${playlistId}`; delete youtubePlaylistStates[hash]; + const staleModal = document.getElementById(`youtube-discovery-modal-${hash}`); + if (staleModal) staleModal.remove(); loadMirroredPlaylists(); } else { showToast(data.error || 'Failed to clear discovery', 'error'); @@ -43240,9 +43256,10 @@ async function discoverMirroredPlaylist(playlistId) { // If state already exists (discovery in progress or completed), just reopen the modal const existingState = youtubePlaylistStates[tempHash]; - if (existingState && existingState.phase !== 'fresh') { + const hasActiveDiscovery = activeYouTubePollers[tempHash] || document.getElementById(`youtube-discovery-modal-${tempHash}`); + if (existingState && (existingState.phase !== 'fresh' || hasActiveDiscovery)) { openYouTubeDiscoveryModal(tempHash); - // Resume polling if discovery is in progress + // Resume polling if discovery is in progress but poller stopped if (existingState.phase === 'discovering' && !activeYouTubePollers[tempHash]) { startYouTubeDiscoveryPolling(tempHash); } diff --git a/webui/static/style.css b/webui/static/style.css index 32f34b9..860fd01 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -28886,23 +28886,45 @@ body { .automations-grid { grid-template-columns: 1fr; } } -/* --- New Automation Button --- */ +/* --- New Automation Button (rainbow glow) --- */ +@keyframes btn-rainbow-glow { + 0% { box-shadow: 0 4px 18px rgba(255, 0, 100, 0.3), 0 2px 6px rgba(0,0,0,0.2); border-color: rgba(255, 0, 100, 0.35); } + 20% { box-shadow: 0 4px 18px rgba(255, 140, 0, 0.3), 0 2px 6px rgba(0,0,0,0.2); border-color: rgba(255, 140, 0, 0.35); } + 40% { box-shadow: 0 4px 18px rgba(0, 210, 120, 0.3), 0 2px 6px rgba(0,0,0,0.2); border-color: rgba(0, 210, 120, 0.35); } + 60% { box-shadow: 0 4px 18px rgba(0, 140, 255, 0.3), 0 2px 6px rgba(0,0,0,0.2); border-color: rgba(0, 140, 255, 0.35); } + 80% { box-shadow: 0 4px 18px rgba(160, 0, 255, 0.3), 0 2px 6px rgba(0,0,0,0.2); border-color: rgba(160, 0, 255, 0.35); } + 100% { box-shadow: 0 4px 18px rgba(255, 0, 100, 0.3), 0 2px 6px rgba(0,0,0,0.2); border-color: rgba(255, 0, 100, 0.35); } +} +@keyframes btn-rainbow-glow-hover { + 0% { box-shadow: 0 6px 28px rgba(255, 0, 100, 0.45), 0 3px 10px rgba(0,0,0,0.25); border-color: rgba(255, 0, 100, 0.5); } + 20% { box-shadow: 0 6px 28px rgba(255, 140, 0, 0.45), 0 3px 10px rgba(0,0,0,0.25); border-color: rgba(255, 140, 0, 0.5); } + 40% { box-shadow: 0 6px 28px rgba(0, 210, 120, 0.45), 0 3px 10px rgba(0,0,0,0.25); border-color: rgba(0, 210, 120, 0.5); } + 60% { box-shadow: 0 6px 28px rgba(0, 140, 255, 0.45), 0 3px 10px rgba(0,0,0,0.25); border-color: rgba(0, 140, 255, 0.5); } + 80% { box-shadow: 0 6px 28px rgba(160, 0, 255, 0.45), 0 3px 10px rgba(0,0,0,0.25); border-color: rgba(160, 0, 255, 0.5); } + 100% { box-shadow: 0 6px 28px rgba(255, 0, 100, 0.45), 0 3px 10px rgba(0,0,0,0.25); border-color: rgba(255, 0, 100, 0.5); } +} .auto-new-btn { - background: linear-gradient(135deg, rgb(var(--accent-rgb)) 0%, rgb(var(--accent-light-rgb)) 100%); + position: relative; + background: linear-gradient(135deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.04) 100%); color: #fff; - border: none; + border: 1.5px solid rgba(255,255,255,0.15); padding: 10px 22px; border-radius: 12px; font-size: 14px; font-weight: 600; cursor: pointer; - transition: all 0.3s ease; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); letter-spacing: 0.3px; + animation: btn-rainbow-glow 6s linear infinite; + backdrop-filter: blur(12px); } .auto-new-btn:hover { transform: translateY(-2px); - box-shadow: 0 8px 24px rgba(var(--accent-rgb), 0.4); - filter: brightness(1.1); + background: linear-gradient(135deg, rgba(255,255,255,0.16) 0%, rgba(255,255,255,0.08) 100%); + animation: btn-rainbow-glow-hover 4s linear infinite; +} +.auto-new-btn:active { + transform: translateY(0) scale(0.97); } /* --- Stats Summary Bar --- */