Merge pull request #457 from kettui/refactor/spotify-auth-flow
Clarify metadata source gating and Spotify auth flow
This commit is contained in:
commit
94b08bbb49
11 changed files with 568 additions and 184 deletions
|
|
@ -18,6 +18,13 @@ logger = get_logger("metadata.registry")
|
||||||
MetadataClientFactory = Callable[[], Any]
|
MetadataClientFactory = Callable[[], Any]
|
||||||
|
|
||||||
METADATA_SOURCE_PRIORITY = ("deezer", "itunes", "spotify", "discogs", "hydrabase")
|
METADATA_SOURCE_PRIORITY = ("deezer", "itunes", "spotify", "discogs", "hydrabase")
|
||||||
|
METADATA_SOURCE_LABELS = {
|
||||||
|
"spotify": "Spotify",
|
||||||
|
"itunes": "iTunes",
|
||||||
|
"deezer": "Deezer",
|
||||||
|
"discogs": "Discogs",
|
||||||
|
"hydrabase": "Hydrabase",
|
||||||
|
}
|
||||||
|
|
||||||
_UNSET = object()
|
_UNSET = object()
|
||||||
_client_cache_lock = threading.RLock()
|
_client_cache_lock = threading.RLock()
|
||||||
|
|
@ -293,6 +300,18 @@ def get_primary_source(spotify_client_factory: Optional[MetadataClientFactory] =
|
||||||
return source
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
def get_spotify_disconnect_source(configured_source: Optional[str] = None) -> str:
|
||||||
|
"""Return the active metadata source after Spotify is disconnected."""
|
||||||
|
source = configured_source if configured_source is not None else _get_config_value("metadata.fallback_source", "deezer")
|
||||||
|
source = source or "deezer"
|
||||||
|
return "deezer" if source == "spotify" else source
|
||||||
|
|
||||||
|
|
||||||
|
def get_metadata_source_label(source: str) -> str:
|
||||||
|
"""Return a human-readable label for a metadata source."""
|
||||||
|
return METADATA_SOURCE_LABELS.get(source, "Unmapped")
|
||||||
|
|
||||||
|
|
||||||
def get_source_priority(preferred_source: str):
|
def get_source_priority(preferred_source: str):
|
||||||
"""Return source priority with preferred source first."""
|
"""Return source priority with preferred source first."""
|
||||||
ordered = []
|
ordered = []
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ from flask_socketio import SocketIO, join_room, leave_room
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_DEFAULT_STATUS_CACHE = {
|
_DEFAULT_STATUS_CACHE = {
|
||||||
'spotify': {'connected': True, 'response_time': 12.5, 'source': 'spotify'},
|
'spotify': {'connected': True, 'authenticated': True, 'response_time': 12.5, 'source': 'spotify'},
|
||||||
'media_server': {'connected': True, 'response_time': 8.1, 'type': 'plex'},
|
'media_server': {'connected': True, 'response_time': 8.1, 'type': 'plex'},
|
||||||
'soulseek': {'connected': True, 'response_time': 5.3, 'source': 'soulseek'},
|
'soulseek': {'connected': True, 'response_time': 5.3, 'source': 'soulseek'},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
26
tests/metadata/test_metadata_registry.py
Normal file
26
tests/metadata/test_metadata_registry.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||||
|
|
||||||
|
from core.metadata import registry
|
||||||
|
|
||||||
|
|
||||||
|
def test_spotify_disconnect_source_uses_deezer_when_spotify_is_primary():
|
||||||
|
assert registry.get_spotify_disconnect_source("spotify") == "deezer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_spotify_disconnect_source_keeps_non_spotify_primary():
|
||||||
|
assert registry.get_spotify_disconnect_source("discogs") == "discogs"
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_source_label_maps_known_sources():
|
||||||
|
assert registry.get_metadata_source_label("spotify") == "Spotify"
|
||||||
|
assert registry.get_metadata_source_label("itunes") == "iTunes"
|
||||||
|
assert registry.get_metadata_source_label("deezer") == "Deezer"
|
||||||
|
assert registry.get_metadata_source_label("discogs") == "Discogs"
|
||||||
|
assert registry.get_metadata_source_label("hydrabase") == "Hydrabase"
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_source_label_falls_back_to_unmapped():
|
||||||
|
assert registry.get_metadata_source_label("apple_music") == "Unmapped"
|
||||||
|
|
@ -56,6 +56,7 @@ class TestServiceStatus:
|
||||||
assert 'media_server' in data
|
assert 'media_server' in data
|
||||||
assert 'soulseek' in data
|
assert 'soulseek' in data
|
||||||
assert 'active_media_server' in data
|
assert 'active_media_server' in data
|
||||||
|
assert 'authenticated' in data['spotify']
|
||||||
|
|
||||||
def test_status_matches_http(self, test_app, shared_state):
|
def test_status_matches_http(self, test_app, shared_state):
|
||||||
"""Socket event data matches HTTP endpoint response exactly."""
|
"""Socket event data matches HTTP endpoint response exactly."""
|
||||||
|
|
|
||||||
293
web_server.py
293
web_server.py
|
|
@ -98,7 +98,9 @@ from core.metadata.cache import get_metadata_cache
|
||||||
from core.metadata import registry as metadata_registry
|
from core.metadata import registry as metadata_registry
|
||||||
from core.metadata.registry import (
|
from core.metadata.registry import (
|
||||||
clear_cached_metadata_client,
|
clear_cached_metadata_client,
|
||||||
|
get_metadata_source_label,
|
||||||
get_spotify_client,
|
get_spotify_client,
|
||||||
|
get_spotify_disconnect_source,
|
||||||
register_runtime_clients,
|
register_runtime_clients,
|
||||||
)
|
)
|
||||||
from core.imports.context import (
|
from core.imports.context import (
|
||||||
|
|
@ -809,11 +811,11 @@ _idle_since = {}
|
||||||
_IDLE_GRACE_SECONDS = 5
|
_IDLE_GRACE_SECONDS = 5
|
||||||
|
|
||||||
_status_cache = {
|
_status_cache = {
|
||||||
'spotify': {'connected': False, 'response_time': 0, 'source': 'itunes'},
|
'spotify': {'connected': False, 'authenticated': False, 'response_time': 0, 'source': 'itunes'},
|
||||||
'media_server': {'connected': False, 'response_time': 0, 'type': None},
|
'media_server': {'connected': False, 'response_time': 0, 'type': None},
|
||||||
'soulseek': {'connected': False, 'response_time': 0},
|
'soulseek': {'connected': False, 'response_time': 0},
|
||||||
}
|
}
|
||||||
_status_cache_timestamps = {
|
_status_cache_timestamps: dict[str, float] = {
|
||||||
'spotify': 0,
|
'spotify': 0,
|
||||||
'media_server': 0,
|
'media_server': 0,
|
||||||
'soulseek': 0,
|
'soulseek': 0,
|
||||||
|
|
@ -3450,6 +3452,7 @@ def get_status():
|
||||||
is_rate_limited = spotify_client.is_rate_limited() if spotify_client else False
|
is_rate_limited = spotify_client.is_rate_limited() if spotify_client else False
|
||||||
rate_limit_info = spotify_client.get_rate_limit_info() if (spotify_client and is_rate_limited) else None
|
rate_limit_info = spotify_client.get_rate_limit_info() if (spotify_client and is_rate_limited) else None
|
||||||
cooldown_remaining = spotify_client.get_post_ban_cooldown_remaining() if spotify_client else 0
|
cooldown_remaining = spotify_client.get_post_ban_cooldown_remaining() if spotify_client else 0
|
||||||
|
spotify_session_active = spotify_client.is_spotify_authenticated() if spotify_client else False
|
||||||
|
|
||||||
# Read configured source once — no auth validation here, we do that explicitly below
|
# Read configured source once — no auth validation here, we do that explicitly below
|
||||||
configured_source = config_manager.get('metadata.fallback_source', 'deezer') or 'deezer'
|
configured_source = config_manager.get('metadata.fallback_source', 'deezer') or 'deezer'
|
||||||
|
|
@ -3470,7 +3473,8 @@ def get_status():
|
||||||
music_source = configured_source
|
music_source = configured_source
|
||||||
|
|
||||||
_status_cache['spotify'] = {
|
_status_cache['spotify'] = {
|
||||||
'connected': True, # Always true — iTunes fallback is always available
|
'connected': spotify_session_active,
|
||||||
|
'authenticated': spotify_session_active,
|
||||||
'response_time': round(spotify_response_time, 1),
|
'response_time': round(spotify_response_time, 1),
|
||||||
'source': music_source,
|
'source': music_source,
|
||||||
'rate_limited': is_rate_limited,
|
'rate_limited': is_rate_limited,
|
||||||
|
|
@ -4799,7 +4803,9 @@ def test_connection_endpoint():
|
||||||
if success:
|
if success:
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
if service == 'spotify':
|
if service == 'spotify':
|
||||||
|
spotify_session_active = spotify_client.is_spotify_authenticated() if spotify_client else False
|
||||||
_status_cache['spotify']['connected'] = True
|
_status_cache['spotify']['connected'] = True
|
||||||
|
_status_cache['spotify']['authenticated'] = spotify_session_active
|
||||||
_status_cache['spotify']['source'] = _get_metadata_fallback_source()
|
_status_cache['spotify']['source'] = _get_metadata_fallback_source()
|
||||||
_status_cache_timestamps['spotify'] = current_time
|
_status_cache_timestamps['spotify'] = current_time
|
||||||
logger.info("Updated Spotify status cache after successful test")
|
logger.info("Updated Spotify status cache after successful test")
|
||||||
|
|
@ -4965,7 +4971,9 @@ def test_dashboard_connection_endpoint():
|
||||||
if success:
|
if success:
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
if service == 'spotify':
|
if service == 'spotify':
|
||||||
|
spotify_session_active = spotify_client.is_spotify_authenticated() if spotify_client else False
|
||||||
_status_cache['spotify']['connected'] = True
|
_status_cache['spotify']['connected'] = True
|
||||||
|
_status_cache['spotify']['authenticated'] = spotify_session_active
|
||||||
_status_cache['spotify']['source'] = _get_metadata_fallback_source()
|
_status_cache['spotify']['source'] = _get_metadata_fallback_source()
|
||||||
_status_cache_timestamps['spotify'] = current_time
|
_status_cache_timestamps['spotify'] = current_time
|
||||||
logger.info("Updated Spotify status cache after successful dashboard test")
|
logger.info("Updated Spotify status cache after successful dashboard test")
|
||||||
|
|
@ -5498,101 +5506,84 @@ def auth_spotify():
|
||||||
'127.0.0.1' not in configured_uri and 'localhost' not in configured_uri
|
'127.0.0.1' not in configured_uri and 'localhost' not in configured_uri
|
||||||
)
|
)
|
||||||
|
|
||||||
if is_remote or is_docker:
|
if not (is_remote or is_docker):
|
||||||
# Show instructions for remote/docker access
|
return redirect(auth_url)
|
||||||
if uses_main_port:
|
|
||||||
# redirect_uri already points to port 8008 or a custom domain —
|
|
||||||
# callback will come through the main Flask app, no manual steps needed
|
|
||||||
return f'''
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<style>
|
|
||||||
body {{ font-family: Arial, sans-serif; padding: 20px; max-width: 800px; margin: 0 auto; }}
|
|
||||||
code {{ background: #f0f0f0; padding: 4px 8px; border-radius: 3px; }}
|
|
||||||
.info {{ background: #e3f2fd; border-left: 4px solid #2196F3; padding: 12px 16px; margin: 16px 0; border-radius: 4px; }}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Spotify Authentication</h1>
|
|
||||||
<p>Click the link below to authenticate with Spotify:</p>
|
|
||||||
<p><a href="{auth_url}" target="_blank" style="font-size: 18px; color: #1DB954;">Authenticate with Spotify</a></p>
|
|
||||||
<div class="info">
|
|
||||||
<strong>Redirect URI:</strong> <code>{configured_uri}</code><br>
|
|
||||||
<small>After authorizing, Spotify will redirect back automatically. Make sure this URL matches your Spotify Dashboard redirect URI.</small>
|
|
||||||
</div>
|
|
||||||
<p>After authentication completes, you can close this window and return to SoulSync.</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
'''
|
|
||||||
else:
|
|
||||||
# redirect_uri points to the standalone callback server — show manual steps AND suggest switching
|
|
||||||
import re as _re
|
|
||||||
_port_match = _re.search(r':(\d+)/', configured_uri)
|
|
||||||
callback_server_port = _port_match.group(1) if _port_match else str(os.environ.get('SOULSYNC_SPOTIFY_CALLBACK_PORT', '8888'))
|
|
||||||
return f'''
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<style>
|
|
||||||
body {{ font-family: Arial, sans-serif; padding: 20px; max-width: 800px; margin: 0 auto; }}
|
|
||||||
code {{ background: #f0f0f0; padding: 10px; display: block; margin: 10px 0; }}
|
|
||||||
.highlight {{ background: #e8f5e9; }}
|
|
||||||
.warning {{ background: #fff3e0; border-left: 4px solid #ff9800; padding: 12px 16px; margin: 16px 0; border-radius: 4px; }}
|
|
||||||
.copy-btn {{
|
|
||||||
background: #1DB954;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 8px 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-left: 10px;
|
|
||||||
}}
|
|
||||||
.copy-btn:hover {{ background: #1ed760; }}
|
|
||||||
.copied {{ background: #4CAF50 !important; }}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Spotify Authentication (Remote/Docker)</h1>
|
|
||||||
|
|
||||||
<div class="warning">
|
if uses_main_port:
|
||||||
<strong>Using a reverse proxy?</strong> Your redirect URI is set to <code style="display:inline; padding: 2px 6px;">{configured_uri}</code>
|
# The OAuth callback returns to the app itself, so there is no
|
||||||
which uses port {callback_server_port}. If you're behind a reverse proxy (Caddy, Nginx, Traefik), change the
|
# need to keep an intermediate page open.
|
||||||
redirect URI in SoulSync settings to use your proxy URL on the main port instead, e.g.:<br>
|
return redirect(auth_url)
|
||||||
<code style="display:inline; padding: 2px 6px; background: #e8f5e9;">https://{host}/callback</code><br>
|
|
||||||
Then update the same URI in your <a href="https://developer.spotify.com/dashboard" target="_blank">Spotify Dashboard</a>.
|
|
||||||
This avoids the need for manual URL editing below.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p><strong>Step 1:</strong> Click the link below to authenticate with Spotify</p>
|
# redirect_uri points to the standalone callback server — show manual steps AND suggest switching
|
||||||
<p><a href="{auth_url}" target="_blank" style="font-size: 18px; color: #1DB954;">{auth_url}</a></p>
|
import re as _re
|
||||||
<hr>
|
_port_match = _re.search(r':(\d+)/', configured_uri)
|
||||||
<p><strong>Step 2:</strong> After authorizing, you'll see a blank page. The URL will look like:</p>
|
callback_server_port = _port_match.group(1) if _port_match else str(os.environ.get('SOULSYNC_SPOTIFY_CALLBACK_PORT', '8888'))
|
||||||
<code>http://127.0.0.1:{callback_server_port}/callback?code=...</code>
|
return f'''
|
||||||
<p><strong>Step 3:</strong> Change <code style="display: inline; background: #ffe6e6; padding: 2px 6px;">127.0.0.1</code> to <code style="display: inline; background: #e8f5e9; padding: 2px 6px;">{host}</code> and press Enter:
|
<html>
|
||||||
<button class="copy-btn" onclick="copyIP()">Copy IP</button>
|
<head>
|
||||||
</p>
|
<style>
|
||||||
<code class="highlight">http://{host}:{callback_server_port}/callback?code=...</code>
|
body {{ font-family: Arial, sans-serif; padding: 20px; max-width: 800px; margin: 0 auto; }}
|
||||||
<p>Authentication will then complete!</p>
|
code {{ background: #f0f0f0; padding: 10px; display: block; margin: 10px 0; }}
|
||||||
|
.highlight {{ background: #e8f5e9; }}
|
||||||
|
.warning {{ background: #fff3e0; border-left: 4px solid #ff9800; padding: 12px 16px; margin: 16px 0; border-radius: 4px; }}
|
||||||
|
.copy-btn {{
|
||||||
|
background: #1DB954;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}}
|
||||||
|
.copy-btn:hover {{ background: #1ed760; }}
|
||||||
|
.copied {{ background: #4CAF50 !important; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Spotify Authentication (Remote/Docker)</h1>
|
||||||
|
|
||||||
<script>
|
<div class="warning">
|
||||||
function copyIP() {{
|
<strong>Using a reverse proxy?</strong> Your redirect URI is set to <code style="display:inline; padding: 2px 6px;">{configured_uri}</code>
|
||||||
navigator.clipboard.writeText('{host}').then(() => {{
|
which uses port {callback_server_port}. If you're behind a reverse proxy (Caddy, Nginx, Traefik), change the
|
||||||
const btn = event.target;
|
redirect URI in SoulSync settings to use your proxy URL on the main port instead, e.g.:<br>
|
||||||
btn.textContent = 'Copied!';
|
<code style="display:inline; padding: 2px 6px; background: #e8f5e9;">https://{host}/callback</code><br>
|
||||||
btn.classList.add('copied');
|
Then update the same URI in your <a href="https://developer.spotify.com/dashboard" target="_blank">Spotify Dashboard</a>.
|
||||||
setTimeout(() => {{
|
This avoids the need for manual URL editing below.
|
||||||
btn.textContent = 'Copy IP';
|
</div>
|
||||||
btn.classList.remove('copied');
|
|
||||||
}}, 2000);
|
<p><strong>Step 1:</strong> Click the link below to authenticate with Spotify</p>
|
||||||
}});
|
<p><a href="{auth_url}" target="_blank" style="font-size: 18px; color: #1DB954;">{auth_url}</a></p>
|
||||||
}}
|
<hr>
|
||||||
</script>
|
<p><strong>Step 2:</strong> After authorizing, you'll see a blank page. The URL will look like:</p>
|
||||||
</body>
|
<code>http://127.0.0.1:{callback_server_port}/callback?code=...</code>
|
||||||
</html>
|
<p><strong>Step 3:</strong> Change <code style="display: inline; background: #ffe6e6; padding: 2px 6px;">127.0.0.1</code> to <code style="display: inline; background: #e8f5e9; padding: 2px 6px;">{host}</code> and press Enter:
|
||||||
'''
|
<button class="copy-btn" onclick="copyIP()">Copy IP</button>
|
||||||
else:
|
</p>
|
||||||
# Local access - simple message
|
<code class="highlight">http://{host}:{callback_server_port}/callback?code=...</code>
|
||||||
return f'<h1>Spotify Authentication</h1><p>Click the link below to authenticate:</p><p><a href="{auth_url}" target="_blank">{auth_url}</a></p><p>After authentication, return to the app.</p>'
|
<p>Authentication will then complete!</p>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.addEventListener('message', function(event) {{
|
||||||
|
if (!event.data || event.data.type !== 'spotify-auth-complete') return;
|
||||||
|
setTimeout(() => window.close(), 300);
|
||||||
|
}});
|
||||||
|
|
||||||
|
function copyIP() {{
|
||||||
|
navigator.clipboard.writeText('{host}').then(() => {{
|
||||||
|
const btn = event.target;
|
||||||
|
btn.textContent = 'Copied!';
|
||||||
|
btn.classList.add('copied');
|
||||||
|
setTimeout(() => {{
|
||||||
|
btn.textContent = 'Copy IP';
|
||||||
|
btn.classList.remove('copied');
|
||||||
|
}}, 2000);
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
'''
|
||||||
else:
|
else:
|
||||||
return "<h1>Spotify Authentication Failed</h1><p>Could not initialize Spotify client. Check your credentials.</p>", 400
|
return "<h1>Spotify Authentication Failed</h1><p>Could not initialize Spotify client. Check your credentials.</p>", 400
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -5753,6 +5744,48 @@ def auth_tidal():
|
||||||
return f"<h1>Tidal Authentication Error</h1><p>{str(e)}</p>", 500
|
return f"<h1>Tidal Authentication Error</h1><p>{str(e)}</p>", 500
|
||||||
|
|
||||||
|
|
||||||
|
def _spotify_auth_result_page(detail_text: str, authenticated: bool = True) -> str:
|
||||||
|
"""Return the post-auth page and notify the opener."""
|
||||||
|
title = "Spotify Authentication Successful" if authenticated else "Spotify Authentication Completed"
|
||||||
|
heading = title
|
||||||
|
close_script = """
|
||||||
|
setTimeout(() => window.close(), 300);
|
||||||
|
""" if authenticated else """
|
||||||
|
const closeBtn = document.getElementById('close-window-btn');
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.addEventListener('click', () => window.close());
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>{title}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>{heading}</h1>
|
||||||
|
<p>{detail_text}</p>
|
||||||
|
{'<button id="close-window-btn" type="button">Close window</button>' if not authenticated else ''}
|
||||||
|
<script>
|
||||||
|
(function() {{
|
||||||
|
try {{
|
||||||
|
if (window.opener && !window.opener.closed) {{
|
||||||
|
window.opener.postMessage({{
|
||||||
|
type: 'spotify-auth-complete',
|
||||||
|
authenticated: {str(authenticated).lower()},
|
||||||
|
detail: {detail_text!r}
|
||||||
|
}}, '*');
|
||||||
|
}}
|
||||||
|
}} catch (error) {{
|
||||||
|
console.warn('Unable to notify opener about Spotify auth completion', error);
|
||||||
|
}}
|
||||||
|
{close_script}
|
||||||
|
}})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
@app.route('/callback')
|
@app.route('/callback')
|
||||||
def spotify_callback():
|
def spotify_callback():
|
||||||
"""
|
"""
|
||||||
|
|
@ -5811,10 +5844,22 @@ def spotify_callback():
|
||||||
)
|
)
|
||||||
token_info = auth_manager.get_access_token(auth_code)
|
token_info = auth_manager.get_access_token(auth_code)
|
||||||
if token_info:
|
if token_info:
|
||||||
# Invalidate cached profile client so it gets recreated with new tokens
|
|
||||||
metadata_registry.clear_cached_profile_spotify_client(profile_id_from_state)
|
metadata_registry.clear_cached_profile_spotify_client(profile_id_from_state)
|
||||||
add_activity_item("", "Spotify Auth Complete", f"Profile {profile_id_from_state} authenticated with Spotify", "Now")
|
profile_client = metadata_registry.get_spotify_client_for_profile(profile_id_from_state)
|
||||||
return "<h1>Spotify Authentication Successful!</h1><p>Your personal Spotify account is now connected. You can close this window.</p>"
|
profile_authenticated = bool(profile_client and profile_client.is_spotify_authenticated())
|
||||||
|
if profile_authenticated:
|
||||||
|
if profile_client:
|
||||||
|
profile_client._invalidate_auth_cache()
|
||||||
|
add_activity_item("", "Spotify Auth Complete", f"Profile {profile_id_from_state} authenticated with Spotify", "Now")
|
||||||
|
return _spotify_auth_result_page("Your personal Spotify account is now connected. You can close this window.", authenticated=True)
|
||||||
|
if profile_client:
|
||||||
|
profile_client._invalidate_auth_cache()
|
||||||
|
_status_cache_timestamps['spotify'] = 0
|
||||||
|
add_activity_item("", "Spotify Auth Warning", f"Profile {profile_id_from_state} completed OAuth but Spotify did not confirm an authenticated session", "Now")
|
||||||
|
return _spotify_auth_result_page(
|
||||||
|
"Spotify authorization completed, but SoulSync could not confirm an authenticated Spotify session for this profile. You can close this window and try Authenticate again.",
|
||||||
|
authenticated=False,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
raise Exception("Failed to exchange authorization code for access token")
|
raise Exception("Failed to exchange authorization code for access token")
|
||||||
|
|
||||||
|
|
@ -5851,9 +5896,16 @@ def spotify_callback():
|
||||||
spotify_enrichment_worker.client.reload_config()
|
spotify_enrichment_worker.client.reload_config()
|
||||||
spotify_enrichment_worker.client._invalidate_auth_cache()
|
spotify_enrichment_worker.client._invalidate_auth_cache()
|
||||||
add_activity_item("", "Spotify Auth Complete", "Successfully authenticated with Spotify", "Now")
|
add_activity_item("", "Spotify Auth Complete", "Successfully authenticated with Spotify", "Now")
|
||||||
return "<h1>Spotify Authentication Successful!</h1><p>You can close this window.</p>"
|
return _spotify_auth_result_page("You can close this window.", authenticated=True)
|
||||||
else:
|
else:
|
||||||
raise Exception("Token exchange succeeded but authentication validation failed")
|
logger.warning("Spotify OAuth token exchange succeeded but authentication validation failed")
|
||||||
|
spotify_client._invalidate_auth_cache()
|
||||||
|
_status_cache_timestamps['spotify'] = 0
|
||||||
|
add_activity_item("", "Spotify Auth Warning", "OAuth completed, but Spotify did not confirm an authenticated session", "Now")
|
||||||
|
return _spotify_auth_result_page(
|
||||||
|
"Spotify authorization completed, but SoulSync could not confirm an authenticated Spotify session. You can close this window and try Authenticate again.",
|
||||||
|
authenticated=False,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
raise Exception("Failed to exchange authorization code for access token")
|
raise Exception("Failed to exchange authorization code for access token")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -5864,26 +5916,37 @@ def spotify_callback():
|
||||||
|
|
||||||
@app.route('/api/spotify/disconnect', methods=['POST'])
|
@app.route('/api/spotify/disconnect', methods=['POST'])
|
||||||
def spotify_disconnect():
|
def spotify_disconnect():
|
||||||
"""Disconnect Spotify and fall back to iTunes/Apple Music"""
|
"""Disconnect Spotify and keep using the active primary metadata source."""
|
||||||
global spotify_client
|
global spotify_client
|
||||||
try:
|
try:
|
||||||
|
configured_source = config_manager.get('metadata.fallback_source', 'deezer') or 'deezer'
|
||||||
# Pause enrichment worker before disconnecting to prevent it from hammering API
|
# Pause enrichment worker before disconnecting to prevent it from hammering API
|
||||||
if spotify_enrichment_worker:
|
if spotify_enrichment_worker:
|
||||||
spotify_enrichment_worker.pause()
|
spotify_enrichment_worker.pause()
|
||||||
spotify_client.disconnect()
|
spotify_client.disconnect()
|
||||||
# Immediately update status cache so UI reflects the change
|
# Immediately update status cache so UI reflects the change
|
||||||
fallback_src = _get_metadata_fallback_source()
|
active_source = get_spotify_disconnect_source(configured_source)
|
||||||
|
source_label = get_metadata_source_label(active_source)
|
||||||
|
if configured_source == 'spotify':
|
||||||
|
config_manager.set('metadata.fallback_source', active_source)
|
||||||
_status_cache['spotify'] = {
|
_status_cache['spotify'] = {
|
||||||
'connected': True, # Fallback source is always available
|
'connected': False,
|
||||||
|
'authenticated': False,
|
||||||
'response_time': 0,
|
'response_time': 0,
|
||||||
'source': fallback_src,
|
'source': active_source,
|
||||||
'rate_limited': False,
|
'rate_limited': False,
|
||||||
'rate_limit': None
|
'rate_limit': None,
|
||||||
|
'post_ban_cooldown': None
|
||||||
}
|
}
|
||||||
_status_cache_timestamps['spotify'] = time.time()
|
_status_cache_timestamps['spotify'] = time.time()
|
||||||
fallback_label = 'Deezer' if fallback_src == 'deezer' else 'Discogs' if fallback_src == 'discogs' else 'iTunes'
|
add_activity_item("", "Spotify Disconnected", f"Using {source_label} for metadata", "Now")
|
||||||
add_activity_item("", "Spotify Disconnected", f"Switched to {fallback_label} metadata source", "Now")
|
return jsonify({
|
||||||
return jsonify({'success': True, 'message': f'Spotify disconnected. Now using {fallback_label}.'})
|
'success': True,
|
||||||
|
'message': f'Spotify disconnected. Using {source_label} for metadata.',
|
||||||
|
'source': active_source,
|
||||||
|
'authenticated': False,
|
||||||
|
'primary_source_changed': configured_source == 'spotify'
|
||||||
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error disconnecting Spotify: {e}")
|
logger.error(f"Error disconnecting Spotify: {e}")
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
@ -31986,9 +32049,19 @@ def start_oauth_callback_servers():
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header('Content-type', 'text/html')
|
self.send_header('Content-type', 'text/html')
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(b'<h1>Spotify Authentication Successful!</h1><p>You can close this window.</p>')
|
self.wfile.write(_spotify_auth_result_page("You can close this window.", authenticated=True).encode("utf-8"))
|
||||||
else:
|
else:
|
||||||
raise Exception("Token exchange succeeded but authentication validation failed")
|
_oauth_logger.warning("Spotify token exchange succeeded but authentication validation failed")
|
||||||
|
spotify_client._invalidate_auth_cache()
|
||||||
|
_status_cache_timestamps['spotify'] = 0
|
||||||
|
add_activity_item("", "Spotify Auth Warning", "OAuth completed, but Spotify did not confirm an authenticated session", "Now")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-type', 'text/html')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(_spotify_auth_result_page(
|
||||||
|
"Spotify authorization completed, but SoulSync could not confirm an authenticated Spotify session. You can close this window and try Authenticate again.",
|
||||||
|
authenticated=False,
|
||||||
|
).encode("utf-8"))
|
||||||
else:
|
else:
|
||||||
raise Exception("Failed to exchange authorization code for access token")
|
raise Exception("Failed to exchange authorization code for access token")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -3693,7 +3693,7 @@
|
||||||
<div class="api-service-frame">
|
<div class="api-service-frame">
|
||||||
<h4 class="service-title" style="color: #e8e8e8;">Metadata Source</h4>
|
<h4 class="service-title" style="color: #e8e8e8;">Metadata Source</h4>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Primary Source:</label>
|
<label>Primary metadata source:</label>
|
||||||
<select id="metadata-fallback-source">
|
<select id="metadata-fallback-source">
|
||||||
<option value="spotify">Spotify</option>
|
<option value="spotify">Spotify</option>
|
||||||
<option value="itunes">iTunes / Apple Music</option>
|
<option value="itunes">iTunes / Apple Music</option>
|
||||||
|
|
@ -3702,7 +3702,7 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="callback-info">
|
<div class="callback-info">
|
||||||
<div class="callback-help">The primary source for artist, album, and track metadata. Spotify requires authentication below. Discogs requires a personal token.</div>
|
<div class="callback-help">Choose the primary source for artist, album, and track metadata. Spotify can only be selected while an active Spotify session exists. Discogs requires a personal token.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -3741,10 +3741,6 @@
|
||||||
<button class="auth-button disconnect-button" id="spotify-disconnect-btn"
|
<button class="auth-button disconnect-button" id="spotify-disconnect-btn"
|
||||||
onclick="disconnectSpotify()" style="display: none;">🔌
|
onclick="disconnectSpotify()" style="display: none;">🔌
|
||||||
Disconnect</button>
|
Disconnect</button>
|
||||||
<button class="auth-button disconnect-button"
|
|
||||||
onclick="clearSpotifyCacheAndFallback()"
|
|
||||||
title="Clear Spotify token cache and switch to your configured fallback metadata source">🗑️
|
|
||||||
Clear Cache & Use Fallback</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -7864,13 +7860,13 @@
|
||||||
<span class="rate-limit-value rate-limit-countdown" id="rate-limit-countdown">—</span>
|
<span class="rate-limit-value rate-limit-countdown" id="rate-limit-countdown">—</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="rate-limit-hint">You can wait for the ban to expire (the app uses Apple Music in the meantime) or disconnect Spotify to clear the ban immediately.</p>
|
<p class="rate-limit-hint">While rate limiting is active, Spotify-specific features are unavailable. You can wait for the ban to expire or disconnect Spotify to clear it immediately.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="confirm-modal-actions rate-limit-modal-actions">
|
<div class="confirm-modal-actions rate-limit-modal-actions">
|
||||||
<button class="modal-button modal-button--secondary" onclick="closeRateLimitModal()">Dismiss</button>
|
<button class="modal-button modal-button--secondary" onclick="closeRateLimitModal()">Dismiss</button>
|
||||||
<button class="modal-button rate-limit-disconnect-btn" onclick="disconnectSpotifyFromRateLimit()">
|
<button class="modal-button rate-limit-disconnect-btn" onclick="disconnectSpotifyFromRateLimit()">
|
||||||
Disconnect Spotify
|
Disconnect
|
||||||
<span class="rate-limit-disconnect-sub">Clear ban, pause enrichment & switch to fallback source</span>
|
<span class="rate-limit-disconnect-sub">Clear the ban and pause Spotify-specific features</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -473,6 +473,16 @@ function handleServiceStatusUpdate(data) {
|
||||||
// Cache for library status card
|
// Cache for library status card
|
||||||
_lastServiceStatus = data;
|
_lastServiceStatus = data;
|
||||||
|
|
||||||
|
if (typeof syncSpotifySettingsAuthState === 'function') {
|
||||||
|
syncSpotifySettingsAuthState(data?.spotify || null);
|
||||||
|
}
|
||||||
|
if (typeof syncPrimaryMetadataSourceAvailability === 'function') {
|
||||||
|
syncPrimaryMetadataSourceAvailability(data?.spotify || null);
|
||||||
|
}
|
||||||
|
if (typeof sanitizeMetadataSourceSelection === 'function') {
|
||||||
|
sanitizeMetadataSourceSelection({ quiet: true });
|
||||||
|
}
|
||||||
|
|
||||||
// Same logic as fetchAndUpdateServiceStatus response handler
|
// Same logic as fetchAndUpdateServiceStatus response handler
|
||||||
updateServiceStatus('spotify', data.spotify);
|
updateServiceStatus('spotify', data.spotify);
|
||||||
updateServiceStatus('media-server', data.media_server);
|
updateServiceStatus('media-server', data.media_server);
|
||||||
|
|
@ -876,4 +886,3 @@ let _lastServiceStatus = null;
|
||||||
let _isSoulsyncStandalone = false; // Global flag: true when no media server (sync buttons hidden)
|
let _isSoulsyncStandalone = false; // Global flag: true when no media server (sync buttons hidden)
|
||||||
|
|
||||||
// ===============================
|
// ===============================
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2979,8 +2979,8 @@ async function _checkSetupStatus() {
|
||||||
const resp = await fetch('/status');
|
const resp = await fetch('/status');
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
// Metadata source: spotify.connected is always true (iTunes fallback), check .source
|
// Metadata source is available when status reports a source.
|
||||||
if (data.spotify?.connected && data.spotify?.source) {
|
if (data.spotify?.source) {
|
||||||
results['metadata-source'] = results['metadata-source'] || Date.now();
|
results['metadata-source'] = results['metadata-source'] || Date.now();
|
||||||
_markSetupComplete('metadata-source');
|
_markSetupComplete('metadata-source');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1970,6 +1970,9 @@ function initApp() {
|
||||||
initExpandedPlayer();
|
initExpandedPlayer();
|
||||||
initializeSyncPage();
|
initializeSyncPage();
|
||||||
initializeWatchlist();
|
initializeWatchlist();
|
||||||
|
if (typeof initializeSpotifyAuthCompletionListener === 'function') {
|
||||||
|
initializeSpotifyAuthCompletionListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Initialize WebSocket connection (falls back to HTTP polling if unavailable)
|
// Initialize WebSocket connection (falls back to HTTP polling if unavailable)
|
||||||
|
|
@ -2371,4 +2374,3 @@ async function loadPageData(pageId) {
|
||||||
// Old updateStatusIndicator function removed - replaced by updateSidebarServiceStatus
|
// Old updateStatusIndicator function removed - replaced by updateSidebarServiceStatus
|
||||||
|
|
||||||
// ===============================
|
// ===============================
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,95 @@ function handleManualSaveClick() {
|
||||||
saveSettings(false);
|
saveSettings(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncMetadataSourceSelection(source) {
|
||||||
|
const select = document.getElementById('metadata-fallback-source');
|
||||||
|
if (!select || !source) return;
|
||||||
|
const option = select.querySelector(`option[value="${source}"]`);
|
||||||
|
if (option) select.value = source;
|
||||||
|
select.dataset.lastValidSource = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isMetadataSourceSelectable(source) {
|
||||||
|
if (source === 'spotify') {
|
||||||
|
return _lastServiceStatus?.spotify?.authenticated === true;
|
||||||
|
}
|
||||||
|
if (source === 'discogs') {
|
||||||
|
const token = document.getElementById('discogs-token');
|
||||||
|
return !!token?.value?.trim();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _metadataSourceFallback(source) {
|
||||||
|
if (source === 'spotify') return 'deezer';
|
||||||
|
if (source === 'discogs') return 'itunes';
|
||||||
|
return 'itunes';
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusServiceSettingsSection(service, message) {
|
||||||
|
const card = document.querySelector(`#settings-page .stg-service[data-service="${service}"]`);
|
||||||
|
if (!card) return;
|
||||||
|
|
||||||
|
const header = card.querySelector('.stg-service-header');
|
||||||
|
if (!card.classList.contains('expanded') && header) {
|
||||||
|
toggleStgService(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
|
||||||
|
const firstControl = card.querySelector('input, button');
|
||||||
|
if (firstControl) {
|
||||||
|
firstControl.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
showToast(message, 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeMetadataSourceSelection({ quiet = true } = {}) {
|
||||||
|
const select = document.getElementById('metadata-fallback-source');
|
||||||
|
if (!select) return false;
|
||||||
|
|
||||||
|
const selectedSource = select.value || 'itunes';
|
||||||
|
if (_isMetadataSourceSelectable(selectedSource)) {
|
||||||
|
select.dataset.lastValidSource = selectedSource;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastValid = select.dataset.lastValidSource;
|
||||||
|
const fallbackSource = lastValid && lastValid !== selectedSource && _isMetadataSourceSelectable(lastValid)
|
||||||
|
? lastValid
|
||||||
|
: _metadataSourceFallback(selectedSource);
|
||||||
|
|
||||||
|
if (fallbackSource && fallbackSource !== selectedSource) {
|
||||||
|
select.value = fallbackSource;
|
||||||
|
}
|
||||||
|
select.dataset.lastValidSource = fallbackSource;
|
||||||
|
|
||||||
|
if (!quiet) {
|
||||||
|
const message = selectedSource === 'discogs'
|
||||||
|
? 'Discogs requires a personal access token before it can be selected as the primary metadata source.'
|
||||||
|
: 'Spotify must be authenticated before it can be selected as the primary metadata source.';
|
||||||
|
focusServiceSettingsSection(selectedSource, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMetadataSourceChange(event) {
|
||||||
|
const select = event.target;
|
||||||
|
if (!select || select.id !== 'metadata-fallback-source') return;
|
||||||
|
|
||||||
|
const selectedSource = select.value;
|
||||||
|
if (_isMetadataSourceSelectable(selectedSource)) {
|
||||||
|
select.dataset.lastValidSource = selectedSource;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitizeMetadataSourceSelection({ quiet: false });
|
||||||
|
}
|
||||||
|
|
||||||
function initializeSettings() {
|
function initializeSettings() {
|
||||||
// This function is called when the settings page is loaded.
|
// This function is called when the settings page is loaded.
|
||||||
// It attaches event listeners to all interactive elements on the page.
|
// It attaches event listeners to all interactive elements on the page.
|
||||||
|
|
@ -76,6 +165,20 @@ function initializeSettings() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const metadataSourceSelect = document.getElementById('metadata-fallback-source');
|
||||||
|
if (metadataSourceSelect) {
|
||||||
|
metadataSourceSelect.addEventListener('change', handleMetadataSourceChange);
|
||||||
|
}
|
||||||
|
const discogsTokenInput = document.getElementById('discogs-token');
|
||||||
|
if (discogsTokenInput) {
|
||||||
|
discogsTokenInput.addEventListener('input', () => {
|
||||||
|
if (typeof syncPrimaryMetadataSourceAvailability === 'function') {
|
||||||
|
syncPrimaryMetadataSourceAvailability(_lastServiceStatus?.spotify || null);
|
||||||
|
}
|
||||||
|
sanitizeMetadataSourceSelection({ quiet: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Server toggle buttons
|
// Server toggle buttons
|
||||||
const plexToggle = document.getElementById('plex-toggle');
|
const plexToggle = document.getElementById('plex-toggle');
|
||||||
if (plexToggle) {
|
if (plexToggle) {
|
||||||
|
|
@ -102,6 +205,16 @@ function initializeSettings() {
|
||||||
|
|
||||||
// Test connection buttons
|
// Test connection buttons
|
||||||
// Test button event listeners removed - they use onclick attributes in HTML to avoid double firing
|
// Test button event listeners removed - they use onclick attributes in HTML to avoid double firing
|
||||||
|
|
||||||
|
if (typeof syncPrimaryMetadataSourceAvailability === 'function') {
|
||||||
|
syncPrimaryMetadataSourceAvailability(_lastServiceStatus?.spotify || null);
|
||||||
|
}
|
||||||
|
syncSpotifySettingsAuthState(_lastServiceStatus?.spotify || null);
|
||||||
|
syncMetadataSourceSelection(_lastServiceStatus?.spotify?.source);
|
||||||
|
sanitizeMetadataSourceSelection({ quiet: true });
|
||||||
|
if (metadataSourceSelect) {
|
||||||
|
metadataSourceSelect.dataset.lastValidSource = metadataSourceSelect.value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetFileOrganizationTemplates() {
|
function resetFileOrganizationTemplates() {
|
||||||
|
|
@ -295,11 +408,37 @@ async function applyServiceStatusGradients() {
|
||||||
else header.appendChild(spinner);
|
else header.appendChild(spinner);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
syncSpotifySettingsAuthState(_lastServiceStatus?.spotify || null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Settings Status] Failed to apply gradients:', e);
|
console.warn('[Settings Status] Failed to apply gradients:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncSpotifySettingsAuthState(statusData) {
|
||||||
|
if (!statusData) return;
|
||||||
|
|
||||||
|
const card = document.querySelector('#settings-page .stg-service[data-service="spotify"]');
|
||||||
|
if (!card) return;
|
||||||
|
|
||||||
|
const header = card.querySelector('.stg-service-header');
|
||||||
|
const dot = card.querySelector('.stg-service-dot');
|
||||||
|
if (!header && !dot) return;
|
||||||
|
|
||||||
|
const authenticated = statusData?.authenticated === true;
|
||||||
|
const rateLimited = !!(statusData?.rate_limited && statusData?.rate_limit);
|
||||||
|
const cooldown = !!(statusData?.post_ban_cooldown > 0);
|
||||||
|
const needsAttention = !authenticated || rateLimited || cooldown;
|
||||||
|
|
||||||
|
if (header) {
|
||||||
|
header.classList.toggle('status-configured', !needsAttention);
|
||||||
|
header.classList.toggle('status-missing', needsAttention);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dot) {
|
||||||
|
dot.style.color = needsAttention ? '#f1c40f' : '#1DB954';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function _stgSetCheckingState(service, isChecking) {
|
function _stgSetCheckingState(service, isChecking) {
|
||||||
const card = document.querySelector(`#settings-page .stg-service[data-service="${service}"]`);
|
const card = document.querySelector(`#settings-page .stg-service[data-service="${service}"]`);
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
|
|
@ -2400,6 +2539,25 @@ async function saveSettings(quiet = false) {
|
||||||
activeServer = 'soulsync';
|
activeServer = 'soulsync';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const metadataSourceSelect = document.getElementById('metadata-fallback-source');
|
||||||
|
const discogsTokenInput = document.getElementById('discogs-token');
|
||||||
|
const discogsTokenPresent = !!discogsTokenInput?.value?.trim();
|
||||||
|
let metadataSource = metadataSourceSelect?.value || 'itunes';
|
||||||
|
const spotifySessionActive = _lastServiceStatus?.spotify?.authenticated === true;
|
||||||
|
if (metadataSource === 'spotify' && !spotifySessionActive) {
|
||||||
|
metadataSource = 'deezer';
|
||||||
|
if (metadataSourceSelect) metadataSourceSelect.value = metadataSource;
|
||||||
|
if (!quiet) {
|
||||||
|
showToast('Spotify is disconnected, so Deezer is used as the primary metadata source.', 'warning');
|
||||||
|
}
|
||||||
|
} else if (metadataSource === 'discogs' && !discogsTokenPresent) {
|
||||||
|
metadataSource = 'itunes';
|
||||||
|
if (metadataSourceSelect) metadataSourceSelect.value = metadataSource;
|
||||||
|
if (!quiet) {
|
||||||
|
showToast('Discogs requires a personal access token before it can be selected as the primary metadata source.', 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
active_media_server: activeServer,
|
active_media_server: activeServer,
|
||||||
spotify: {
|
spotify: {
|
||||||
|
|
@ -2472,7 +2630,7 @@ async function saveSettings(quiet = false) {
|
||||||
token: document.getElementById('discogs-token').value,
|
token: document.getElementById('discogs-token').value,
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
fallback_source: document.getElementById('metadata-fallback-source').value || 'itunes'
|
fallback_source: metadataSource
|
||||||
},
|
},
|
||||||
hydrabase: {
|
hydrabase: {
|
||||||
url: document.getElementById('hydrabase-url').value,
|
url: document.getElementById('hydrabase-url').value,
|
||||||
|
|
@ -3056,7 +3214,7 @@ async function authenticateSpotify() {
|
||||||
// Save settings first to ensure client_id/client_secret are persisted
|
// Save settings first to ensure client_id/client_secret are persisted
|
||||||
await saveSettings();
|
await saveSettings();
|
||||||
showToast('Spotify authentication started', 'success');
|
showToast('Spotify authentication started', 'success');
|
||||||
window.open('/auth/spotify', '_blank');
|
window._spotifyAuthWindow = window.open('/auth/spotify', '_blank');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error authenticating Spotify:', error);
|
console.error('Error authenticating Spotify:', error);
|
||||||
showToast('Failed to start Spotify authentication', 'error', 'gs-connecting');
|
showToast('Failed to start Spotify authentication', 'error', 'gs-connecting');
|
||||||
|
|
@ -3066,8 +3224,10 @@ async function authenticateSpotify() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function disconnectSpotify() {
|
async function disconnectSpotify() {
|
||||||
const fallbackName = currentMusicSourceName !== 'Spotify' ? currentMusicSourceName : 'the configured fallback source';
|
if (!await showConfirmDialog({
|
||||||
if (!await showConfirmDialog({ title: 'Disconnect Spotify', message: `Disconnect Spotify? The app will switch to ${fallbackName} for metadata.` })) {
|
title: 'Disconnect Spotify',
|
||||||
|
message: 'Disconnect Spotify? Spotify-specific actions will stop until you reauthenticate.'
|
||||||
|
})) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -3075,7 +3235,8 @@ async function disconnectSpotify() {
|
||||||
const response = await fetch('/api/spotify/disconnect', { method: 'POST' });
|
const response = await fetch('/api/spotify/disconnect', { method: 'POST' });
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast(`Spotify disconnected. Now using ${fallbackName}.`, 'success');
|
showToast(data.message || 'Spotify disconnected.', 'success');
|
||||||
|
syncMetadataSourceSelection(data.source || 'deezer');
|
||||||
// Immediately refresh status to update UI
|
// Immediately refresh status to update UI
|
||||||
await fetchAndUpdateServiceStatus();
|
await fetchAndUpdateServiceStatus();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -3089,29 +3250,6 @@ async function disconnectSpotify() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearSpotifyCacheAndFallback() {
|
|
||||||
const fallbackName = currentMusicSourceName !== 'Spotify' ? currentMusicSourceName : 'the configured fallback source';
|
|
||||||
if (!await showConfirmDialog({
|
|
||||||
title: 'Clear Spotify Cache',
|
|
||||||
message: `This will clear the Spotify token cache and switch metadata to ${fallbackName}. You can re-authenticate later.`
|
|
||||||
})) return;
|
|
||||||
try {
|
|
||||||
showLoadingOverlay('Clearing Spotify cache...');
|
|
||||||
const response = await fetch('/api/spotify/disconnect', { method: 'POST' });
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.success) {
|
|
||||||
showToast(data.message || `Switched to ${fallbackName}`, 'success');
|
|
||||||
await fetchAndUpdateServiceStatus();
|
|
||||||
} else {
|
|
||||||
showToast(`Failed: ${data.error}`, 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showToast('Failed to clear Spotify cache', 'error');
|
|
||||||
} finally {
|
|
||||||
hideLoadingOverlay();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Spotify Rate Limit Handling ───────────────────────────────────────────
|
// ── Spotify Rate Limit Handling ───────────────────────────────────────────
|
||||||
let _spotifyRateLimitShown = false;
|
let _spotifyRateLimitShown = false;
|
||||||
let _spotifyInCooldown = false;
|
let _spotifyInCooldown = false;
|
||||||
|
|
@ -3198,7 +3336,8 @@ async function disconnectSpotifyFromRateLimit() {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
_spotifyRateLimitShown = false;
|
_spotifyRateLimitShown = false;
|
||||||
showToast(`Spotify disconnected. Now using ${currentMusicSourceName}.`, 'success');
|
showToast(data.message || 'Spotify disconnected.', 'success');
|
||||||
|
syncMetadataSourceSelection(data.source || 'deezer');
|
||||||
await fetchAndUpdateServiceStatus();
|
await fetchAndUpdateServiceStatus();
|
||||||
if (currentPage === 'discover') {
|
if (currentPage === 'discover') {
|
||||||
loadDiscoverPage();
|
loadDiscoverPage();
|
||||||
|
|
@ -3879,4 +4018,3 @@ function togglePathLock(pathType, btn) {
|
||||||
|
|
||||||
|
|
||||||
// ===============================
|
// ===============================
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3107,6 +3107,32 @@ async function _forceServiceStatusRefresh() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _spotifyAuthCompletionListenerInstalled = false;
|
||||||
|
window._spotifyAuthWindow = window._spotifyAuthWindow || null;
|
||||||
|
|
||||||
|
function initializeSpotifyAuthCompletionListener() {
|
||||||
|
if (_spotifyAuthCompletionListenerInstalled) return;
|
||||||
|
_spotifyAuthCompletionListenerInstalled = true;
|
||||||
|
|
||||||
|
window.addEventListener('message', async event => {
|
||||||
|
if (!event.data || event.data.type !== 'spotify-auth-complete') return;
|
||||||
|
if (window._spotifyAuthWindow && event.source && event.source !== window._spotifyAuthWindow) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
window._spotifyAuthWindow = null;
|
||||||
|
await _forceServiceStatusRefresh();
|
||||||
|
if (event.data.authenticated === false) {
|
||||||
|
showToast(
|
||||||
|
event.data.detail || 'Spotify authorization completed, but no authenticated session was detected.',
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not refresh Spotify status after auth completion:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchAndUpdateServiceStatus() {
|
async function fetchAndUpdateServiceStatus() {
|
||||||
if (document.hidden) return; // Skip polling when tab is not visible
|
if (document.hidden) return; // Skip polling when tab is not visible
|
||||||
if (socketConnected) return; // WebSocket is pushing updates — skip HTTP poll
|
if (socketConnected) return; // WebSocket is pushing updates — skip HTTP poll
|
||||||
|
|
@ -3119,6 +3145,16 @@ async function fetchAndUpdateServiceStatus() {
|
||||||
// Cache for library status card
|
// Cache for library status card
|
||||||
_lastServiceStatus = data;
|
_lastServiceStatus = data;
|
||||||
|
|
||||||
|
if (typeof syncSpotifySettingsAuthState === 'function') {
|
||||||
|
syncSpotifySettingsAuthState(data?.spotify || null);
|
||||||
|
}
|
||||||
|
if (typeof syncPrimaryMetadataSourceAvailability === 'function') {
|
||||||
|
syncPrimaryMetadataSourceAvailability(data?.spotify || null);
|
||||||
|
}
|
||||||
|
if (typeof sanitizeMetadataSourceSelection === 'function') {
|
||||||
|
sanitizeMetadataSourceSelection({ quiet: true });
|
||||||
|
}
|
||||||
|
|
||||||
// Update service status indicators and text (dashboard)
|
// Update service status indicators and text (dashboard)
|
||||||
updateServiceStatus('spotify', data.spotify);
|
updateServiceStatus('spotify', data.spotify);
|
||||||
updateServiceStatus('media-server', data.media_server);
|
updateServiceStatus('media-server', data.media_server);
|
||||||
|
|
@ -3160,28 +3196,109 @@ async function fetchAndUpdateServiceStatus() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncPrimaryMetadataSourceAvailability(statusData) {
|
||||||
|
const select = document.getElementById('metadata-fallback-source');
|
||||||
|
if (!select) return;
|
||||||
|
if (!statusData) return;
|
||||||
|
|
||||||
|
const spotifyOption = select.querySelector('option[value="spotify"]');
|
||||||
|
const discogsOption = select.querySelector('option[value="discogs"]');
|
||||||
|
|
||||||
|
const spotifyAvailable = statusData?.authenticated === true;
|
||||||
|
if (spotifyOption) {
|
||||||
|
spotifyOption.dataset.unavailable = spotifyAvailable ? 'false' : 'true';
|
||||||
|
spotifyOption.textContent = spotifyAvailable ? 'Spotify' : '🔒 Spotify';
|
||||||
|
spotifyOption.title = spotifyAvailable
|
||||||
|
? 'Spotify'
|
||||||
|
: 'Spotify authentication is required before this source can be selected.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (discogsOption) {
|
||||||
|
const discogsToken = document.getElementById('discogs-token');
|
||||||
|
const discogsAvailable = !!discogsToken?.value?.trim();
|
||||||
|
discogsOption.dataset.unavailable = discogsAvailable ? 'false' : 'true';
|
||||||
|
discogsOption.textContent = discogsAvailable ? 'Discogs' : '🔒 Discogs';
|
||||||
|
discogsOption.title = discogsAvailable
|
||||||
|
? 'Discogs'
|
||||||
|
: 'Discogs personal access token is required before this source can be selected.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetadataSourceLabel(source) {
|
||||||
|
if (source === 'deezer') return 'Deezer';
|
||||||
|
if (source === 'discogs') return 'Discogs';
|
||||||
|
if (source === 'itunes') return 'iTunes';
|
||||||
|
if (source === 'spotify') return 'Spotify';
|
||||||
|
return 'Unmapped';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSpotifyStatusPresentation(statusData) {
|
||||||
|
const sourceLabel = getMetadataSourceLabel(statusData?.source);
|
||||||
|
const rateLimited = !!(statusData?.rate_limited && statusData?.rate_limit);
|
||||||
|
const cooldown = !!(statusData?.post_ban_cooldown > 0);
|
||||||
|
const sessionActive = statusData?.authenticated === true || (statusData?.authenticated === undefined && statusData?.source === 'spotify');
|
||||||
|
|
||||||
|
if (rateLimited) {
|
||||||
|
const remaining = statusData.rate_limit?.remaining_seconds || 0;
|
||||||
|
return {
|
||||||
|
statusClass: 'rate-limited',
|
||||||
|
statusText: `Spotify paused \u2014 ${formatRateLimitDuration(remaining)}`,
|
||||||
|
dotClass: 'rate-limited',
|
||||||
|
dotTitle: `Spotify paused \u2014 ${formatRateLimitDuration(remaining)} remaining`,
|
||||||
|
sessionActive
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cooldown) {
|
||||||
|
const remaining = statusData.post_ban_cooldown;
|
||||||
|
return {
|
||||||
|
statusClass: 'rate-limited',
|
||||||
|
statusText: `Spotify recovering \u2014 ${formatRateLimitDuration(remaining)}`,
|
||||||
|
dotClass: 'rate-limited',
|
||||||
|
dotTitle: `Spotify recovering \u2014 ${formatRateLimitDuration(remaining)} cooldown`,
|
||||||
|
sessionActive
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusData?.source && statusData.source !== 'spotify') {
|
||||||
|
return {
|
||||||
|
statusClass: 'connected',
|
||||||
|
statusText: sourceLabel,
|
||||||
|
dotClass: 'connected',
|
||||||
|
dotTitle: sourceLabel,
|
||||||
|
sessionActive
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusClass: 'connected',
|
||||||
|
statusText: `Connected (${statusData?.response_time}ms)`,
|
||||||
|
dotClass: 'connected',
|
||||||
|
dotTitle: '',
|
||||||
|
sessionActive
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function updateServiceStatus(service, statusData) {
|
function updateServiceStatus(service, statusData) {
|
||||||
const indicator = document.getElementById(`${service}-status-indicator`);
|
const indicator = document.getElementById(`${service}-status-indicator`);
|
||||||
const statusText = document.getElementById(`${service}-status-text`);
|
const statusText = document.getElementById(`${service}-status-text`);
|
||||||
|
|
||||||
if (indicator && statusText) {
|
if (indicator && statusText) {
|
||||||
if (service === 'spotify' && (statusData.rate_limited || statusData.post_ban_cooldown)) {
|
if (service === 'spotify') {
|
||||||
indicator.className = 'service-card-indicator rate-limited';
|
const presentation = getSpotifyStatusPresentation(statusData || {});
|
||||||
const remaining = statusData.rate_limited
|
indicator.className = `service-card-indicator ${presentation.statusClass}`;
|
||||||
? formatRateLimitDuration(statusData.rate_limit?.remaining_seconds || 0)
|
statusText.textContent = presentation.statusText;
|
||||||
: formatRateLimitDuration(statusData.post_ban_cooldown);
|
statusText.className = `service-card-status-text ${presentation.statusClass}`;
|
||||||
const phase = statusData.rate_limited ? 'paused' : 'recovering';
|
|
||||||
const fallbackLabel = statusData.source === 'deezer' ? 'Deezer' : 'iTunes';
|
|
||||||
statusText.textContent = `${fallbackLabel} (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)`;
|
|
||||||
statusText.className = 'service-card-status-text connected';
|
|
||||||
} else {
|
} else {
|
||||||
indicator.className = 'service-card-indicator disconnected';
|
if (statusData.connected) {
|
||||||
statusText.textContent = 'Disconnected';
|
indicator.className = 'service-card-indicator connected';
|
||||||
statusText.className = 'service-card-status-text disconnected';
|
statusText.textContent = `Connected (${statusData.response_time}ms)`;
|
||||||
|
statusText.className = 'service-card-status-text connected';
|
||||||
|
} else {
|
||||||
|
indicator.className = 'service-card-indicator disconnected';
|
||||||
|
statusText.textContent = 'Disconnected';
|
||||||
|
statusText.className = 'service-card-status-text disconnected';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3189,16 +3306,23 @@ function updateServiceStatus(service, statusData) {
|
||||||
if (service === 'spotify' && statusData.source) {
|
if (service === 'spotify' && statusData.source) {
|
||||||
const musicSourceTitleElement = document.getElementById('music-source-title');
|
const musicSourceTitleElement = document.getElementById('music-source-title');
|
||||||
if (musicSourceTitleElement) {
|
if (musicSourceTitleElement) {
|
||||||
const sourceName = statusData.source === 'spotify' ? 'Spotify' : statusData.source === 'deezer' ? 'Deezer' : statusData.source === 'discogs' ? 'Discogs' : 'iTunes';
|
const sourceName = getMetadataSourceLabel(statusData.source);
|
||||||
musicSourceTitleElement.textContent = sourceName;
|
musicSourceTitleElement.textContent = sourceName;
|
||||||
currentMusicSourceName = sourceName;
|
currentMusicSourceName = sourceName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show/hide Spotify disconnect button based on connection state
|
// Keep the Spotify action buttons aligned with the actual auth session.
|
||||||
|
const spotifySessionActive = getSpotifyStatusPresentation(statusData || {}).sessionActive;
|
||||||
|
const authBtn = document.querySelector('button[onclick="authenticateSpotify()"]');
|
||||||
const disconnectBtn = document.getElementById('spotify-disconnect-btn');
|
const disconnectBtn = document.getElementById('spotify-disconnect-btn');
|
||||||
if (disconnectBtn) {
|
if (authBtn) {
|
||||||
disconnectBtn.style.display = statusData.source === 'spotify' ? '' : 'none';
|
authBtn.style.display = spotifySessionActive ? 'none' : '';
|
||||||
}
|
}
|
||||||
|
if (disconnectBtn) {
|
||||||
|
disconnectBtn.style.display = spotifySessionActive ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
syncPrimaryMetadataSourceAvailability(statusData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update download source title on dashboard card
|
// Update download source title on dashboard card
|
||||||
|
|
@ -3217,16 +3341,12 @@ function updateSidebarServiceStatus(service, statusData) {
|
||||||
const nameElement = indicator.querySelector('.status-name');
|
const nameElement = indicator.querySelector('.status-name');
|
||||||
|
|
||||||
if (dot) {
|
if (dot) {
|
||||||
if (service === 'spotify' && (statusData.rate_limited || statusData.post_ban_cooldown)) {
|
if (service === 'spotify') {
|
||||||
dot.className = 'status-dot rate-limited';
|
const presentation = getSpotifyStatusPresentation(statusData || {});
|
||||||
dot.title = statusData.rate_limited
|
dot.className = `status-dot ${presentation.dotClass}`;
|
||||||
? `Spotify paused \u2014 ${formatRateLimitDuration(statusData.rate_limit?.remaining_seconds || 0)} remaining`
|
dot.title = presentation.dotTitle;
|
||||||
: `Spotify recovering \u2014 ${formatRateLimitDuration(statusData.post_ban_cooldown)} cooldown`;
|
|
||||||
} else if (statusData.connected) {
|
|
||||||
dot.className = 'status-dot connected';
|
|
||||||
dot.title = '';
|
|
||||||
} else {
|
} else {
|
||||||
dot.className = 'status-dot disconnected';
|
dot.className = statusData?.connected ? 'status-dot connected' : 'status-dot disconnected';
|
||||||
dot.title = '';
|
dot.title = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3244,7 +3364,7 @@ function updateSidebarServiceStatus(service, statusData) {
|
||||||
if (service === 'spotify' && statusData.source) {
|
if (service === 'spotify' && statusData.source) {
|
||||||
const musicSourceNameElement = document.getElementById('music-source-name');
|
const musicSourceNameElement = document.getElementById('music-source-name');
|
||||||
if (musicSourceNameElement) {
|
if (musicSourceNameElement) {
|
||||||
const sourceName = statusData.source === 'spotify' ? 'Spotify' : statusData.source === 'deezer' ? 'Deezer' : statusData.source === 'discogs' ? 'Discogs' : 'iTunes';
|
const sourceName = getMetadataSourceLabel(statusData.source);
|
||||||
musicSourceNameElement.textContent = sourceName;
|
musicSourceNameElement.textContent = sourceName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue