Smart Spotify rate limit detection with global ban, auto-suppression, and frontend modal
This commit is contained in:
parent
1ea262c749
commit
eac97a6c2b
5 changed files with 355 additions and 19 deletions
|
|
@ -20,6 +20,86 @@ import queue
|
|||
_request_queue = queue.Queue()
|
||||
_queue_processor_running = False
|
||||
|
||||
# Global rate limit ban state — when Spotify returns a long Retry-After (>60s),
|
||||
# we set this so ALL API calls are suppressed until the ban expires.
|
||||
_rate_limit_lock = threading.Lock()
|
||||
_rate_limit_until = 0 # Unix timestamp when the ban expires (0 = not banned)
|
||||
_rate_limit_retry_after = 0 # Original Retry-After value in seconds
|
||||
_rate_limit_endpoint = None # Which function triggered the ban
|
||||
_rate_limit_set_at = 0 # When the ban was set
|
||||
|
||||
# Threshold: if Retry-After exceeds this, activate global ban instead of sleeping
|
||||
_LONG_RATE_LIMIT_THRESHOLD = 60 # seconds
|
||||
|
||||
|
||||
class SpotifyRateLimitError(Exception):
|
||||
"""Raised when Spotify API calls are blocked due to active global rate limit ban."""
|
||||
def __init__(self, retry_after, endpoint=None):
|
||||
self.retry_after = retry_after
|
||||
self.endpoint = endpoint
|
||||
super().__init__(f"Spotify rate limited for {retry_after}s (triggered by {endpoint})")
|
||||
|
||||
|
||||
def _set_global_rate_limit(retry_after_seconds, endpoint_name):
|
||||
"""Activate the global rate limit ban."""
|
||||
global _rate_limit_until, _rate_limit_retry_after, _rate_limit_endpoint, _rate_limit_set_at
|
||||
with _rate_limit_lock:
|
||||
now = time.time()
|
||||
new_until = now + retry_after_seconds
|
||||
# Only update if this extends the existing ban
|
||||
if new_until > _rate_limit_until:
|
||||
_rate_limit_until = new_until
|
||||
_rate_limit_retry_after = retry_after_seconds
|
||||
_rate_limit_endpoint = endpoint_name
|
||||
_rate_limit_set_at = now
|
||||
logger.warning(
|
||||
f"GLOBAL RATE LIMIT ACTIVATED: {retry_after_seconds}s ban "
|
||||
f"(expires {time.strftime('%H:%M:%S', time.localtime(new_until))}) "
|
||||
f"triggered by {endpoint_name}"
|
||||
)
|
||||
|
||||
|
||||
def _is_globally_rate_limited():
|
||||
"""Check if the global rate limit ban is active."""
|
||||
with _rate_limit_lock:
|
||||
if _rate_limit_until <= 0:
|
||||
return False
|
||||
if time.time() >= _rate_limit_until:
|
||||
# Ban expired — clear it
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _get_rate_limit_info():
|
||||
"""Get current rate limit ban details. Returns None if not rate limited."""
|
||||
with _rate_limit_lock:
|
||||
if _rate_limit_until <= 0:
|
||||
return None
|
||||
now = time.time()
|
||||
remaining = _rate_limit_until - now
|
||||
if remaining <= 0:
|
||||
return None
|
||||
return {
|
||||
'active': True,
|
||||
'remaining_seconds': int(remaining),
|
||||
'retry_after': _rate_limit_retry_after,
|
||||
'endpoint': _rate_limit_endpoint,
|
||||
'set_at': _rate_limit_set_at,
|
||||
'expires_at': _rate_limit_until
|
||||
}
|
||||
|
||||
|
||||
def _clear_rate_limit():
|
||||
"""Manually clear the global rate limit ban (e.g. after disconnect/reconnect)."""
|
||||
global _rate_limit_until, _rate_limit_retry_after, _rate_limit_endpoint, _rate_limit_set_at
|
||||
with _rate_limit_lock:
|
||||
_rate_limit_until = 0
|
||||
_rate_limit_retry_after = 0
|
||||
_rate_limit_endpoint = None
|
||||
_rate_limit_set_at = 0
|
||||
logger.info("Global rate limit ban cleared")
|
||||
|
||||
|
||||
def rate_limited(func):
|
||||
"""Decorator to enforce rate limiting on Spotify API calls with retry and exponential backoff"""
|
||||
@wraps(func)
|
||||
|
|
@ -42,12 +122,14 @@ def rate_limited(func):
|
|||
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except SpotifyRateLimitError:
|
||||
raise # Don't retry our own ban errors
|
||||
except Exception as e:
|
||||
error_str = str(e).lower()
|
||||
is_rate_limit = "rate limit" in error_str or "429" in str(e)
|
||||
is_server_error = "502" in str(e) or "503" in str(e)
|
||||
|
||||
if is_rate_limit and attempt < max_retries:
|
||||
if is_rate_limit:
|
||||
# Try to extract Retry-After from spotipy exception headers
|
||||
retry_after = None
|
||||
if hasattr(e, 'headers') and e.headers:
|
||||
|
|
@ -55,15 +137,26 @@ def rate_limited(func):
|
|||
|
||||
if retry_after:
|
||||
try:
|
||||
delay = int(retry_after) + 1
|
||||
delay = int(retry_after)
|
||||
except (ValueError, TypeError):
|
||||
delay = None
|
||||
|
||||
# If Retry-After is long, activate global ban instead of sleeping
|
||||
if delay and delay > _LONG_RATE_LIMIT_THRESHOLD:
|
||||
_set_global_rate_limit(delay, func.__name__)
|
||||
raise SpotifyRateLimitError(delay, func.__name__)
|
||||
|
||||
if delay:
|
||||
delay = delay + 1
|
||||
else:
|
||||
delay = 3.0 * (2 ** attempt)
|
||||
else:
|
||||
delay = 3.0 * (2 ** attempt) # 3, 6, 12, 24, 48
|
||||
|
||||
logger.warning(f"Spotify rate limit hit, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries}): {func.__name__}")
|
||||
time.sleep(delay)
|
||||
continue
|
||||
if attempt < max_retries:
|
||||
logger.warning(f"Spotify rate limit hit, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries}): {func.__name__}")
|
||||
time.sleep(delay)
|
||||
continue
|
||||
|
||||
elif is_server_error and attempt < max_retries:
|
||||
delay = 2.0 * (2 ** attempt) # 2, 4, 8, 16, 32
|
||||
|
|
@ -246,7 +339,10 @@ class SpotifyClient:
|
|||
cache_path='config/.spotify_cache'
|
||||
)
|
||||
|
||||
self.sp = spotipy.Spotify(auth_manager=auth_manager)
|
||||
self.sp = spotipy.Spotify(auth_manager=auth_manager, retries=0, requests_timeout=15)
|
||||
# retries=0: prevent spotipy from sleeping for Retry-After duration on 429s
|
||||
# (can be hours). Our rate_limited decorator + global ban handle retries instead.
|
||||
# requests_timeout=15: prevent any single request from hanging indefinitely.
|
||||
# Don't fetch user info on startup - do it lazily to avoid blocking UI
|
||||
self.user_id = None
|
||||
logger.info("Spotify client initialized (user info will be fetched when needed)")
|
||||
|
|
@ -280,15 +376,20 @@ class SpotifyClient:
|
|||
if self.sp is None:
|
||||
return False
|
||||
|
||||
# If globally rate limited, report as NOT authenticated so callers
|
||||
# skip Spotify and fall through to iTunes fallback naturally.
|
||||
# This prevents any API calls that could extend the ban.
|
||||
if _is_globally_rate_limited():
|
||||
return False
|
||||
|
||||
# Check cache first (lock only for brief read)
|
||||
with self._auth_cache_lock:
|
||||
if self._auth_cached_result is not None and (time.time() - self._auth_cache_time) < self._AUTH_CACHE_TTL:
|
||||
return self._auth_cached_result
|
||||
|
||||
# Cache miss — make API call outside the lock.
|
||||
# Use a no-retry client to avoid spotipy blocking for hours on 429s
|
||||
# (Retry-After can be 2+ hours). The main self.sp client keeps its
|
||||
# retries for normal API calls.
|
||||
# Use a dedicated probe client (retries=0) so a 429 here propagates
|
||||
# immediately and we can detect long Retry-After bans.
|
||||
try:
|
||||
probe = spotipy.Spotify(auth_manager=self.sp.auth_manager, retries=0)
|
||||
probe.current_user()
|
||||
|
|
@ -298,6 +399,17 @@ class SpotifyClient:
|
|||
# Rate limit means we ARE authenticated — just throttled
|
||||
if "rate" in error_str.lower() or "429" in error_str:
|
||||
logger.warning("Spotify rate limited during auth check — treating as authenticated")
|
||||
# Check if there's a Retry-After header indicating a long ban
|
||||
retry_after = None
|
||||
if hasattr(e, 'headers') and e.headers:
|
||||
retry_after = e.headers.get('Retry-After') or e.headers.get('retry-after')
|
||||
if retry_after:
|
||||
try:
|
||||
delay = int(retry_after)
|
||||
if delay > _LONG_RATE_LIMIT_THRESHOLD:
|
||||
_set_global_rate_limit(delay, 'is_spotify_authenticated')
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
result = True
|
||||
else:
|
||||
logger.debug(f"Spotify authentication check failed: {e}")
|
||||
|
|
@ -310,11 +422,12 @@ class SpotifyClient:
|
|||
return result
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect Spotify: clear client, delete cache, invalidate auth cache"""
|
||||
"""Disconnect Spotify: clear client, delete cache, invalidate auth cache, clear rate limit"""
|
||||
import os
|
||||
self.sp = None
|
||||
self.user_id = None
|
||||
self._invalidate_auth_cache()
|
||||
_clear_rate_limit()
|
||||
|
||||
cache_path = 'config/.spotify_cache'
|
||||
try:
|
||||
|
|
@ -325,6 +438,21 @@ class SpotifyClient:
|
|||
logger.warning(f"Failed to delete Spotify cache: {e}")
|
||||
|
||||
logger.info("Spotify client disconnected")
|
||||
|
||||
@staticmethod
|
||||
def is_rate_limited():
|
||||
"""Check if Spotify is globally rate limited."""
|
||||
return _is_globally_rate_limited()
|
||||
|
||||
@staticmethod
|
||||
def get_rate_limit_info():
|
||||
"""Get rate limit ban details. Returns None if not rate limited."""
|
||||
return _get_rate_limit_info()
|
||||
|
||||
@staticmethod
|
||||
def clear_rate_limit():
|
||||
"""Manually clear the rate limit ban."""
|
||||
_clear_rate_limit()
|
||||
|
||||
def _ensure_user_id(self) -> bool:
|
||||
"""Ensure user_id is loaded (may make API call)"""
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from typing import Optional, Dict, Any, List
|
|||
from datetime import datetime, timedelta
|
||||
from utils.logging_config import get_logger
|
||||
from database.music_database import MusicDatabase
|
||||
from core.spotify_client import SpotifyClient
|
||||
from core.spotify_client import SpotifyClient, SpotifyRateLimitError
|
||||
|
||||
logger = get_logger("spotify_worker")
|
||||
|
||||
|
|
@ -97,9 +97,13 @@ class SpotifyWorker:
|
|||
is_idle = is_actually_running and not self.paused and self.stats['pending'] == 0 and self.current_item is None
|
||||
|
||||
try:
|
||||
authenticated = self.client.is_spotify_authenticated()
|
||||
# During rate limit, is_spotify_authenticated() returns False to suppress calls,
|
||||
# but we're still authenticated — just banned. Report truthfully.
|
||||
rate_limited = self.client.is_rate_limited()
|
||||
authenticated = rate_limited or self.client.is_spotify_authenticated()
|
||||
except Exception:
|
||||
authenticated = False
|
||||
rate_limited = False
|
||||
|
||||
return {
|
||||
'enabled': True,
|
||||
|
|
@ -107,6 +111,7 @@ class SpotifyWorker:
|
|||
'paused': self.paused,
|
||||
'idle': is_idle,
|
||||
'authenticated': authenticated,
|
||||
'rate_limited': rate_limited,
|
||||
'current_item': self.current_item,
|
||||
'stats': self.stats.copy(),
|
||||
'progress': progress
|
||||
|
|
@ -122,6 +127,14 @@ class SpotifyWorker:
|
|||
time.sleep(1)
|
||||
continue
|
||||
|
||||
# Rate limit guard — if globally rate limited, sleep until ban expires
|
||||
if self.client.is_rate_limited():
|
||||
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
|
||||
continue
|
||||
|
||||
# Auth guard — don't process anything without Spotify auth
|
||||
if not self.client.is_spotify_authenticated():
|
||||
# Try reloading config in case user re-authenticated via settings
|
||||
|
|
@ -143,6 +156,9 @@ class SpotifyWorker:
|
|||
self._process_item(item)
|
||||
time.sleep(self.inter_item_sleep)
|
||||
|
||||
except SpotifyRateLimitError:
|
||||
logger.debug("Spotify rate limit hit in worker loop, will retry after ban expires")
|
||||
time.sleep(10)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in worker loop: {e}")
|
||||
time.sleep(5)
|
||||
|
|
|
|||
|
|
@ -3350,16 +3350,26 @@ def get_status():
|
|||
# Test Spotify - with caching to avoid excessive API calls
|
||||
if current_time - _status_cache_timestamps['spotify'] > STATUS_CACHE_TTL:
|
||||
spotify_start = time.time()
|
||||
# Single auth check — is_spotify_authenticated() is cached internally (60s TTL)
|
||||
# Auth check first — may detect and set a new rate limit ban via probe
|
||||
spotify_connected = spotify_client.is_spotify_authenticated()
|
||||
spotify_response_time = (time.time() - spotify_start) * 1000
|
||||
|
||||
music_source = 'spotify' if spotify_connected else 'itunes'
|
||||
# Check rate limit AFTER auth (auth probe may have just set the ban)
|
||||
rate_limit_info = spotify_client.get_rate_limit_info()
|
||||
|
||||
# During rate limit, is_spotify_authenticated() returns False to suppress calls,
|
||||
# but we still report source as 'spotify' so UI doesn't flip to "Apple Music"
|
||||
if rate_limit_info:
|
||||
music_source = 'spotify'
|
||||
else:
|
||||
music_source = 'spotify' if spotify_connected else 'itunes'
|
||||
|
||||
_status_cache['spotify'] = {
|
||||
'connected': True, # Always true — iTunes fallback is always available
|
||||
'response_time': round(spotify_response_time, 1),
|
||||
'source': music_source
|
||||
'source': music_source,
|
||||
'rate_limited': rate_limit_info is not None,
|
||||
'rate_limit': rate_limit_info
|
||||
}
|
||||
_status_cache_timestamps['spotify'] = current_time
|
||||
# else: use cached value
|
||||
|
|
@ -5302,12 +5312,17 @@ def spotify_disconnect():
|
|||
"""Disconnect Spotify and fall back to iTunes/Apple Music"""
|
||||
global spotify_client
|
||||
try:
|
||||
# Pause enrichment worker before disconnecting to prevent it from hammering API
|
||||
if spotify_enrichment_worker:
|
||||
spotify_enrichment_worker.pause()
|
||||
spotify_client.disconnect()
|
||||
# Immediately update status cache so UI reflects the change
|
||||
_status_cache['spotify'] = {
|
||||
'connected': True, # iTunes fallback is always available
|
||||
'response_time': 0,
|
||||
'source': 'itunes'
|
||||
'source': 'itunes',
|
||||
'rate_limited': False,
|
||||
'rate_limit': None
|
||||
}
|
||||
_status_cache_timestamps['spotify'] = time.time()
|
||||
add_activity_item("🔌", "Spotify Disconnected", "Switched to Apple Music/iTunes metadata source", "Now")
|
||||
|
|
@ -5317,6 +5332,25 @@ def spotify_disconnect():
|
|||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/spotify/rate-limit-status', methods=['GET'])
|
||||
def spotify_rate_limit_status():
|
||||
"""Get Spotify rate limit ban details"""
|
||||
try:
|
||||
info = spotify_client.get_rate_limit_info()
|
||||
if info:
|
||||
return jsonify({
|
||||
'rate_limited': True,
|
||||
'remaining_seconds': info['remaining_seconds'],
|
||||
'retry_after': info['retry_after'],
|
||||
'endpoint': info['endpoint'],
|
||||
'expires_at': info['expires_at']
|
||||
})
|
||||
return jsonify({'rate_limited': False})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting Spotify rate limit status: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/tidal/callback')
|
||||
def tidal_callback():
|
||||
"""
|
||||
|
|
@ -34160,8 +34194,15 @@ def _build_status_payload():
|
|||
download_mode = config_manager.get('download_source.mode', 'soulseek')
|
||||
soulseek_data = dict(_status_cache.get('soulseek', {}))
|
||||
soulseek_data['source'] = download_mode
|
||||
|
||||
# Always include fresh rate limit info (it changes over time as ban expires)
|
||||
spotify_data = dict(_status_cache.get('spotify', {}))
|
||||
rate_limit_info = spotify_client.get_rate_limit_info() if spotify_client else None
|
||||
spotify_data['rate_limited'] = rate_limit_info is not None
|
||||
spotify_data['rate_limit'] = rate_limit_info
|
||||
|
||||
return {
|
||||
'spotify': _status_cache.get('spotify', {}),
|
||||
'spotify': spotify_data,
|
||||
'media_server': _status_cache.get('media_server', {}),
|
||||
'soulseek': soulseek_data,
|
||||
'active_media_server': config_manager.get_active_media_server()
|
||||
|
|
|
|||
|
|
@ -4346,6 +4346,42 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spotify Rate Limit Modal -->
|
||||
<div class="modal-overlay hidden" id="spotify-rate-limit-overlay" onclick="closeSpotifyRateLimitModal()">
|
||||
<div class="support-modal" onclick="event.stopPropagation()" style="max-width: 480px;">
|
||||
<div class="support-modal-header">
|
||||
<h2>Spotify Rate Limited</h2>
|
||||
<button class="support-modal-close" onclick="closeSpotifyRateLimitModal()">×</button>
|
||||
</div>
|
||||
<div class="support-modal-body" style="padding: 20px;">
|
||||
<p style="margin: 0 0 12px; color: var(--text-secondary); line-height: 1.5;">
|
||||
Spotify has temporarily blocked API access. All Spotify features (search, enrichment, playlists) are paused until the ban expires.
|
||||
</p>
|
||||
<div style="background: var(--bg-tertiary); border-radius: 8px; padding: 16px; margin-bottom: 16px;">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||||
<span style="color: var(--text-secondary);">Time remaining</span>
|
||||
<span id="rate-limit-remaining" style="color: var(--accent); font-weight: 600;">--</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||||
<span style="color: var(--text-secondary);">Original ban duration</span>
|
||||
<span id="rate-limit-duration" style="color: var(--text-primary);">--</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: var(--text-secondary);">Triggered by</span>
|
||||
<span id="rate-limit-endpoint" style="color: var(--text-primary); font-family: monospace; font-size: 0.85em;">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin: 0 0 16px; color: var(--text-tertiary); font-size: 0.85em; line-height: 1.5;">
|
||||
You can wait for the ban to expire, or disconnect Spotify entirely and switch to Apple Music/iTunes for metadata.
|
||||
</p>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<button onclick="closeSpotifyRateLimitModal()" style="flex: 1; padding: 10px; border-radius: 8px; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-primary); cursor: pointer;">Wait It Out</button>
|
||||
<button onclick="disconnectSpotifyFromRateLimitModal()" style="flex: 1; padding: 10px; border-radius: 8px; border: none; background: var(--danger, #e74c3c); color: white; cursor: pointer; font-weight: 600;">Disconnect Spotify</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add to Wishlist Modal -->
|
||||
<div class="modal-overlay hidden" id="add-to-wishlist-modal-overlay">
|
||||
<div class="add-to-wishlist-modal" id="add-to-wishlist-modal">
|
||||
|
|
|
|||
|
|
@ -280,6 +280,13 @@ function handleServiceStatusUpdate(data) {
|
|||
updateSidebarServiceStatus('spotify', data.spotify);
|
||||
updateSidebarServiceStatus('media-server', data.media_server);
|
||||
updateSidebarServiceStatus('soulseek', data.soulseek);
|
||||
|
||||
// Check for Spotify rate limit
|
||||
if (data.spotify && data.spotify.rate_limited && data.spotify.rate_limit) {
|
||||
handleSpotifyRateLimit(data.spotify.rate_limit);
|
||||
} else if (_spotifyRateLimitShown) {
|
||||
handleSpotifyRateLimit(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleWatchlistCountUpdate(data) {
|
||||
|
|
@ -5060,6 +5067,97 @@ async function disconnectSpotify() {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Spotify Rate Limit Modal ──────────────────────────────────────────────
|
||||
let _spotifyRateLimitShown = false;
|
||||
let _rateLimitCountdownInterval = null;
|
||||
let _rateLimitRemainingSeconds = 0; // Local countdown, synced from server updates
|
||||
|
||||
function handleSpotifyRateLimit(rateLimitInfo) {
|
||||
if (!rateLimitInfo || !rateLimitInfo.active) {
|
||||
// Rate limit cleared — hide modal if showing
|
||||
if (_spotifyRateLimitShown) {
|
||||
_spotifyRateLimitShown = false;
|
||||
closeSpotifyRateLimitModal();
|
||||
showToast('Spotify rate limit expired — API access restored', 'success');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync local countdown from server's authoritative remaining_seconds
|
||||
_rateLimitRemainingSeconds = rateLimitInfo.remaining_seconds;
|
||||
|
||||
// Update static fields (only change on new ban events)
|
||||
const durationEl = document.getElementById('rate-limit-duration');
|
||||
const endpointEl = document.getElementById('rate-limit-endpoint');
|
||||
if (durationEl) durationEl.textContent = formatRateLimitDuration(rateLimitInfo.retry_after);
|
||||
if (endpointEl) endpointEl.textContent = rateLimitInfo.endpoint || 'unknown';
|
||||
|
||||
// Update remaining display
|
||||
const remainingEl = document.getElementById('rate-limit-remaining');
|
||||
if (remainingEl) remainingEl.textContent = formatRateLimitDuration(_rateLimitRemainingSeconds);
|
||||
|
||||
// Show modal and start countdown once per rate limit event
|
||||
if (!_spotifyRateLimitShown) {
|
||||
_spotifyRateLimitShown = true;
|
||||
const overlay = document.getElementById('spotify-rate-limit-overlay');
|
||||
if (overlay) overlay.classList.remove('hidden');
|
||||
|
||||
// Start local 1s countdown for smooth display between server updates
|
||||
if (_rateLimitCountdownInterval) clearInterval(_rateLimitCountdownInterval);
|
||||
_rateLimitCountdownInterval = setInterval(() => {
|
||||
_rateLimitRemainingSeconds--;
|
||||
if (_rateLimitRemainingSeconds <= 0) {
|
||||
_spotifyRateLimitShown = false;
|
||||
closeSpotifyRateLimitModal();
|
||||
showToast('Spotify rate limit expired — API access restored', 'success');
|
||||
return;
|
||||
}
|
||||
const el = document.getElementById('rate-limit-remaining');
|
||||
if (el) el.textContent = formatRateLimitDuration(_rateLimitRemainingSeconds);
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function formatRateLimitDuration(seconds) {
|
||||
if (!seconds || seconds <= 0) return '0s';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
if (m > 0) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
function closeSpotifyRateLimitModal() {
|
||||
const overlay = document.getElementById('spotify-rate-limit-overlay');
|
||||
if (overlay) overlay.classList.add('hidden');
|
||||
if (_rateLimitCountdownInterval) {
|
||||
clearInterval(_rateLimitCountdownInterval);
|
||||
_rateLimitCountdownInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectSpotifyFromRateLimitModal() {
|
||||
closeSpotifyRateLimitModal();
|
||||
_spotifyRateLimitShown = false;
|
||||
try {
|
||||
showLoadingOverlay('Disconnecting Spotify...');
|
||||
const response = await fetch('/api/spotify/disconnect', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showToast('Spotify disconnected. Now using Apple Music/iTunes.', 'success');
|
||||
await fetchAndUpdateServiceStatus();
|
||||
} else {
|
||||
showToast(`Failed to disconnect: ${data.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error disconnecting Spotify:', error);
|
||||
showToast('Failed to disconnect Spotify', 'error');
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticateTidal() {
|
||||
try {
|
||||
showLoadingOverlay('Starting Tidal authentication...');
|
||||
|
|
@ -29188,6 +29286,13 @@ async function fetchAndUpdateServiceStatus() {
|
|||
updateSidebarServiceStatus('media-server', data.media_server);
|
||||
updateSidebarServiceStatus('soulseek', data.soulseek);
|
||||
|
||||
// Check for Spotify rate limit
|
||||
if (data.spotify && data.spotify.rate_limited && data.spotify.rate_limit) {
|
||||
handleSpotifyRateLimit(data.spotify.rate_limit);
|
||||
} else if (_spotifyRateLimitShown) {
|
||||
handleSpotifyRateLimit(null);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Could not fetch service status:', error);
|
||||
}
|
||||
|
|
@ -29198,7 +29303,12 @@ function updateServiceStatus(service, statusData) {
|
|||
const statusText = document.getElementById(`${service}-status-text`);
|
||||
|
||||
if (indicator && statusText) {
|
||||
if (statusData.connected) {
|
||||
if (service === 'spotify' && statusData.rate_limited) {
|
||||
indicator.className = 'service-card-indicator disconnected';
|
||||
const remaining = statusData.rate_limit ? formatRateLimitDuration(statusData.rate_limit.remaining_seconds) : '';
|
||||
statusText.textContent = `Rate Limited${remaining ? ' (' + remaining + ')' : ''}`;
|
||||
statusText.className = 'service-card-status-text disconnected';
|
||||
} else if (statusData.connected) {
|
||||
indicator.className = 'service-card-indicator connected';
|
||||
statusText.textContent = `Connected (${statusData.response_time}ms)`;
|
||||
statusText.className = 'service-card-status-text connected';
|
||||
|
|
@ -29242,10 +29352,15 @@ function updateSidebarServiceStatus(service, statusData) {
|
|||
const nameElement = indicator.querySelector('.status-name');
|
||||
|
||||
if (dot) {
|
||||
if (statusData.connected) {
|
||||
if (service === 'spotify' && statusData.rate_limited) {
|
||||
dot.className = 'status-dot disconnected';
|
||||
dot.title = 'Rate Limited';
|
||||
} else if (statusData.connected) {
|
||||
dot.className = 'status-dot connected';
|
||||
dot.title = '';
|
||||
} else {
|
||||
dot.className = 'status-dot disconnected';
|
||||
dot.title = '';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue