Fix discovery modal persistence, artist dict handling, and rate limiter scope

pull/253/head
Broque Thomas 1 month ago
parent 05b5c376e9
commit e11ee8622e

@ -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)

@ -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']

@ -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);
}

@ -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 --- */

Loading…
Cancel
Save