diff --git a/web_server.py b/web_server.py
index 8d397665..c98307ee 100644
--- a/web_server.py
+++ b/web_server.py
@@ -3587,7 +3587,7 @@ def get_status():
if is_rate_limited or cooldown_remaining > 0:
# During rate limit or post-ban cooldown, skip the auth probe entirely.
# Probing Spotify here would reset the rate limit timer.
- music_source = 'spotify'
+ music_source = 'itunes' # App uses iTunes during ban — reflect truth
spotify_response_time = 0
else:
spotify_start = time.time()
@@ -3600,7 +3600,8 @@ def get_status():
'response_time': round(spotify_response_time, 1),
'source': music_source,
'rate_limited': is_rate_limited,
- 'rate_limit': rate_limit_info
+ 'rate_limit': rate_limit_info,
+ 'post_ban_cooldown': cooldown_remaining if cooldown_remaining > 0 else None
}
_status_cache_timestamps['spotify'] = current_time
# else: use cached value
diff --git a/webui/index.html b/webui/index.html
index 6adc7f28..df5915d6 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -4760,42 +4760,6 @@
-
-
diff --git a/webui/static/script.js b/webui/static/script.js
index fbd4fff9..bf30842d 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -285,11 +285,23 @@ function handleServiceStatusUpdate(data) {
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) {
+ // Spotify rate limit / cooldown / recovery
+ if (data.spotify?.rate_limited && data.spotify.rate_limit) {
handleSpotifyRateLimit(data.spotify.rate_limit);
- } else if (_spotifyRateLimitShown) {
- handleSpotifyRateLimit(null);
+ _spotifyInCooldown = false;
+ } else if (data.spotify?.post_ban_cooldown > 0) {
+ if (_spotifyRateLimitShown && !_spotifyInCooldown) {
+ _spotifyRateLimitShown = false;
+ _spotifyInCooldown = true;
+ showToast('Spotify ban expired \u2014 recovering shortly', 'info');
+ }
+ } else {
+ if (_spotifyInCooldown) {
+ _spotifyInCooldown = false;
+ showToast('Spotify access restored', 'success');
+ } else if (_spotifyRateLimitShown) {
+ handleSpotifyRateLimit(null);
+ }
}
}
@@ -5782,54 +5794,23 @@ async function disconnectSpotify() {
}
}
-// ── Spotify Rate Limit Modal ──────────────────────────────────────────────
+// ── Spotify Rate Limit Handling ───────────────────────────────────────────
let _spotifyRateLimitShown = false;
-let _rateLimitCountdownInterval = null;
-let _rateLimitRemainingSeconds = 0; // Local countdown, synced from server updates
+let _spotifyInCooldown = false;
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');
+ showToast('Spotify 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);
+ _spotifyInCooldown = false;
+ const duration = formatRateLimitDuration(rateLimitInfo.remaining_seconds);
+ showToast(`Spotify rate limited (${duration}) \u2014 using Apple Music`, 'warning');
}
}
@@ -5843,36 +5824,6 @@ function formatRateLimitDuration(seconds) {
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...');
@@ -30131,11 +30082,14 @@ function updateServiceStatus(service, statusData) {
const statusText = document.getElementById(`${service}-status-text`);
if (indicator && statusText) {
- 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';
+ if (service === 'spotify' && (statusData.rate_limited || statusData.post_ban_cooldown)) {
+ indicator.className = 'service-card-indicator rate-limited';
+ const remaining = statusData.rate_limited
+ ? formatRateLimitDuration(statusData.rate_limit?.remaining_seconds || 0)
+ : formatRateLimitDuration(statusData.post_ban_cooldown);
+ const phase = statusData.rate_limited ? 'paused' : 'recovering';
+ statusText.textContent = `Apple Music (Spotify ${phase} \u2014 ${remaining})`;
+ statusText.className = 'service-card-status-text rate-limited';
} else if (statusData.connected) {
indicator.className = 'service-card-indicator connected';
statusText.textContent = `Connected (${statusData.response_time}ms)`;
@@ -30180,9 +30134,11 @@ function updateSidebarServiceStatus(service, statusData) {
const nameElement = indicator.querySelector('.status-name');
if (dot) {
- if (service === 'spotify' && statusData.rate_limited) {
- dot.className = 'status-dot disconnected';
- dot.title = 'Rate Limited';
+ if (service === 'spotify' && (statusData.rate_limited || statusData.post_ban_cooldown)) {
+ dot.className = 'status-dot rate-limited';
+ dot.title = statusData.rate_limited
+ ? `Spotify paused \u2014 ${formatRateLimitDuration(statusData.rate_limit?.remaining_seconds || 0)} remaining`
+ : `Spotify recovering \u2014 ${formatRateLimitDuration(statusData.post_ban_cooldown)} cooldown`;
} else if (statusData.connected) {
dot.className = 'status-dot connected';
dot.title = '';
diff --git a/webui/static/style.css b/webui/static/style.css
index 7d300bf0..564f4516 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -1394,6 +1394,32 @@ body {
content: '';
}
+.status-dot.rate-limited {
+ background: rgba(250, 204, 21, 0.8);
+ border: none;
+ box-shadow: 0 0 6px rgba(250, 204, 21, 0.4);
+ animation: status-pulse-amber 3s ease-in-out infinite;
+}
+
+.status-dot.rate-limited::before {
+ content: '';
+ position: absolute;
+ inset: -3px;
+ border-radius: 50%;
+ border: 1.5px solid rgba(250, 204, 21, 0.3);
+ animation: status-ring-pulse 3s ease-in-out infinite;
+}
+
+@keyframes status-pulse-amber {
+ 0%, 100% { box-shadow: 0 0 6px rgba(250, 204, 21, 0.4), 0 0 12px rgba(250, 204, 21, 0.2); }
+ 50% { box-shadow: 0 0 8px rgba(250, 204, 21, 0.6), 0 0 20px rgba(250, 204, 21, 0.3); }
+}
+
+.status-indicator:has(.rate-limited) .status-name {
+ color: rgba(250, 204, 21, 0.9);
+ font-weight: 500;
+}
+
.status-name {
font-family: 'SF Pro Text', -apple-system, sans-serif;
font-size: 11px;
@@ -3558,6 +3584,16 @@ body {
background: linear-gradient(135deg, rgba(255, 107, 107, 0.1) 0%, rgba(26, 26, 26, 0.95) 100%);
}
+.toast.warning {
+ border-color: rgba(250, 204, 21, 0.5);
+ background: linear-gradient(135deg, rgba(250, 204, 21, 0.1) 0%, rgba(26, 26, 26, 0.95) 100%);
+}
+
+.toast.info {
+ border-color: rgba(59, 130, 246, 0.5);
+ background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(26, 26, 26, 0.95) 100%);
+}
+
@keyframes slideIn {
from {
transform: translateY(100%);
@@ -5551,6 +5587,15 @@ body {
text-shadow: 0 0 8px rgba(255, 68, 68, 0.5), 0 0 16px rgba(255, 68, 68, 0.2);
}
+.service-card-indicator.rate-limited {
+ color: #facc15;
+ text-shadow: 0 0 8px rgba(250, 204, 21, 0.5);
+}
+
+.service-card-status-text.rate-limited {
+ color: rgba(250, 204, 21, 0.9);
+}
+
@keyframes service-pulse-green {
0%, 100% { text-shadow: 0 0 8px rgba(var(--accent-rgb), 0.6), 0 0 16px rgba(var(--accent-rgb), 0.3); }
50% { text-shadow: 0 0 12px rgba(var(--accent-rgb), 0.8), 0 0 24px rgba(var(--accent-rgb), 0.5); }