From 952c35de3990832bfdeb4611b267f7585a5adf1f Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:49:49 -0700 Subject: [PATCH 01/41] Add source param to /api/enhanced-search, bump to 2.40 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the Search/Artists unification project: the endpoint now accepts an optional `source` (spotify, itunes, deezer, discogs, hydrabase, musicbrainz) so callers can target a single metadata source instead of always fanning out. Omitted or `auto` preserves current multi-source behavior — no existing callers break. Cache keys include the source tag so per-source and fan-out results don't collide. --- web_server.py | 128 ++++++++++++++++++++++++++++++++++++++--- webui/static/helper.js | 5 ++ 2 files changed, 124 insertions(+), 9 deletions(-) diff --git a/web_server.py b/web_server.py index 8164f3f4..426838c4 100644 --- a/web_server.py +++ b/web_server.py @@ -37,7 +37,7 @@ _log_dir = Path(_log_path).parent logger = setup_logging(_log_level, _log_path) # App version — single source of truth for backup metadata, version-info endpoint, etc. -_SOULSYNC_BASE_VERSION = "2.39" +_SOULSYNC_BASE_VERSION = "2.40" def _build_version_string(): """Append short commit hash to version when available (e.g. 2.35+abc1234).""" @@ -2803,8 +2803,11 @@ _ENHANCED_SEARCH_CACHE_TTL = 600 _ENHANCED_SEARCH_CACHE_MAX_ENTRIES = 100 -def _get_enhanced_search_cache_key(query): - """Build a cache key that follows the current metadata/search configuration.""" +def _get_enhanced_search_cache_key(query, requested_source=None): + """Build a cache key that follows the current metadata/search configuration. + + When an explicit `requested_source` is provided (single-source search), it is + included in the key so that results for different sources don't collide.""" normalized_query = (query or '').strip().lower() try: @@ -2822,7 +2825,45 @@ def _get_enhanced_search_cache_key(query): except Exception: hydrabase_active = False - return (normalized_query, active_server, fallback_source, hydrabase_active) + source_tag = (requested_source or '').strip().lower() or 'auto' + return (normalized_query, active_server, fallback_source, hydrabase_active, source_tag) + + +ENHANCED_SEARCH_VALID_SOURCES = ( + 'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz', +) + + +def _resolve_enhanced_search_client(source_name): + """Return (client, is_available) for a single requested metadata source. + + Mirrors the client-resolution logic used by the /source/ endpoint so + that the `source` param on the main endpoint behaves consistently.""" + if source_name == 'spotify': + if spotify_client and spotify_client.is_spotify_authenticated(): + return spotify_client, True + return None, False + if source_name == 'itunes': + return _get_itunes_client(), True + if source_name == 'deezer': + return _get_deezer_client(), True + if source_name == 'discogs': + token = config_manager.get('discogs.token', '') + if not token: + return None, False + return _get_discogs_client(token), True + if source_name == 'hydrabase': + if hydrabase_client and hydrabase_client.is_connected(): + return hydrabase_client, True + return None, False + if source_name == 'musicbrainz': + try: + from core.musicbrainz_search import MusicBrainzSearchClient + return MusicBrainzSearchClient(), True + except Exception as e: + logger.warning(f"MusicBrainz search client init failed: {e}") + return None, False + return None, False def _get_cached_enhanced_search_response(cache_key): @@ -9215,10 +9256,22 @@ def enhanced_search(): Fires parallel queries against all available sources (Spotify, iTunes, Deezer) and returns results keyed by source, plus backward-compatible top-level keys mapped from the primary source. + + Optional JSON body param `source` (one of: auto, spotify, itunes, deezer, + discogs, hydrabase, musicbrainz). When set to a specific source, the endpoint + bypasses the primary-source fan-out and returns just that source's results + (plus the usual db_artists). `auto` and omitted behave identically — current + multi-source fan-out. """ data = request.get_json() query = data.get('query', '').strip() - cache_key = _get_enhanced_search_cache_key(query) + requested_source = (data.get('source') or '').strip().lower() + if requested_source == 'auto': + requested_source = '' + if requested_source and requested_source not in ENHANCED_SEARCH_VALID_SOURCES: + return jsonify({"error": f"Unknown source: {requested_source}"}), 400 + + cache_key = _get_enhanced_search_cache_key(query, requested_source) empty_source = {"artists": [], "albums": [], "tracks": [], "available": False} @@ -9238,7 +9291,10 @@ def enhanced_search(): logger.info(f"Enhanced search cache hit for: '{query}'") return jsonify(cached_response) - logger.info(f"Enhanced search initiated for: '{query}'") + logger.info( + f"Enhanced search initiated for: '{query}' " + f"(source={requested_source or 'auto'})" + ) try: # Search local database for artists (always) @@ -9260,20 +9316,62 @@ def enhanced_search(): # Very short queries are usually broad enough that remote metadata searches # just add latency without improving the result quality much. Keep them local. if len(query) < 3: - fb_source = _get_metadata_fallback_source() + short_source = requested_source or _get_metadata_fallback_source() response_data = { "db_artists": db_artists, "spotify_artists": [], "spotify_albums": [], "spotify_tracks": [], - "metadata_source": fb_source, - "primary_source": fb_source, + "metadata_source": short_source, + "primary_source": short_source, "alternate_sources": [], "sources": {}, } _set_cached_enhanced_search_response(cache_key, response_data) return jsonify(response_data) + # Explicit single-source search — bypass primary-source fan-out entirely. + if requested_source: + client, available = _resolve_enhanced_search_client(requested_source) + if not client: + response_data = { + "db_artists": db_artists, + "spotify_artists": [], + "spotify_albums": [], + "spotify_tracks": [], + "metadata_source": requested_source, + "primary_source": requested_source, + "alternate_sources": [], + "source_available": False, + } + _set_cached_enhanced_search_response(cache_key, response_data) + return jsonify(response_data) + + try: + source_results = _enhanced_search_source(query, client, requested_source) + except Exception as e: + logger.warning(f"Single-source search ({requested_source}) failed: {e}") + source_results = {"artists": [], "albums": [], "tracks": [], "available": False} + + logger.info( + f"Enhanced search [source={requested_source}] results: " + f"{len(db_artists)} DB, {len(source_results['artists'])} artists, " + f"{len(source_results['albums'])} albums, {len(source_results['tracks'])} tracks" + ) + + response_data = { + "db_artists": db_artists, + "spotify_artists": source_results["artists"], + "spotify_albums": source_results["albums"], + "spotify_tracks": source_results["tracks"], + "metadata_source": requested_source, + "primary_source": requested_source, + "alternate_sources": [], + "source_available": True, + } + _set_cached_enhanced_search_response(cache_key, response_data) + return jsonify(response_data) + # ── Determine primary source and search it synchronously ── primary_source = "spotify" primary_results = empty_source @@ -22711,6 +22809,18 @@ def get_version_info(): "title": "What's New in SoulSync", "subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes", "sections": [ + { + "title": "Explicit Source Selection on Enhanced Search", + "description": "The /api/enhanced-search endpoint now accepts an optional `source` parameter so callers can target a single metadata source instead of always fanning out across every provider", + "features": [ + "• Accepts one of: auto, spotify, itunes, deezer, discogs, hydrabase, musicbrainz (omitted or 'auto' preserves the existing multi-source behavior)", + "• When a specific source is chosen, only that provider is queried — no more surprise Spotify calls from flows that didn't need Spotify", + "• Foundation for upcoming unified Search page with a source picker (Phase 1 of the Search/Artists unification project)", + "• db_artists (local library results) still returned in every mode so library matches keep surfacing", + "• Cache keys now include the requested source — single-source and multi-source searches no longer share cached entries", + "• Validates source names and returns 400 on unknown values", + ], + }, { "title": "Fix Wrong-Artist Tracks Silently Downloading", "description": "A critical bug where searching for a track could silently download a completely different artist's song with the same name", diff --git a/webui/static/helper.js b/webui/static/helper.js index c75bf24b..b3687b05 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3599,6 +3599,11 @@ function closeHelperSearch() { // ═══════════════════════════════════════════════════════════════════════════ const WHATS_NEW = { + '2.40': [ + // --- April 22, 2026 (late) --- + { date: 'April 22, 2026 (late)' }, + { title: 'Explicit Source Selection on Enhanced Search', desc: 'The /api/enhanced-search endpoint now accepts an optional `source` parameter (spotify, itunes, deezer, discogs, hydrabase, musicbrainz) so callers can target a single metadata source instead of fanning out across every provider. Omitted or `auto` preserves the existing multi-source behavior — nothing breaks. This is the foundation for the upcoming unified Search page with a source picker (Phase 1 of the Search/Artists unification project). db_artists (local library matches) still returned in every mode. Cache keys now isolate per-source so single-source and multi-source results don\'t collide', page: 'downloads' }, + ], '2.39': [ // --- April 22, 2026 --- { date: 'April 22, 2026' }, From 377343326fcfdfcf0c39586accfa7414c43e8f09 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:59:05 -0700 Subject: [PATCH 02/41] Dedupe enhanced-search fetch in widget and page, bump to 2.41 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the Search/Artists unification: the Search page dropdown and the global spotlight widget both POST to /api/enhanced-search with identical boilerplate. Extracted into enhancedSearchFetch() in search.js (loaded before downloads.js). Both callers migrated. Zero UX change — purely sets up Phase 3 to wire a source picker in one place instead of two. --- web_server.py | 12 +++++++++++- webui/static/downloads.js | 8 +------- webui/static/helper.js | 5 +++++ webui/static/search.js | 27 +++++++++++++++++---------- 4 files changed, 34 insertions(+), 18 deletions(-) diff --git a/web_server.py b/web_server.py index 426838c4..d2e879b4 100644 --- a/web_server.py +++ b/web_server.py @@ -37,7 +37,7 @@ _log_dir = Path(_log_path).parent logger = setup_logging(_log_level, _log_path) # App version — single source of truth for backup metadata, version-info endpoint, etc. -_SOULSYNC_BASE_VERSION = "2.40" +_SOULSYNC_BASE_VERSION = "2.41" def _build_version_string(): """Append short commit hash to version when available (e.g. 2.35+abc1234).""" @@ -22809,6 +22809,16 @@ def get_version_info(): "title": "What's New in SoulSync", "subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes", "sections": [ + { + "title": "Shared Enhanced-Search Fetch Helper", + "description": "Internal refactor — the Search page and the global search widget now route through one shared fetch helper, so future source-picker work only needs wiring in one place", + "features": [ + "• New enhancedSearchFetch(query, { source, signal }) in search.js", + "• Both callers (Search page dropdown + global widget) deduplicated — single /api/enhanced-search chokepoint", + "• Accepts the source param added in 2.40, letting future UI wire a picker without touching both callers", + "• Zero UX change — pure plumbing (Phase 2 of the Search/Artists unification project)", + ], + }, { "title": "Explicit Source Selection on Enhanced Search", "description": "The /api/enhanced-search endpoint now accepts an optional `source` parameter so callers can target a single metadata source instead of always fanning out across every provider", diff --git a/webui/static/downloads.js b/webui/static/downloads.js index 3b18282f..8610caeb 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -5492,13 +5492,7 @@ async function _gsPerformSearch(query) { results.classList.add('visible'); try { - const res = await fetch('/api/enhanced-search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }), - signal: _gsState.abortCtrl.signal, - }); - const data = await res.json(); + const data = await enhancedSearchFetch(query, { signal: _gsState.abortCtrl.signal }); _gsState.data = data; _gsState.activeSource = data.primary_source || 'spotify'; _gsState.sources = {}; diff --git a/webui/static/helper.js b/webui/static/helper.js index b3687b05..eff92aec 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3599,6 +3599,11 @@ function closeHelperSearch() { // ═══════════════════════════════════════════════════════════════════════════ const WHATS_NEW = { + '2.41': [ + // --- April 22, 2026 (late night) --- + { date: 'April 22, 2026 (late night)' }, + { title: 'Shared Enhanced-Search Fetch Helper', desc: 'Internal refactor — the Search page dropdown and the global search widget now route through one shared enhancedSearchFetch helper in search.js, so both callers hit /api/enhanced-search through a single chokepoint. Zero UX change, but it means the upcoming source picker (Phase 3) only needs wiring in one place instead of two. Also lets both surfaces accept the source parameter added in 2.40 (Phase 2 of the Search/Artists unification project)', page: 'downloads' }, + ], '2.40': [ // --- April 22, 2026 (late) --- { date: 'April 22, 2026 (late)' }, diff --git a/webui/static/search.js b/webui/static/search.js index 2931d91b..cbf19504 100644 --- a/webui/static/search.js +++ b/webui/static/search.js @@ -1,6 +1,22 @@ // SEARCH FUNCTIONALITY // =============================== +// Shared enhanced-search fetch used by the Search page and the global widget. +// Pass source to restrict results to a single metadata provider; omit or pass +// null/'auto' to let the backend fan out across all configured sources. +async function enhancedSearchFetch(query, { source = null, signal = null } = {}) { + const body = { query }; + if (source && source !== 'auto') body.source = source; + const res = await fetch('/api/enhanced-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: signal || undefined, + }); + if (!res.ok) throw new Error(`Enhanced search failed: ${res.status}`); + return res.json(); +} + function initializeSearch() { // --- FIX: Corrected the element IDs to match the HTML --- const searchInput = document.getElementById('downloads-search-input'); @@ -225,16 +241,7 @@ function initializeSearchModeToggle() { _enhancedSearchData = { db_artists: [], primary_source: null, sources: {}, searchId, query }; try { - const response = await fetch('/api/enhanced-search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }), - signal: abortController.signal - }); - - if (!response.ok) throw new Error('Search failed'); - - const data = await response.json(); + const data = await enhancedSearchFetch(query, { signal: abortController.signal }); console.log('Enhanced results:', data); // Store multi-source state From 68d46c5aba132de1c270ffe86af5acdd3fd3c01e Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:06:13 -0700 Subject: [PATCH 03/41] Replace Enhanced/Basic toggle with source picker, bump to 2.42 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Search/Artists unification. The Search page's two-mode toggle is replaced by a single 'Search from' dropdown: All sources (Auto), Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz, or Soulseek (raw files). Auto keeps today's fan-out behavior for backwards compatibility; picking a specific source hits only that provider. 'Soulseek' routes to the raw-file basic section, so one picker covers both old modes. Loading text and the enhanced fetch now respect the selected source. Zero API changes — uses the source param added in 2.40 and the shared fetch helper from 2.41. --- web_server.py | 15 ++++++++- webui/index.html | 27 ++++++++-------- webui/static/helper.js | 5 +++ webui/static/search.js | 59 +++++++++++++++++++---------------- webui/static/style.css | 70 ++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 135 insertions(+), 41 deletions(-) diff --git a/web_server.py b/web_server.py index d2e879b4..40052fc3 100644 --- a/web_server.py +++ b/web_server.py @@ -37,7 +37,7 @@ _log_dir = Path(_log_path).parent logger = setup_logging(_log_level, _log_path) # App version — single source of truth for backup metadata, version-info endpoint, etc. -_SOULSYNC_BASE_VERSION = "2.41" +_SOULSYNC_BASE_VERSION = "2.42" def _build_version_string(): """Append short commit hash to version when available (e.g. 2.35+abc1234).""" @@ -22809,6 +22809,19 @@ def get_version_info(): "title": "What's New in SoulSync", "subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes", "sections": [ + { + "title": "Search Source Picker — Pick Where You're Searching", + "description": "The Search page's Enhanced/Basic toggle is replaced by a single 'Search from' dropdown so you can explicitly pick which source to query instead of fanning out to every provider", + "features": [ + "• Choose from: All sources (Auto), Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz, or Soulseek (raw files)", + "• Auto keeps today's multi-source fan-out behavior — no change if you want the current results", + "• Picking a specific source hits only that provider — no more surprise Spotify rate-limit hits from flows that didn't need Spotify", + "• 'Soulseek' routes to the raw-file search (what 'Basic' used to do) — one picker now covers both modes", + "• Loading text shows the selected source (e.g., 'Searching across Apple Music and your library...')", + "• Phase 3 of the Search/Artists unification project — builds on the shared fetch helper from 2.41", + ], + "usage_note": "Sidebar → Search → pick a source in the 'Search from' dropdown above the search bar." + }, { "title": "Shared Enhanced-Search Fetch Helper", "description": "Internal refactor — the Search page and the global search widget now route through one shared fetch helper, so future source-picker work only needs wiring in one place", diff --git a/webui/index.html b/webui/index.html index 10c63f68..08f4b85c 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1869,18 +1869,21 @@ - -
-
- - -
+ +
+ +
+ +
diff --git a/webui/static/helper.js b/webui/static/helper.js index eff92aec..9ee59750 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3599,6 +3599,11 @@ function closeHelperSearch() { // ═══════════════════════════════════════════════════════════════════════════ const WHATS_NEW = { + '2.42': [ + // --- April 23, 2026 --- + { date: 'April 23, 2026' }, + { title: 'Search Source Picker — Pick Where You\'re Searching', desc: 'The Search page\'s Enhanced/Basic toggle is replaced by a single "Search from" dropdown at the top. Choose All sources (Auto — keeps today\'s multi-source fan-out), Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz, or Soulseek (raw files). Picking a specific source hits only that provider — no more surprise Spotify rate-limit hits from flows that didn\'t need Spotify. "Soulseek" routes to the raw-file search (what "Basic" used to do), so one picker now covers both modes. Loading text reflects the selected source (e.g., "Searching across Apple Music..."). Phase 3 of the Search/Artists unification project', page: 'downloads' }, + ], '2.41': [ // --- April 22, 2026 (late night) --- { date: 'April 22, 2026 (late night)' }, diff --git a/webui/static/search.js b/webui/static/search.js index cbf19504..c6b38573 100644 --- a/webui/static/search.js +++ b/webui/static/search.js @@ -56,41 +56,38 @@ function initializeSearchModeToggle() { return; } - const toggleContainer = document.querySelector('.search-mode-toggle'); - const modeBtns = document.querySelectorAll('.search-mode-btn'); + const sourceSelect = document.getElementById('search-source-select'); const basicSection = document.getElementById('basic-search-section'); const enhancedSection = document.getElementById('enhanced-search-section'); - if (!toggleContainer || !modeBtns.length || !basicSection || !enhancedSection) { - console.warn('Search mode toggle elements not found'); + if (!sourceSelect || !basicSection || !enhancedSection) { + console.warn('Search source picker elements not found'); return; } searchModeToggleInitialized = true; - console.log('✅ Initializing search mode toggle (first time only)'); + console.log('✅ Initializing search source picker (first time only)'); - modeBtns.forEach(btn => { - btn.addEventListener('click', () => { - const mode = btn.dataset.mode; + // Current source selection — 'auto' (fan-out) by default. Soulseek routes + // to the raw-file basic search; everything else routes to enhanced. + let currentSearchSource = sourceSelect.value || 'auto'; - // Update button active states - modeBtns.forEach(b => b.classList.remove('active')); - btn.classList.add('active'); + const applySourceSelection = (value) => { + currentSearchSource = value; + if (value === 'soulseek') { + basicSection.classList.add('active'); + enhancedSection.classList.remove('active'); + } else { + basicSection.classList.remove('active'); + enhancedSection.classList.add('active'); + } + }; - // Update toggle slider position - toggleContainer.setAttribute('data-active', mode); + applySourceSelection(currentSearchSource); - // Toggle sections - if (mode === 'basic') { - basicSection.classList.add('active'); - enhancedSection.classList.remove('active'); - console.log('Switched to basic search mode'); - } else { - basicSection.classList.remove('active'); - enhancedSection.classList.add('active'); - console.log('Switched to enhanced search mode'); - } - }); + sourceSelect.addEventListener('change', (e) => { + applySourceSelection(e.target.value); + console.log('Search source →', currentSearchSource); }); // Initialize enhanced search @@ -221,7 +218,14 @@ function initializeSearchModeToggle() { showDropdown(); const loadingText = document.getElementById('enhanced-loading-text'); if (loadingText) { - loadingText.textContent = `Searching across ${currentMusicSourceName} and your library...`; + const _sourceLabelMap = { + spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', + discogs: 'Discogs', hydrabase: 'Hydrabase', musicbrainz: 'MusicBrainz', + }; + const _sourceName = currentSearchSource && currentSearchSource !== 'auto' + ? (_sourceLabelMap[currentSearchSource] || currentSearchSource) + : currentMusicSourceName; + loadingText.textContent = `Searching across ${_sourceName} and your library...`; } loadingState.classList.remove('hidden'); emptyState.classList.add('hidden'); @@ -241,7 +245,10 @@ function initializeSearchModeToggle() { _enhancedSearchData = { db_artists: [], primary_source: null, sources: {}, searchId, query }; try { - const data = await enhancedSearchFetch(query, { signal: abortController.signal }); + const data = await enhancedSearchFetch(query, { + source: currentSearchSource, + signal: abortController.signal, + }); console.log('Enhanced results:', data); // Store multi-source state diff --git a/webui/static/style.css b/webui/static/style.css index d71b03ef..5cc7d083 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -32832,10 +32832,76 @@ div.artist-hero-badge { } /* ========================================= */ -/* SEARCH MODE TOGGLE SYSTEM */ +/* ========================================= */ +/* SEARCH SOURCE PICKER (replaces Enhanced/Basic toggle) */ /* ========================================= */ -/* Toggle Container */ +.search-source-picker-container { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + margin: 20px 0; +} + +.search-source-picker-label { + color: rgba(255, 255, 255, 0.7); + font-family: 'Segoe UI', sans-serif; + font-size: 13px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.search-source-picker-wrapper { + position: relative; + display: inline-flex; + align-items: center; +} + +.search-source-picker-select { + appearance: none; + -webkit-appearance: none; + background: rgba(30, 30, 30, 0.8); + border: 1px solid rgba(64, 64, 64, 0.4); + border-radius: 10px; + color: #ffffff; + padding: 10px 40px 10px 16px; + font-family: 'Segoe UI', sans-serif; + font-size: 14px; + font-weight: 500; + cursor: pointer; + min-width: 220px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.search-source-picker-select:hover { + border-color: rgba(138, 43, 226, 0.6); +} + +.search-source-picker-select:focus { + outline: none; + border-color: rgba(138, 43, 226, 0.9); + box-shadow: 0 2px 10px rgba(138, 43, 226, 0.35); +} + +.search-source-picker-select option { + background: #1e1e1e; + color: #ffffff; +} + +.search-source-picker-caret { + position: absolute; + right: 14px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: rgba(255, 255, 255, 0.6); + font-size: 12px; +} + +/* Legacy toggle (kept for any other references, but unused by the Search page) */ .search-mode-toggle-container { display: flex; justify-content: center; From 6992e2e5b5c9e36c1e4389f17f1924cdbe5dcb45 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:22:49 -0700 Subject: [PATCH 04/41] Rename Search page id from 'downloads' to 'search', bump to 2.43 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3b of the Search/Artists unification. The Search page's internal id was 'downloads', which clashed with the actual Downloads page (id 'active-downloads') and confused anyone reading the code. Renamed to 'search' across HTML, navigation, DOM selectors, and the deep-link route list. Backwards compat: navigateToPage('downloads') aliases to 'search' at the top of the function; /downloads URL still serves index.html and the client router resolves the page correctly; profile ACL checks accept both 'search' and 'downloads' so existing profiles with 'downloads' in allowed_pages keep working without migration. Sidebar label unchanged. Zero visual change — pure internal tidy. --- tests/test_spa_deep_linking.py | 2 +- web_server.py | 15 +++++++++++++-- webui/index.html | 8 ++++---- webui/static/downloads.js | 13 +++++++------ webui/static/helper.js | 7 ++++++- webui/static/init.js | 19 ++++++++++++++----- webui/static/search.js | 10 +++++----- 7 files changed, 50 insertions(+), 24 deletions(-) diff --git a/tests/test_spa_deep_linking.py b/tests/test_spa_deep_linking.py index cd28f096..38a90534 100644 --- a/tests/test_spa_deep_linking.py +++ b/tests/test_spa_deep_linking.py @@ -68,7 +68,7 @@ class TestSpaRoutes: """Deep-link paths for valid client pages should serve index.html.""" @pytest.mark.parametrize("page", [ - 'dashboard', 'sync', 'downloads', 'discover', 'artists', + 'dashboard', 'sync', 'search', 'downloads', 'discover', 'artists', 'automations', 'library', 'import', 'settings', 'help', 'issues', 'stats', 'watchlist', 'wishlist', 'active-downloads', 'artist-detail', 'playlist-explorer', 'hydrabase', 'tools', diff --git a/web_server.py b/web_server.py index 40052fc3..3c1d1b35 100644 --- a/web_server.py +++ b/web_server.py @@ -37,7 +37,7 @@ _log_dir = Path(_log_path).parent logger = setup_logging(_log_level, _log_path) # App version — single source of truth for backup metadata, version-info endpoint, etc. -_SOULSYNC_BASE_VERSION = "2.42" +_SOULSYNC_BASE_VERSION = "2.43" def _build_version_string(): """Append short commit hash to version when available (e.g. 2.35+abc1234).""" @@ -320,7 +320,7 @@ def get_spotify_client_for_profile(profile_id=None): return spotify_client # Fall back to global # Valid page IDs for profile permission validation -VALID_PAGE_IDS = {'dashboard', 'sync', 'downloads', 'discover', 'artists', 'automations', 'library', 'import', 'settings', 'help'} +VALID_PAGE_IDS = {'dashboard', 'sync', 'search', 'downloads', 'discover', 'artists', 'automations', 'library', 'import', 'settings', 'help'} def check_download_permission(): """Check if current profile has download permission. Returns error response or None if allowed.""" @@ -22809,6 +22809,17 @@ def get_version_info(): "title": "What's New in SoulSync", "subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes", "sections": [ + { + "title": "Search Page Renamed to /search", + "description": "The Search page's internal id is now 'search' instead of 'downloads', which no longer conflicts with the real Downloads page. URL /downloads still works for bookmarks and external links", + "features": [ + "• Sidebar label unchanged (still reads 'Search') — no visual change", + "• URL now /search; /downloads stays as an alias so no existing link breaks", + "• DOM id renamed to #search-page; all internal references follow", + "• Profile ACL stored as 'search' going forward; existing profiles with 'downloads' in allowed_pages still resolve through a legacy-compat check", + "• Phase 3b of the Search/Artists unification project", + ], + }, { "title": "Search Source Picker — Pick Where You're Searching", "description": "The Search page's Enhanced/Basic toggle is replaced by a single 'Search from' dropdown so you can explicitly pick which source to query instead of fanning out to every provider", diff --git a/webui/index.html b/webui/index.html index 08f4b85c..8e196335 100644 --- a/webui/index.html +++ b/webui/index.html @@ -98,7 +98,7 @@ - + @@ -113,7 +113,7 @@
- + @@ -196,7 +196,7 @@ Sync - @@ -1843,7 +1843,7 @@
-
+
-
+
- +
@@ -1859,13 +1859,9 @@
-

Music Downloads

-

Search, discover, and download high-quality music

+

Search

+

Find artists, albums, and tracks from any metadata source

-
@@ -2107,44 +2103,6 @@
- - - -
- - -
-

Download Manager

-
-

• Active Downloads: 0

-

• Finished Downloads: 0

-
-
- - -
-
- - -
-
- - -
-
- - -
-
No active downloads.
-
- - -
-
No finished downloads.
-
-
-
-
diff --git a/webui/static/downloads.js b/webui/static/downloads.js index 9fdd9bef..7348f4ca 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -4267,335 +4267,7 @@ function updateModalSyncProgress(playlistId, progress) { } -// Download tracking state management - matching GUI functionality -let activeDownloads = {}; -let finishedDownloads = {}; -let downloadStatusInterval = null; -let isDownloadPollingActive = false; - -async function loadDownloadsData() { - // Downloads page loads search results dynamically - console.log('Downloads page loaded'); - - // Event listeners are already set up in initializeSearch() - don't duplicate them - const clearButton = document.querySelector('.controls-panel__clear-btn'); - const cancelAllButton = document.querySelector('.controls-panel__cancel-all-btn'); - - if (clearButton) { - clearButton.addEventListener('click', clearFinishedDownloads); - } - if (cancelAllButton) { - cancelAllButton.addEventListener('click', cancelAllDownloads); - } - - // Start sophisticated polling system (1-second interval like GUI) - startDownloadPolling(); - - // Initialize tab management - initializeDownloadTabs(); -} - -function startDownloadPolling() { - if (isDownloadPollingActive) return; - - console.log('Starting download status polling (1-second interval)'); - isDownloadPollingActive = true; - - // Initial call - updateDownloadQueues(); - - // Start 1-second polling (matching GUI's 1000ms timer) - downloadStatusInterval = setInterval(updateDownloadQueues, 1000); -} - -function stopDownloadPolling() { - if (downloadStatusInterval) { - clearInterval(downloadStatusInterval); - downloadStatusInterval = null; - } - isDownloadPollingActive = false; - console.log('Stopped download status polling'); -} - -async function updateDownloadQueues() { - if (document.hidden) return; // Skip polling when tab is not visible - try { - const response = await fetch('/api/downloads/status'); - const data = await response.json(); - - if (data.error) { - console.error("Error fetching download status:", data.error); - return; - } - - const newActive = {}; - const newFinished = {}; - - // Terminal states matching GUI logic - const terminalStates = ['Completed', 'Succeeded', 'Cancelled', 'Canceled', 'Failed', 'Errored']; - - // Process transfers exactly like GUI - data.transfers.forEach(item => { - const isTerminal = terminalStates.some(state => - item.state && item.state.includes(state) - ); - - if (isTerminal) { - newFinished[item.id] = item; - } else { - newActive[item.id] = item; - } - }); - - // Update global state - activeDownloads = newActive; - finishedDownloads = newFinished; - - // Render both queues - renderQueue('active-queue', activeDownloads, true); - renderQueue('finished-queue', finishedDownloads, false); - - // Update tab counts - updateTabCounts(); - - // Update stats in the side panel - updateDownloadStats(); - - } catch (error) { - // Only log errors occasionally to avoid console spam - if (Math.random() < 0.1) { - console.error("Failed to update download queues:", error); - } - } -} - -function renderQueue(containerId, downloads, isActiveQueue) { - const container = document.getElementById(containerId); - if (!container) return; - - const downloadIds = Object.keys(downloads); - - if (downloadIds.length === 0) { - container.innerHTML = `
${isActiveQueue ? 'No active downloads.' : 'No finished downloads.'}
`; - return; - } - - let html = ''; - for (const id of downloadIds) { - const item = downloads[id]; - - // Extract display title from filename - let title = 'Unknown File'; - if (item.filename) { - // YouTube/Tidal filenames are encoded as "id||title" - if ((item.username === 'youtube' || item.username === 'tidal' || item.username === 'qobuz' || item.username === 'hifi') && item.filename.includes('||')) { - const parts = item.filename.split('||'); - title = parts[1] || parts[0]; // Use title part, fallback to id - } else { - // Regular Soulseek filename - extract last part of path - title = item.filename.split(/[\\/]/).pop(); - } - } - - const progress = item.percentComplete || 0; - const bytesTransferred = item.bytesTransferred || 0; - const totalBytes = item.size || 0; - const speed = item.averageSpeed || 0; - - // Format file size - const formatSize = (bytes) => { - if (!bytes) return 'Unknown size'; - const units = ['B', 'KB', 'MB', 'GB']; - let size = bytes; - let unitIndex = 0; - while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024; - unitIndex++; - } - return `${size.toFixed(1)} ${units[unitIndex]}`; - }; - - // Format speed - const formatSpeed = (bytesPerSecond) => { - if (!bytesPerSecond || bytesPerSecond <= 0) return ''; - return `${formatSize(bytesPerSecond)}/s`; - }; - - let actionButtonHTML = ''; - if (isActiveQueue) { - // Active items get progress bar and cancel button - actionButtonHTML = ` -
-
-
-
-
- ${item.state} - ${progress.toFixed(1)}% - ${speed > 0 ? `• ${formatSpeed(speed)}` : ''} - ${totalBytes > 0 ? `• ${formatSize(bytesTransferred)} / ${formatSize(totalBytes)}` : ''} -
-
- - `; - } else { - // Finished items get status and open button - let statusClass = ''; - if (item.state.includes('Cancelled')) statusClass = 'status--cancelled'; - else if (item.state.includes('Failed') || item.state.includes('Errored')) statusClass = 'status--failed'; - else if (item.state.includes('Completed') || item.state.includes('Succeeded')) statusClass = 'status--completed'; - - actionButtonHTML = ` -
- ${item.state} -
- - `; - } - - // Enrich with metadata from backend context (artist, album, artwork) - const meta = item._meta || {}; - const sourceLabels = { youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', lidarr: 'Lidarr' }; - const sourceBadge = sourceLabels[item.username] || item.username; - - html += ` -
-
- ${meta.artwork_url - ? `` - : '
'} -
-
-
${title}
- ${meta.artist || meta.album ? ` -
- ${meta.artist ? `${escapeHtml(meta.artist)}` : ''} - ${meta.artist && meta.album ? '·' : ''} - ${meta.album ? `${escapeHtml(meta.album)}` : ''} -
- ` : ''} -
- ${sourceBadge} - ${meta.quality ? `${escapeHtml(meta.quality)}` : ''} -
-
-
- ${actionButtonHTML} -
-
- `; - } - container.innerHTML = html; -} - -function updateTabCounts() { - const activeCount = Object.keys(activeDownloads).length; - const finishedCount = Object.keys(finishedDownloads).length; - - const activeTabBtn = document.querySelector('.tab-btn[data-tab="active-queue"]'); - const finishedTabBtn = document.querySelector('.tab-btn[data-tab="finished-queue"]'); - - if (activeTabBtn) activeTabBtn.textContent = `Download Queue (${activeCount})`; - if (finishedTabBtn) finishedTabBtn.textContent = `Finished (${finishedCount})`; -} - -function updateDownloadStats() { - const activeCount = Object.keys(activeDownloads).length; - const finishedCount = Object.keys(finishedDownloads).length; - - const activeLabel = document.getElementById('active-downloads-label'); - const finishedLabel = document.getElementById('finished-downloads-label'); - - if (activeLabel) activeLabel.textContent = `• Active Downloads: ${activeCount}`; - if (finishedLabel) finishedLabel.textContent = `• Finished Downloads: ${finishedCount}`; -} - -function initializeDownloadTabs() { - const tabButtons = document.querySelectorAll('.tab-btn'); - tabButtons.forEach(btn => { - btn.addEventListener('click', () => switchDownloadTab(btn)); - }); -} - -function switchDownloadTab(button) { - const targetTabId = button.getAttribute('data-tab'); - - // Update buttons - document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active')); - button.classList.add('active'); - - // Update content panes - document.querySelectorAll('.download-queue').forEach(queue => queue.classList.remove('active')); - const targetQueue = document.getElementById(targetTabId); - if (targetQueue) targetQueue.classList.add('active'); -} - -async function cancelDownloadItem(downloadId, username) { - try { - const response = await fetch('/api/downloads/cancel', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ download_id: downloadId, username: username }) - }); - const result = await response.json(); - - if (result.success) { - showToast('Download cancelled', 'success'); - } else { - showToast(`Failed to cancel: ${result.error}`, 'error'); - } - } catch (error) { - console.error('Error cancelling download:', error); - showToast('Error sending cancel request', 'error'); - } -} - -async function clearFinishedDownloads() { - const finishedCount = Object.keys(finishedDownloads).length; - if (finishedCount === 0) { - showToast('No finished downloads to clear', 'error'); - return; - } - - try { - const response = await fetch('/api/downloads/clear-finished', { - method: 'POST' - }); - const result = await response.json(); - - if (result.success) { - showToast('Finished downloads cleared', 'success'); - } else { - showToast(`Failed to clear: ${result.error}`, 'error'); - } - } catch (error) { - console.error('Error clearing finished downloads:', error); - showToast('Error sending clear request', 'error'); - } -} - -async function cancelAllDownloads() { - if (!await showConfirmDialog({ title: 'Cancel All Downloads', message: 'Cancel ALL active downloads and clear the transfer list? This cannot be undone.', confirmText: 'Cancel All', destructive: true })) { - return; - } - - try { - const response = await fetch('/api/downloads/cancel-all', { - method: 'POST' - }); - const result = await response.json(); - - if (result.success) { - showToast('All downloads cancelled and cleared', 'success'); - } else { - showToast(`Failed to cancel: ${result.error}`, 'error'); - } - } catch (error) { - console.error('Error cancelling all downloads:', error); - showToast('Error cancelling downloads', 'error'); - } -} - -// REPLACE the old performDownloadsSearch function with this new one. +// Raw Soulseek file search (used by the 'Soulseek (raw files)' source picker option). async function performDownloadsSearch() { const query = document.getElementById('downloads-search-input').value.trim(); if (!query) { diff --git a/webui/static/helper.js b/webui/static/helper.js index 2c4fd0e5..fe201416 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3599,6 +3599,11 @@ function closeHelperSearch() { // ═══════════════════════════════════════════════════════════════════════════ const WHATS_NEW = { + '2.44': [ + // --- April 23, 2026 (evening) --- + { date: 'April 23, 2026 (evening)' }, + { title: 'Remove Embedded Download Manager from Search Page', desc: 'The Search page used to carry a second copy of the Download Manager (active + finished queues, clear/cancel-all buttons) that was hidden by default and duplicated the dedicated Downloads page. That duplicate is gone — toggle button, side-panel HTML, and its 1-second polling loop all removed. About 330 lines of dead code cleaned up across downloads.js and init.js. CSS grid for the Search page collapsed to single-column now that the right panel is gone. The dedicated Downloads sidebar page is now the single downloads UI. Phase 3c of the Search/Artists unification project', page: 'search' }, + ], '2.43': [ // --- April 23, 2026 (later) --- { date: 'April 23, 2026 (later)' }, diff --git a/webui/static/init.js b/webui/static/init.js index 8f1870f6..e8e38463 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -1925,7 +1925,6 @@ function initApp() { initExpandedPlayer(); initializeSyncPage(); initializeWatchlist(); - initializeDownloadManagerToggle(); // Initialize WebSocket connection (falls back to HTTP polling if unavailable) @@ -2115,37 +2114,6 @@ function initializeWatchlist() { console.log('Watchlist system initialized'); } -function initializeDownloadManagerToggle() { - const toggleButton = document.getElementById('toggle-download-manager-btn'); - const downloadsContent = document.querySelector('.downloads-content'); - - if (!toggleButton || !downloadsContent) { - console.log('Download manager toggle not found on this page'); - return; - } - - // Load saved state from localStorage (hidden by default for more search space) - const isHidden = localStorage.getItem('downloadManagerHidden') !== 'false'; - if (isHidden) { - downloadsContent.classList.add('manager-hidden'); - } - - // Add click handler - toggleButton.addEventListener('click', () => { - const isCurrentlyHidden = downloadsContent.classList.contains('manager-hidden'); - - if (isCurrentlyHidden) { - downloadsContent.classList.remove('manager-hidden'); - localStorage.setItem('downloadManagerHidden', 'false'); - } else { - downloadsContent.classList.add('manager-hidden'); - localStorage.setItem('downloadManagerHidden', 'true'); - } - }); - - console.log('Download manager toggle initialized'); -} - function navigateToPage(pageId, options = {}) { // Backwards-compat alias — the Search page used to live under id 'downloads'. if (pageId === 'downloads') pageId = 'search'; @@ -2249,7 +2217,6 @@ async function loadPageData(pageId) { initializeSearch(); initializeSearchModeToggle(); initializeFilters(); - await loadDownloadsData(); break; case 'artists': // Only fully initialize if not already initialized diff --git a/webui/static/style.css b/webui/static/style.css index 5cc7d083..7298bc44 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -4255,8 +4255,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { /* Main Layout: Replicates QSplitter */ .downloads-content { display: grid; - grid-template-columns: 1fr 370px; - /* Left panel is flexible, right is fixed */ + grid-template-columns: 1fr; gap: 24px; height: 100%; /* Fill parent .page content area */ From 9361c2996578fde60283e933c4373c3db86e8f8d Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:36:36 -0700 Subject: [PATCH 06/41] Route all artist-detail callers to the standalone page, bump to 2.45 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4a of the Search/Artists unification. The app had two artist- detail implementations: the standalone page Library navigates to via navigateToArtistDetail (its own route, deep-link support, highlights Library in the sidebar), and an inline state inside the Artists page reached via selectArtistForDetail. They rendered similar content but were separate code paths and kept drifting apart (PR #356 just had to fix source propagation in both). Every external caller of selectArtistForDetail (9 sites across api-monitor.js, discover.js, downloads.js, search.js) now calls navigateToArtistDetail(id, name, source) directly. Removed ~63 lines of the navigate-then-setTimeout-then-select dance. Source context (Spotify/iTunes/Deezer/etc.) carries cleanly through via the new third argument. Artists sidebar entry, its inline search, and selectArtistForDetail all still work — they just have no external callers. Phase 4b will retire the sidebar entry and artists.js. --- web_server.py | 13 +++++++++++- webui/static/api-monitor.js | 11 ++-------- webui/static/discover.js | 40 +++++++------------------------------ webui/static/downloads.js | 25 +++-------------------- webui/static/helper.js | 5 +++++ webui/static/library.js | 6 +++--- webui/static/search.js | 13 +----------- 7 files changed, 33 insertions(+), 80 deletions(-) diff --git a/web_server.py b/web_server.py index 7b01e306..8865c21f 100644 --- a/web_server.py +++ b/web_server.py @@ -37,7 +37,7 @@ _log_dir = Path(_log_path).parent logger = setup_logging(_log_level, _log_path) # App version — single source of truth for backup metadata, version-info endpoint, etc. -_SOULSYNC_BASE_VERSION = "2.44" +_SOULSYNC_BASE_VERSION = "2.45" def _build_version_string(): """Append short commit hash to version when available (e.g. 2.35+abc1234).""" @@ -22809,6 +22809,17 @@ def get_version_info(): "title": "What's New in SoulSync", "subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes", "sections": [ + { + "title": "Artist Links Everywhere Go to the Same Page", + "description": "Clicking an artist result in Search, Discover, the API Monitor, or anywhere else now lands on the standalone artist detail page Library already uses — instead of swapping into the Artists page's inline detail view", + "features": [ + "• One artist detail page, not two — less navigation surprise and a stable URL (/artist-detail) for deep links", + "• Source context (Spotify/iTunes/Deezer/etc.) now carries cleanly into the destination so non-Spotify albums load from the right provider", + "• Removed the navigate-then-setTimeout dance at 9 callsites (api-monitor, discover, downloads, search, library recommendations)", + "• Artists sidebar entry and its inline search still work for now — next phase retires that page entirely", + "• Phase 4a of the Search/Artists unification project", + ], + }, { "title": "Remove Embedded Download Manager from Search Page", "description": "The Search page used to carry a second copy of the Download Manager (active + finished queues, clear/cancel-all buttons) that was hidden by default and duplicated the dedicated Downloads page. That duplicate is gone", diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js index 3784e28c..3dcbd1b7 100644 --- a/webui/static/api-monitor.js +++ b/webui/static/api-monitor.js @@ -2382,16 +2382,9 @@ async function openWatchlistArtistDetailView(artistId, artistName) { source = spotify_artist_id ? 'spotify' : discogs_artist_id ? 'discogs' : deezer_artist_id ? 'deezer' : 'itunes'; } if (discogId) { - // Close detail overlay and navigate to Artists page + // Close detail overlay and navigate to the standalone artist detail page closeWatchlistArtistDetailView(); - // Navigate to Artists page and load discography - navigateToPage('artists'); - setTimeout(() => { - selectArtistForDetail( - { id: discogId, name: artistName, image_url: artist.image_url || '' }, - { source: source } - ); - }, 200); + navigateToArtistDetail(discogId, artistName, source); } }); diff --git a/webui/static/discover.js b/webui/static/discover.js index 6763054f..e70e3b65 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -739,16 +739,7 @@ async function checkRecommendedWatchlistStatuses(artists) { async function viewRecommendedArtistDiscography(artistId, artistName) { closeRecommendedArtistsModal(); - - const artist = { - id: artistId, - name: artistName - }; - - // Use same navigation pattern as hero slider - navigateToPage('artists'); - await new Promise(resolve => setTimeout(resolve, 100)); - await selectArtistForDetail(artist); + navigateToArtistDetail(artistId, artistName); } async function checkAllHeroWatchlistStatus() { @@ -830,25 +821,8 @@ async function viewDiscoverHeroDiscography() { return; } - // Create artist object matching the expected format - const artist = { - id: artistId, - name: artistName, - image_url: discoverHeroArtists[discoverHeroIndex]?.image_url || '', - genres: discoverHeroArtists[discoverHeroIndex]?.genres || [], - popularity: discoverHeroArtists[discoverHeroIndex]?.popularity || 0 - }; - console.log(`🎵 Navigating to artist detail for: ${artistName}`); - - // Navigate to Artists page - navigateToPage('artists'); - - // Small delay to let the page load - await new Promise(resolve => setTimeout(resolve, 100)); - - // Load the artist details - await selectArtistForDetail(artist); + navigateToArtistDetail(artistId, artistName); } function showDiscoverHeroEmpty() { @@ -4639,9 +4613,9 @@ function _renderYourArtistCard(artist) { const watchlistClass = artist.on_watchlist ? 'active' : ''; const hasId = artist.active_source_id && artist.active_source_id !== ''; - // Navigate to artist page (name click) + // Navigate to standalone artist detail page (name click) const navAction = hasId - ? `event.stopPropagation(); navigateToPage('artists'); setTimeout(() => selectArtistForDetail({id:'${escapeForInlineJs(artist.active_source_id)}', name:'${escapeForInlineJs(artist.artist_name)}', image_url:'${escapeForInlineJs(img)}'}), 200)` + ? `event.stopPropagation(); navigateToArtistDetail('${escapeForInlineJs(artist.active_source_id)}', '${escapeForInlineJs(artist.artist_name)}')` : ''; // Open info modal (card body click) — pass pool ID so we can look up all data @@ -4820,7 +4794,7 @@ async function openYourArtistInfoModal(poolId) { Explore - @@ -6720,7 +6694,7 @@ function _artMapSetupInteraction(canvas) {
Artist Info
-
+
💿 View Discography
@@ -7408,7 +7382,7 @@ async function openGenreDeepDive(genre) { // Always open on Artists page with discography — pass source for correct routing const imgUrl = _esc(a.image_url || ''); const artSource = _esc(a.source || ''); - const clickAction = `onclick="document.getElementById('genre-deep-dive-modal').remove();navigateToPage('artists');setTimeout(()=>selectArtistForDetail({id:'${_esc(a.entity_id)}',name:'${_esc(a.name)}',image_url:'${imgUrl}'},{source:'${artSource}'}),300)"`; + const clickAction = `onclick="document.getElementById('genre-deep-dive-modal').remove();navigateToArtistDetail('${_esc(a.entity_id)}','${_esc(a.name)}','${artSource}' || null)"`; const srcClass = (a.source || '').toLowerCase(); return `
diff --git a/webui/static/downloads.js b/webui/static/downloads.js index 7348f4ca..f13bfb2e 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -634,16 +634,7 @@ function _navigateToArtistFromModal(artistId, artistName, imageUrl, source, play if (!artistName) return; // Close the download modal if (playlistId) closeDownloadMissingModal(playlistId); - // Navigate to Artists page and load discography - navigateToPage('artists'); - setTimeout(() => { - // If we have an artist ID, use it directly - // If not, search by name — selectArtistForDetail handles both - selectArtistForDetail( - { id: artistId || artistName, name: artistName, image_url: imageUrl || '' }, - source ? { source: source } : undefined - ); - }, 200); + navigateToArtistDetail(artistId || artistName, artistName, source || null); } async function closeDownloadMissingModal(playlistId) { @@ -5434,18 +5425,8 @@ function _gsSwitchSource(src) { function _gsClickArtist(id, name, isLibrary) { _gsDeactivate(); - if (isLibrary) { - // Same as enhanced search: navigateToArtistDetail - navigateToArtistDetail(id, name); - } else { - // Same as enhanced search: navigate to Artists page + selectArtistForDetail - navigateToPage('artists'); - setTimeout(() => { - selectArtistForDetail({ id, name, image_url: '' }, { - source: _gsState.activeSource || '', - }); - }, 150); - } + const source = isLibrary ? null : (_gsState.activeSource || null); + navigateToArtistDetail(id, name, source); } async function _gsClickAlbum(albumId, albumName, artistName, imageUrl, source) { diff --git a/webui/static/helper.js b/webui/static/helper.js index fe201416..6be8d1bb 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3599,6 +3599,11 @@ function closeHelperSearch() { // ═══════════════════════════════════════════════════════════════════════════ const WHATS_NEW = { + '2.45': [ + // --- April 23, 2026 (night) --- + { date: 'April 23, 2026 (night)' }, + { title: 'Artist Links Everywhere Go to the Same Page', desc: 'Clicking an artist result in Search, Discover, the API Monitor, or anywhere else now lands on the standalone artist detail page that Library already uses — instead of swapping into the Artists page\'s inline detail view. One artist detail page, not two: less navigation surprise, a stable /artist-detail URL for deep links, and source context (Spotify/iTunes/Deezer/etc.) now carries cleanly into the destination so non-Spotify albums load from the right provider. Removed the navigate-then-setTimeout dance at 9 callsites. Artists sidebar entry and its inline search still work for now — next phase retires that page entirely. Phase 4a of the Search/Artists unification project', page: 'library' }, + ], '2.44': [ // --- April 23, 2026 (evening) --- { date: 'April 23, 2026 (evening)' }, diff --git a/webui/static/library.js b/webui/static/library.js index 675187b8..3f7fb359 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -655,8 +655,8 @@ let discographyFilterState = { ownership: 'all' // 'all', 'owned', 'missing' }; -function navigateToArtistDetail(artistId, artistName) { - console.log(`🎵 Navigating to artist detail: ${artistName} (ID: ${artistId})`); +function navigateToArtistDetail(artistId, artistName, sourceOverride = null) { + console.log(`🎵 Navigating to artist detail: ${artistName} (ID: ${artistId}${sourceOverride ? `, source: ${sourceOverride}` : ''})`); // Abort any in-progress completion stream if (artistDetailPageState.completionController) { @@ -672,7 +672,7 @@ function navigateToArtistDetail(artistId, artistName) { // Store current artist info and reset enhanced view state artistDetailPageState.currentArtistId = artistId; artistDetailPageState.currentArtistName = artistName; - artistDetailPageState.currentArtistSource = null; + artistDetailPageState.currentArtistSource = sourceOverride || null; artistDetailPageState.enhancedData = null; artistDetailPageState.expandedAlbums = new Set(); artistDetailPageState.selectedTracks = new Set(); diff --git a/webui/static/search.js b/webui/static/search.js index 4dad56aa..be9147c5 100644 --- a/webui/static/search.js +++ b/webui/static/search.js @@ -340,18 +340,7 @@ function initializeSearchModeToggle() { const sourceOverride = _activeSearchSource; console.log(`🎵 Opening artist detail: ${artist.name} (ID: ${artist.id}, source: ${sourceOverride})`); hideDropdown(); - - // Navigate to Artists page - navigateToPage('artists'); - - // Small delay to let the page load - await new Promise(resolve => setTimeout(resolve, 100)); - - // Load the artist details with source context - await selectArtistForDetail(artist, { - source: sourceOverride, - plugin: artist.external_urls?.hydrabase_plugin, - }); + navigateToArtistDetail(artist.id, artist.name, sourceOverride || null); } }) ); From 09f15ce7d2997c925ce7da69f14656065845a68b Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:40:22 -0700 Subject: [PATCH 07/41] Retire Artists sidebar entry, redirect entry points to Search, bump to 2.46 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4b of the Search/Artists unification. Cin flagged that 'Artists' in the sidebar read like a library section but was actually a dedicated artist-search page, duplicating what unified Search already does. Removed the sidebar entry so users funnel through Search. - Sidebar Artists button gone - 'Browse Artists' on empty Watchlist now opens Search - 'View artist from Wishlist' opens Search pre-filled with the name - Profile Home Page + Page Access drop the Artists option artists.js stays on disk: it defines ~30 shared helpers used across the app (escapeHtml, openDownloadMissingModalForArtistAlbum, service status, download bubbles, image helpers) that library/discover/etc. depend on. Wholesale deletion would orphan too much. The inline Artists page and its selectArtistForDetail flow are still there — just unreachable from the sidebar — so /artists deep links keep working for bookmarks. --- web_server.py | 14 +++++++++++++- webui/index.html | 10 ++-------- webui/static/api-monitor.js | 13 ++++++++----- webui/static/helper.js | 5 +++++ webui/static/library.js | 2 +- 5 files changed, 29 insertions(+), 15 deletions(-) diff --git a/web_server.py b/web_server.py index 8865c21f..646945c0 100644 --- a/web_server.py +++ b/web_server.py @@ -37,7 +37,7 @@ _log_dir = Path(_log_path).parent logger = setup_logging(_log_level, _log_path) # App version — single source of truth for backup metadata, version-info endpoint, etc. -_SOULSYNC_BASE_VERSION = "2.45" +_SOULSYNC_BASE_VERSION = "2.46" def _build_version_string(): """Append short commit hash to version when available (e.g. 2.35+abc1234).""" @@ -22809,6 +22809,18 @@ def get_version_info(): "title": "What's New in SoulSync", "subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes", "sections": [ + { + "title": "Artists Sidebar Entry Retired — Use Search Instead", + "description": "Cin flagged that 'Artists' in the sidebar read like a library section but was actually a dedicated artist-search page, duplicating what the unified Search already does. The sidebar entry is gone; the same flow now runs through Search", + "features": [ + "• Sidebar → Search → type an artist's name → click their result → land on the artist detail page (same page Library links to)", + "• 'Browse Artists' button on the empty Watchlist page now opens Search instead of the retired Artists page", + "• 'View artist from Wishlist' button now opens Search pre-filled with the artist's name", + "• Removed 'Artists' from the profile Home Page and Page Access options so new profiles don't point to a missing sidebar entry", + "• Deep link to /artists still resolves so old bookmarks work; the page and its inline search just aren't promoted anywhere", + "• Phase 4b of the Search/Artists unification project", + ], + }, { "title": "Artist Links Everywhere Go to the Same Page", "description": "Clicking an artist result in Search, Discover, the API Monitor, or anywhere else now lands on the standalone artist detail page Library already uses — instead of swapping into the Artists page's inline detail view", diff --git a/webui/index.html b/webui/index.html index 053245a9..35fb1a2a 100644 --- a/webui/index.html +++ b/webui/index.html @@ -100,7 +100,6 @@ - @@ -115,7 +114,6 @@ - @@ -208,10 +206,6 @@ Explorer -

Your watchlist is empty

-

Add artists from the Artists page to monitor them for new releases.

- +

Use Search to find an artist, then add them to your watchlist from the artist page.

+
diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js index 3dcbd1b7..8e8962cd 100644 --- a/webui/static/api-monitor.js +++ b/webui/static/api-monitor.js @@ -1231,13 +1231,16 @@ function _renderWishlistNebula(albumTracks, singleTracks, artistImageMap, curren field.innerHTML = html; } -// Enhancement 8: navigate to artist detail from wishlist +// Enhancement 8: navigate to the Search page pre-filled with this artist's name function _navigateToArtistFromWishlist(artistName) { - // Try to find the artist in the library DB by searching - navigateToPage('artists'); + navigateToPage('search'); setTimeout(() => { - const searchInput = document.querySelector('.artist-search-input, #artist-search'); - if (searchInput) { searchInput.value = artistName; searchInput.dispatchEvent(new Event('input')); } + const searchInput = document.getElementById('enhanced-search-input'); + if (searchInput) { + searchInput.value = artistName; + searchInput.dispatchEvent(new Event('input')); + searchInput.focus(); + } }, 300); } diff --git a/webui/static/helper.js b/webui/static/helper.js index 6be8d1bb..7e494dfd 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3599,6 +3599,11 @@ function closeHelperSearch() { // ═══════════════════════════════════════════════════════════════════════════ const WHATS_NEW = { + '2.46': [ + // --- April 23, 2026 (late night) --- + { date: 'April 23, 2026 (late night)' }, + { title: 'Artists Sidebar Entry Retired — Use Search Instead', desc: 'Cin flagged that "Artists" in the sidebar read like a library section but was actually a dedicated artist-search page, duplicating what the unified Search already does. The sidebar entry is gone. New flow: Sidebar → Search → type artist name → click their result → land on the artist detail page (same page Library links to). "Browse Artists" on the empty Watchlist page and "View artist from Wishlist" now open Search pre-filled with the artist\'s name. Removed "Artists" from profile Home Page + Page Access options. Deep link to /artists still resolves so old bookmarks keep working — the page just isn\'t promoted anywhere. Phase 4b of the Search/Artists unification project', page: 'search' }, + ], '2.45': [ // --- April 23, 2026 (night) --- { date: 'April 23, 2026 (night)' }, diff --git a/webui/static/library.js b/webui/static/library.js index 3f7fb359..3be4d03c 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -2021,7 +2021,7 @@ async function openDiscographyModal() { } if (!artist || !discography) { - showToast('No discography found. Try searching this artist on the Artists page instead.', 'error'); + showToast('No discography found. Try searching this artist from the Search page instead.', 'error'); return; } From d037643908b70962e68794cab5d22bf76faae4d3 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:59:42 -0700 Subject: [PATCH 08/41] Clean up interactive help annotations for unified Search, bump to 2.47 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4c of the Search/Artists unification — docs-only cleanup. The click-for-help system and the 'Your First Download' guided tour referenced elements that no longer exist (the Basic/Enhanced toggle, the embedded download-manager toggle, the active/finished queue panels). Updated annotations + tour steps to match the current UI. - New annotation for .search-source-picker-container (the dropdown) - Removed 6 annotations for deleted elements - 'first-download' tour now walks users through the source picker and uses page: 'search' (PAGE_TOUR_MAP accepts both 'search' and the legacy 'downloads' id so older bookmarks still match) - Retired the 'artists-browse' standalone tour — no sidebar entry - Dropped the dead #finished-queue detection in the setup milestone check (the dashboard stat card is the single source of truth) --- web_server.py | 13 +++++- webui/static/helper.js | 97 +++++++++++------------------------------- 2 files changed, 37 insertions(+), 73 deletions(-) diff --git a/web_server.py b/web_server.py index 646945c0..b66d2a45 100644 --- a/web_server.py +++ b/web_server.py @@ -37,7 +37,7 @@ _log_dir = Path(_log_path).parent logger = setup_logging(_log_level, _log_path) # App version — single source of truth for backup metadata, version-info endpoint, etc. -_SOULSYNC_BASE_VERSION = "2.46" +_SOULSYNC_BASE_VERSION = "2.47" def _build_version_string(): """Append short commit hash to version when available (e.g. 2.35+abc1234).""" @@ -22809,6 +22809,17 @@ def get_version_info(): "title": "What's New in SoulSync", "subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes", "sections": [ + { + "title": "Interactive Help Annotations Updated for Unified Search", + "description": "The click-for-help annotations and the 'Your First Download' guided tour were rewritten for the new Search page. Stale annotations pointing at removed elements (toggle buttons, side-panel queues) were deleted; the tour now walks users through the source picker instead of the old mode toggle", + "features": [ + "• Retired the 'Browse Artists' tour since Artists is no longer a sidebar page", + "• First-download tour now runs on /search and opens the source picker as its first step", + "• PAGE_TOUR_MAP accepts both 'search' and the legacy 'downloads' id so old bookmarks still match a tour", + "• Removed 6 broken selector annotations (#toggle-download-manager-btn, .search-mode-toggle, .downloads-side-panel, #active-queue, #finished-queue, .controls-panel*)", + "• Phase 4c of the Search/Artists unification project — pure docs cleanup", + ], + }, { "title": "Artists Sidebar Entry Retired — Use Search Instead", "description": "Cin flagged that 'Artists' in the sidebar read like a library section but was actually a dedicated artist-search page, duplicating what the unified Search already does. The sidebar entry is gone; the same flow now runs through Search", diff --git a/webui/static/helper.js b/webui/static/helper.js index 7e494dfd..db7502c7 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -917,17 +917,13 @@ const HELPER_CONTENT = { description: 'Search for music across your configured metadata sources and download from Soulseek, YouTube, Tidal, Qobuz, HiFi, or Deezer.', docsId: 'search' }, - '#toggle-download-manager-btn': { - title: 'Toggle Download Manager', - description: 'Show or hide the download manager panel on the right side. The panel shows active downloads, finished downloads, and queue management.', - docsId: 'search-manager' - }, - '.search-mode-toggle': { - title: 'Search Mode', - description: 'Switch between Enhanced Search (categorized metadata results with album art) and Basic Search (raw Soulseek results with detailed file info and filters).', + '.search-source-picker-container': { + title: 'Search From', + description: 'Pick which metadata source to search. "All sources (Auto)" keeps the multi-source fan-out behavior; any specific source hits only that provider. "Soulseek (raw files)" switches to raw P2P file search with quality filters.', tips: [ - 'Enhanced: shows Artists, Albums, Singles, Tracks from your metadata source', - 'Basic: shows raw Soulseek P2P results with format, bitrate, size, uploader info' + 'Auto: searches your configured primary source plus library matches', + 'Spotify / Apple Music / Deezer / Discogs / Hydrabase / MusicBrainz: metadata-only results for that provider', + 'Soulseek: raw file results with format, bitrate, size, uploader — same as the old Basic Search' ], docsId: 'search-enhanced' }, @@ -955,7 +951,7 @@ const HELPER_CONTENT = { }, '#enh-spotify-artists-section': { title: 'Artists', - description: 'Artists from your metadata source matching the search. Click to view their full discography on the Artists page.', + description: 'Artists from your metadata source matching the search. Click one to open their discography.', }, '#enh-albums-section': { title: 'Albums', @@ -1010,33 +1006,7 @@ const HELPER_CONTENT = { docsId: 'search-basic' }, - // Download Manager Side Panel - '.downloads-side-panel': { - title: 'Download Manager', - description: 'Shows all active and completed downloads. Manage downloads, clear completed items, or cancel active transfers.', - docsId: 'search-manager' - }, - '.controls-panel': { - title: 'Download Controls', - description: 'Overview of active and finished download counts. Clear Completed removes finished items from the list. Clear Current cancels all active downloads.', - docsId: 'search-manager' - }, - '.controls-panel__clear-btn': { - title: 'Clear Completed', - description: 'Remove all finished downloads from the download manager list. Doesn\'t affect the downloaded files — they\'re already in your library.', - }, - '.controls-panel__cancel-all-btn': { - title: 'Clear Current', - description: 'Cancel all active downloads in progress. Files that were partially downloaded will be cleaned up.', - }, - '#active-queue': { - title: 'Download Queue', - description: 'Active downloads in progress. Each item shows track name, format, download speed, progress, and a cancel button.', - }, - '#finished-queue': { - title: 'Finished Downloads', - description: 'Completed downloads. Shows track name, format, file size, and final status (success or error).', - }, + // (Download Manager side-panel was retired — see the dedicated Downloads page) // ─── DISCOVER PAGE ──────────────────────────────────────────────── @@ -2378,9 +2348,9 @@ function toggleHelperMode() { const PAGE_TOUR_MAP = { 'dashboard': 'dashboard', 'sync': 'sync-playlist', - 'downloads': 'first-download', + 'search': 'first-download', + 'downloads': 'first-download', // legacy id — the Search page used to be called 'downloads' 'discover': 'discover', - 'artists': 'artists-browse', 'automations': 'automations', 'library': 'library', 'stats': 'stats', @@ -2542,15 +2512,11 @@ const HELPER_TOURS = { description: 'Step-by-step guide to downloading your first album.', icon: '⬇️', steps: [ - // Search page layout (top-to-bottom, elements visible on load) - { page: 'downloads', selector: '.search-mode-toggle', title: 'Search Modes', description: 'Two search modes: Enhanced Search (default) shows categorized results from your metadata source. Classic Search queries your download source directly for raw file results.' }, - { page: 'downloads', selector: '.enhanced-search-input-wrapper', title: 'Search for Music', description: 'Type an artist or album name here. Results appear in categorized sections — Artists, Albums, Singles/EPs, and Tracks. Try searching for your favorite artist now!' }, - { page: 'downloads', selector: '#toggle-download-manager-btn', title: 'Download Manager', description: 'This button toggles the download manager panel on the right side. It shows active downloads with progress bars, queued items, and completed downloads with their file paths.' }, - - // What results look like (describe since they appear after searching) - { page: 'downloads', selector: '#enh-results-container', title: 'Search Results', description: 'After searching, results appear here organized by type: Artists at the top as cards, then Albums, Singles/EPs, and individual Tracks. "In Library" badges mark items you already own.' }, - { page: 'downloads', selector: '.search-mode-toggle', title: 'Downloading an Album', description: 'Click any album card to open the download modal. You\'ll see the tracklist, quality options, and a big "Download Album" button. Individual tracks have a play button to preview before downloading.' }, - { page: 'downloads', selector: '.enhanced-search-input-wrapper', title: 'That\'s It!', description: 'Search, click, download — it\'s that simple. Albums go to your configured download path, get tagged with metadata, and sync to your media server automatically. 🎉' }, + { page: 'search', selector: '.search-source-picker-container', title: 'Pick a Search Source', description: '"All sources (Auto)" fans out across every provider. Pick a specific one (Spotify, Apple Music, Deezer, etc.) to get results from just that catalog. "Soulseek (raw files)" is the old Basic mode — raw P2P file results with quality filters.' }, + { page: 'search', selector: '.enhanced-search-input-wrapper', title: 'Search for Music', description: 'Type an artist or album name here. Results appear in categorized sections — Artists, Albums, Singles/EPs, and Tracks. Try searching for your favorite artist now!' }, + { page: 'search', selector: '#enh-results-container', title: 'Search Results', description: 'After searching, results appear organized by type: Artists at the top as cards, then Albums, Singles/EPs, and individual Tracks. "In Library" badges mark items you already own.' }, + { page: 'search', selector: '.enhanced-search-input-wrapper', title: 'Downloading an Album', description: 'Click any album card to open the download modal. You\'ll see the tracklist, quality options, and a big "Download Album" button. Individual tracks have a play button to preview before downloading.' }, + { page: 'search', selector: '.enhanced-search-input-wrapper', title: 'That\'s It!', description: 'Search, click, download. Albums go to your configured download path, get tagged with metadata, and sync to your media server automatically. Active downloads live on the dedicated Downloads page.' }, ] }, 'sync-playlist': { @@ -2576,20 +2542,8 @@ const HELPER_TOURS = { { page: 'sync', selector: '.sync-sidebar', title: 'Sync Controls', description: 'The command center. Select playlists with checkboxes on the left, then click "Start Sync" here. Progress bars, match counts, and logs update in real-time. That\'s the sync flow! 🎉' }, ] }, - 'artists-browse': { - title: 'Browse Artists', - description: 'Search for artists and explore their discography.', - icon: '🎤', - steps: [ - // Artists list page (visible on load) - { page: 'artists', selector: '#artists-search-input', title: 'Search for an Artist', description: 'Type any artist name to search your metadata source. Results appear instantly as cards below. Click one to open their full profile and discography.' }, - - // Artist detail page (describe what they'll see after clicking) - { page: 'artists', selector: '#artists-search-input', title: 'Artist Profile', description: 'After clicking an artist, you\'ll see a rich hero section with their photo, bio, genres, listening stats from Last.fm, and links to external services like Spotify and MusicBrainz.' }, - { page: 'artists', selector: '#artists-search-input', title: 'Discography & Downloads', description: 'Below the hero, tabs show Albums and Singles/EPs. Click any release to open the download modal. The "Similar Artists" section at the bottom shows recommendations — click any to keep exploring.' }, - { page: 'artists', selector: '#artists-search-input', title: 'Try It Now!', description: 'Search for your favorite artist above to see their full profile. From there you can download albums, explore similar artists, and add them to your watchlist. 🎉' }, - ] - }, + // 'artists-browse' tour retired — the Artists sidebar entry was replaced by the + // unified Search page (see the first-download tour for the new flow). 'automations': { title: 'Build an Automation', description: 'Create automated workflows with triggers and actions.', @@ -3111,7 +3065,7 @@ const SETUP_STEPS = [ { id: 'download-source', label: 'Set Up Download Source', desc: 'Soulseek, YouTube, Tidal, Qobuz, HiFi, or Deezer', icon: '⬇️', page: 'settings', settingsTab: 'downloads' }, { id: 'download-paths', label: 'Configure Download Paths', desc: 'Where music is saved and organized', icon: '📁', page: 'settings', settingsTab: 'downloads' }, { id: 'first-scan', label: 'Run First Library Scan', desc: 'Import your existing collection from media server', icon: '🔍', page: 'dashboard', selector: '#db-updater-card' }, - { id: 'first-download', label: 'Download Your First Track', desc: 'Search for and download something', icon: '🎶', page: 'downloads' }, + { id: 'first-download', label: 'Download Your First Track', desc: 'Search for and download something', icon: '🎶', page: 'search' }, { id: 'watchlist', label: 'Add an Artist to Watchlist', desc: 'Monitor for new releases automatically', icon: '👁️', page: 'library' }, { id: 'automation', label: 'Create an Automation', desc: 'Schedule tasks and build workflows', icon: '🤖', page: 'automations' }, ]; @@ -3218,14 +3172,8 @@ async function _checkSetupStatus() { results['first-download'] = Date.now(); _markSetupComplete('first-download'); } - // Also check the finished queue (if on downloads page) - if (!results['first-download']) { - const fq = document.querySelector('#finished-queue'); - if (fq && fq.querySelector('.download-item')) { - results['first-download'] = Date.now(); - _markSetupComplete('first-download'); - } - } + // (The legacy #finished-queue side-panel was retired; the dashboard stat card + // above is now the single source of truth for the first-download milestone.) } return results; @@ -3599,6 +3547,11 @@ function closeHelperSearch() { // ═══════════════════════════════════════════════════════════════════════════ const WHATS_NEW = { + '2.47': [ + // --- April 24, 2026 --- + { date: 'April 24, 2026' }, + { title: 'Interactive Help Updated for Unified Search', desc: 'The click-for-help annotations and the "Your First Download" guided tour were rewritten for the new Search page. Stale annotations pointing at removed elements (Basic/Enhanced toggle button, side-panel queues, download-manager controls) are deleted. The first-download tour now runs on /search and opens with the source picker. PAGE_TOUR_MAP accepts both "search" and the legacy "downloads" id so old bookmarks still match a tour. Retired the standalone "Browse Artists" tour since Artists is no longer a sidebar page. Phase 4c of the Search/Artists unification project — pure docs cleanup', page: 'help' }, + ], '2.46': [ // --- April 23, 2026 (late night) --- { date: 'April 23, 2026 (late night)' }, From 19e917486684b6895ac1827c024c6f6f59aa8272 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:17:06 -0700 Subject: [PATCH 09/41] =?UTF-8?q?Fix=20404=20on=20source-artist=20click=20?= =?UTF-8?q?=E2=80=94=20revert=20Phase=204a=20source=20migrations,=20bump?= =?UTF-8?q?=20to=202.48?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4a (9361c29) mistakenly routed every artist click to navigateToArtistDetail, which fetches /api/artist-detail/. That endpoint only knows how to look up local DB primary keys. For source artists (Spotify/Deezer/iTunes/etc.) the id is a metadata-source id, not a library PK — so clicks 404'd out. Library artists (db_artists section in search results, library page clicks, stats links, media player) continue to go to the standalone /artist-detail page as before. Source artists now route back to the Artists page's inline view via selectArtistForDetail, which calls /api/artist//discography with a source param — the endpoint that actually handles non-library IDs. Reverted 7 migration points: - search.js: Enhanced Search source-artists onClick - downloads.js: global widget _gsClickArtist non-library branch - downloads.js: _navigateToArtistFromModal fallback - discover.js: viewRecommendedArtistDiscography - discover.js: viewDiscoverHeroDiscography - discover.js: 'Your Artists' card name-click inline HTML - discover.js: 'Your Artists' info-modal 'View All' button - discover.js: artist-map context menu - discover.js: genre-deep-dive artist click - api-monitor.js: watchlist artist discography view Phase 4a's goal of "one artist page for everything" is deferred — it needs backend work on /api/artist-detail to accept a source param and fall back to metadata-source lookup when the local DB lookup fails. Keeping the signature extension on navigateToArtistDetail (source parameter) in place for when that lands. --- web_server.py | 12 +++++++++++- webui/static/api-monitor.js | 11 +++++++++-- webui/static/discover.js | 34 +++++++++++++++++++++++++++------- webui/static/downloads.js | 25 ++++++++++++++++++++++--- webui/static/helper.js | 5 +++++ webui/static/search.js | 12 +++++++++++- 6 files changed, 85 insertions(+), 14 deletions(-) diff --git a/web_server.py b/web_server.py index b66d2a45..b69b21f4 100644 --- a/web_server.py +++ b/web_server.py @@ -37,7 +37,7 @@ _log_dir = Path(_log_path).parent logger = setup_logging(_log_level, _log_path) # App version — single source of truth for backup metadata, version-info endpoint, etc. -_SOULSYNC_BASE_VERSION = "2.47" +_SOULSYNC_BASE_VERSION = "2.48" def _build_version_string(): """Append short commit hash to version when available (e.g. 2.35+abc1234).""" @@ -22809,6 +22809,16 @@ def get_version_info(): "title": "What's New in SoulSync", "subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes", "sections": [ + { + "title": "Fix 404 When Clicking Source Artists in Search", + "description": "Phase 4a mistakenly routed every artist click — including source artists from Spotify/Deezer/iTunes/etc. — to the library artist detail page, which only knows how to look up local DB primary keys. Source artist IDs (like Deezer's 525046) 404'd out", + "features": [ + "• Library artists (db_artists section) continue to go to the standalone /artist-detail page — same as before, still works", + "• Source artists (Spotify/Deezer/iTunes/Discogs/Hydrabase/MusicBrainz sections) now route back to the Artists page's inline view, which fetches discography via /api/artist//discography with source context — the endpoint that actually knows how to handle non-library IDs", + "• Applied to all 7 Phase 4a migration points: Search results, global widget, Discover 'Your Artists' cards, Discover hero recommendations, Discover artist-map context menu, Discover genre-deep-dive, watchlist discography, download-missing modal, recommended artists modal", + "• Phase 4a's 'one artist page for everything' goal remains deferred until /api/artist-detail gains source-aware fallback behavior", + ], + }, { "title": "Interactive Help Annotations Updated for Unified Search", "description": "The click-for-help annotations and the 'Your First Download' guided tour were rewritten for the new Search page. Stale annotations pointing at removed elements (toggle buttons, side-panel queues) were deleted; the tour now walks users through the source picker instead of the old mode toggle", diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js index 8e8962cd..bc86b223 100644 --- a/webui/static/api-monitor.js +++ b/webui/static/api-monitor.js @@ -2385,9 +2385,16 @@ async function openWatchlistArtistDetailView(artistId, artistName) { source = spotify_artist_id ? 'spotify' : discogs_artist_id ? 'discogs' : deezer_artist_id ? 'deezer' : 'itunes'; } if (discogId) { - // Close detail overlay and navigate to the standalone artist detail page + // Watchlist discogId is a metadata-source id (Spotify/Deezer/iTunes), + // not a library PK — route through the Artists page inline view. closeWatchlistArtistDetailView(); - navigateToArtistDetail(discogId, artistName, source); + navigateToPage('artists'); + setTimeout(() => { + selectArtistForDetail( + { id: discogId, name: artistName, image_url: artist.image_url || '' }, + { source: source } + ); + }, 200); } }); diff --git a/webui/static/discover.js b/webui/static/discover.js index e70e3b65..e0293a60 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -739,7 +739,14 @@ async function checkRecommendedWatchlistStatuses(artists) { async function viewRecommendedArtistDiscography(artistId, artistName) { closeRecommendedArtistsModal(); - navigateToArtistDetail(artistId, artistName); + + const artist = { id: artistId, name: artistName }; + + // Recommended artists come from the metadata source — route through the + // Artists page's inline view so the source-provided id resolves correctly. + navigateToPage('artists'); + await new Promise(resolve => setTimeout(resolve, 100)); + await selectArtistForDetail(artist); } async function checkAllHeroWatchlistStatus() { @@ -821,8 +828,21 @@ async function viewDiscoverHeroDiscography() { return; } + const artist = { + id: artistId, + name: artistName, + image_url: discoverHeroArtists[discoverHeroIndex]?.image_url || '', + genres: discoverHeroArtists[discoverHeroIndex]?.genres || [], + popularity: discoverHeroArtists[discoverHeroIndex]?.popularity || 0 + }; + console.log(`🎵 Navigating to artist detail for: ${artistName}`); - navigateToArtistDetail(artistId, artistName); + + // Hero artists are source-provided recommendations — route through the + // Artists page's inline view so the source id resolves correctly. + navigateToPage('artists'); + await new Promise(resolve => setTimeout(resolve, 100)); + await selectArtistForDetail(artist); } function showDiscoverHeroEmpty() { @@ -4613,9 +4633,9 @@ function _renderYourArtistCard(artist) { const watchlistClass = artist.on_watchlist ? 'active' : ''; const hasId = artist.active_source_id && artist.active_source_id !== ''; - // Navigate to standalone artist detail page (name click) + // Navigate to Artists page (name click) — source artist id, needs inline view const navAction = hasId - ? `event.stopPropagation(); navigateToArtistDetail('${escapeForInlineJs(artist.active_source_id)}', '${escapeForInlineJs(artist.artist_name)}')` + ? `event.stopPropagation(); navigateToPage('artists'); setTimeout(() => selectArtistForDetail({id:'${escapeForInlineJs(artist.active_source_id)}', name:'${escapeForInlineJs(artist.artist_name)}', image_url:'${escapeForInlineJs(img)}'}), 200)` : ''; // Open info modal (card body click) — pass pool ID so we can look up all data @@ -4794,7 +4814,7 @@ async function openYourArtistInfoModal(poolId) { Explore - @@ -6694,7 +6714,7 @@ function _artMapSetupInteraction(canvas) {
Artist Info
-
+
💿 View Discography
@@ -7382,7 +7402,7 @@ async function openGenreDeepDive(genre) { // Always open on Artists page with discography — pass source for correct routing const imgUrl = _esc(a.image_url || ''); const artSource = _esc(a.source || ''); - const clickAction = `onclick="document.getElementById('genre-deep-dive-modal').remove();navigateToArtistDetail('${_esc(a.entity_id)}','${_esc(a.name)}','${artSource}' || null)"`; + const clickAction = `onclick="document.getElementById('genre-deep-dive-modal').remove();navigateToPage('artists');setTimeout(()=>selectArtistForDetail({id:'${_esc(a.entity_id)}',name:'${_esc(a.name)}',image_url:'${imgUrl}'},{source:'${artSource}'}),300)"`; const srcClass = (a.source || '').toLowerCase(); return `
diff --git a/webui/static/downloads.js b/webui/static/downloads.js index f13bfb2e..86d0ad24 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -634,7 +634,15 @@ function _navigateToArtistFromModal(artistId, artistName, imageUrl, source, play if (!artistName) return; // Close the download modal if (playlistId) closeDownloadMissingModal(playlistId); - navigateToArtistDetail(artistId || artistName, artistName, source || null); + // The id from a download modal is typically a metadata-source id; route via + // the Artists page inline view so the source-aware discography endpoint runs. + navigateToPage('artists'); + setTimeout(() => { + selectArtistForDetail( + { id: artistId || artistName, name: artistName, image_url: imageUrl || '' }, + source ? { source: source } : undefined + ); + }, 200); } async function closeDownloadMissingModal(playlistId) { @@ -5425,8 +5433,19 @@ function _gsSwitchSource(src) { function _gsClickArtist(id, name, isLibrary) { _gsDeactivate(); - const source = isLibrary ? null : (_gsState.activeSource || null); - navigateToArtistDetail(id, name, source); + if (isLibrary) { + // Library artists: id is a local DB PK — use the standalone artist-detail page. + navigateToArtistDetail(id, name); + } else { + // Source artists: id is a Deezer/Spotify/iTunes id — route to the Artists + // page's inline view which fetches discography from the source. + navigateToPage('artists'); + setTimeout(() => { + selectArtistForDetail({ id, name, image_url: '' }, { + source: _gsState.activeSource || '', + }); + }, 150); + } } async function _gsClickAlbum(albumId, albumName, artistName, imageUrl, source) { diff --git a/webui/static/helper.js b/webui/static/helper.js index db7502c7..60c03f98 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3547,6 +3547,11 @@ function closeHelperSearch() { // ═══════════════════════════════════════════════════════════════════════════ const WHATS_NEW = { + '2.48': [ + // --- April 24, 2026 (fix) --- + { date: 'April 24, 2026 (fix)' }, + { title: 'Fix 404 When Clicking Source Artists in Search', desc: 'Phase 4a mistakenly routed every artist click — including source artists from Spotify/Deezer/iTunes/etc. — to the library artist detail page, which only knows how to look up local DB primary keys. Source artist IDs (like Deezer\'s 525046) 404\'d out. Library artists continue to land on the standalone /artist-detail page. Source artists now route back to the Artists page\'s inline view, which fetches discography via /api/artist//discography with source context — the endpoint that actually knows how to handle non-library IDs. Fix applied to 7 Phase 4a migration points: Search results, global widget, Discover "Your Artists" cards, Discover hero recommendations, artist-map context menu, genre-deep-dive, watchlist discography, download-missing modal, recommended artists modal', page: 'search' }, + ], '2.47': [ // --- April 24, 2026 --- { date: 'April 24, 2026' }, diff --git a/webui/static/search.js b/webui/static/search.js index be9147c5..fd8b0b37 100644 --- a/webui/static/search.js +++ b/webui/static/search.js @@ -340,7 +340,17 @@ function initializeSearchModeToggle() { const sourceOverride = _activeSearchSource; console.log(`🎵 Opening artist detail: ${artist.name} (ID: ${artist.id}, source: ${sourceOverride})`); hideDropdown(); - navigateToArtistDetail(artist.id, artist.name, sourceOverride || null); + + // Source artists are NOT library entries — their id is a Deezer/ + // Spotify/iTunes id, not a library PK. Route to the Artists page's + // inline selectArtistForDetail which fetches discography from the + // source directly, not the library's /api/artist-detail endpoint. + navigateToPage('artists'); + await new Promise(resolve => setTimeout(resolve, 100)); + await selectArtistForDetail(artist, { + source: sourceOverride, + plugin: artist.external_urls?.hydrabase_plugin, + }); } }) ); From 78c14d3084bfbd0d2ee82873e93c2f059d45056a Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:25:20 -0700 Subject: [PATCH 10/41] Hold version at 2.39 and fold unification changelog into one 2.40 entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the 2.40→2.49 version spam from this session — every phase commit was bumping the display version when the whole Search/Artists unification project should really be a single release. Changes: - _SOULSYNC_BASE_VERSION back to 2.39 - All session-level version-info sections consolidated — the endpoint response is back to the pre-session 2.39 shape - helper.js WHATS_NEW entries for 2.40–2.49 collapsed into a single '2.40' block with one bullet per phase, marked unreleased - _getLatestWhatsNewVersion / _showOlderNotes filter out entries whose version is higher than the current build, so the 2.40 block won't fire the 'new' badge or appear in the What's New panel until we actually flip the build version - Picks up the artist-detail back-button fix from the previous turn (falls back to browser history when the user reached the inline detail from outside the Artists page) When the unification project is done, a single commit that bumps _SOULSYNC_BASE_VERSION to 2.40 will publish the whole folded entry. --- web_server.py | 102 +--------------------------------------- webui/static/artists.js | 18 +++++-- webui/static/helper.js | 71 ++++++++++------------------ 3 files changed, 39 insertions(+), 152 deletions(-) diff --git a/web_server.py b/web_server.py index b69b21f4..77ae5381 100644 --- a/web_server.py +++ b/web_server.py @@ -37,7 +37,7 @@ _log_dir = Path(_log_path).parent logger = setup_logging(_log_level, _log_path) # App version — single source of truth for backup metadata, version-info endpoint, etc. -_SOULSYNC_BASE_VERSION = "2.48" +_SOULSYNC_BASE_VERSION = "2.39" def _build_version_string(): """Append short commit hash to version when available (e.g. 2.35+abc1234).""" @@ -22809,106 +22809,6 @@ def get_version_info(): "title": "What's New in SoulSync", "subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes", "sections": [ - { - "title": "Fix 404 When Clicking Source Artists in Search", - "description": "Phase 4a mistakenly routed every artist click — including source artists from Spotify/Deezer/iTunes/etc. — to the library artist detail page, which only knows how to look up local DB primary keys. Source artist IDs (like Deezer's 525046) 404'd out", - "features": [ - "• Library artists (db_artists section) continue to go to the standalone /artist-detail page — same as before, still works", - "• Source artists (Spotify/Deezer/iTunes/Discogs/Hydrabase/MusicBrainz sections) now route back to the Artists page's inline view, which fetches discography via /api/artist//discography with source context — the endpoint that actually knows how to handle non-library IDs", - "• Applied to all 7 Phase 4a migration points: Search results, global widget, Discover 'Your Artists' cards, Discover hero recommendations, Discover artist-map context menu, Discover genre-deep-dive, watchlist discography, download-missing modal, recommended artists modal", - "• Phase 4a's 'one artist page for everything' goal remains deferred until /api/artist-detail gains source-aware fallback behavior", - ], - }, - { - "title": "Interactive Help Annotations Updated for Unified Search", - "description": "The click-for-help annotations and the 'Your First Download' guided tour were rewritten for the new Search page. Stale annotations pointing at removed elements (toggle buttons, side-panel queues) were deleted; the tour now walks users through the source picker instead of the old mode toggle", - "features": [ - "• Retired the 'Browse Artists' tour since Artists is no longer a sidebar page", - "• First-download tour now runs on /search and opens the source picker as its first step", - "• PAGE_TOUR_MAP accepts both 'search' and the legacy 'downloads' id so old bookmarks still match a tour", - "• Removed 6 broken selector annotations (#toggle-download-manager-btn, .search-mode-toggle, .downloads-side-panel, #active-queue, #finished-queue, .controls-panel*)", - "• Phase 4c of the Search/Artists unification project — pure docs cleanup", - ], - }, - { - "title": "Artists Sidebar Entry Retired — Use Search Instead", - "description": "Cin flagged that 'Artists' in the sidebar read like a library section but was actually a dedicated artist-search page, duplicating what the unified Search already does. The sidebar entry is gone; the same flow now runs through Search", - "features": [ - "• Sidebar → Search → type an artist's name → click their result → land on the artist detail page (same page Library links to)", - "• 'Browse Artists' button on the empty Watchlist page now opens Search instead of the retired Artists page", - "• 'View artist from Wishlist' button now opens Search pre-filled with the artist's name", - "• Removed 'Artists' from the profile Home Page and Page Access options so new profiles don't point to a missing sidebar entry", - "• Deep link to /artists still resolves so old bookmarks work; the page and its inline search just aren't promoted anywhere", - "• Phase 4b of the Search/Artists unification project", - ], - }, - { - "title": "Artist Links Everywhere Go to the Same Page", - "description": "Clicking an artist result in Search, Discover, the API Monitor, or anywhere else now lands on the standalone artist detail page Library already uses — instead of swapping into the Artists page's inline detail view", - "features": [ - "• One artist detail page, not two — less navigation surprise and a stable URL (/artist-detail) for deep links", - "• Source context (Spotify/iTunes/Deezer/etc.) now carries cleanly into the destination so non-Spotify albums load from the right provider", - "• Removed the navigate-then-setTimeout dance at 9 callsites (api-monitor, discover, downloads, search, library recommendations)", - "• Artists sidebar entry and its inline search still work for now — next phase retires that page entirely", - "• Phase 4a of the Search/Artists unification project", - ], - }, - { - "title": "Remove Embedded Download Manager from Search Page", - "description": "The Search page used to carry a second copy of the Download Manager (active + finished queues, clear/cancel-all buttons) that was hidden by default and duplicated the dedicated Downloads page. That duplicate is gone", - "features": [ - "• Toggle button, side-panel HTML, and its 1-second polling loop removed — Downloads page is now the single downloads UI", - "• About 330 lines of dead code gone across downloads.js and init.js", - "• CSS grid for the Search page collapsed to a single-column layout now that the right panel is gone", - "• Phase 3c of the Search/Artists unification project", - ], - }, - { - "title": "Search Page Renamed to /search", - "description": "The Search page's internal id is now 'search' instead of 'downloads', which no longer conflicts with the real Downloads page. URL /downloads still works for bookmarks and external links", - "features": [ - "• Sidebar label unchanged (still reads 'Search') — no visual change", - "• URL now /search; /downloads stays as an alias so no existing link breaks", - "• DOM id renamed to #search-page; all internal references follow", - "• Profile ACL stored as 'search' going forward; existing profiles with 'downloads' in allowed_pages still resolve through a legacy-compat check", - "• Phase 3b of the Search/Artists unification project", - ], - }, - { - "title": "Search Source Picker — Pick Where You're Searching", - "description": "The Search page's Enhanced/Basic toggle is replaced by a single 'Search from' dropdown so you can explicitly pick which source to query instead of fanning out to every provider", - "features": [ - "• Choose from: All sources (Auto), Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz, or Soulseek (raw files)", - "• Auto keeps today's multi-source fan-out behavior — no change if you want the current results", - "• Picking a specific source hits only that provider — no more surprise Spotify rate-limit hits from flows that didn't need Spotify", - "• 'Soulseek' routes to the raw-file search (what 'Basic' used to do) — one picker now covers both modes", - "• Loading text shows the selected source (e.g., 'Searching across Apple Music and your library...')", - "• Phase 3 of the Search/Artists unification project — builds on the shared fetch helper from 2.41", - ], - "usage_note": "Sidebar → Search → pick a source in the 'Search from' dropdown above the search bar." - }, - { - "title": "Shared Enhanced-Search Fetch Helper", - "description": "Internal refactor — the Search page and the global search widget now route through one shared fetch helper, so future source-picker work only needs wiring in one place", - "features": [ - "• New enhancedSearchFetch(query, { source, signal }) in search.js", - "• Both callers (Search page dropdown + global widget) deduplicated — single /api/enhanced-search chokepoint", - "• Accepts the source param added in 2.40, letting future UI wire a picker without touching both callers", - "• Zero UX change — pure plumbing (Phase 2 of the Search/Artists unification project)", - ], - }, - { - "title": "Explicit Source Selection on Enhanced Search", - "description": "The /api/enhanced-search endpoint now accepts an optional `source` parameter so callers can target a single metadata source instead of always fanning out across every provider", - "features": [ - "• Accepts one of: auto, spotify, itunes, deezer, discogs, hydrabase, musicbrainz (omitted or 'auto' preserves the existing multi-source behavior)", - "• When a specific source is chosen, only that provider is queried — no more surprise Spotify calls from flows that didn't need Spotify", - "• Foundation for upcoming unified Search page with a source picker (Phase 1 of the Search/Artists unification project)", - "• db_artists (local library results) still returned in every mode so library matches keep surfacing", - "• Cache keys now include the requested source — single-source and multi-source searches no longer share cached entries", - "• Validates source names and returns 400 on unknown values", - ], - }, { "title": "Fix Wrong-Artist Tracks Silently Downloading", "description": "A critical bug where searching for a track could silently download a completely different artist's song with the same name", diff --git a/webui/static/artists.js b/webui/static/artists.js index c4e4744e..505febf9 100644 --- a/webui/static/artists.js +++ b/webui/static/artists.js @@ -31,12 +31,20 @@ function initializeArtistsPage() { if (detailBackButton) { detailBackButton.addEventListener('click', () => { - // If there are no search results (user navigated directly to artist), - // go straight to the main search view instead of showing an empty results page - if (!artistsPageState.searchResults || artistsPageState.searchResults.length === 0) { - showArtistsSearchState(); - } else { + // If the user searched within the Artists page, back returns to the + // results list so they can pick a different artist. + if (artistsPageState.searchResults && artistsPageState.searchResults.length > 0) { showArtistsResultsState(); + return; + } + // Otherwise the user reached this detail view from elsewhere (Search, + // Discover, watchlist, etc.). The Artists page is no longer a sidebar + // entry, so there's nothing useful to fall back to here — let the + // browser take them back to wherever they came from. + if (window.history.length > 1) { + window.history.back(); + } else { + navigateToPage('dashboard'); } }); } diff --git a/webui/static/helper.js b/webui/static/helper.js index 60c03f98..1f0a4f0a 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3546,51 +3546,21 @@ function closeHelperSearch() { // WHAT'S NEW (Phase 6) // ═══════════════════════════════════════════════════════════════════════════ +// Entries tagged with `unreleased: true` are accumulating under a version label +// but won't display until the build version catches up. The Search/Artists +// unification project stays folded here at 2.40 until the whole thing ships. const WHATS_NEW = { - '2.48': [ - // --- April 24, 2026 (fix) --- - { date: 'April 24, 2026 (fix)' }, - { title: 'Fix 404 When Clicking Source Artists in Search', desc: 'Phase 4a mistakenly routed every artist click — including source artists from Spotify/Deezer/iTunes/etc. — to the library artist detail page, which only knows how to look up local DB primary keys. Source artist IDs (like Deezer\'s 525046) 404\'d out. Library artists continue to land on the standalone /artist-detail page. Source artists now route back to the Artists page\'s inline view, which fetches discography via /api/artist//discography with source context — the endpoint that actually knows how to handle non-library IDs. Fix applied to 7 Phase 4a migration points: Search results, global widget, Discover "Your Artists" cards, Discover hero recommendations, artist-map context menu, genre-deep-dive, watchlist discography, download-missing modal, recommended artists modal', page: 'search' }, - ], - '2.47': [ - // --- April 24, 2026 --- - { date: 'April 24, 2026' }, - { title: 'Interactive Help Updated for Unified Search', desc: 'The click-for-help annotations and the "Your First Download" guided tour were rewritten for the new Search page. Stale annotations pointing at removed elements (Basic/Enhanced toggle button, side-panel queues, download-manager controls) are deleted. The first-download tour now runs on /search and opens with the source picker. PAGE_TOUR_MAP accepts both "search" and the legacy "downloads" id so old bookmarks still match a tour. Retired the standalone "Browse Artists" tour since Artists is no longer a sidebar page. Phase 4c of the Search/Artists unification project — pure docs cleanup', page: 'help' }, - ], - '2.46': [ - // --- April 23, 2026 (late night) --- - { date: 'April 23, 2026 (late night)' }, - { title: 'Artists Sidebar Entry Retired — Use Search Instead', desc: 'Cin flagged that "Artists" in the sidebar read like a library section but was actually a dedicated artist-search page, duplicating what the unified Search already does. The sidebar entry is gone. New flow: Sidebar → Search → type artist name → click their result → land on the artist detail page (same page Library links to). "Browse Artists" on the empty Watchlist page and "View artist from Wishlist" now open Search pre-filled with the artist\'s name. Removed "Artists" from profile Home Page + Page Access options. Deep link to /artists still resolves so old bookmarks keep working — the page just isn\'t promoted anywhere. Phase 4b of the Search/Artists unification project', page: 'search' }, - ], - '2.45': [ - // --- April 23, 2026 (night) --- - { date: 'April 23, 2026 (night)' }, - { title: 'Artist Links Everywhere Go to the Same Page', desc: 'Clicking an artist result in Search, Discover, the API Monitor, or anywhere else now lands on the standalone artist detail page that Library already uses — instead of swapping into the Artists page\'s inline detail view. One artist detail page, not two: less navigation surprise, a stable /artist-detail URL for deep links, and source context (Spotify/iTunes/Deezer/etc.) now carries cleanly into the destination so non-Spotify albums load from the right provider. Removed the navigate-then-setTimeout dance at 9 callsites. Artists sidebar entry and its inline search still work for now — next phase retires that page entirely. Phase 4a of the Search/Artists unification project', page: 'library' }, - ], - '2.44': [ - // --- April 23, 2026 (evening) --- - { date: 'April 23, 2026 (evening)' }, - { title: 'Remove Embedded Download Manager from Search Page', desc: 'The Search page used to carry a second copy of the Download Manager (active + finished queues, clear/cancel-all buttons) that was hidden by default and duplicated the dedicated Downloads page. That duplicate is gone — toggle button, side-panel HTML, and its 1-second polling loop all removed. About 330 lines of dead code cleaned up across downloads.js and init.js. CSS grid for the Search page collapsed to single-column now that the right panel is gone. The dedicated Downloads sidebar page is now the single downloads UI. Phase 3c of the Search/Artists unification project', page: 'search' }, - ], - '2.43': [ - // --- April 23, 2026 (later) --- - { date: 'April 23, 2026 (later)' }, - { title: 'Search Page Renamed to /search', desc: 'The Search page\'s internal id is now "search" instead of the confusing "downloads" (which clashed with the actual Downloads page). Sidebar label unchanged. URL is now /search; /downloads still resolves so old bookmarks keep working. Profile ACL "Page Access" now saves as "search"; existing profiles with "downloads" in allowed_pages still resolve through a legacy-compat check. Phase 3b of the Search/Artists unification project', page: 'search' }, - ], - '2.42': [ - // --- April 23, 2026 --- - { date: 'April 23, 2026' }, - { title: 'Search Source Picker — Pick Where You\'re Searching', desc: 'The Search page\'s Enhanced/Basic toggle is replaced by a single "Search from" dropdown at the top. Choose All sources (Auto — keeps today\'s multi-source fan-out), Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz, or Soulseek (raw files). Picking a specific source hits only that provider — no more surprise Spotify rate-limit hits from flows that didn\'t need Spotify. "Soulseek" routes to the raw-file search (what "Basic" used to do), so one picker now covers both modes. Loading text reflects the selected source (e.g., "Searching across Apple Music..."). Phase 3 of the Search/Artists unification project', page: 'search' }, - ], - '2.41': [ - // --- April 22, 2026 (late night) --- - { date: 'April 22, 2026 (late night)' }, - { title: 'Shared Enhanced-Search Fetch Helper', desc: 'Internal refactor — the Search page dropdown and the global search widget now route through one shared enhancedSearchFetch helper in search.js, so both callers hit /api/enhanced-search through a single chokepoint. Zero UX change, but it means the upcoming source picker (Phase 3) only needs wiring in one place instead of two. Also lets both surfaces accept the source parameter added in 2.40 (Phase 2 of the Search/Artists unification project)', page: 'downloads' }, - ], '2.40': [ - // --- April 22, 2026 (late) --- - { date: 'April 22, 2026 (late)' }, - { title: 'Explicit Source Selection on Enhanced Search', desc: 'The /api/enhanced-search endpoint now accepts an optional `source` parameter (spotify, itunes, deezer, discogs, hydrabase, musicbrainz) so callers can target a single metadata source instead of fanning out across every provider. Omitted or `auto` preserves the existing multi-source behavior — nothing breaks. This is the foundation for the upcoming unified Search page with a source picker (Phase 1 of the Search/Artists unification project). db_artists (local library matches) still returned in every mode. Cache keys now isolate per-source so single-source and multi-source results don\'t collide', page: 'downloads' }, + // --- Search & Artists unification (in progress, not yet released) --- + { date: 'Unreleased — Search & Artists unification', unreleased: true }, + { title: 'Search Source Picker', desc: 'The Search page\'s Enhanced/Basic toggle is replaced by a single "Search from" dropdown at the top — pick All sources (Auto), Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz, or Soulseek (raw files). Auto keeps today\'s multi-source fan-out; picking a specific source hits only that provider so there are no more surprise Spotify rate-limit hits from flows that didn\'t need Spotify. "Soulseek" routes to the raw-file search (what "Basic" used to do), so one picker now covers both old modes. Loading text reflects the selected source', page: 'search', unreleased: true }, + { title: 'Explicit Source Selection on /api/enhanced-search', desc: 'The enhanced-search endpoint now accepts an optional `source` body param (spotify, itunes, deezer, discogs, hydrabase, musicbrainz, auto). When a specific source is chosen, only that provider is queried and db_artists (local library matches) still come back. Cache keys isolate per-source so single-source and multi-source results don\'t collide. Omitted or `auto` preserves the old multi-source fan-out behavior unchanged — nothing breaks for existing callers', page: 'search', unreleased: true }, + { title: 'Shared Enhanced-Search Fetch Helper', desc: 'Internal refactor — the Search page dropdown and the global search widget now route through one shared enhancedSearchFetch helper in search.js instead of duplicating the POST boilerplate. Zero UX change, but it means any future source-picker tweak only needs wiring in one place', page: 'search', unreleased: true }, + { title: 'Search Page Renamed to /search', desc: 'The Search page\'s internal id is now "search" instead of the confusing "downloads" (which clashed with the actual Downloads page). Sidebar label unchanged. URL is now /search; /downloads still resolves so old bookmarks keep working. Profile ACL "Page Access" now saves as "search"; existing profiles with "downloads" in allowed_pages still resolve through a legacy-compat check', page: 'search', unreleased: true }, + { title: 'Embedded Download Manager Removed from Search Page', desc: 'The Search page used to carry a second copy of the Download Manager (active + finished queues, clear/cancel-all buttons) that was hidden by default and duplicated the dedicated Downloads page. That duplicate is gone — toggle button, side-panel HTML, and its 1-second polling loop all removed. About 330 lines of dead code cleaned up. The dedicated Downloads sidebar page is now the single downloads UI', page: 'search', unreleased: true }, + { title: 'Artists Sidebar Entry Retired — Use Search Instead', desc: 'Cin flagged that "Artists" in the sidebar read like a library section but was actually a dedicated artist-search page, duplicating what the unified Search already does. The sidebar entry is gone. New flow: Sidebar → Search → type artist name → click their result. "Browse Artists" on the empty Watchlist page and "View artist from Wishlist" now open Search pre-filled with the artist\'s name. Removed "Artists" from profile Home Page + Page Access options. Deep link to /artists still resolves so old bookmarks keep working — the page just isn\'t promoted anywhere', page: 'search', unreleased: true }, + { title: 'Artist Detail Back Button Fallback', desc: 'The back button on the Artists-page inline detail view used to dump users on an empty "Search for an artist..." screen when they arrived from outside the Artists page — a dead end now that Artists isn\'t in the sidebar. If you searched inside the Artists page, back still returns to your results list. Otherwise (arriving from Search, Discover, Watchlist, etc.), back uses the browser history to land you on whichever page you came from. Falls back to Dashboard only when there\'s no browser history to go back to', page: 'search', unreleased: true }, + { title: 'Interactive Help Updated for Unified Search', desc: 'The click-for-help annotations and the "Your First Download" guided tour were rewritten for the new Search page. Stale annotations pointing at removed elements (Basic/Enhanced toggle button, side-panel queues, download-manager controls) are deleted. The first-download tour now runs on /search and opens with the source picker. PAGE_TOUR_MAP accepts both "search" and the legacy "downloads" id so old bookmarks still match a tour. Retired the standalone "Browse Artists" tour', page: 'help', unreleased: true }, ], '2.39': [ // --- April 22, 2026 --- @@ -3795,7 +3765,13 @@ function _getCurrentVersion() { } function _getLatestWhatsNewVersion() { - const versions = Object.keys(WHATS_NEW).sort((a, b) => parseFloat(b) - parseFloat(a)); + // Only surface entries whose version number is <= the current build. Entries + // sitting at higher versions are unreleased work-in-progress and shouldn't + // flag as "new" in the helper badge until the build catches up. + const buildVer = parseFloat(_getCurrentVersion()) || 2.39; + const versions = Object.keys(WHATS_NEW) + .filter(v => (parseFloat(v) || 0) <= buildVer) + .sort((a, b) => parseFloat(b) - parseFloat(a)); return versions[0] || '2.39'; } @@ -3884,8 +3860,11 @@ function _openFullChangelog() { } function _showOlderNotes() { - // Cycle to next older version in the what's new panel - const versions = Object.keys(WHATS_NEW).sort((a, b) => parseFloat(b) - parseFloat(a)); + // Cycle to next older version in the what's new panel (skip unreleased entries) + const buildVer = parseFloat(_getCurrentVersion()) || 2.39; + const versions = Object.keys(WHATS_NEW) + .filter(v => (parseFloat(v) || 0) <= buildVer) + .sort((a, b) => parseFloat(b) - parseFloat(a)); const panel = _helperPopover; if (!panel) return; const currentTitle = panel.querySelector('.helper-popover-title'); From 3c7dc4de6e58d1cac251f82d7f87eb7b5b05d164 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:29:30 -0700 Subject: [PATCH 11/41] Artist detail back button falls back to Search, not Dashboard When the user reached the inline Artists detail view from outside the Artists page and no browser history is available (direct bookmark), fall back to the Search page instead of Dashboard. Search is the natural next step for finding a different artist. --- webui/static/artists.js | 5 +++-- webui/static/helper.js | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/webui/static/artists.js b/webui/static/artists.js index 505febf9..1f741c01 100644 --- a/webui/static/artists.js +++ b/webui/static/artists.js @@ -40,11 +40,12 @@ function initializeArtistsPage() { // Otherwise the user reached this detail view from elsewhere (Search, // Discover, watchlist, etc.). The Artists page is no longer a sidebar // entry, so there's nothing useful to fall back to here — let the - // browser take them back to wherever they came from. + // browser take them back to wherever they came from, or drop them on + // Search (the go-forward way to find another artist). if (window.history.length > 1) { window.history.back(); } else { - navigateToPage('dashboard'); + navigateToPage('search'); } }); } diff --git a/webui/static/helper.js b/webui/static/helper.js index 1f0a4f0a..bafe250c 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3559,7 +3559,7 @@ const WHATS_NEW = { { title: 'Search Page Renamed to /search', desc: 'The Search page\'s internal id is now "search" instead of the confusing "downloads" (which clashed with the actual Downloads page). Sidebar label unchanged. URL is now /search; /downloads still resolves so old bookmarks keep working. Profile ACL "Page Access" now saves as "search"; existing profiles with "downloads" in allowed_pages still resolve through a legacy-compat check', page: 'search', unreleased: true }, { title: 'Embedded Download Manager Removed from Search Page', desc: 'The Search page used to carry a second copy of the Download Manager (active + finished queues, clear/cancel-all buttons) that was hidden by default and duplicated the dedicated Downloads page. That duplicate is gone — toggle button, side-panel HTML, and its 1-second polling loop all removed. About 330 lines of dead code cleaned up. The dedicated Downloads sidebar page is now the single downloads UI', page: 'search', unreleased: true }, { title: 'Artists Sidebar Entry Retired — Use Search Instead', desc: 'Cin flagged that "Artists" in the sidebar read like a library section but was actually a dedicated artist-search page, duplicating what the unified Search already does. The sidebar entry is gone. New flow: Sidebar → Search → type artist name → click their result. "Browse Artists" on the empty Watchlist page and "View artist from Wishlist" now open Search pre-filled with the artist\'s name. Removed "Artists" from profile Home Page + Page Access options. Deep link to /artists still resolves so old bookmarks keep working — the page just isn\'t promoted anywhere', page: 'search', unreleased: true }, - { title: 'Artist Detail Back Button Fallback', desc: 'The back button on the Artists-page inline detail view used to dump users on an empty "Search for an artist..." screen when they arrived from outside the Artists page — a dead end now that Artists isn\'t in the sidebar. If you searched inside the Artists page, back still returns to your results list. Otherwise (arriving from Search, Discover, Watchlist, etc.), back uses the browser history to land you on whichever page you came from. Falls back to Dashboard only when there\'s no browser history to go back to', page: 'search', unreleased: true }, + { title: 'Artist Detail Back Button Fallback', desc: 'The back button on the Artists-page inline detail view used to dump users on an empty "Search for an artist..." screen when they arrived from outside the Artists page — a dead end now that Artists isn\'t in the sidebar. If you searched inside the Artists page, back still returns to your results list. Otherwise (arriving from Search, Discover, Watchlist, etc.), back uses the browser history to land you on whichever page you came from. Falls back to the Search page only when there\'s no browser history to go back to (the natural place to find another artist)', page: 'search', unreleased: true }, { title: 'Interactive Help Updated for Unified Search', desc: 'The click-for-help annotations and the "Your First Download" guided tour were rewritten for the new Search page. Stale annotations pointing at removed elements (Basic/Enhanced toggle button, side-panel queues, download-manager controls) are deleted. The first-download tour now runs on /search and opens with the source picker. PAGE_TOUR_MAP accepts both "search" and the legacy "downloads" id so old bookmarks still match a tour. Retired the standalone "Browse Artists" tour', page: 'help', unreleased: true }, ], '2.39': [ From a5d97261e48afe469daabf6c8f17414246cb6f3a Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:25:59 -0700 Subject: [PATCH 12/41] Extract shared helpers from artists.js to shared-helpers.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part C of the deferred unification cleanup. The Artists page is no longer in the sidebar, but its JS file can't be deleted yet because it houses ~20 general-purpose helpers that other modules depend on (escapeHtml used in 229 places, service-status polling, image-colour extraction, download-bubble infrastructure, discography completion checking, enrichment card rendering). Moved all non-page-specific code from artists.js into the new webui/static/shared-helpers.js — pure copy/paste, zero logic change. Two contiguous blocks extracted: Block A (lines 1097..1398 of original artists.js): discography completion suite — checkDiscographyCompletion, handleStreaming- CompletionUpdate, cacheCompletionData, updateAlbumCompletion- Overlay, getCompletionStatusText, setAlbumDownloadedStatus, setAlbumDownloadingStatus. Block B (lines 2206..EOF of original artists.js): download-bubble infrastructure (artist + search + Beatport clusters with their snapshot/hydrate/modal/monitor helpers), openDownloadMissingModal- ForArtistAlbum, image-colour extractor and dynamic-glow helper, escapeHtml, service-status polling, renderEnrichmentCards. Function declarations in a plain + diff --git a/webui/static/artists.js b/webui/static/artists.js index 1f741c01..7d70c875 100644 --- a/webui/static/artists.js +++ b/webui/static/artists.js @@ -1094,308 +1094,6 @@ function restoreCachedCompletionData(artistId) { /** * Check completion status for entire discography with streaming updates */ -async function checkDiscographyCompletion(artistId, discography) { - console.log(`🔍 Starting streaming completion check for artist: ${artistId}`); - - try { - // Create new abort controller for this completion check - artistCompletionController = new AbortController(); - - // Use fetch with streaming response - const response = await fetch(`/api/artist/${artistId}/completion-stream`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - discography: discography, - artist_name: artistsPageState.selectedArtist?.name || 'Unknown Artist', - source: discography?.source || artistsPageState.sourceOverride || null, - }), - signal: artistCompletionController.signal - }); - - if (!response.ok) { - throw new Error(`Failed to start completion check: ${response.status}`); - } - - // Handle streaming response - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - const chunk = decoder.decode(value); - const lines = chunk.split('\n'); - - for (const line of lines) { - if (line.startsWith('data: ')) { - try { - const data = JSON.parse(line.slice(6)); - handleStreamingCompletionUpdate(data); - } catch (e) { - console.warn('Failed to parse streaming data:', line); - } - } - } - } - - // Clear the controller when done - artistCompletionController = null; - - } catch (error) { - // Don't show error if it was aborted (user navigated away) - if (error.name === 'AbortError') { - console.log('⏹️ Completion check aborted (user navigated to new artist)'); - return; - } - - console.error('❌ Failed to check completion status:', error); - showCompletionError(); - } finally { - // Always clear the controller - artistCompletionController = null; - } -} - -/** - * Handle individual streaming completion updates - */ -function handleStreamingCompletionUpdate(data) { - console.log('🔄 Streaming update received:', data.type, data.name || data.artist_name); - - switch (data.type) { - case 'start': - console.log(`🎤 Starting completion check for ${data.artist_name} (${data.total_items} items)`); - // Initialize cache for this artist if not exists - const artistId = artistsPageState.selectedArtist?.id; - if (artistId && !artistsPageState.cache.completionData[artistId]) { - artistsPageState.cache.completionData[artistId] = { - albums: [], - singles: [] - }; - } - break; - - case 'album_completion': - updateAlbumCompletionOverlay(data, 'albums'); - // Cache the completion data - cacheCompletionData(data, 'albums'); - console.log(`📀 Updated album: ${data.name} (${data.status})`); - break; - - case 'single_completion': - updateAlbumCompletionOverlay(data, 'singles'); - // Cache the completion data - cacheCompletionData(data, 'singles'); - console.log(`🎵 Updated single: ${data.name} (${data.status})`); - break; - - case 'error': - console.error('❌ Error processing item:', data.name, data.error); - // Could show error for specific item - break; - - case 'complete': - console.log(`✅ Completion check finished (${data.processed_count} items processed)`); - break; - - default: - console.log('Unknown streaming update type:', data.type); - } -} - -/** - * Cache completion data for future restoration - */ -function cacheCompletionData(completionData, type) { - const artistId = artistsPageState.selectedArtist?.id; - if (!artistId) return; - - // Ensure cache structure exists - if (!artistsPageState.cache.completionData[artistId]) { - artistsPageState.cache.completionData[artistId] = { - albums: [], - singles: [] - }; - } - - // Add to appropriate cache array - if (type === 'albums') { - artistsPageState.cache.completionData[artistId].albums.push(completionData); - } else if (type === 'singles') { - artistsPageState.cache.completionData[artistId].singles.push(completionData); - } -} - -/** - * Update completion overlay for a specific album/single - */ -function updateAlbumCompletionOverlay(completionData, containerType) { - const containerId = containerType === 'albums' ? 'album-cards-container' : 'singles-cards-container'; - const container = document.getElementById(containerId); - - if (!container) { - console.warn(`Container ${containerId} not found`); - return; - } - - // Find the album card by data-album-id - const albumCard = container.querySelector(`[data-album-id="${completionData.id}"]`); - - if (!albumCard) { - console.warn(`Album card not found for ID: ${completionData.id}`); - return; - } - - // Reclassify and move cards when track count reveals single/EP (Discogs lazy fetch) - const currentType = albumCard.dataset.albumType; - const expectedTracks = completionData.expected_tracks || 0; - if (expectedTracks > 0) { - albumCard.dataset.totalTracks = expectedTracks; - let newType = currentType; - if (currentType === 'album' && expectedTracks <= 3) newType = 'single'; - else if (currentType === 'album' && expectedTracks <= 6) newType = 'ep'; - - if (newType !== currentType) { - albumCard.dataset.albumType = newType; - const typeEl = albumCard.querySelector('.album-card-type'); - if (typeEl) typeEl.textContent = newType === 'single' ? 'Single' : 'EP'; - - // Move card from albums grid to singles grid - const singlesGrid = document.getElementById('singles-grid'); - const singlesSection = singlesGrid?.closest('.discography-section'); - if (singlesGrid) { - albumCard.remove(); - singlesGrid.appendChild(albumCard); - if (singlesSection) singlesSection.style.display = ''; - } - } - } - - const overlay = albumCard.querySelector('.completion-overlay'); - if (!overlay) { - console.warn(`Completion overlay not found for album: ${completionData.name}`); - return; - } - - // Remove existing status classes - overlay.classList.remove('checking', 'completed', 'nearly_complete', 'partial', 'missing', 'downloading', 'downloaded', 'error'); - - // Add new status class - overlay.classList.add(completionData.status); - - // Update overlay text and content - const statusText = getCompletionStatusText(completionData); - const progressText = completionData.expected_tracks > 0 - ? `${completionData.owned_tracks}/${completionData.expected_tracks}` - : ''; - - overlay.innerHTML = progressText - ? `${statusText}${progressText}` - : `${statusText}`; - - // Add tooltip with more details - overlay.title = `${completionData.name}\n${statusText} (${completionData.completion_percentage}%)\nTracks: ${completionData.owned_tracks}/${completionData.expected_tracks}\nConfidence: ${completionData.confidence}`; - - // Add brief flash animation to indicate update - overlay.style.animation = 'none'; - overlay.offsetHeight; // Trigger reflow - overlay.style.animation = 'completionOverlayFadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1)'; - - console.log(`📊 Updated overlay for "${completionData.name}": ${statusText} (${completionData.completion_percentage}%)`); -} - -/** - * Get human-readable status text for completion overlay - */ -function getCompletionStatusText(completionData) { - switch (completionData.status) { - case 'completed': - return 'Complete'; - case 'nearly_complete': - return 'Nearly Complete'; - case 'partial': - return 'Partial'; - case 'missing': - return 'Missing'; - case 'downloading': - return 'Downloading...'; - case 'downloaded': - return 'Downloaded'; - case 'error': - return 'Error'; - default: - return 'Unknown'; - } -} - -/** - * Set album to downloaded status after download finishes - */ -function setAlbumDownloadedStatus(albumId) { - console.log(`✅ [DOWNLOAD COMPLETE] Setting album ${albumId} to downloaded status`); - - const completionData = { - id: albumId, - status: 'downloaded', - owned_tracks: 0, - expected_tracks: 0, - name: 'Downloaded', - completion_percentage: 100 - }; - - // Find if it's in albums or singles container - let containerType = 'albums'; - let albumCard = document.querySelector(`#album-cards-container [data-album-id="${albumId}"]`); - if (!albumCard) { - containerType = 'singles'; - albumCard = document.querySelector(`#singles-cards-container [data-album-id="${albumId}"]`); - } - - if (albumCard) { - updateAlbumCompletionOverlay(completionData, containerType); - console.log(`✅ [DOWNLOAD COMPLETE] Album ${albumId} set to Downloaded status`); - } else { - console.warn(`❌ [DOWNLOAD COMPLETE] Album card not found for ID: "${albumId}"`); - } -} - -/** - * Set album to downloading status - */ -function setAlbumDownloadingStatus(albumId, downloaded = 0, total = 0) { - console.log(`🔍 [DOWNLOAD STATUS] Searching for album card with ID: "${albumId}"`); - - const completionData = { - id: albumId, - status: 'downloading', - owned_tracks: downloaded, - expected_tracks: total, - name: 'Downloading', - completion_percentage: Math.round((downloaded / total) * 100) || 0 - }; - - // Find if it's in albums or singles container - let containerType = 'albums'; - let albumCard = document.querySelector(`#album-cards-container [data-album-id="${albumId}"]`); - if (!albumCard) { - containerType = 'singles'; - albumCard = document.querySelector(`#singles-cards-container [data-album-id="${albumId}"]`); - } - - if (albumCard) { - console.log(`✅ [DOWNLOAD STATUS] Found album card in ${containerType} container, updating overlay`); - updateAlbumCompletionOverlay(completionData, containerType); - } else { - console.warn(`❌ [DOWNLOAD STATUS] Album card not found for ID: "${albumId}"`); - // Debug: List all available album cards - const allAlbums = document.querySelectorAll('#album-cards-container [data-album-id], #singles-cards-container [data-album-id]'); - console.log(`🔍 [DEBUG] Available album IDs:`, Array.from(allAlbums).map(card => card.dataset.albumId)); - } -} /** * Show error state on all completion overlays @@ -2203,2436 +1901,3 @@ async function createArtistAlbumVirtualPlaylist(album, albumType) { * Open download missing tracks modal specifically for artist albums * Similar to openDownloadMissingModalForYouTube but for artist albums */ -async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, spotifyTracks, album, artist, showLoadingOverlayParam = true, contextType = 'artist_album') { - if (showLoadingOverlayParam) { - showLoadingOverlay('Loading album...'); - } - // Check if a process is already active for this virtual playlist - if (activeDownloadProcesses[virtualPlaylistId]) { - console.log(`Modal for ${virtualPlaylistId} already exists. Showing it.`); - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process.modalElement) { - if (process.status === 'complete') { - showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); - } - process.modalElement.style.display = 'flex'; - if (showLoadingOverlayParam) { - hideLoadingOverlay(); - } - } - return; - } - - console.log(`📥 Opening Download Missing Tracks modal for artist album: ${virtualPlaylistId}`); - - // Create virtual playlist object for compatibility with existing modal logic - const virtualPlaylist = { - id: virtualPlaylistId, - name: playlistName, - track_count: spotifyTracks.length - }; - - // Store the tracks in the cache for the modal to use - playlistTrackCache[virtualPlaylistId] = spotifyTracks; - currentPlaylistTracks = spotifyTracks; - currentModalPlaylistId = virtualPlaylistId; - - let modal = document.createElement('div'); - modal.id = `download-missing-modal-${virtualPlaylistId}`; - modal.className = 'download-missing-modal'; - modal.style.display = 'none'; - document.body.appendChild(modal); - - // Register the new process in our global state tracker using the same structure as other modals - activeDownloadProcesses[virtualPlaylistId] = { - status: 'idle', - modalElement: modal, - poller: null, - batchId: null, - playlist: virtualPlaylist, - tracks: spotifyTracks, - // Additional metadata for artist albums - artist: artist, - album: album, - albumType: album.album_type, - source: artist?.source || album?.source || artistsPageState.artistDiscography?.source || null - }; - - // Generate hero section — 'artist_album' for releases, 'playlist' for charts/compilations - const heroContext = contextType === 'playlist' ? { - type: 'playlist', - playlist: { name: playlistName, owner: 'Beatport' }, - trackCount: spotifyTracks.length, - playlistId: virtualPlaylistId - } : { - type: 'artist_album', - artist: artist, - album: album, - trackCount: spotifyTracks.length, - playlistId: virtualPlaylistId - }; - - // Use the exact same modal HTML structure as the existing modals - modal.innerHTML = ` -
-
- ${generateDownloadModalHeroSection(heroContext)} -
- -
-
-
-
- 🔍 Library Analysis - Ready to start -
-
-
-
-
-
-
- ⏬ Downloads - Waiting for analysis -
-
-
-
-
-
- -
-
-

📋 Track Analysis & Download Status

- ${spotifyTracks.length} / ${spotifyTracks.length} tracks selected -
-
- - - - - - - - - - - - - - - ${spotifyTracks.map((track, index) => ` - - - - - - - - - - - `).join('')} - -
- - #Track NameArtist(s)DurationLibrary StatusDownload StatusActions
- - ${index + 1}${escapeHtml(track.name)}${escapeHtml(formatArtists(track.artists))}${formatDuration(track.duration_ms)}🔍 Pending--
-
-
-
- - - - -
- `; - - applyProgressiveTrackRendering(virtualPlaylistId, spotifyTracks.length); - modal.style.display = 'flex'; - hideLoadingOverlay(); - - console.log(`✅ Successfully opened download missing tracks modal for: ${playlistName}`); -} - -// =============================== -// ARTIST DOWNLOADS MANAGEMENT SYSTEM -// =============================== - -/** - * Register a new artist download for bubble management - */ -function registerArtistDownload(artist, album, virtualPlaylistId, albumType) { - console.log(`📝 Registering artist download: ${artist.name} - ${album.name}`); - - const artistId = artist.id; - - // Initialize artist bubble if it doesn't exist - if (!artistDownloadBubbles[artistId]) { - artistDownloadBubbles[artistId] = { - artist: artist, - downloads: [], - element: null, - hasCompletedDownloads: false - }; - } - - // Add this download to the artist's downloads - const downloadInfo = { - virtualPlaylistId: virtualPlaylistId, - album: album, - albumType: albumType, - status: 'in_progress', // 'in_progress', 'completed', 'view_results' - startTime: new Date() - }; - - artistDownloadBubbles[artistId].downloads.push(downloadInfo); - - // Show/update the artist downloads section - updateArtistDownloadsSection(); - - // Save snapshot of current state - saveArtistBubbleSnapshot(); - - // Monitor this download for completion - monitorArtistDownload(artistId, virtualPlaylistId); -} - -/** - * Debounced update for artist downloads section to prevent rapid updates - */ -function updateArtistDownloadsSection() { - if (downloadsUpdateTimeout) { - clearTimeout(downloadsUpdateTimeout); - } - downloadsUpdateTimeout = setTimeout(() => { - showArtistDownloadsSection(); - showLibraryDownloadsSection(); - showBeatportDownloadsSection(); - updateDashboardDownloads(); - }, 300); // 300ms debounce -} - -// --- Artist Bubble Snapshot System --- - -let snapshotSaveTimeout = null; // Debounce snapshot saves - -async function saveArtistBubbleSnapshot() { - /** - * Saves current artistDownloadBubbles state to backend for persistence. - * Debounced to prevent excessive backend calls. - */ - - // Clear any existing timeout - if (snapshotSaveTimeout) { - clearTimeout(snapshotSaveTimeout); - } - - // Debounce the actual save - snapshotSaveTimeout = setTimeout(async () => { - try { - const bubbleCount = Object.keys(artistDownloadBubbles).length; - - // Don't save empty state - if (bubbleCount === 0) { - console.log('📸 Skipping snapshot save - no artist bubbles to save'); - return; - } - - console.log(`📸 Saving artist bubble snapshot: ${bubbleCount} artists`); - - // Prepare snapshot data (clean up DOM references) - const cleanBubbles = {}; - for (const [artistId, bubbleData] of Object.entries(artistDownloadBubbles)) { - cleanBubbles[artistId] = { - artist: bubbleData.artist, - downloads: bubbleData.downloads.map(download => ({ - virtualPlaylistId: download.virtualPlaylistId, - album: download.album, - albumType: download.albumType, - status: download.status, - startTime: download.startTime instanceof Date ? download.startTime.toISOString() : download.startTime - })), - hasCompletedDownloads: bubbleData.hasCompletedDownloads - }; - } - - const response = await fetch('/api/artist_bubbles/snapshot', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - bubbles: cleanBubbles - }) - }); - - const data = await response.json(); - - if (data.success) { - console.log(`✅ Artist bubble snapshot saved: ${bubbleCount} artists`); - } else { - console.error('❌ Failed to save artist bubble snapshot:', data.error); - } - - } catch (error) { - console.error('❌ Error saving artist bubble snapshot:', error); - } - }, 1000); // 1 second debounce -} - -async function hydrateArtistBubblesFromSnapshot() { - /** - * Hydrates artist download bubbles from backend snapshot with live status. - * Called on page load to restore bubble state. - */ - try { - console.log('🔄 Loading artist bubble snapshot from backend...'); - - const response = await fetch('/api/artist_bubbles/hydrate'); - const data = await response.json(); - - if (!data.success) { - console.error('❌ Failed to load artist bubble snapshot:', data.error); - return; - } - - const bubbles = data.bubbles || {}; - const stats = data.stats || {}; - - console.log(`🔄 Loaded bubble snapshot: ${stats.total_artists || 0} artists, ${stats.active_downloads || 0} active, ${stats.completed_downloads || 0} completed`); - - if (Object.keys(bubbles).length === 0) { - console.log('ℹ️ No artist bubbles to hydrate'); - return; - } - - // Clear existing state - artistDownloadBubbles = {}; - - // Restore artistDownloadBubbles with hydrated data - for (const [artistId, bubbleData] of Object.entries(bubbles)) { - artistDownloadBubbles[artistId] = { - artist: bubbleData.artist, - downloads: bubbleData.downloads.map(download => ({ - virtualPlaylistId: download.virtualPlaylistId, - album: download.album, - albumType: download.albumType, - status: download.status, // Live status from backend - startTime: new Date(download.startTime) - })), - element: null, // Will be created when UI updates - hasCompletedDownloads: bubbleData.hasCompletedDownloads - }; - - console.log(`🔄 Hydrated artist: ${bubbleData.artist.name} (${bubbleData.downloads.length} downloads)`); - - // Start monitoring for any in-progress downloads - for (const download of bubbleData.downloads) { - if (download.status === 'in_progress') { - console.log(`📡 Starting monitoring for: ${download.album.name}`); - monitorArtistDownload(artistId, download.virtualPlaylistId); - } - } - } - - // Update UI to show hydrated bubbles - updateArtistDownloadsSection(); - - const totalArtists = Object.keys(artistDownloadBubbles).length; - console.log(`✅ Successfully hydrated ${totalArtists} artist download bubbles`); - - } catch (error) { - console.error('❌ Error hydrating artist bubbles from snapshot:', error); - } -} - -// --- Search Bubble Snapshot System --- - -async function saveSearchBubbleSnapshot() { - /** - * Saves current searchDownloadBubbles state to backend for persistence. - */ - try { - // Rate limit saves to avoid spamming backend - if (saveSearchBubbleSnapshot.lastSaveTime) { - const timeSinceLastSave = Date.now() - saveSearchBubbleSnapshot.lastSaveTime; - if (timeSinceLastSave < 2000) { - console.log('⏱️ Skipping search bubble snapshot save (rate limited)'); - return; - } - } - - const bubbleCount = Object.keys(searchDownloadBubbles).length; - - if (bubbleCount === 0) { - console.log('📸 Skipping snapshot save - no search bubbles to save'); - return; - } - - console.log(`📸 Saving search bubble snapshot: ${bubbleCount} artists`); - - // Convert search bubbles to plain objects for serialization - const bubblesToSave = {}; - for (const [artistName, bubbleData] of Object.entries(searchDownloadBubbles)) { - bubblesToSave[artistName] = { - artist: bubbleData.artist, - downloads: bubbleData.downloads.map(d => ({ - virtualPlaylistId: d.virtualPlaylistId, - item: d.item, - type: d.type, - status: d.status, - startTime: d.startTime - })) - }; - } - - const response = await fetch('/api/search_bubbles/snapshot', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ bubbles: bubblesToSave }) - }); - - const data = await response.json(); - - if (data.success) { - console.log(`✅ Search bubble snapshot saved: ${bubbleCount} artists`); - saveSearchBubbleSnapshot.lastSaveTime = Date.now(); - } else { - console.error('❌ Failed to save search bubble snapshot:', data.error); - } - - } catch (error) { - console.error('❌ Error saving search bubble snapshot:', error); - } -} - -async function hydrateSearchBubblesFromSnapshot() { - /** - * Hydrates search download bubbles from backend snapshot with live status. - */ - try { - console.log('🔄 Loading search bubble snapshot from backend...'); - - const response = await fetch('/api/search_bubbles/hydrate'); - const data = await response.json(); - - if (!data.success) { - console.error('❌ Failed to load search bubble snapshot:', data.error); - return; - } - - const bubbles = data.bubbles || {}; - const stats = data.stats || {}; - - if (Object.keys(bubbles).length === 0) { - console.log('ℹ️ No search bubbles to hydrate'); - return; - } - - // Clear and restore search bubbles - searchDownloadBubbles = {}; - - for (const [artistName, bubbleData] of Object.entries(bubbles)) { - searchDownloadBubbles[artistName] = { - artist: bubbleData.artist, - downloads: bubbleData.downloads || [] - }; - - console.log(`🔄 Hydrated artist: ${artistName} (${bubbleData.downloads.length} downloads)`); - - // Setup monitoring for each download - for (const download of bubbleData.downloads) { - if (download.status === 'in_progress') { - monitorSearchDownload(artistName, download.virtualPlaylistId); - } - } - } - - const totalArtists = Object.keys(searchDownloadBubbles).length; - console.log(`✅ Successfully hydrated ${totalArtists} search download bubbles`); - - // Refresh display - showSearchDownloadBubbles(); - - } catch (error) { - console.error('❌ Error hydrating search bubbles from snapshot:', error); - } -} - -/** - * Register a new search download for bubble management (grouped by artist) - */ -function registerSearchDownload(item, type, virtualPlaylistId, artistName) { - console.log(`📝 [REGISTER] Registering search download: ${item.name} (${type}) by ${artistName}`); - - // Initialize artist bubble if it doesn't exist - if (!searchDownloadBubbles[artistName]) { - searchDownloadBubbles[artistName] = { - artist: { - name: artistName, - image_url: item.image_url || (item.images && item.images[0]?.url) || null - }, - downloads: [] - }; - } - - // Add this download to the artist's downloads - const downloadInfo = { - virtualPlaylistId: virtualPlaylistId, - item: item, - type: type, // 'album' or 'track' - status: 'in_progress', - startTime: new Date().toISOString() - }; - - searchDownloadBubbles[artistName].downloads.push(downloadInfo); - - console.log(`✅ [REGISTER] Registered search download for ${artistName} - ${item.name}`); - - // Save snapshot - saveSearchBubbleSnapshot(); - - // Setup monitoring - monitorSearchDownload(artistName, virtualPlaylistId); - - // Refresh display - updateSearchDownloadsSection(); -} - -/** - * Debounced update for search downloads section - */ -function updateSearchDownloadsSection() { - if (window.searchUpdateTimeout) { - clearTimeout(window.searchUpdateTimeout); - } - window.searchUpdateTimeout = setTimeout(() => { - showSearchDownloadBubbles(); - updateDashboardDownloads(); - }, 300); -} - -/** - * Monitor a search download for completion status changes - */ -function monitorSearchDownload(artistName, virtualPlaylistId) { - const checkCompletion = setInterval(() => { - const process = activeDownloadProcesses[virtualPlaylistId]; - - if (!process || !searchDownloadBubbles[artistName]) { - clearInterval(checkCompletion); - return; - } - - // Find the download in the artist's downloads - const download = searchDownloadBubbles[artistName].downloads.find( - d => d.virtualPlaylistId === virtualPlaylistId - ); - - if (!download) { - clearInterval(checkCompletion); - return; - } - - // Update status - const newStatus = process.status === 'complete' || process.status === 'view_results' - ? 'view_results' - : 'in_progress'; - - if (download.status !== newStatus) { - console.log(`🔄 [MONITOR] Status changed for ${download.item.name}: ${download.status} -> ${newStatus}`); - download.status = newStatus; - - // Save snapshot and refresh - saveSearchBubbleSnapshot(); - updateSearchDownloadsSection(); - } - }, 2000); -} - -/** - * Show or update the search downloads bubble section - */ -function showSearchDownloadBubbles() { - console.log(`🔄 [SHOW] showSearchDownloadBubbles() called`); - - const resultsArea = document.getElementById('enhanced-main-results-area'); - if (!resultsArea) { - console.log(`⏭️ [SHOW] Skipping - no enhanced-main-results-area found`); - return; - } - - // Count active artists (those with downloads) - const activeArtists = Object.keys(searchDownloadBubbles).filter(artistName => - searchDownloadBubbles[artistName].downloads.length > 0 - ); - - if (activeArtists.length === 0) { - // Show placeholder - resultsArea.innerHTML = ` -
-

Search results will appear here when you select an album or track.

-
- `; - return; - } - - // Create bubbles display - const bubblesHTML = activeArtists.map(artistName => - createSearchBubbleCard(searchDownloadBubbles[artistName]) - ).join(''); - - resultsArea.innerHTML = ` -
-
-

Active Downloads

- ${activeArtists.length} -
-
- ${bubblesHTML} -
-
- `; - - console.log(`✅ [SHOW] Displayed ${activeArtists.length} search bubbles`); -} - -/** - * Create HTML for a search bubble card (grouped by artist) - */ -function createSearchBubbleCard(artistBubbleData) { - const { artist, downloads } = artistBubbleData; - const activeCount = downloads.filter(d => d.status === 'in_progress').length; - const completedCount = downloads.filter(d => d.status === 'view_results').length; - const allCompleted = activeCount === 0 && completedCount > 0; - - console.log(`🔵 [BUBBLE] Creating bubble for ${artist.name}:`, { - totalDownloads: downloads.length, - activeCount, - completedCount, - allCompleted - }); - - const imageUrl = artist.image_url || ''; - const backgroundStyle = imageUrl ? - `background-image: url('${escapeHtml(imageUrl)}');` : - `background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`; - - return ` -
-
-
-
-
${escapeHtml(artist.name)}
-
- ${activeCount > 0 ? `${activeCount} active` : ''} - ${completedCount > 0 ? `${completedCount} completed` : ''} -
-
- ${allCompleted ? ` -
- -
- ` : ''} -
- `; -} - -/** - * Open modal showing all downloads for an artist - */ -async function openSearchDownloadModal(artistName) { - const artistBubbleData = searchDownloadBubbles[artistName]; - if (!artistBubbleData || searchDownloadModalOpen) return; - - console.log(`🎵 [MODAL OPEN] Opening search download modal for: ${artistBubbleData.artist.name}`); - - searchDownloadModalOpen = true; - - const modal = document.createElement('div'); - modal.id = 'search-download-management-modal'; - modal.className = 'artist-download-management-modal'; - modal.innerHTML = ` -
-
-
-
-
-
- ${artistBubbleData.artist.image_url - ? `${escapeHtml(artistBubbleData.artist.name)}` - : '
🎵
' - } -
-
-

${escapeHtml(artistBubbleData.artist.name)}

-

${artistBubbleData.downloads.length} active download${artistBubbleData.downloads.length !== 1 ? 's' : ''}

-
-
- × -
-
- -
-
- ${artistBubbleData.downloads.map((download, index) => createSearchDownloadItem(download, index)).join('')} -
-
-
-
- `; - - document.body.appendChild(modal); - modal.style.display = 'flex'; - - // Start monitoring for status changes - // Start monitoring for status changes - monitorSearchDownloadModal(artistName); - - // Lazy load artist image if missing (common for iTunes) - if (!artistBubbleData.artist.image_url) { - console.log(`🖼️ Lazy loading modal image for ${artistBubbleData.artist.name} (${artistBubbleData.artist.id})`); - fetch(`/api/artist/${artistBubbleData.artist.id}/image`) - .then(response => response.json()) - .then(data => { - if (data.success && data.image_url) { - // Update header background - const headerBg = modal.querySelector('.artist-download-modal-hero-bg'); - if (headerBg) { - headerBg.style.backgroundImage = `url('${data.image_url}')`; - } - - // Update avatar - const avatarContainer = modal.querySelector('.artist-download-modal-hero-avatar'); - if (avatarContainer) { - avatarContainer.innerHTML = `${artistBubbleData.artist.name}`; - } - - // Update artist object in memory - artistBubbleData.artist.image_url = data.image_url; - } - }) - .catch(err => console.error('❌ Failed to load modal image:', err)); - } -} - -/** - * Create HTML for a download item in the search modal - */ -function createSearchDownloadItem(download, index) { - const { item, type, status, virtualPlaylistId } = download; - const buttonText = status === 'view_results' ? 'View Results' : 'View Progress'; - const buttonClass = status === 'view_results' ? 'completed' : 'active'; - const typeLabel = type === 'album' ? 'Album' : type === 'single' ? 'Single' : 'Track'; - - return ` -
-
- ${item.image_url - ? `${escapeHtml(item.name)}` - : `
- ${type === 'album' ? '💿' : '🎵'} -
` - } -
-
-
${escapeHtml(item.name)}
-
${typeLabel}
-
-
- -
-
- `; -} - -/** - * Reopen an individual download modal from the artist modal - */ -async function reopenDownloadModal(virtualPlaylistId) { - const process = activeDownloadProcesses[virtualPlaylistId]; - - // If process exists, show the existing modal - if (process && process.modalElement) { - console.log(`✅ [REOPEN] Showing existing modal for ${virtualPlaylistId}`); - closeSearchDownloadModal(); - setTimeout(() => { - process.modalElement.style.display = 'flex'; - }, 100); - return; - } - - // Process doesn't exist (after page refresh) - recreate it - console.log(`🔄 [REOPEN] Modal not found, recreating for ${virtualPlaylistId}`); - - // Find the download in searchDownloadBubbles - let downloadData = null; - for (const artistName in searchDownloadBubbles) { - const bubble = searchDownloadBubbles[artistName]; - const download = bubble.downloads.find(d => d.virtualPlaylistId === virtualPlaylistId); - if (download) { - downloadData = download; - break; - } - } - - if (!downloadData) { - console.warn(`⚠️ No download data found for ${virtualPlaylistId}`); - return; - } - - // Close search modal first - closeSearchDownloadModal(); - - // Recreate the modal based on type - const { item, type } = downloadData; - - if (type === 'album') { - // For albums, we need to fetch the tracks - console.log(`📥 [REOPEN] Recreating album modal for: ${item.name}`); - - // Fetch album tracks (pass name/artist for Hydrabase support) - showLoadingOverlay(`Loading ${item.name}...`); - - try { - const _sap2 = new URLSearchParams({ name: item.name || '', artist: item.artist || '' }); - const response = await fetch(`/api/spotify/album/${item.id}?${_sap2}`); - if (!response.ok) { - throw new Error('Failed to fetch album tracks'); - } - - const albumData = await response.json(); - if (!albumData.tracks || albumData.tracks.length === 0) { - throw new Error('No tracks found in album'); - } - - const spotifyTracks = albumData.tracks.map(track => ({ - id: track.id, - name: track.name, - artists: track.artists || [{ name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' }], - album: { - name: item.name, - images: item.image_url ? [{ url: item.image_url }] : [] - }, - duration_ms: track.duration_ms || 0 - })); - - hideLoadingOverlay(); - - // Open the modal - await openDownloadMissingModalForArtistAlbum( - virtualPlaylistId, - item.name, - spotifyTracks, - item, - { name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' }, - false // Don't show loading overlay again - ); - - // Sync with backend to check for active batch process - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process) { - try { - const processResponse = await fetch('/api/active-processes'); - if (processResponse.ok) { - const processData = await processResponse.json(); - const activeProcess = processData.active_processes?.find(p => p.playlist_id === virtualPlaylistId); - - if (activeProcess) { - console.log(`📡 [REOPEN] Found active batch for album: ${activeProcess.batch_id}`); - process.status = 'running'; - process.batchId = activeProcess.batch_id; - - // Update UI to show running state - const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - - // Start polling for live updates - startModalDownloadPolling(virtualPlaylistId); - } - } - } catch (err) { - console.warn('Could not check for active processes:', err); - } - } - - } catch (error) { - hideLoadingOverlay(); - showToast(`Failed to load album: ${error.message}`, 'error'); - console.error('Error loading album:', error); - } - - } else { - // For tracks, create enriched track and open modal - console.log(`🎵 [REOPEN] Recreating track modal for: ${item.name}`); - - const enrichedTrack = { - id: item.id, - name: item.name, - artists: item.artists || [{ name: item.artist || 'Unknown Artist' }], - album: item.album || { - name: item.album?.name || 'Unknown Album', - images: item.image_url ? [{ url: item.image_url }] : [] - }, - duration_ms: item.duration_ms || 0 - }; - - await openDownloadMissingModalForYouTube( - virtualPlaylistId, - `${enrichedTrack.name} - ${enrichedTrack.artists[0].name || enrichedTrack.artists[0]}`, - [enrichedTrack] - ); - - // Sync with backend to check for active batch process - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process) { - try { - const processResponse = await fetch('/api/active-processes'); - if (processResponse.ok) { - const processData = await processResponse.json(); - const activeProcess = processData.active_processes?.find(p => p.playlist_id === virtualPlaylistId); - - if (activeProcess) { - console.log(`📡 [REOPEN] Found active batch for track: ${activeProcess.batch_id}`); - process.status = 'running'; - process.batchId = activeProcess.batch_id; - - // Update UI to show running state - const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - - // Start polling for live updates - startModalDownloadPolling(virtualPlaylistId); - } - } - } catch (err) { - console.warn('Could not check for active processes:', err); - } - } - } -} - -/** - * Monitor search download modal for status changes - */ -function monitorSearchDownloadModal(artistName) { - const updateModal = () => { - if (!searchDownloadModalOpen) return; - - const modal = document.getElementById('search-download-management-modal'); - const itemsContainer = document.getElementById('search-download-items'); - - if (!modal || !itemsContainer || !searchDownloadBubbles[artistName]) return; - - const downloads = searchDownloadBubbles[artistName].downloads; - - // If no downloads at all, close modal - if (downloads.length === 0) { - closeSearchDownloadModal(); - return; - } - - // Update modal content and sync status with active processes - let statusChanged = false; - itemsContainer.innerHTML = downloads.map((download, index) => { - const process = activeDownloadProcesses[download.virtualPlaylistId]; - - // Only update status if process exists (otherwise keep current status) - if (process) { - const newStatus = process.status === 'complete' || process.status === 'view_results' - ? 'view_results' - : 'in_progress'; - - if (download.status !== newStatus) { - console.log(`🔄 [MODAL MONITOR] Status changed: ${download.item.name} ${download.status} -> ${newStatus}`); - download.status = newStatus; - statusChanged = true; - } - } - - return createSearchDownloadItem(download, index); - }).join(''); - - // If status changed, refresh bubble display and save - if (statusChanged) { - updateSearchDownloadsSection(); - saveSearchBubbleSnapshot(); - } - - // Continue monitoring - setTimeout(updateModal, 2000); - }; - - setTimeout(updateModal, 1000); -} - -/** - * Close the search download modal - */ -function closeSearchDownloadModal() { - const modal = document.getElementById('search-download-management-modal'); - if (modal) { - modal.style.display = 'none'; - if (modal.parentElement) { - modal.parentElement.removeChild(modal); - } - } - searchDownloadModalOpen = false; -} - -/** - * Bulk complete all downloads for an artist (called when user clicks green checkmark) - */ -function bulkCompleteSearchDownloads(artistName) { - console.log(`🎯 Bulk completing downloads for artist: ${artistName}`); - - const artistBubbleData = searchDownloadBubbles[artistName]; - if (!artistBubbleData) { - console.warn(`❌ No artist bubble data found for ${artistName}`); - return; - } - - // Find all completed downloads - const completedDownloads = artistBubbleData.downloads.filter(d => d.status === 'view_results'); - console.log(`📋 Found ${completedDownloads.length} completed downloads to close:`, - completedDownloads.map(d => d.item.name)); - - if (completedDownloads.length === 0) { - console.warn(`⚠️ No completed downloads found for bulk close`); - showToast('No completed downloads to close', 'info'); - return; - } - - // Close all completed modals - completedDownloads.forEach(download => { - const process = activeDownloadProcesses[download.virtualPlaylistId]; - if (process && process.modalElement) { - console.log(`🗑️ Closing modal for: ${download.item.name}`); - closeDownloadMissingModal(download.virtualPlaylistId); - } else { - // No modal open — clean up the bubble entry directly - console.log(`🧹 Direct cleanup (no modal) for: ${download.item.name}`); - cleanupSearchDownload(download.virtualPlaylistId); - } - }); - - showToast(`Completed ${completedDownloads.length} downloads for ${artistBubbleData.artist.name}`, 'success'); -} - -/** - * Cleanup search download when modal is closed - */ -function cleanupSearchDownload(virtualPlaylistId) { - console.log(`🔍 [CLEANUP] Looking for search download to cleanup: ${virtualPlaylistId}`); - - // Find which artist this download belongs to - for (const artistName in searchDownloadBubbles) { - const downloads = searchDownloadBubbles[artistName].downloads; - const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId); - - if (downloadIndex !== -1) { - console.log(`🧹 [CLEANUP] Found download in artist ${artistName}: ${downloads[downloadIndex].item.name}`); - - // Remove this download - downloads.splice(downloadIndex, 1); - console.log(`🗑️ [CLEANUP] Removed download from ${artistName}'s bubble`); - - // If no more downloads for this artist, remove the bubble - if (downloads.length === 0) { - delete searchDownloadBubbles[artistName]; - console.log(`🧹 [CLEANUP] No more downloads - removed artist bubble: ${artistName}`); - } - - // Save snapshot and refresh - saveSearchBubbleSnapshot(); - updateSearchDownloadsSection(); - - return; - } - } - - console.log(`⚠️ [CLEANUP] No matching search download found for: ${virtualPlaylistId}`); -} - -/** - * Show or update the artist downloads section in search state - */ -function showArtistDownloadsSection() { - console.log(`🔄 [SHOW] showArtistDownloadsSection() called - refreshing artist bubbles`); - console.log(`🔄 [SHOW] Current view: ${artistsPageState.currentView}, artistDownloadBubbles count: ${Object.keys(artistDownloadBubbles).length}`); - - // Only show in search state - if (artistsPageState.currentView !== 'search') { - console.log(`⏭️ [SHOW] Skipping - not in search state (current: ${artistsPageState.currentView})`); - return; - } - - const artistsSearchState = document.getElementById('artists-search-state'); - if (!artistsSearchState) { - console.log(`⏭️ [SHOW] Skipping - no artists-search-state element found`); - return; - } - - let downloadsSection = document.getElementById('artist-downloads-section'); - - // Create section if it doesn't exist - if (!downloadsSection) { - downloadsSection = document.createElement('div'); - downloadsSection.id = 'artist-downloads-section'; - downloadsSection.className = 'artist-downloads-section'; - - // Insert after the search container - const searchContainer = artistsSearchState.querySelector('.artists-search-container'); - if (searchContainer) { - searchContainer.insertAdjacentElement('afterend', downloadsSection); - } - } - - // Count active artists (those with downloads) - const activeArtists = Object.keys(artistDownloadBubbles).filter(artistId => - artistDownloadBubbles[artistId].downloads.length > 0 - ); - - if (activeArtists.length === 0) { - downloadsSection.style.display = 'none'; - return; - } - - // Show and populate the section - downloadsSection.style.display = 'block'; - downloadsSection.innerHTML = ` -
-

Current Downloads

-

Active download processes

-
-
- ${activeArtists.map(artistId => createArtistBubbleCard(artistDownloadBubbles[artistId])).join('')} -
- `; - - // Add event listeners to bubble cards - activeArtists.forEach(artistId => { - const bubbleCard = downloadsSection.querySelector(`[data-artist-id="${artistId}"]`); - if (bubbleCard) { - bubbleCard.addEventListener('click', () => openArtistDownloadModal(artistId)); - - // Add dynamic glow effect - const artist = artistDownloadBubbles[artistId].artist; - if (artist.image_url) { - extractImageColors(artist.image_url, (colors) => { - applyDynamicGlow(bubbleCard, colors); - }); - } - } - }); -} - -/** - * Show download bubbles on the Library page (mirrors showArtistDownloadsSection) - */ -function showLibraryDownloadsSection() { - const libraryContent = document.querySelector('.library-content'); - if (!libraryContent) return; - - let downloadsSection = document.getElementById('library-downloads-section'); - - // Create section if it doesn't exist - if (!downloadsSection) { - downloadsSection = document.createElement('div'); - downloadsSection.id = 'library-downloads-section'; - downloadsSection.className = 'artist-downloads-section'; - - // Insert before the artist grid - const artistGrid = document.getElementById('library-artists-grid'); - if (artistGrid) { - libraryContent.insertBefore(downloadsSection, artistGrid); - } - } - - // Count active artists (reuses artistDownloadBubbles state) - const activeArtists = Object.keys(artistDownloadBubbles).filter(artistId => - artistDownloadBubbles[artistId].downloads.length > 0 - ); - - if (activeArtists.length === 0) { - downloadsSection.style.display = 'none'; - return; - } - - downloadsSection.style.display = 'block'; - downloadsSection.innerHTML = ` -
-

Current Downloads

-

Active download processes

-
-
- ${activeArtists.map(artistId => createArtistBubbleCard(artistDownloadBubbles[artistId])).join('')} -
- `; - - // Add click handlers + glow effects - activeArtists.forEach(artistId => { - const bubbleCard = downloadsSection.querySelector(`[data-artist-id="${artistId}"]`); - if (bubbleCard) { - bubbleCard.addEventListener('click', () => openArtistDownloadModal(artistId)); - const artist = artistDownloadBubbles[artistId].artist; - if (artist.image_url) { - extractImageColors(artist.image_url, (colors) => { - applyDynamicGlow(bubbleCard, colors); - }); - } - } - }); -} - -/** - * Create HTML for an artist bubble card - */ -function createArtistBubbleCard(artistBubbleData) { - const { artist, downloads } = artistBubbleData; - const activeCount = downloads.filter(d => d.status === 'in_progress').length; - const completedCount = downloads.filter(d => d.status === 'view_results').length; - const allCompleted = activeCount === 0 && completedCount > 0; - - // Enhanced debug logging for bubble card creation and green checkmark detection - console.log(`🔵 [BUBBLE] Creating bubble for ${artist.name}:`, { - totalDownloads: downloads.length, - activeCount, - completedCount, - allCompleted, - downloadStatuses: downloads.map(d => `${d.album.name}: ${d.status}`) - }); - - // CRITICAL: Green checkmark detection logging - if (allCompleted) { - console.log(`🟢 [BUBBLE] GREEN CHECKMARK DETECTED for ${artist.name} - all ${downloads.length} downloads completed`); - console.log(`✅ [BUBBLE] This bubble will have 'all-completed' class and green checkmark`); - } else if (activeCount === 0 && completedCount === 0) { - console.log(`⭕ [BUBBLE] No active or completed downloads for ${artist.name} - this shouldn't happen`); - } else { - console.log(`⏳ [BUBBLE] Still waiting for completion: ${activeCount} active, ${completedCount} completed`); - } - - const imageUrl = artist.image_url || ''; - const backgroundStyle = imageUrl ? - `background-image: url('${imageUrl}');` : - `background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`; - - return ` -
-
-
-
-
${escapeHtml(artist.name)}
-
- ${activeCount > 0 ? `${activeCount} active` : ''} - ${completedCount > 0 ? `${completedCount} completed` : ''} -
-
- ${allCompleted ? ` -
- -
- ` : ''} -
- `; -} - -/** - * Monitor an artist download for completion status changes - */ -function monitorArtistDownload(artistId, virtualPlaylistId) { - // Check if the download process exists and monitor its status - const checkStatus = () => { - const process = activeDownloadProcesses[virtualPlaylistId]; - if (!process || !artistDownloadBubbles[artistId]) { - return; // Process or artist bubble no longer exists - } - - // Find this download in the artist's downloads - const download = artistDownloadBubbles[artistId].downloads.find(d => d.virtualPlaylistId === virtualPlaylistId); - if (!download) return; - - // Update download status based on process status - if (process.status === 'complete' && download.status === 'in_progress') { - download.status = 'view_results'; - console.log(`✅ Download completed for ${artistDownloadBubbles[artistId].artist.name} - ${download.album.name}`); - console.log(`📊 Artist ${artistId} downloads status:`, artistDownloadBubbles[artistId].downloads.map(d => `${d.album.name}: ${d.status}`)); - - // Update the downloads section - updateArtistDownloadsSection(); - - // Save snapshot of updated state - saveArtistBubbleSnapshot(); - - // Check if all downloads for this artist are now completed - const artistDownloads = artistDownloadBubbles[artistId].downloads; - const allCompleted = artistDownloads.every(d => d.status === 'view_results'); - if (allCompleted) { - console.log(`🟢 All downloads completed for ${artistDownloadBubbles[artistId].artist.name} - green checkmark should appear`); - console.log(`🎯 [STATUS DEBUG] Green checkmark trigger - forcing bubble refresh`); - // Force immediate bubble refresh to show green checkmark - setTimeout(updateArtistDownloadsSection, 100); - } - } - - // Continue monitoring if still active - if (process.status !== 'complete') { - setTimeout(checkStatus, 2000); // Check every 2 seconds - } - }; - - // Start monitoring after a brief delay - setTimeout(checkStatus, 1000); -} - -/** - * Open the artist download management modal - */ -function openArtistDownloadModal(artistId) { - const artistBubbleData = artistDownloadBubbles[artistId]; - if (!artistBubbleData || artistDownloadModalOpen) return; - - console.log(`🎵 [MODAL OPEN] Opening artist download modal for: ${artistBubbleData.artist.name}`); - console.log(`📊 [MODAL OPEN] Current download statuses:`, artistBubbleData.downloads.map(d => `${d.album.name}: ${d.status}`)); - artistDownloadModalOpen = true; - - const modal = document.createElement('div'); - modal.id = 'artist-download-management-modal'; - modal.className = 'artist-download-management-modal'; - modal.innerHTML = ` -
-
-
-
-
-
- ${artistBubbleData.artist.image_url - ? `${escapeHtml(artistBubbleData.artist.name)}` - : '
' - } -
-
-

${escapeHtml(artistBubbleData.artist.name)}

-

${artistBubbleData.downloads.length} active download${artistBubbleData.downloads.length !== 1 ? 's' : ''}

-
-
- × -
-
- -
-
- ${artistBubbleData.downloads.map((download, index) => createArtistDownloadItem(download, index)).join('')} -
-
-
-
- `; - - document.body.appendChild(modal); - modal.style.display = 'flex'; - - // Monitor for real-time updates - startArtistDownloadModalMonitoring(artistId); -} - -/** - * Create HTML for an individual download item in the artist modal - */ -function createArtistDownloadItem(download, index) { - const { album, albumType, status, virtualPlaylistId } = download; - const buttonText = status === 'view_results' ? 'View Results' : 'View Progress'; - const buttonClass = status === 'view_results' ? 'completed' : 'active'; - - // Enhanced debugging for button text generation - console.log(`🎯 [BUTTON] Creating item for ${album.name}: status='${status}' → buttonText='${buttonText}'`); - - return ` -
-
- ${album.image_url - ? `${escapeHtml(album.name)}` - : `
- -
` - } -
-
-
${escapeHtml(album.name)}
-
${albumType === 'album' ? 'Album' : albumType === 'single' ? 'Single' : 'EP'}
-
-
- -
-
- `; -} - -/** - * Monitor artist download modal for real-time updates - */ -function startArtistDownloadModalMonitoring(artistId) { - if (!artistDownloadModalOpen) return; - - const updateModal = () => { - const modal = document.getElementById('artist-download-management-modal'); - const itemsContainer = document.getElementById(`artist-download-items-${artistId}`); - - if (!modal || !itemsContainer || !artistDownloadBubbles[artistId]) return; - - // Check for completed downloads that need to be removed - const activeDownloads = artistDownloadBubbles[artistId].downloads.filter(download => { - const process = activeDownloadProcesses[download.virtualPlaylistId]; - // Keep if process exists or if it's completed but not yet cleaned up - return process !== undefined; - }); - - // Update the downloads array - artistDownloadBubbles[artistId].downloads = activeDownloads; - - // If no downloads left, close modal - if (activeDownloads.length === 0) { - closeArtistDownloadModal(); - return; - } - - // Update modal content and synchronize with bubble state - let statusChanged = false; - itemsContainer.innerHTML = activeDownloads.map((download, index) => { - const process = activeDownloadProcesses[download.virtualPlaylistId]; - if (process) { - const newStatus = process.status === 'complete' ? 'view_results' : 'in_progress'; - if (download.status !== newStatus) { - console.log(`🔄 [ARTIST MODAL] Updating ${download.album.name} status from ${download.status} to ${newStatus}`); - download.status = newStatus; - statusChanged = true; - } - } - return createArtistDownloadItem(download, index); - }).join(''); - - // CRITICAL: If any status changed, immediately refresh artist bubble to show green checkmarks - if (statusChanged) { - console.log(`🎯 [SYNC] Status change detected in artist modal - refreshing bubble display`); - updateArtistDownloadsSection(); - - // Check if all downloads for this artist are now completed - const artistDownloads = artistDownloadBubbles[artistId].downloads; - const allCompleted = artistDownloads.every(d => d.status === 'view_results'); - if (allCompleted) { - console.log(`🟢 [ARTIST MODAL] All downloads completed for artist ${artistId} - triggering green checkmark`); - // Force additional refresh after a brief delay to ensure UI updates - setTimeout(() => { - console.log(`✨ [ARTIST MODAL] Forcing final refresh for green checkmark`); - updateArtistDownloadsSection(); - }, 200); - } - } - - // Continue monitoring - setTimeout(updateModal, 2000); - }; - - setTimeout(updateModal, 1000); -} - -/** - * Open a specific artist download process modal - */ -function openArtistDownloadProcess(virtualPlaylistId) { - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process && process.modalElement) { - // Close artist management modal first - closeArtistDownloadModal(); - - // Show the download process modal - process.modalElement.style.display = 'flex'; - - if (process.status === 'complete') { - showToast('Review download results and click "Close" to finish.', 'info'); - } - } -} - -/** - * Close the artist download management modal - */ -function closeArtistDownloadModal() { - const modal = document.getElementById('artist-download-management-modal'); - if (modal) { - modal.remove(); - } - artistDownloadModalOpen = false; -} - -/** - * Bulk complete all downloads for an artist (when all are in 'view_results' state) - */ -function bulkCompleteArtistDownloads(artistId) { - console.log(`🎯 Bulk completing downloads for artist: ${artistId}`); - - const artistBubbleData = artistDownloadBubbles[artistId]; - if (!artistBubbleData) { - console.warn(`❌ No artist bubble data found for ${artistId}`); - return; - } - - // Find all downloads in 'view_results' state - const completedDownloads = artistBubbleData.downloads.filter(d => d.status === 'view_results'); - console.log(`📋 Found ${completedDownloads.length} completed downloads to close:`, - completedDownloads.map(d => d.album.name)); - - if (completedDownloads.length === 0) { - console.warn(`⚠️ No completed downloads found for bulk close`); - showToast('No completed downloads to close', 'info'); - return; - } - - // Programmatically close all completed modals - completedDownloads.forEach(download => { - const process = activeDownloadProcesses[download.virtualPlaylistId]; - if (process && process.modalElement) { - console.log(`🗑️ Closing modal for: ${download.album.name}`); - // Trigger the close function which handles cleanup - closeDownloadMissingModal(download.virtualPlaylistId); - } else { - // No modal open — clean up the bubble entry directly - console.log(`🧹 Direct cleanup (no modal) for: ${download.album.name}`); - cleanupArtistDownload(download.virtualPlaylistId); - } - }); - - showToast(`Completed ${completedDownloads.length} downloads for ${artistBubbleData.artist.name}`, 'success'); -} - -// ======================================== -// Beatport Download Bubbles -// ======================================== - -/** - * Register a new Beatport chart download for bubble management - */ -function registerBeatportDownload(chartName, chartImage, virtualPlaylistId) { - console.log(`📝 Registering Beatport download: ${chartName}`); - - // Use chart name as key (sanitised) - const chartKey = chartName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase(); - - if (!beatportDownloadBubbles[chartKey]) { - beatportDownloadBubbles[chartKey] = { - chart: { name: chartName, image: chartImage || '' }, - downloads: [] - }; - } - - beatportDownloadBubbles[chartKey].downloads.push({ - virtualPlaylistId: virtualPlaylistId, - status: 'in_progress', - startTime: new Date() - }); - - updateBeatportDownloadsSection(); - saveBeatportBubbleSnapshot(); - monitorBeatportDownload(chartKey, virtualPlaylistId); -} - -/** - * Debounced update for Beatport downloads section - */ -function updateBeatportDownloadsSection() { - if (beatportDownloadsUpdateTimeout) { - clearTimeout(beatportDownloadsUpdateTimeout); - } - beatportDownloadsUpdateTimeout = setTimeout(() => { - showBeatportDownloadsSection(); - updateDashboardDownloads(); - }, 300); -} - -/** - * Render Beatport download bubbles on the Beatport page - */ -function showBeatportDownloadsSection() { - const downloadsSection = document.getElementById('beatport-downloads-section'); - if (!downloadsSection) return; - - const activeCharts = Object.keys(beatportDownloadBubbles).filter(key => - beatportDownloadBubbles[key].downloads.length > 0 - ); - - if (activeCharts.length === 0) { - downloadsSection.style.display = 'none'; - return; - } - - downloadsSection.style.display = 'block'; - downloadsSection.innerHTML = ` -
-

Beatport Downloads

-

Active chart download processes

-
-
- ${activeCharts.map(key => createBeatportBubbleCard(beatportDownloadBubbles[key])).join('')} -
- `; - - // Attach click handlers + glow - activeCharts.forEach(chartKey => { - const card = downloadsSection.querySelector(`[data-chart-key="${chartKey}"]`); - if (card) { - card.addEventListener('click', () => openBeatportBubbleModal(chartKey)); - const chartImage = beatportDownloadBubbles[chartKey].chart.image; - if (chartImage) { - extractImageColors(chartImage, (colors) => { - applyDynamicGlow(card, colors); - }); - } - } - }); -} - -/** - * Create HTML for a Beatport bubble card (reuses artist bubble CSS) - */ -function createBeatportBubbleCard(bubbleData) { - const { chart, downloads } = bubbleData; - const chartKey = chart.name.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase(); - const activeCount = downloads.filter(d => d.status === 'in_progress').length; - const completedCount = downloads.filter(d => d.status === 'view_results').length; - const allCompleted = activeCount === 0 && completedCount > 0; - - const backgroundStyle = chart.image - ? `background-image: url('${chart.image}');` - : `background: linear-gradient(135deg, rgba(0, 210, 120, 0.3) 0%, rgba(0, 170, 100, 0.2) 100%);`; - - return ` -
-
-
-
-
${escapeHtml(chart.name)}
-
- ${activeCount > 0 ? `${activeCount} active` : ''} - ${completedCount > 0 ? `${completedCount} completed` : ''} -
-
- ${allCompleted ? ` -
- -
- ` : ''} -
- `; -} - -/** - * Monitor a Beatport download for completion - */ -function monitorBeatportDownload(chartKey, virtualPlaylistId) { - const checkStatus = () => { - const process = activeDownloadProcesses[virtualPlaylistId]; - if (!process || !beatportDownloadBubbles[chartKey]) return; - - const download = beatportDownloadBubbles[chartKey].downloads.find(d => d.virtualPlaylistId === virtualPlaylistId); - if (!download) return; - - if (process.status === 'complete' && download.status === 'in_progress') { - download.status = 'view_results'; - console.log(`✅ Beatport download completed for ${beatportDownloadBubbles[chartKey].chart.name}`); - - updateBeatportDownloadsSection(); - saveBeatportBubbleSnapshot(); - - const allCompleted = beatportDownloadBubbles[chartKey].downloads.every(d => d.status === 'view_results'); - if (allCompleted) { - console.log(`🟢 All Beatport downloads completed for ${beatportDownloadBubbles[chartKey].chart.name}`); - setTimeout(updateBeatportDownloadsSection, 100); - } - } - - if (process.status !== 'complete') { - setTimeout(checkStatus, 2000); - } - }; - - setTimeout(checkStatus, 1000); -} - -/** - * Open the download modal for a Beatport chart bubble - */ -function openBeatportBubbleModal(chartKey) { - const bubbleData = beatportDownloadBubbles[chartKey]; - if (!bubbleData) return; - - // Find the first download with an active modal - for (const download of bubbleData.downloads) { - const process = activeDownloadProcesses[download.virtualPlaylistId]; - if (process && process.modalElement) { - process.modalElement.style.display = 'flex'; - if (process.status === 'complete') { - showToast('Review download results and click "Close" to finish.', 'info'); - } - return; - } - } - - showToast('No active download modal found for this chart', 'info'); -} - -/** - * Bulk complete all downloads for a Beatport chart - */ -function bulkCompleteBeatportDownloads(chartKey) { - console.log(`🎯 Bulk completing Beatport downloads for chart: ${chartKey}`); - - const bubbleData = beatportDownloadBubbles[chartKey]; - if (!bubbleData) return; - - const completedDownloads = bubbleData.downloads.filter(d => d.status === 'view_results'); - if (completedDownloads.length === 0) { - showToast('No completed downloads to close', 'info'); - return; - } - - completedDownloads.forEach(download => { - const process = activeDownloadProcesses[download.virtualPlaylistId]; - if (process && process.modalElement) { - closeDownloadMissingModal(download.virtualPlaylistId); - } else { - cleanupBeatportDownload(download.virtualPlaylistId); - } - }); - - showToast(`Completed ${completedDownloads.length} downloads for ${bubbleData.chart.name}`, 'success'); -} - -/** - * Clean up a Beatport download when its modal is closed - */ -function cleanupBeatportDownload(virtualPlaylistId) { - console.log(`🔍 [CLEANUP] Looking for Beatport download to cleanup: ${virtualPlaylistId}`); - - for (const chartKey in beatportDownloadBubbles) { - const downloads = beatportDownloadBubbles[chartKey].downloads; - const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId); - - if (downloadIndex !== -1) { - downloads.splice(downloadIndex, 1); - console.log(`🧹 [CLEANUP] Removed Beatport download from ${chartKey}. Remaining: ${downloads.length}`); - - if (downloads.length === 0) { - delete beatportDownloadBubbles[chartKey]; - console.log(`🧹 [CLEANUP] No more downloads - removed Beatport bubble: ${chartKey}`); - } - - updateBeatportDownloadsSection(); - saveBeatportBubbleSnapshot(); - return; - } - } -} - -// --- Beatport Bubble Snapshot System --- - -let beatportSnapshotSaveTimeout = null; - -async function saveBeatportBubbleSnapshot() { - if (beatportSnapshotSaveTimeout) { - clearTimeout(beatportSnapshotSaveTimeout); - } - - beatportSnapshotSaveTimeout = setTimeout(async () => { - try { - const bubbleCount = Object.keys(beatportDownloadBubbles).length; - if (bubbleCount === 0) return; - - const cleanBubbles = {}; - for (const [chartKey, bubbleData] of Object.entries(beatportDownloadBubbles)) { - cleanBubbles[chartKey] = { - chart: bubbleData.chart, - downloads: bubbleData.downloads.map(d => ({ - virtualPlaylistId: d.virtualPlaylistId, - status: d.status, - startTime: d.startTime instanceof Date ? d.startTime.toISOString() : d.startTime - })) - }; - } - - const response = await fetch('/api/beatport_bubbles/snapshot', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ bubbles: cleanBubbles }) - }); - - const data = await response.json(); - if (data.success) { - console.log(`✅ Beatport bubble snapshot saved: ${bubbleCount} charts`); - } - } catch (error) { - console.error('❌ Error saving Beatport bubble snapshot:', error); - } - }, 1000); -} - -async function hydrateBeatportBubblesFromSnapshot() { - try { - console.log('🔄 Loading Beatport bubble snapshot from backend...'); - - const signal = getBeatportContentSignal(); - const response = await fetch('/api/beatport_bubbles/hydrate', signal ? { signal } : undefined); - const data = await response.json(); - - if (!data.success) { - console.error('❌ Failed to load Beatport bubble snapshot:', data.error); - return; - } - - const bubbles = data.bubbles || {}; - if (Object.keys(bubbles).length === 0) { - console.log('ℹ️ No Beatport bubbles to hydrate'); - return; - } - - beatportDownloadBubbles = {}; - - for (const [chartKey, bubbleData] of Object.entries(bubbles)) { - beatportDownloadBubbles[chartKey] = { - chart: bubbleData.chart, - downloads: bubbleData.downloads.map(d => ({ - virtualPlaylistId: d.virtualPlaylistId, - status: d.status, - startTime: new Date(d.startTime) - })) - }; - - for (const download of bubbleData.downloads) { - if (download.status === 'in_progress') { - monitorBeatportDownload(chartKey, download.virtualPlaylistId); - } - } - } - - updateBeatportDownloadsSection(); - console.log(`✅ Hydrated ${Object.keys(beatportDownloadBubbles).length} Beatport download bubbles`); - } catch (error) { - if (error && error.name === 'AbortError') { - console.log('⏹ Beatport bubble hydration aborted'); - return; - } - console.error('❌ Error hydrating Beatport bubbles:', error); - } -} - -/** - * Clean up artist download when a modal is closed - */ -function cleanupArtistDownload(virtualPlaylistId) { - console.log(`🔍 [CLEANUP] Looking for download to cleanup: ${virtualPlaylistId}`); - console.log(`🔍 [CLEANUP] Current artist bubbles:`, Object.keys(artistDownloadBubbles)); - - // Find which artist this download belongs to - for (const artistId in artistDownloadBubbles) { - const downloads = artistDownloadBubbles[artistId].downloads; - const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId); - - console.log(`🔍 [CLEANUP] Checking artist ${artistId}: ${downloads.length} downloads`); - downloads.forEach(d => console.log(` - ${d.album.name} (${d.virtualPlaylistId}): ${d.status}`)); - - if (downloadIndex !== -1) { - const downloadToRemove = downloads[downloadIndex]; - console.log(`🧹 [CLEANUP] Found download to cleanup: ${downloadToRemove.album.name} (status: ${downloadToRemove.status})`); - - // Remove this download from the artist's downloads - downloads.splice(downloadIndex, 1); - console.log(`✅ [CLEANUP] Removed download from artist ${artistId}. Remaining: ${downloads.length}`); - - // If no more downloads for this artist, remove the bubble - if (downloads.length === 0) { - delete artistDownloadBubbles[artistId]; - console.log(`🧹 [CLEANUP] No more downloads - removed artist bubble: ${artistId}`); - } else { - console.log(`📊 [CLEANUP] Artist ${artistId} still has ${downloads.length} downloads remaining`); - } - - // Update the downloads section - console.log(`🔄 [CLEANUP] Updating artist downloads section...`); - updateArtistDownloadsSection(); - - // Save snapshot of updated state - saveArtistBubbleSnapshot(); - break; - } - } - console.log(`✅ [CLEANUP] Cleanup process completed for ${virtualPlaylistId}`); -} - -/** - * Force refresh all artist download statuses (useful for debugging) - */ -function refreshAllArtistDownloadStatuses() { - console.log('🔄 Force refreshing all artist download statuses...'); - - for (const artistId in artistDownloadBubbles) { - const artistData = artistDownloadBubbles[artistId]; - let hasChanges = false; - - artistData.downloads.forEach(download => { - const process = activeDownloadProcesses[download.virtualPlaylistId]; - if (process) { - const expectedStatus = process.status === 'complete' ? 'view_results' : 'in_progress'; - if (download.status !== expectedStatus) { - console.log(`🔧 Fixing status for ${download.album.name}: ${download.status} → ${expectedStatus}`); - download.status = expectedStatus; - hasChanges = true; - } - } - }); - - if (hasChanges) { - console.log(`✅ Updated statuses for ${artistData.artist.name}`); - } - } - - // Force update the downloads section - showArtistDownloadsSection(); -} - -/** - * Extract dominant colors from an image for dynamic glow effects - */ -async function extractImageColors(imageUrl, callback) { - if (!imageUrl) { - callback(getAccentFallbackColors()); // Fallback to Spotify green - return; - } - - // Check cache first for performance - if (artistsPageState.cache.colors[imageUrl]) { - callback(artistsPageState.cache.colors[imageUrl]); - return; - } - - try { - // Create a canvas to analyze the image - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - const img = new Image(); - - img.crossOrigin = 'anonymous'; - - img.onload = function () { - // Resize to small dimensions for faster processing - const size = 50; - canvas.width = size; - canvas.height = size; - - // Draw image to canvas - ctx.drawImage(img, 0, 0, size, size); - - try { - // Get image data - const imageData = ctx.getImageData(0, 0, size, size); - const data = imageData.data; - - // Extract colors (sample every few pixels for performance) - const colors = []; - for (let i = 0; i < data.length; i += 16) { // Sample every 4th pixel - const r = data[i]; - const g = data[i + 1]; - const b = data[i + 2]; - const alpha = data[i + 3]; - - // Skip transparent or very dark pixels - if (alpha > 128 && (r + g + b) > 150) { - colors.push({ r, g, b }); - } - } - - if (colors.length === 0) { - callback(getAccentFallbackColors()); // Fallback - return; - } - - // Find dominant colors using a simple clustering approach - const dominantColors = findDominantColors(colors, 2); - - // Convert to CSS hex colors - const hexColors = dominantColors.map(color => - `#${((1 << 24) + (color.r << 16) + (color.g << 8) + color.b).toString(16).slice(1)}` - ); - - // Cache the colors for future use - artistsPageState.cache.colors[imageUrl] = hexColors; - - callback(hexColors); - - } catch (e) { - console.warn('Color extraction failed, using fallback colors:', e); - callback(getAccentFallbackColors()); - } - }; - - img.onerror = function () { - callback(getAccentFallbackColors()); // Fallback on error - }; - - img.src = imageUrl; - - } catch (error) { - console.warn('Image color extraction error:', error); - callback(getAccentFallbackColors()); - } -} - -/** - * Simple color clustering to find dominant colors - */ -function findDominantColors(colors, numColors = 2) { - if (colors.length === 0) return [{ r: 29, g: 185, b: 84 }]; - - // Simple k-means clustering - let centroids = []; - - // Initialize centroids randomly - for (let i = 0; i < numColors; i++) { - centroids.push(colors[Math.floor(Math.random() * colors.length)]); - } - - // Run a few iterations of k-means - for (let iteration = 0; iteration < 5; iteration++) { - const clusters = Array(numColors).fill().map(() => []); - - // Assign each color to nearest centroid - colors.forEach(color => { - let minDistance = Infinity; - let nearestCluster = 0; - - centroids.forEach((centroid, i) => { - const distance = Math.sqrt( - Math.pow(color.r - centroid.r, 2) + - Math.pow(color.g - centroid.g, 2) + - Math.pow(color.b - centroid.b, 2) - ); - - if (distance < minDistance) { - minDistance = distance; - nearestCluster = i; - } - }); - - clusters[nearestCluster].push(color); - }); - - // Update centroids - centroids = clusters.map(cluster => { - if (cluster.length === 0) return centroids[0]; // Fallback - - const avgR = cluster.reduce((sum, c) => sum + c.r, 0) / cluster.length; - const avgG = cluster.reduce((sum, c) => sum + c.g, 0) / cluster.length; - const avgB = cluster.reduce((sum, c) => sum + c.b, 0) / cluster.length; - - return { r: Math.round(avgR), g: Math.round(avgG), b: Math.round(avgB) }; - }); - } - - // Ensure we have vibrant colors by boosting saturation - return centroids.map(color => { - const max = Math.max(color.r, color.g, color.b); - const min = Math.min(color.r, color.g, color.b); - const saturation = max === 0 ? 0 : (max - min) / max; - - // Boost low saturation colors - if (saturation < 0.4) { - const factor = 1.3; - return { - r: Math.min(255, Math.round(color.r * factor)), - g: Math.min(255, Math.round(color.g * factor)), - b: Math.min(255, Math.round(color.b * factor)) - }; - } - - return color; - }); -} - -/** - * Apply dynamic glow effect to a card element - */ -function applyDynamicGlow(cardElement, colors) { - if (!cardElement || colors.length < 2) return; - - const color1 = colors[0]; - const color2 = colors[1]; - - // Add a small delay to make the effect feel more natural - setTimeout(() => { - // Create CSS custom properties for the dynamic colors - cardElement.style.setProperty('--glow-color-1', color1); - cardElement.style.setProperty('--glow-color-2', color2); - cardElement.classList.add('has-dynamic-glow'); - - console.log(`🎨 Applied dynamic glow: ${color1}, ${color2}`); - }, Math.random() * 200 + 100); // Random delay between 100-300ms -} - -/** - * Utility function to escape HTML - */ -function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -// --- Service Status and System Stats Functions --- - -async function _forceServiceStatusRefresh() { - // Force an immediate status refresh (bypasses WebSocket check) — used after settings save - try { - const response = await fetch('/status'); - if (!response.ok) return; - const data = await response.json(); - handleServiceStatusUpdate(data); - } catch (error) { - console.warn('Could not force service status refresh:', error); - } -} - -async function fetchAndUpdateServiceStatus() { - if (document.hidden) return; // Skip polling when tab is not visible - if (socketConnected) return; // WebSocket is pushing updates — skip HTTP poll - try { - const response = await fetch('/status'); - if (!response.ok) return; - - const data = await response.json(); - - // Cache for library status card - _lastServiceStatus = data; - - // Update service status indicators and text (dashboard) - updateServiceStatus('spotify', data.spotify); - updateServiceStatus('media-server', data.media_server); - updateServiceStatus('soulseek', data.soulseek); - - // Update sidebar service status indicators - updateSidebarServiceStatus('spotify', data.spotify); - updateSidebarServiceStatus('media-server', data.media_server); - updateSidebarServiceStatus('soulseek', data.soulseek); - - // Update downloads nav badge - if (data.active_downloads !== undefined) _updateDlNavBadge(data.active_downloads); - - // Hide sync buttons (not the page) for standalone mode - const isSoulsyncStandalone2 = data.media_server?.type === 'soulsync'; - _isSoulsyncStandalone = isSoulsyncStandalone2; - document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => { - if (isSoulsyncStandalone2) { - btn.dataset.hiddenByStandalone = '1'; - btn.style.display = 'none'; - } else if (btn.dataset.hiddenByStandalone) { - delete btn.dataset.hiddenByStandalone; - btn.style.display = ''; - } - }); - - // Update enrichment service cards - if (data.enrichment) renderEnrichmentCards(data.enrichment); - - // 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); - } -} - -function updateServiceStatus(service, statusData) { - const indicator = document.getElementById(`${service}-status-indicator`); - const statusText = document.getElementById(`${service}-status-text`); - - if (indicator && statusText) { - 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'; - 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 { - indicator.className = 'service-card-indicator disconnected'; - statusText.textContent = 'Disconnected'; - statusText.className = 'service-card-status-text disconnected'; - } - } - - // Update music source title based on active source - if (service === 'spotify' && statusData.source) { - const musicSourceTitleElement = document.getElementById('music-source-title'); - if (musicSourceTitleElement) { - const sourceName = statusData.source === 'spotify' ? 'Spotify' : statusData.source === 'deezer' ? 'Deezer' : statusData.source === 'discogs' ? 'Discogs' : 'iTunes'; - musicSourceTitleElement.textContent = sourceName; - currentMusicSourceName = sourceName; - } - - // Show/hide Spotify disconnect button based on connection state - const disconnectBtn = document.getElementById('spotify-disconnect-btn'); - if (disconnectBtn) { - disconnectBtn.style.display = statusData.source === 'spotify' ? '' : 'none'; - } - } - - // Update download source title on dashboard card - if (service === 'soulseek' && statusData.source) { - const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Hybrid' }; - const displayName = sourceNames[statusData.source] || 'Soulseek'; - const titleEl = document.getElementById('download-source-title'); - if (titleEl) titleEl.textContent = displayName; - } -} - -function updateSidebarServiceStatus(service, statusData) { - const indicator = document.getElementById(`${service}-indicator`); - if (indicator) { - const dot = indicator.querySelector('.status-dot'); - const nameElement = indicator.querySelector('.status-name'); - - if (dot) { - 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 = ''; - } else { - dot.className = 'status-dot disconnected'; - dot.title = ''; - } - } - - // Update media server name if it's the media server indicator - if (service === 'media-server' && statusData.type) { - const mediaServerNameElement = document.getElementById('media-server-name'); - if (mediaServerNameElement) { - const serverName = statusData.type.charAt(0).toUpperCase() + statusData.type.slice(1); - mediaServerNameElement.textContent = serverName; - } - } - - // Update music source name in sidebar based on active source - if (service === 'spotify' && statusData.source) { - const musicSourceNameElement = document.getElementById('music-source-name'); - if (musicSourceNameElement) { - const sourceName = statusData.source === 'spotify' ? 'Spotify' : statusData.source === 'deezer' ? 'Deezer' : statusData.source === 'discogs' ? 'Discogs' : 'iTunes'; - musicSourceNameElement.textContent = sourceName; - } - } - - // Update download source name based on configured mode - if (service === 'soulseek' && statusData.source) { - const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Hybrid' }; - const displayName = sourceNames[statusData.source] || 'Soulseek'; - const sidebarName = document.getElementById('download-source-name'); - if (sidebarName) sidebarName.textContent = displayName; - } - } -} - -function renderEnrichmentCards(enrichment) { - const grid = document.getElementById('enrichment-status-grid'); - if (!grid || !enrichment) return; - - // Service display order - const serviceOrder = [ - 'musicbrainz', 'spotify_enrichment', 'itunes_enrichment', 'deezer_enrichment', - 'tidal_enrichment', 'qobuz_enrichment', 'lastfm', 'genius', 'audiodb', - 'acoustid', 'listenbrainz' - ]; - - // Map service keys to their settings page selector for click-to-configure - const settingsSelectors = { - 'spotify_enrichment': '.spotify-title', - 'tidal_enrichment': '.tidal-title', - 'qobuz_enrichment': '.qobuz-title', - 'lastfm': '.lastfm-title', - 'genius': '.genius-title', - 'acoustid': '.acoustid-title', - 'listenbrainz': '.listenbrainz-title', - }; - - const chips = []; - for (const key of serviceOrder) { - const svc = enrichment[key]; - if (!svc) continue; - - // Determine status class and text - let statusClass, statusLabel; - if ('running' in svc) { - if (!svc.configured) { - statusClass = 'not-configured'; - statusLabel = 'Set up'; - } else if (svc.paused) { - statusClass = 'paused'; - statusLabel = svc.yield_reason === 'downloads' ? 'Yielding' : 'Paused'; - } else if (svc.running) { - statusClass = svc.idle ? 'idle' : 'running'; - statusLabel = svc.idle ? 'Idle' : 'Running'; - } else { - statusClass = 'stopped'; - statusLabel = 'Stopped'; - } - } else { - statusClass = svc.configured ? 'running' : 'not-configured'; - statusLabel = svc.configured ? 'Ready' : 'Set up'; - } - - const selector = settingsSelectors[key]; - const clickAttr = selector - ? `onclick="navigateToPage('settings'); setTimeout(() => { switchSettingsTab('connections'); setTimeout(() => { const el = document.querySelector('${selector}'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 100); }, 50);"` - : ''; - - // Build activity display — human-readable, not cryptic numbers - let activityHtml = ''; - let metaHtml = ''; - const isSpotify = key === 'spotify_enrichment'; - - if ('running' in svc && svc.configured) { - const c1h = svc.calls_1h || 0; - const c24h = svc.calls_24h || 0; - - if (isSpotify && svc.daily_budget) { - // Spotify: show budget usage prominently - const b = svc.daily_budget; - const pct = Math.min(100, Math.round((b.used / b.limit) * 100)); - const barClass = b.exhausted ? 'exhausted' : pct > 80 ? 'high' : ''; - activityHtml = `${b.used.toLocaleString()} / ${b.limit.toLocaleString()}`; - metaHtml = `
-
-
`; - } else if (c24h > 0) { - // Other services: show 24h count - activityHtml = `${c24h.toLocaleString()} / 24h`; - } - } - - // Tooltip: full details including 1h breakdown - let tooltipLines = [svc.name + ' — ' + statusLabel]; - if ('running' in svc && svc.configured) { - const c1h = svc.calls_1h || 0; - const c24h = svc.calls_24h || 0; - if (c24h > 0 || c1h > 0) tooltipLines.push('Last hour: ' + c1h + ' · Last 24h: ' + c24h); - } - if (isSpotify && svc.daily_budget) { - const b = svc.daily_budget; - tooltipLines.push('Daily budget: ' + b.used + ' / ' + b.limit + (b.exhausted ? ' (exhausted)' : '')); - } - if (selector && statusClass === 'not-configured') { - tooltipLines = ['Click to configure in Settings']; - } - - const statusDisplay = statusClass === 'not-configured' && selector ? 'Configure →' : statusLabel; - - chips.push(` -
- - ${svc.name} - ${activityHtml} - ${statusDisplay} - ${metaHtml} -
- `); - } - - grid.innerHTML = chips.join(''); -} - -// =============================== diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js new file mode 100644 index 00000000..d45737f5 --- /dev/null +++ b/webui/static/shared-helpers.js @@ -0,0 +1,2761 @@ +// SHARED HELPERS +// ============================================================================ +// General-purpose helpers extracted from artists.js. These functions are used +// across discover.js, api-monitor.js, library.js, enrichment.js, wishlist- +// tools.js and others — they have no conceptual home in the old Artists page +// file. Moved here so artists.js can be deleted once the inline Artists page +// is fully retired. +// +// Load order: this file must load AFTER core.js (uses artistsPageState, +// artistDownloadBubbles, searchDownloadBubbles, beatportDownloadBubbles +// globals declared there) and BEFORE any file that calls these functions. +// ============================================================================ + + +// ---------------------------------------------------------------------------- +// Discography completion checking (for artist-detail pages, library page) +// ---------------------------------------------------------------------------- + +async function checkDiscographyCompletion(artistId, discography) { + console.log(`🔍 Starting streaming completion check for artist: ${artistId}`); + + try { + // Create new abort controller for this completion check + artistCompletionController = new AbortController(); + + // Use fetch with streaming response + const response = await fetch(`/api/artist/${artistId}/completion-stream`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + discography: discography, + artist_name: artistsPageState.selectedArtist?.name || 'Unknown Artist', + source: discography?.source || artistsPageState.sourceOverride || null, + }), + signal: artistCompletionController.signal + }); + + if (!response.ok) { + throw new Error(`Failed to start completion check: ${response.status}`); + } + + // Handle streaming response + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)); + handleStreamingCompletionUpdate(data); + } catch (e) { + console.warn('Failed to parse streaming data:', line); + } + } + } + } + + // Clear the controller when done + artistCompletionController = null; + + } catch (error) { + // Don't show error if it was aborted (user navigated away) + if (error.name === 'AbortError') { + console.log('⏹️ Completion check aborted (user navigated to new artist)'); + return; + } + + console.error('❌ Failed to check completion status:', error); + showCompletionError(); + } finally { + // Always clear the controller + artistCompletionController = null; + } +} + +/** + * Handle individual streaming completion updates + */ +function handleStreamingCompletionUpdate(data) { + console.log('🔄 Streaming update received:', data.type, data.name || data.artist_name); + + switch (data.type) { + case 'start': + console.log(`🎤 Starting completion check for ${data.artist_name} (${data.total_items} items)`); + // Initialize cache for this artist if not exists + const artistId = artistsPageState.selectedArtist?.id; + if (artistId && !artistsPageState.cache.completionData[artistId]) { + artistsPageState.cache.completionData[artistId] = { + albums: [], + singles: [] + }; + } + break; + + case 'album_completion': + updateAlbumCompletionOverlay(data, 'albums'); + // Cache the completion data + cacheCompletionData(data, 'albums'); + console.log(`📀 Updated album: ${data.name} (${data.status})`); + break; + + case 'single_completion': + updateAlbumCompletionOverlay(data, 'singles'); + // Cache the completion data + cacheCompletionData(data, 'singles'); + console.log(`🎵 Updated single: ${data.name} (${data.status})`); + break; + + case 'error': + console.error('❌ Error processing item:', data.name, data.error); + // Could show error for specific item + break; + + case 'complete': + console.log(`✅ Completion check finished (${data.processed_count} items processed)`); + break; + + default: + console.log('Unknown streaming update type:', data.type); + } +} + +/** + * Cache completion data for future restoration + */ +function cacheCompletionData(completionData, type) { + const artistId = artistsPageState.selectedArtist?.id; + if (!artistId) return; + + // Ensure cache structure exists + if (!artistsPageState.cache.completionData[artistId]) { + artistsPageState.cache.completionData[artistId] = { + albums: [], + singles: [] + }; + } + + // Add to appropriate cache array + if (type === 'albums') { + artistsPageState.cache.completionData[artistId].albums.push(completionData); + } else if (type === 'singles') { + artistsPageState.cache.completionData[artistId].singles.push(completionData); + } +} + +/** + * Update completion overlay for a specific album/single + */ +function updateAlbumCompletionOverlay(completionData, containerType) { + const containerId = containerType === 'albums' ? 'album-cards-container' : 'singles-cards-container'; + const container = document.getElementById(containerId); + + if (!container) { + console.warn(`Container ${containerId} not found`); + return; + } + + // Find the album card by data-album-id + const albumCard = container.querySelector(`[data-album-id="${completionData.id}"]`); + + if (!albumCard) { + console.warn(`Album card not found for ID: ${completionData.id}`); + return; + } + + // Reclassify and move cards when track count reveals single/EP (Discogs lazy fetch) + const currentType = albumCard.dataset.albumType; + const expectedTracks = completionData.expected_tracks || 0; + if (expectedTracks > 0) { + albumCard.dataset.totalTracks = expectedTracks; + let newType = currentType; + if (currentType === 'album' && expectedTracks <= 3) newType = 'single'; + else if (currentType === 'album' && expectedTracks <= 6) newType = 'ep'; + + if (newType !== currentType) { + albumCard.dataset.albumType = newType; + const typeEl = albumCard.querySelector('.album-card-type'); + if (typeEl) typeEl.textContent = newType === 'single' ? 'Single' : 'EP'; + + // Move card from albums grid to singles grid + const singlesGrid = document.getElementById('singles-grid'); + const singlesSection = singlesGrid?.closest('.discography-section'); + if (singlesGrid) { + albumCard.remove(); + singlesGrid.appendChild(albumCard); + if (singlesSection) singlesSection.style.display = ''; + } + } + } + + const overlay = albumCard.querySelector('.completion-overlay'); + if (!overlay) { + console.warn(`Completion overlay not found for album: ${completionData.name}`); + return; + } + + // Remove existing status classes + overlay.classList.remove('checking', 'completed', 'nearly_complete', 'partial', 'missing', 'downloading', 'downloaded', 'error'); + + // Add new status class + overlay.classList.add(completionData.status); + + // Update overlay text and content + const statusText = getCompletionStatusText(completionData); + const progressText = completionData.expected_tracks > 0 + ? `${completionData.owned_tracks}/${completionData.expected_tracks}` + : ''; + + overlay.innerHTML = progressText + ? `${statusText}${progressText}` + : `${statusText}`; + + // Add tooltip with more details + overlay.title = `${completionData.name}\n${statusText} (${completionData.completion_percentage}%)\nTracks: ${completionData.owned_tracks}/${completionData.expected_tracks}\nConfidence: ${completionData.confidence}`; + + // Add brief flash animation to indicate update + overlay.style.animation = 'none'; + overlay.offsetHeight; // Trigger reflow + overlay.style.animation = 'completionOverlayFadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1)'; + + console.log(`📊 Updated overlay for "${completionData.name}": ${statusText} (${completionData.completion_percentage}%)`); +} + +/** + * Get human-readable status text for completion overlay + */ +function getCompletionStatusText(completionData) { + switch (completionData.status) { + case 'completed': + return 'Complete'; + case 'nearly_complete': + return 'Nearly Complete'; + case 'partial': + return 'Partial'; + case 'missing': + return 'Missing'; + case 'downloading': + return 'Downloading...'; + case 'downloaded': + return 'Downloaded'; + case 'error': + return 'Error'; + default: + return 'Unknown'; + } +} + +/** + * Set album to downloaded status after download finishes + */ +function setAlbumDownloadedStatus(albumId) { + console.log(`✅ [DOWNLOAD COMPLETE] Setting album ${albumId} to downloaded status`); + + const completionData = { + id: albumId, + status: 'downloaded', + owned_tracks: 0, + expected_tracks: 0, + name: 'Downloaded', + completion_percentage: 100 + }; + + // Find if it's in albums or singles container + let containerType = 'albums'; + let albumCard = document.querySelector(`#album-cards-container [data-album-id="${albumId}"]`); + if (!albumCard) { + containerType = 'singles'; + albumCard = document.querySelector(`#singles-cards-container [data-album-id="${albumId}"]`); + } + + if (albumCard) { + updateAlbumCompletionOverlay(completionData, containerType); + console.log(`✅ [DOWNLOAD COMPLETE] Album ${albumId} set to Downloaded status`); + } else { + console.warn(`❌ [DOWNLOAD COMPLETE] Album card not found for ID: "${albumId}"`); + } +} + +/** + * Set album to downloading status + */ +function setAlbumDownloadingStatus(albumId, downloaded = 0, total = 0) { + console.log(`🔍 [DOWNLOAD STATUS] Searching for album card with ID: "${albumId}"`); + + const completionData = { + id: albumId, + status: 'downloading', + owned_tracks: downloaded, + expected_tracks: total, + name: 'Downloading', + completion_percentage: Math.round((downloaded / total) * 100) || 0 + }; + + // Find if it's in albums or singles container + let containerType = 'albums'; + let albumCard = document.querySelector(`#album-cards-container [data-album-id="${albumId}"]`); + if (!albumCard) { + containerType = 'singles'; + albumCard = document.querySelector(`#singles-cards-container [data-album-id="${albumId}"]`); + } + + if (albumCard) { + console.log(`✅ [DOWNLOAD STATUS] Found album card in ${containerType} container, updating overlay`); + updateAlbumCompletionOverlay(completionData, containerType); + } else { + console.warn(`❌ [DOWNLOAD STATUS] Album card not found for ID: "${albumId}"`); + // Debug: List all available album cards + const allAlbums = document.querySelectorAll('#album-cards-container [data-album-id], #singles-cards-container [data-album-id]'); + console.log(`🔍 [DEBUG] Available album IDs:`, Array.from(allAlbums).map(card => card.dataset.albumId)); + } +} + + +// ---------------------------------------------------------------------------- +// Download bubble infrastructure, image colour/glow helpers, HTML escape, +// service-status polling, enrichment-card rendering. All originally defined +// in artists.js but used broadly across the app. +// ---------------------------------------------------------------------------- + +async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, spotifyTracks, album, artist, showLoadingOverlayParam = true, contextType = 'artist_album') { + if (showLoadingOverlayParam) { + showLoadingOverlay('Loading album...'); + } + // Check if a process is already active for this virtual playlist + if (activeDownloadProcesses[virtualPlaylistId]) { + console.log(`Modal for ${virtualPlaylistId} already exists. Showing it.`); + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process.modalElement) { + if (process.status === 'complete') { + showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); + } + process.modalElement.style.display = 'flex'; + if (showLoadingOverlayParam) { + hideLoadingOverlay(); + } + } + return; + } + + console.log(`📥 Opening Download Missing Tracks modal for artist album: ${virtualPlaylistId}`); + + // Create virtual playlist object for compatibility with existing modal logic + const virtualPlaylist = { + id: virtualPlaylistId, + name: playlistName, + track_count: spotifyTracks.length + }; + + // Store the tracks in the cache for the modal to use + playlistTrackCache[virtualPlaylistId] = spotifyTracks; + currentPlaylistTracks = spotifyTracks; + currentModalPlaylistId = virtualPlaylistId; + + let modal = document.createElement('div'); + modal.id = `download-missing-modal-${virtualPlaylistId}`; + modal.className = 'download-missing-modal'; + modal.style.display = 'none'; + document.body.appendChild(modal); + + // Register the new process in our global state tracker using the same structure as other modals + activeDownloadProcesses[virtualPlaylistId] = { + status: 'idle', + modalElement: modal, + poller: null, + batchId: null, + playlist: virtualPlaylist, + tracks: spotifyTracks, + // Additional metadata for artist albums + artist: artist, + album: album, + albumType: album.album_type, + source: artist?.source || album?.source || artistsPageState.artistDiscography?.source || null + }; + + // Generate hero section — 'artist_album' for releases, 'playlist' for charts/compilations + const heroContext = contextType === 'playlist' ? { + type: 'playlist', + playlist: { name: playlistName, owner: 'Beatport' }, + trackCount: spotifyTracks.length, + playlistId: virtualPlaylistId + } : { + type: 'artist_album', + artist: artist, + album: album, + trackCount: spotifyTracks.length, + playlistId: virtualPlaylistId + }; + + // Use the exact same modal HTML structure as the existing modals + modal.innerHTML = ` +
+
+ ${generateDownloadModalHeroSection(heroContext)} +
+ +
+
+
+
+ 🔍 Library Analysis + Ready to start +
+
+
+
+
+
+
+ ⏬ Downloads + Waiting for analysis +
+
+
+
+
+
+ +
+
+

📋 Track Analysis & Download Status

+ ${spotifyTracks.length} / ${spotifyTracks.length} tracks selected +
+
+ + + + + + + + + + + + + + + ${spotifyTracks.map((track, index) => ` + + + + + + + + + + + `).join('')} + +
+ + #Track NameArtist(s)DurationLibrary StatusDownload StatusActions
+ + ${index + 1}${escapeHtml(track.name)}${escapeHtml(formatArtists(track.artists))}${formatDuration(track.duration_ms)}🔍 Pending--
+
+
+
+ + + + +
+ `; + + applyProgressiveTrackRendering(virtualPlaylistId, spotifyTracks.length); + modal.style.display = 'flex'; + hideLoadingOverlay(); + + console.log(`✅ Successfully opened download missing tracks modal for: ${playlistName}`); +} + +// =============================== +// ARTIST DOWNLOADS MANAGEMENT SYSTEM +// =============================== + +/** + * Register a new artist download for bubble management + */ +function registerArtistDownload(artist, album, virtualPlaylistId, albumType) { + console.log(`📝 Registering artist download: ${artist.name} - ${album.name}`); + + const artistId = artist.id; + + // Initialize artist bubble if it doesn't exist + if (!artistDownloadBubbles[artistId]) { + artistDownloadBubbles[artistId] = { + artist: artist, + downloads: [], + element: null, + hasCompletedDownloads: false + }; + } + + // Add this download to the artist's downloads + const downloadInfo = { + virtualPlaylistId: virtualPlaylistId, + album: album, + albumType: albumType, + status: 'in_progress', // 'in_progress', 'completed', 'view_results' + startTime: new Date() + }; + + artistDownloadBubbles[artistId].downloads.push(downloadInfo); + + // Show/update the artist downloads section + updateArtistDownloadsSection(); + + // Save snapshot of current state + saveArtistBubbleSnapshot(); + + // Monitor this download for completion + monitorArtistDownload(artistId, virtualPlaylistId); +} + +/** + * Debounced update for artist downloads section to prevent rapid updates + */ +function updateArtistDownloadsSection() { + if (downloadsUpdateTimeout) { + clearTimeout(downloadsUpdateTimeout); + } + downloadsUpdateTimeout = setTimeout(() => { + showArtistDownloadsSection(); + showLibraryDownloadsSection(); + showBeatportDownloadsSection(); + updateDashboardDownloads(); + }, 300); // 300ms debounce +} + +// --- Artist Bubble Snapshot System --- + +let snapshotSaveTimeout = null; // Debounce snapshot saves + +async function saveArtistBubbleSnapshot() { + /** + * Saves current artistDownloadBubbles state to backend for persistence. + * Debounced to prevent excessive backend calls. + */ + + // Clear any existing timeout + if (snapshotSaveTimeout) { + clearTimeout(snapshotSaveTimeout); + } + + // Debounce the actual save + snapshotSaveTimeout = setTimeout(async () => { + try { + const bubbleCount = Object.keys(artistDownloadBubbles).length; + + // Don't save empty state + if (bubbleCount === 0) { + console.log('📸 Skipping snapshot save - no artist bubbles to save'); + return; + } + + console.log(`📸 Saving artist bubble snapshot: ${bubbleCount} artists`); + + // Prepare snapshot data (clean up DOM references) + const cleanBubbles = {}; + for (const [artistId, bubbleData] of Object.entries(artistDownloadBubbles)) { + cleanBubbles[artistId] = { + artist: bubbleData.artist, + downloads: bubbleData.downloads.map(download => ({ + virtualPlaylistId: download.virtualPlaylistId, + album: download.album, + albumType: download.albumType, + status: download.status, + startTime: download.startTime instanceof Date ? download.startTime.toISOString() : download.startTime + })), + hasCompletedDownloads: bubbleData.hasCompletedDownloads + }; + } + + const response = await fetch('/api/artist_bubbles/snapshot', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + bubbles: cleanBubbles + }) + }); + + const data = await response.json(); + + if (data.success) { + console.log(`✅ Artist bubble snapshot saved: ${bubbleCount} artists`); + } else { + console.error('❌ Failed to save artist bubble snapshot:', data.error); + } + + } catch (error) { + console.error('❌ Error saving artist bubble snapshot:', error); + } + }, 1000); // 1 second debounce +} + +async function hydrateArtistBubblesFromSnapshot() { + /** + * Hydrates artist download bubbles from backend snapshot with live status. + * Called on page load to restore bubble state. + */ + try { + console.log('🔄 Loading artist bubble snapshot from backend...'); + + const response = await fetch('/api/artist_bubbles/hydrate'); + const data = await response.json(); + + if (!data.success) { + console.error('❌ Failed to load artist bubble snapshot:', data.error); + return; + } + + const bubbles = data.bubbles || {}; + const stats = data.stats || {}; + + console.log(`🔄 Loaded bubble snapshot: ${stats.total_artists || 0} artists, ${stats.active_downloads || 0} active, ${stats.completed_downloads || 0} completed`); + + if (Object.keys(bubbles).length === 0) { + console.log('ℹ️ No artist bubbles to hydrate'); + return; + } + + // Clear existing state + artistDownloadBubbles = {}; + + // Restore artistDownloadBubbles with hydrated data + for (const [artistId, bubbleData] of Object.entries(bubbles)) { + artistDownloadBubbles[artistId] = { + artist: bubbleData.artist, + downloads: bubbleData.downloads.map(download => ({ + virtualPlaylistId: download.virtualPlaylistId, + album: download.album, + albumType: download.albumType, + status: download.status, // Live status from backend + startTime: new Date(download.startTime) + })), + element: null, // Will be created when UI updates + hasCompletedDownloads: bubbleData.hasCompletedDownloads + }; + + console.log(`🔄 Hydrated artist: ${bubbleData.artist.name} (${bubbleData.downloads.length} downloads)`); + + // Start monitoring for any in-progress downloads + for (const download of bubbleData.downloads) { + if (download.status === 'in_progress') { + console.log(`📡 Starting monitoring for: ${download.album.name}`); + monitorArtistDownload(artistId, download.virtualPlaylistId); + } + } + } + + // Update UI to show hydrated bubbles + updateArtistDownloadsSection(); + + const totalArtists = Object.keys(artistDownloadBubbles).length; + console.log(`✅ Successfully hydrated ${totalArtists} artist download bubbles`); + + } catch (error) { + console.error('❌ Error hydrating artist bubbles from snapshot:', error); + } +} + +// --- Search Bubble Snapshot System --- + +async function saveSearchBubbleSnapshot() { + /** + * Saves current searchDownloadBubbles state to backend for persistence. + */ + try { + // Rate limit saves to avoid spamming backend + if (saveSearchBubbleSnapshot.lastSaveTime) { + const timeSinceLastSave = Date.now() - saveSearchBubbleSnapshot.lastSaveTime; + if (timeSinceLastSave < 2000) { + console.log('⏱️ Skipping search bubble snapshot save (rate limited)'); + return; + } + } + + const bubbleCount = Object.keys(searchDownloadBubbles).length; + + if (bubbleCount === 0) { + console.log('📸 Skipping snapshot save - no search bubbles to save'); + return; + } + + console.log(`📸 Saving search bubble snapshot: ${bubbleCount} artists`); + + // Convert search bubbles to plain objects for serialization + const bubblesToSave = {}; + for (const [artistName, bubbleData] of Object.entries(searchDownloadBubbles)) { + bubblesToSave[artistName] = { + artist: bubbleData.artist, + downloads: bubbleData.downloads.map(d => ({ + virtualPlaylistId: d.virtualPlaylistId, + item: d.item, + type: d.type, + status: d.status, + startTime: d.startTime + })) + }; + } + + const response = await fetch('/api/search_bubbles/snapshot', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bubbles: bubblesToSave }) + }); + + const data = await response.json(); + + if (data.success) { + console.log(`✅ Search bubble snapshot saved: ${bubbleCount} artists`); + saveSearchBubbleSnapshot.lastSaveTime = Date.now(); + } else { + console.error('❌ Failed to save search bubble snapshot:', data.error); + } + + } catch (error) { + console.error('❌ Error saving search bubble snapshot:', error); + } +} + +async function hydrateSearchBubblesFromSnapshot() { + /** + * Hydrates search download bubbles from backend snapshot with live status. + */ + try { + console.log('🔄 Loading search bubble snapshot from backend...'); + + const response = await fetch('/api/search_bubbles/hydrate'); + const data = await response.json(); + + if (!data.success) { + console.error('❌ Failed to load search bubble snapshot:', data.error); + return; + } + + const bubbles = data.bubbles || {}; + const stats = data.stats || {}; + + if (Object.keys(bubbles).length === 0) { + console.log('ℹ️ No search bubbles to hydrate'); + return; + } + + // Clear and restore search bubbles + searchDownloadBubbles = {}; + + for (const [artistName, bubbleData] of Object.entries(bubbles)) { + searchDownloadBubbles[artistName] = { + artist: bubbleData.artist, + downloads: bubbleData.downloads || [] + }; + + console.log(`🔄 Hydrated artist: ${artistName} (${bubbleData.downloads.length} downloads)`); + + // Setup monitoring for each download + for (const download of bubbleData.downloads) { + if (download.status === 'in_progress') { + monitorSearchDownload(artistName, download.virtualPlaylistId); + } + } + } + + const totalArtists = Object.keys(searchDownloadBubbles).length; + console.log(`✅ Successfully hydrated ${totalArtists} search download bubbles`); + + // Refresh display + showSearchDownloadBubbles(); + + } catch (error) { + console.error('❌ Error hydrating search bubbles from snapshot:', error); + } +} + +/** + * Register a new search download for bubble management (grouped by artist) + */ +function registerSearchDownload(item, type, virtualPlaylistId, artistName) { + console.log(`📝 [REGISTER] Registering search download: ${item.name} (${type}) by ${artistName}`); + + // Initialize artist bubble if it doesn't exist + if (!searchDownloadBubbles[artistName]) { + searchDownloadBubbles[artistName] = { + artist: { + name: artistName, + image_url: item.image_url || (item.images && item.images[0]?.url) || null + }, + downloads: [] + }; + } + + // Add this download to the artist's downloads + const downloadInfo = { + virtualPlaylistId: virtualPlaylistId, + item: item, + type: type, // 'album' or 'track' + status: 'in_progress', + startTime: new Date().toISOString() + }; + + searchDownloadBubbles[artistName].downloads.push(downloadInfo); + + console.log(`✅ [REGISTER] Registered search download for ${artistName} - ${item.name}`); + + // Save snapshot + saveSearchBubbleSnapshot(); + + // Setup monitoring + monitorSearchDownload(artistName, virtualPlaylistId); + + // Refresh display + updateSearchDownloadsSection(); +} + +/** + * Debounced update for search downloads section + */ +function updateSearchDownloadsSection() { + if (window.searchUpdateTimeout) { + clearTimeout(window.searchUpdateTimeout); + } + window.searchUpdateTimeout = setTimeout(() => { + showSearchDownloadBubbles(); + updateDashboardDownloads(); + }, 300); +} + +/** + * Monitor a search download for completion status changes + */ +function monitorSearchDownload(artistName, virtualPlaylistId) { + const checkCompletion = setInterval(() => { + const process = activeDownloadProcesses[virtualPlaylistId]; + + if (!process || !searchDownloadBubbles[artistName]) { + clearInterval(checkCompletion); + return; + } + + // Find the download in the artist's downloads + const download = searchDownloadBubbles[artistName].downloads.find( + d => d.virtualPlaylistId === virtualPlaylistId + ); + + if (!download) { + clearInterval(checkCompletion); + return; + } + + // Update status + const newStatus = process.status === 'complete' || process.status === 'view_results' + ? 'view_results' + : 'in_progress'; + + if (download.status !== newStatus) { + console.log(`🔄 [MONITOR] Status changed for ${download.item.name}: ${download.status} -> ${newStatus}`); + download.status = newStatus; + + // Save snapshot and refresh + saveSearchBubbleSnapshot(); + updateSearchDownloadsSection(); + } + }, 2000); +} + +/** + * Show or update the search downloads bubble section + */ +function showSearchDownloadBubbles() { + console.log(`🔄 [SHOW] showSearchDownloadBubbles() called`); + + const resultsArea = document.getElementById('enhanced-main-results-area'); + if (!resultsArea) { + console.log(`⏭️ [SHOW] Skipping - no enhanced-main-results-area found`); + return; + } + + // Count active artists (those with downloads) + const activeArtists = Object.keys(searchDownloadBubbles).filter(artistName => + searchDownloadBubbles[artistName].downloads.length > 0 + ); + + if (activeArtists.length === 0) { + // Show placeholder + resultsArea.innerHTML = ` +
+

Search results will appear here when you select an album or track.

+
+ `; + return; + } + + // Create bubbles display + const bubblesHTML = activeArtists.map(artistName => + createSearchBubbleCard(searchDownloadBubbles[artistName]) + ).join(''); + + resultsArea.innerHTML = ` +
+
+

Active Downloads

+ ${activeArtists.length} +
+
+ ${bubblesHTML} +
+
+ `; + + console.log(`✅ [SHOW] Displayed ${activeArtists.length} search bubbles`); +} + +/** + * Create HTML for a search bubble card (grouped by artist) + */ +function createSearchBubbleCard(artistBubbleData) { + const { artist, downloads } = artistBubbleData; + const activeCount = downloads.filter(d => d.status === 'in_progress').length; + const completedCount = downloads.filter(d => d.status === 'view_results').length; + const allCompleted = activeCount === 0 && completedCount > 0; + + console.log(`🔵 [BUBBLE] Creating bubble for ${artist.name}:`, { + totalDownloads: downloads.length, + activeCount, + completedCount, + allCompleted + }); + + const imageUrl = artist.image_url || ''; + const backgroundStyle = imageUrl ? + `background-image: url('${escapeHtml(imageUrl)}');` : + `background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`; + + return ` +
+
+
+
+
${escapeHtml(artist.name)}
+
+ ${activeCount > 0 ? `${activeCount} active` : ''} + ${completedCount > 0 ? `${completedCount} completed` : ''} +
+
+ ${allCompleted ? ` +
+ +
+ ` : ''} +
+ `; +} + +/** + * Open modal showing all downloads for an artist + */ +async function openSearchDownloadModal(artistName) { + const artistBubbleData = searchDownloadBubbles[artistName]; + if (!artistBubbleData || searchDownloadModalOpen) return; + + console.log(`🎵 [MODAL OPEN] Opening search download modal for: ${artistBubbleData.artist.name}`); + + searchDownloadModalOpen = true; + + const modal = document.createElement('div'); + modal.id = 'search-download-management-modal'; + modal.className = 'artist-download-management-modal'; + modal.innerHTML = ` +
+
+
+
+
+
+ ${artistBubbleData.artist.image_url + ? `${escapeHtml(artistBubbleData.artist.name)}` + : '
🎵
' + } +
+
+

${escapeHtml(artistBubbleData.artist.name)}

+

${artistBubbleData.downloads.length} active download${artistBubbleData.downloads.length !== 1 ? 's' : ''}

+
+
+ × +
+
+ +
+
+ ${artistBubbleData.downloads.map((download, index) => createSearchDownloadItem(download, index)).join('')} +
+
+
+
+ `; + + document.body.appendChild(modal); + modal.style.display = 'flex'; + + // Start monitoring for status changes + // Start monitoring for status changes + monitorSearchDownloadModal(artistName); + + // Lazy load artist image if missing (common for iTunes) + if (!artistBubbleData.artist.image_url) { + console.log(`🖼️ Lazy loading modal image for ${artistBubbleData.artist.name} (${artistBubbleData.artist.id})`); + fetch(`/api/artist/${artistBubbleData.artist.id}/image`) + .then(response => response.json()) + .then(data => { + if (data.success && data.image_url) { + // Update header background + const headerBg = modal.querySelector('.artist-download-modal-hero-bg'); + if (headerBg) { + headerBg.style.backgroundImage = `url('${data.image_url}')`; + } + + // Update avatar + const avatarContainer = modal.querySelector('.artist-download-modal-hero-avatar'); + if (avatarContainer) { + avatarContainer.innerHTML = `${artistBubbleData.artist.name}`; + } + + // Update artist object in memory + artistBubbleData.artist.image_url = data.image_url; + } + }) + .catch(err => console.error('❌ Failed to load modal image:', err)); + } +} + +/** + * Create HTML for a download item in the search modal + */ +function createSearchDownloadItem(download, index) { + const { item, type, status, virtualPlaylistId } = download; + const buttonText = status === 'view_results' ? 'View Results' : 'View Progress'; + const buttonClass = status === 'view_results' ? 'completed' : 'active'; + const typeLabel = type === 'album' ? 'Album' : type === 'single' ? 'Single' : 'Track'; + + return ` +
+
+ ${item.image_url + ? `${escapeHtml(item.name)}` + : `
+ ${type === 'album' ? '💿' : '🎵'} +
` + } +
+
+
${escapeHtml(item.name)}
+
${typeLabel}
+
+
+ +
+
+ `; +} + +/** + * Reopen an individual download modal from the artist modal + */ +async function reopenDownloadModal(virtualPlaylistId) { + const process = activeDownloadProcesses[virtualPlaylistId]; + + // If process exists, show the existing modal + if (process && process.modalElement) { + console.log(`✅ [REOPEN] Showing existing modal for ${virtualPlaylistId}`); + closeSearchDownloadModal(); + setTimeout(() => { + process.modalElement.style.display = 'flex'; + }, 100); + return; + } + + // Process doesn't exist (after page refresh) - recreate it + console.log(`🔄 [REOPEN] Modal not found, recreating for ${virtualPlaylistId}`); + + // Find the download in searchDownloadBubbles + let downloadData = null; + for (const artistName in searchDownloadBubbles) { + const bubble = searchDownloadBubbles[artistName]; + const download = bubble.downloads.find(d => d.virtualPlaylistId === virtualPlaylistId); + if (download) { + downloadData = download; + break; + } + } + + if (!downloadData) { + console.warn(`⚠️ No download data found for ${virtualPlaylistId}`); + return; + } + + // Close search modal first + closeSearchDownloadModal(); + + // Recreate the modal based on type + const { item, type } = downloadData; + + if (type === 'album') { + // For albums, we need to fetch the tracks + console.log(`📥 [REOPEN] Recreating album modal for: ${item.name}`); + + // Fetch album tracks (pass name/artist for Hydrabase support) + showLoadingOverlay(`Loading ${item.name}...`); + + try { + const _sap2 = new URLSearchParams({ name: item.name || '', artist: item.artist || '' }); + const response = await fetch(`/api/spotify/album/${item.id}?${_sap2}`); + if (!response.ok) { + throw new Error('Failed to fetch album tracks'); + } + + const albumData = await response.json(); + if (!albumData.tracks || albumData.tracks.length === 0) { + throw new Error('No tracks found in album'); + } + + const spotifyTracks = albumData.tracks.map(track => ({ + id: track.id, + name: track.name, + artists: track.artists || [{ name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' }], + album: { + name: item.name, + images: item.image_url ? [{ url: item.image_url }] : [] + }, + duration_ms: track.duration_ms || 0 + })); + + hideLoadingOverlay(); + + // Open the modal + await openDownloadMissingModalForArtistAlbum( + virtualPlaylistId, + item.name, + spotifyTracks, + item, + { name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' }, + false // Don't show loading overlay again + ); + + // Sync with backend to check for active batch process + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process) { + try { + const processResponse = await fetch('/api/active-processes'); + if (processResponse.ok) { + const processData = await processResponse.json(); + const activeProcess = processData.active_processes?.find(p => p.playlist_id === virtualPlaylistId); + + if (activeProcess) { + console.log(`📡 [REOPEN] Found active batch for album: ${activeProcess.batch_id}`); + process.status = 'running'; + process.batchId = activeProcess.batch_id; + + // Update UI to show running state + const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + + // Start polling for live updates + startModalDownloadPolling(virtualPlaylistId); + } + } + } catch (err) { + console.warn('Could not check for active processes:', err); + } + } + + } catch (error) { + hideLoadingOverlay(); + showToast(`Failed to load album: ${error.message}`, 'error'); + console.error('Error loading album:', error); + } + + } else { + // For tracks, create enriched track and open modal + console.log(`🎵 [REOPEN] Recreating track modal for: ${item.name}`); + + const enrichedTrack = { + id: item.id, + name: item.name, + artists: item.artists || [{ name: item.artist || 'Unknown Artist' }], + album: item.album || { + name: item.album?.name || 'Unknown Album', + images: item.image_url ? [{ url: item.image_url }] : [] + }, + duration_ms: item.duration_ms || 0 + }; + + await openDownloadMissingModalForYouTube( + virtualPlaylistId, + `${enrichedTrack.name} - ${enrichedTrack.artists[0].name || enrichedTrack.artists[0]}`, + [enrichedTrack] + ); + + // Sync with backend to check for active batch process + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process) { + try { + const processResponse = await fetch('/api/active-processes'); + if (processResponse.ok) { + const processData = await processResponse.json(); + const activeProcess = processData.active_processes?.find(p => p.playlist_id === virtualPlaylistId); + + if (activeProcess) { + console.log(`📡 [REOPEN] Found active batch for track: ${activeProcess.batch_id}`); + process.status = 'running'; + process.batchId = activeProcess.batch_id; + + // Update UI to show running state + const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + + // Start polling for live updates + startModalDownloadPolling(virtualPlaylistId); + } + } + } catch (err) { + console.warn('Could not check for active processes:', err); + } + } + } +} + +/** + * Monitor search download modal for status changes + */ +function monitorSearchDownloadModal(artistName) { + const updateModal = () => { + if (!searchDownloadModalOpen) return; + + const modal = document.getElementById('search-download-management-modal'); + const itemsContainer = document.getElementById('search-download-items'); + + if (!modal || !itemsContainer || !searchDownloadBubbles[artistName]) return; + + const downloads = searchDownloadBubbles[artistName].downloads; + + // If no downloads at all, close modal + if (downloads.length === 0) { + closeSearchDownloadModal(); + return; + } + + // Update modal content and sync status with active processes + let statusChanged = false; + itemsContainer.innerHTML = downloads.map((download, index) => { + const process = activeDownloadProcesses[download.virtualPlaylistId]; + + // Only update status if process exists (otherwise keep current status) + if (process) { + const newStatus = process.status === 'complete' || process.status === 'view_results' + ? 'view_results' + : 'in_progress'; + + if (download.status !== newStatus) { + console.log(`🔄 [MODAL MONITOR] Status changed: ${download.item.name} ${download.status} -> ${newStatus}`); + download.status = newStatus; + statusChanged = true; + } + } + + return createSearchDownloadItem(download, index); + }).join(''); + + // If status changed, refresh bubble display and save + if (statusChanged) { + updateSearchDownloadsSection(); + saveSearchBubbleSnapshot(); + } + + // Continue monitoring + setTimeout(updateModal, 2000); + }; + + setTimeout(updateModal, 1000); +} + +/** + * Close the search download modal + */ +function closeSearchDownloadModal() { + const modal = document.getElementById('search-download-management-modal'); + if (modal) { + modal.style.display = 'none'; + if (modal.parentElement) { + modal.parentElement.removeChild(modal); + } + } + searchDownloadModalOpen = false; +} + +/** + * Bulk complete all downloads for an artist (called when user clicks green checkmark) + */ +function bulkCompleteSearchDownloads(artistName) { + console.log(`🎯 Bulk completing downloads for artist: ${artistName}`); + + const artistBubbleData = searchDownloadBubbles[artistName]; + if (!artistBubbleData) { + console.warn(`❌ No artist bubble data found for ${artistName}`); + return; + } + + // Find all completed downloads + const completedDownloads = artistBubbleData.downloads.filter(d => d.status === 'view_results'); + console.log(`📋 Found ${completedDownloads.length} completed downloads to close:`, + completedDownloads.map(d => d.item.name)); + + if (completedDownloads.length === 0) { + console.warn(`⚠️ No completed downloads found for bulk close`); + showToast('No completed downloads to close', 'info'); + return; + } + + // Close all completed modals + completedDownloads.forEach(download => { + const process = activeDownloadProcesses[download.virtualPlaylistId]; + if (process && process.modalElement) { + console.log(`🗑️ Closing modal for: ${download.item.name}`); + closeDownloadMissingModal(download.virtualPlaylistId); + } else { + // No modal open — clean up the bubble entry directly + console.log(`🧹 Direct cleanup (no modal) for: ${download.item.name}`); + cleanupSearchDownload(download.virtualPlaylistId); + } + }); + + showToast(`Completed ${completedDownloads.length} downloads for ${artistBubbleData.artist.name}`, 'success'); +} + +/** + * Cleanup search download when modal is closed + */ +function cleanupSearchDownload(virtualPlaylistId) { + console.log(`🔍 [CLEANUP] Looking for search download to cleanup: ${virtualPlaylistId}`); + + // Find which artist this download belongs to + for (const artistName in searchDownloadBubbles) { + const downloads = searchDownloadBubbles[artistName].downloads; + const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId); + + if (downloadIndex !== -1) { + console.log(`🧹 [CLEANUP] Found download in artist ${artistName}: ${downloads[downloadIndex].item.name}`); + + // Remove this download + downloads.splice(downloadIndex, 1); + console.log(`🗑️ [CLEANUP] Removed download from ${artistName}'s bubble`); + + // If no more downloads for this artist, remove the bubble + if (downloads.length === 0) { + delete searchDownloadBubbles[artistName]; + console.log(`🧹 [CLEANUP] No more downloads - removed artist bubble: ${artistName}`); + } + + // Save snapshot and refresh + saveSearchBubbleSnapshot(); + updateSearchDownloadsSection(); + + return; + } + } + + console.log(`⚠️ [CLEANUP] No matching search download found for: ${virtualPlaylistId}`); +} + +/** + * Show or update the artist downloads section in search state + */ +function showArtistDownloadsSection() { + console.log(`🔄 [SHOW] showArtistDownloadsSection() called - refreshing artist bubbles`); + console.log(`🔄 [SHOW] Current view: ${artistsPageState.currentView}, artistDownloadBubbles count: ${Object.keys(artistDownloadBubbles).length}`); + + // Only show in search state + if (artistsPageState.currentView !== 'search') { + console.log(`⏭️ [SHOW] Skipping - not in search state (current: ${artistsPageState.currentView})`); + return; + } + + const artistsSearchState = document.getElementById('artists-search-state'); + if (!artistsSearchState) { + console.log(`⏭️ [SHOW] Skipping - no artists-search-state element found`); + return; + } + + let downloadsSection = document.getElementById('artist-downloads-section'); + + // Create section if it doesn't exist + if (!downloadsSection) { + downloadsSection = document.createElement('div'); + downloadsSection.id = 'artist-downloads-section'; + downloadsSection.className = 'artist-downloads-section'; + + // Insert after the search container + const searchContainer = artistsSearchState.querySelector('.artists-search-container'); + if (searchContainer) { + searchContainer.insertAdjacentElement('afterend', downloadsSection); + } + } + + // Count active artists (those with downloads) + const activeArtists = Object.keys(artistDownloadBubbles).filter(artistId => + artistDownloadBubbles[artistId].downloads.length > 0 + ); + + if (activeArtists.length === 0) { + downloadsSection.style.display = 'none'; + return; + } + + // Show and populate the section + downloadsSection.style.display = 'block'; + downloadsSection.innerHTML = ` +
+

Current Downloads

+

Active download processes

+
+
+ ${activeArtists.map(artistId => createArtistBubbleCard(artistDownloadBubbles[artistId])).join('')} +
+ `; + + // Add event listeners to bubble cards + activeArtists.forEach(artistId => { + const bubbleCard = downloadsSection.querySelector(`[data-artist-id="${artistId}"]`); + if (bubbleCard) { + bubbleCard.addEventListener('click', () => openArtistDownloadModal(artistId)); + + // Add dynamic glow effect + const artist = artistDownloadBubbles[artistId].artist; + if (artist.image_url) { + extractImageColors(artist.image_url, (colors) => { + applyDynamicGlow(bubbleCard, colors); + }); + } + } + }); +} + +/** + * Show download bubbles on the Library page (mirrors showArtistDownloadsSection) + */ +function showLibraryDownloadsSection() { + const libraryContent = document.querySelector('.library-content'); + if (!libraryContent) return; + + let downloadsSection = document.getElementById('library-downloads-section'); + + // Create section if it doesn't exist + if (!downloadsSection) { + downloadsSection = document.createElement('div'); + downloadsSection.id = 'library-downloads-section'; + downloadsSection.className = 'artist-downloads-section'; + + // Insert before the artist grid + const artistGrid = document.getElementById('library-artists-grid'); + if (artistGrid) { + libraryContent.insertBefore(downloadsSection, artistGrid); + } + } + + // Count active artists (reuses artistDownloadBubbles state) + const activeArtists = Object.keys(artistDownloadBubbles).filter(artistId => + artistDownloadBubbles[artistId].downloads.length > 0 + ); + + if (activeArtists.length === 0) { + downloadsSection.style.display = 'none'; + return; + } + + downloadsSection.style.display = 'block'; + downloadsSection.innerHTML = ` +
+

Current Downloads

+

Active download processes

+
+
+ ${activeArtists.map(artistId => createArtistBubbleCard(artistDownloadBubbles[artistId])).join('')} +
+ `; + + // Add click handlers + glow effects + activeArtists.forEach(artistId => { + const bubbleCard = downloadsSection.querySelector(`[data-artist-id="${artistId}"]`); + if (bubbleCard) { + bubbleCard.addEventListener('click', () => openArtistDownloadModal(artistId)); + const artist = artistDownloadBubbles[artistId].artist; + if (artist.image_url) { + extractImageColors(artist.image_url, (colors) => { + applyDynamicGlow(bubbleCard, colors); + }); + } + } + }); +} + +/** + * Create HTML for an artist bubble card + */ +function createArtistBubbleCard(artistBubbleData) { + const { artist, downloads } = artistBubbleData; + const activeCount = downloads.filter(d => d.status === 'in_progress').length; + const completedCount = downloads.filter(d => d.status === 'view_results').length; + const allCompleted = activeCount === 0 && completedCount > 0; + + // Enhanced debug logging for bubble card creation and green checkmark detection + console.log(`🔵 [BUBBLE] Creating bubble for ${artist.name}:`, { + totalDownloads: downloads.length, + activeCount, + completedCount, + allCompleted, + downloadStatuses: downloads.map(d => `${d.album.name}: ${d.status}`) + }); + + // CRITICAL: Green checkmark detection logging + if (allCompleted) { + console.log(`🟢 [BUBBLE] GREEN CHECKMARK DETECTED for ${artist.name} - all ${downloads.length} downloads completed`); + console.log(`✅ [BUBBLE] This bubble will have 'all-completed' class and green checkmark`); + } else if (activeCount === 0 && completedCount === 0) { + console.log(`⭕ [BUBBLE] No active or completed downloads for ${artist.name} - this shouldn't happen`); + } else { + console.log(`⏳ [BUBBLE] Still waiting for completion: ${activeCount} active, ${completedCount} completed`); + } + + const imageUrl = artist.image_url || ''; + const backgroundStyle = imageUrl ? + `background-image: url('${imageUrl}');` : + `background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`; + + return ` +
+
+
+
+
${escapeHtml(artist.name)}
+
+ ${activeCount > 0 ? `${activeCount} active` : ''} + ${completedCount > 0 ? `${completedCount} completed` : ''} +
+
+ ${allCompleted ? ` +
+ +
+ ` : ''} +
+ `; +} + +/** + * Monitor an artist download for completion status changes + */ +function monitorArtistDownload(artistId, virtualPlaylistId) { + // Check if the download process exists and monitor its status + const checkStatus = () => { + const process = activeDownloadProcesses[virtualPlaylistId]; + if (!process || !artistDownloadBubbles[artistId]) { + return; // Process or artist bubble no longer exists + } + + // Find this download in the artist's downloads + const download = artistDownloadBubbles[artistId].downloads.find(d => d.virtualPlaylistId === virtualPlaylistId); + if (!download) return; + + // Update download status based on process status + if (process.status === 'complete' && download.status === 'in_progress') { + download.status = 'view_results'; + console.log(`✅ Download completed for ${artistDownloadBubbles[artistId].artist.name} - ${download.album.name}`); + console.log(`📊 Artist ${artistId} downloads status:`, artistDownloadBubbles[artistId].downloads.map(d => `${d.album.name}: ${d.status}`)); + + // Update the downloads section + updateArtistDownloadsSection(); + + // Save snapshot of updated state + saveArtistBubbleSnapshot(); + + // Check if all downloads for this artist are now completed + const artistDownloads = artistDownloadBubbles[artistId].downloads; + const allCompleted = artistDownloads.every(d => d.status === 'view_results'); + if (allCompleted) { + console.log(`🟢 All downloads completed for ${artistDownloadBubbles[artistId].artist.name} - green checkmark should appear`); + console.log(`🎯 [STATUS DEBUG] Green checkmark trigger - forcing bubble refresh`); + // Force immediate bubble refresh to show green checkmark + setTimeout(updateArtistDownloadsSection, 100); + } + } + + // Continue monitoring if still active + if (process.status !== 'complete') { + setTimeout(checkStatus, 2000); // Check every 2 seconds + } + }; + + // Start monitoring after a brief delay + setTimeout(checkStatus, 1000); +} + +/** + * Open the artist download management modal + */ +function openArtistDownloadModal(artistId) { + const artistBubbleData = artistDownloadBubbles[artistId]; + if (!artistBubbleData || artistDownloadModalOpen) return; + + console.log(`🎵 [MODAL OPEN] Opening artist download modal for: ${artistBubbleData.artist.name}`); + console.log(`📊 [MODAL OPEN] Current download statuses:`, artistBubbleData.downloads.map(d => `${d.album.name}: ${d.status}`)); + artistDownloadModalOpen = true; + + const modal = document.createElement('div'); + modal.id = 'artist-download-management-modal'; + modal.className = 'artist-download-management-modal'; + modal.innerHTML = ` +
+
+
+
+
+
+ ${artistBubbleData.artist.image_url + ? `${escapeHtml(artistBubbleData.artist.name)}` + : '
' + } +
+
+

${escapeHtml(artistBubbleData.artist.name)}

+

${artistBubbleData.downloads.length} active download${artistBubbleData.downloads.length !== 1 ? 's' : ''}

+
+
+ × +
+
+ +
+
+ ${artistBubbleData.downloads.map((download, index) => createArtistDownloadItem(download, index)).join('')} +
+
+
+
+ `; + + document.body.appendChild(modal); + modal.style.display = 'flex'; + + // Monitor for real-time updates + startArtistDownloadModalMonitoring(artistId); +} + +/** + * Create HTML for an individual download item in the artist modal + */ +function createArtistDownloadItem(download, index) { + const { album, albumType, status, virtualPlaylistId } = download; + const buttonText = status === 'view_results' ? 'View Results' : 'View Progress'; + const buttonClass = status === 'view_results' ? 'completed' : 'active'; + + // Enhanced debugging for button text generation + console.log(`🎯 [BUTTON] Creating item for ${album.name}: status='${status}' → buttonText='${buttonText}'`); + + return ` +
+
+ ${album.image_url + ? `${escapeHtml(album.name)}` + : `
+ +
` + } +
+
+
${escapeHtml(album.name)}
+
${albumType === 'album' ? 'Album' : albumType === 'single' ? 'Single' : 'EP'}
+
+
+ +
+
+ `; +} + +/** + * Monitor artist download modal for real-time updates + */ +function startArtistDownloadModalMonitoring(artistId) { + if (!artistDownloadModalOpen) return; + + const updateModal = () => { + const modal = document.getElementById('artist-download-management-modal'); + const itemsContainer = document.getElementById(`artist-download-items-${artistId}`); + + if (!modal || !itemsContainer || !artistDownloadBubbles[artistId]) return; + + // Check for completed downloads that need to be removed + const activeDownloads = artistDownloadBubbles[artistId].downloads.filter(download => { + const process = activeDownloadProcesses[download.virtualPlaylistId]; + // Keep if process exists or if it's completed but not yet cleaned up + return process !== undefined; + }); + + // Update the downloads array + artistDownloadBubbles[artistId].downloads = activeDownloads; + + // If no downloads left, close modal + if (activeDownloads.length === 0) { + closeArtistDownloadModal(); + return; + } + + // Update modal content and synchronize with bubble state + let statusChanged = false; + itemsContainer.innerHTML = activeDownloads.map((download, index) => { + const process = activeDownloadProcesses[download.virtualPlaylistId]; + if (process) { + const newStatus = process.status === 'complete' ? 'view_results' : 'in_progress'; + if (download.status !== newStatus) { + console.log(`🔄 [ARTIST MODAL] Updating ${download.album.name} status from ${download.status} to ${newStatus}`); + download.status = newStatus; + statusChanged = true; + } + } + return createArtistDownloadItem(download, index); + }).join(''); + + // CRITICAL: If any status changed, immediately refresh artist bubble to show green checkmarks + if (statusChanged) { + console.log(`🎯 [SYNC] Status change detected in artist modal - refreshing bubble display`); + updateArtistDownloadsSection(); + + // Check if all downloads for this artist are now completed + const artistDownloads = artistDownloadBubbles[artistId].downloads; + const allCompleted = artistDownloads.every(d => d.status === 'view_results'); + if (allCompleted) { + console.log(`🟢 [ARTIST MODAL] All downloads completed for artist ${artistId} - triggering green checkmark`); + // Force additional refresh after a brief delay to ensure UI updates + setTimeout(() => { + console.log(`✨ [ARTIST MODAL] Forcing final refresh for green checkmark`); + updateArtistDownloadsSection(); + }, 200); + } + } + + // Continue monitoring + setTimeout(updateModal, 2000); + }; + + setTimeout(updateModal, 1000); +} + +/** + * Open a specific artist download process modal + */ +function openArtistDownloadProcess(virtualPlaylistId) { + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process && process.modalElement) { + // Close artist management modal first + closeArtistDownloadModal(); + + // Show the download process modal + process.modalElement.style.display = 'flex'; + + if (process.status === 'complete') { + showToast('Review download results and click "Close" to finish.', 'info'); + } + } +} + +/** + * Close the artist download management modal + */ +function closeArtistDownloadModal() { + const modal = document.getElementById('artist-download-management-modal'); + if (modal) { + modal.remove(); + } + artistDownloadModalOpen = false; +} + +/** + * Bulk complete all downloads for an artist (when all are in 'view_results' state) + */ +function bulkCompleteArtistDownloads(artistId) { + console.log(`🎯 Bulk completing downloads for artist: ${artistId}`); + + const artistBubbleData = artistDownloadBubbles[artistId]; + if (!artistBubbleData) { + console.warn(`❌ No artist bubble data found for ${artistId}`); + return; + } + + // Find all downloads in 'view_results' state + const completedDownloads = artistBubbleData.downloads.filter(d => d.status === 'view_results'); + console.log(`📋 Found ${completedDownloads.length} completed downloads to close:`, + completedDownloads.map(d => d.album.name)); + + if (completedDownloads.length === 0) { + console.warn(`⚠️ No completed downloads found for bulk close`); + showToast('No completed downloads to close', 'info'); + return; + } + + // Programmatically close all completed modals + completedDownloads.forEach(download => { + const process = activeDownloadProcesses[download.virtualPlaylistId]; + if (process && process.modalElement) { + console.log(`🗑️ Closing modal for: ${download.album.name}`); + // Trigger the close function which handles cleanup + closeDownloadMissingModal(download.virtualPlaylistId); + } else { + // No modal open — clean up the bubble entry directly + console.log(`🧹 Direct cleanup (no modal) for: ${download.album.name}`); + cleanupArtistDownload(download.virtualPlaylistId); + } + }); + + showToast(`Completed ${completedDownloads.length} downloads for ${artistBubbleData.artist.name}`, 'success'); +} + +// ======================================== +// Beatport Download Bubbles +// ======================================== + +/** + * Register a new Beatport chart download for bubble management + */ +function registerBeatportDownload(chartName, chartImage, virtualPlaylistId) { + console.log(`📝 Registering Beatport download: ${chartName}`); + + // Use chart name as key (sanitised) + const chartKey = chartName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase(); + + if (!beatportDownloadBubbles[chartKey]) { + beatportDownloadBubbles[chartKey] = { + chart: { name: chartName, image: chartImage || '' }, + downloads: [] + }; + } + + beatportDownloadBubbles[chartKey].downloads.push({ + virtualPlaylistId: virtualPlaylistId, + status: 'in_progress', + startTime: new Date() + }); + + updateBeatportDownloadsSection(); + saveBeatportBubbleSnapshot(); + monitorBeatportDownload(chartKey, virtualPlaylistId); +} + +/** + * Debounced update for Beatport downloads section + */ +function updateBeatportDownloadsSection() { + if (beatportDownloadsUpdateTimeout) { + clearTimeout(beatportDownloadsUpdateTimeout); + } + beatportDownloadsUpdateTimeout = setTimeout(() => { + showBeatportDownloadsSection(); + updateDashboardDownloads(); + }, 300); +} + +/** + * Render Beatport download bubbles on the Beatport page + */ +function showBeatportDownloadsSection() { + const downloadsSection = document.getElementById('beatport-downloads-section'); + if (!downloadsSection) return; + + const activeCharts = Object.keys(beatportDownloadBubbles).filter(key => + beatportDownloadBubbles[key].downloads.length > 0 + ); + + if (activeCharts.length === 0) { + downloadsSection.style.display = 'none'; + return; + } + + downloadsSection.style.display = 'block'; + downloadsSection.innerHTML = ` +
+

Beatport Downloads

+

Active chart download processes

+
+
+ ${activeCharts.map(key => createBeatportBubbleCard(beatportDownloadBubbles[key])).join('')} +
+ `; + + // Attach click handlers + glow + activeCharts.forEach(chartKey => { + const card = downloadsSection.querySelector(`[data-chart-key="${chartKey}"]`); + if (card) { + card.addEventListener('click', () => openBeatportBubbleModal(chartKey)); + const chartImage = beatportDownloadBubbles[chartKey].chart.image; + if (chartImage) { + extractImageColors(chartImage, (colors) => { + applyDynamicGlow(card, colors); + }); + } + } + }); +} + +/** + * Create HTML for a Beatport bubble card (reuses artist bubble CSS) + */ +function createBeatportBubbleCard(bubbleData) { + const { chart, downloads } = bubbleData; + const chartKey = chart.name.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase(); + const activeCount = downloads.filter(d => d.status === 'in_progress').length; + const completedCount = downloads.filter(d => d.status === 'view_results').length; + const allCompleted = activeCount === 0 && completedCount > 0; + + const backgroundStyle = chart.image + ? `background-image: url('${chart.image}');` + : `background: linear-gradient(135deg, rgba(0, 210, 120, 0.3) 0%, rgba(0, 170, 100, 0.2) 100%);`; + + return ` +
+
+
+
+
${escapeHtml(chart.name)}
+
+ ${activeCount > 0 ? `${activeCount} active` : ''} + ${completedCount > 0 ? `${completedCount} completed` : ''} +
+
+ ${allCompleted ? ` +
+ +
+ ` : ''} +
+ `; +} + +/** + * Monitor a Beatport download for completion + */ +function monitorBeatportDownload(chartKey, virtualPlaylistId) { + const checkStatus = () => { + const process = activeDownloadProcesses[virtualPlaylistId]; + if (!process || !beatportDownloadBubbles[chartKey]) return; + + const download = beatportDownloadBubbles[chartKey].downloads.find(d => d.virtualPlaylistId === virtualPlaylistId); + if (!download) return; + + if (process.status === 'complete' && download.status === 'in_progress') { + download.status = 'view_results'; + console.log(`✅ Beatport download completed for ${beatportDownloadBubbles[chartKey].chart.name}`); + + updateBeatportDownloadsSection(); + saveBeatportBubbleSnapshot(); + + const allCompleted = beatportDownloadBubbles[chartKey].downloads.every(d => d.status === 'view_results'); + if (allCompleted) { + console.log(`🟢 All Beatport downloads completed for ${beatportDownloadBubbles[chartKey].chart.name}`); + setTimeout(updateBeatportDownloadsSection, 100); + } + } + + if (process.status !== 'complete') { + setTimeout(checkStatus, 2000); + } + }; + + setTimeout(checkStatus, 1000); +} + +/** + * Open the download modal for a Beatport chart bubble + */ +function openBeatportBubbleModal(chartKey) { + const bubbleData = beatportDownloadBubbles[chartKey]; + if (!bubbleData) return; + + // Find the first download with an active modal + for (const download of bubbleData.downloads) { + const process = activeDownloadProcesses[download.virtualPlaylistId]; + if (process && process.modalElement) { + process.modalElement.style.display = 'flex'; + if (process.status === 'complete') { + showToast('Review download results and click "Close" to finish.', 'info'); + } + return; + } + } + + showToast('No active download modal found for this chart', 'info'); +} + +/** + * Bulk complete all downloads for a Beatport chart + */ +function bulkCompleteBeatportDownloads(chartKey) { + console.log(`🎯 Bulk completing Beatport downloads for chart: ${chartKey}`); + + const bubbleData = beatportDownloadBubbles[chartKey]; + if (!bubbleData) return; + + const completedDownloads = bubbleData.downloads.filter(d => d.status === 'view_results'); + if (completedDownloads.length === 0) { + showToast('No completed downloads to close', 'info'); + return; + } + + completedDownloads.forEach(download => { + const process = activeDownloadProcesses[download.virtualPlaylistId]; + if (process && process.modalElement) { + closeDownloadMissingModal(download.virtualPlaylistId); + } else { + cleanupBeatportDownload(download.virtualPlaylistId); + } + }); + + showToast(`Completed ${completedDownloads.length} downloads for ${bubbleData.chart.name}`, 'success'); +} + +/** + * Clean up a Beatport download when its modal is closed + */ +function cleanupBeatportDownload(virtualPlaylistId) { + console.log(`🔍 [CLEANUP] Looking for Beatport download to cleanup: ${virtualPlaylistId}`); + + for (const chartKey in beatportDownloadBubbles) { + const downloads = beatportDownloadBubbles[chartKey].downloads; + const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId); + + if (downloadIndex !== -1) { + downloads.splice(downloadIndex, 1); + console.log(`🧹 [CLEANUP] Removed Beatport download from ${chartKey}. Remaining: ${downloads.length}`); + + if (downloads.length === 0) { + delete beatportDownloadBubbles[chartKey]; + console.log(`🧹 [CLEANUP] No more downloads - removed Beatport bubble: ${chartKey}`); + } + + updateBeatportDownloadsSection(); + saveBeatportBubbleSnapshot(); + return; + } + } +} + +// --- Beatport Bubble Snapshot System --- + +let beatportSnapshotSaveTimeout = null; + +async function saveBeatportBubbleSnapshot() { + if (beatportSnapshotSaveTimeout) { + clearTimeout(beatportSnapshotSaveTimeout); + } + + beatportSnapshotSaveTimeout = setTimeout(async () => { + try { + const bubbleCount = Object.keys(beatportDownloadBubbles).length; + if (bubbleCount === 0) return; + + const cleanBubbles = {}; + for (const [chartKey, bubbleData] of Object.entries(beatportDownloadBubbles)) { + cleanBubbles[chartKey] = { + chart: bubbleData.chart, + downloads: bubbleData.downloads.map(d => ({ + virtualPlaylistId: d.virtualPlaylistId, + status: d.status, + startTime: d.startTime instanceof Date ? d.startTime.toISOString() : d.startTime + })) + }; + } + + const response = await fetch('/api/beatport_bubbles/snapshot', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bubbles: cleanBubbles }) + }); + + const data = await response.json(); + if (data.success) { + console.log(`✅ Beatport bubble snapshot saved: ${bubbleCount} charts`); + } + } catch (error) { + console.error('❌ Error saving Beatport bubble snapshot:', error); + } + }, 1000); +} + +async function hydrateBeatportBubblesFromSnapshot() { + try { + console.log('🔄 Loading Beatport bubble snapshot from backend...'); + + const signal = getBeatportContentSignal(); + const response = await fetch('/api/beatport_bubbles/hydrate', signal ? { signal } : undefined); + const data = await response.json(); + + if (!data.success) { + console.error('❌ Failed to load Beatport bubble snapshot:', data.error); + return; + } + + const bubbles = data.bubbles || {}; + if (Object.keys(bubbles).length === 0) { + console.log('ℹ️ No Beatport bubbles to hydrate'); + return; + } + + beatportDownloadBubbles = {}; + + for (const [chartKey, bubbleData] of Object.entries(bubbles)) { + beatportDownloadBubbles[chartKey] = { + chart: bubbleData.chart, + downloads: bubbleData.downloads.map(d => ({ + virtualPlaylistId: d.virtualPlaylistId, + status: d.status, + startTime: new Date(d.startTime) + })) + }; + + for (const download of bubbleData.downloads) { + if (download.status === 'in_progress') { + monitorBeatportDownload(chartKey, download.virtualPlaylistId); + } + } + } + + updateBeatportDownloadsSection(); + console.log(`✅ Hydrated ${Object.keys(beatportDownloadBubbles).length} Beatport download bubbles`); + } catch (error) { + if (error && error.name === 'AbortError') { + console.log('⏹ Beatport bubble hydration aborted'); + return; + } + console.error('❌ Error hydrating Beatport bubbles:', error); + } +} + +/** + * Clean up artist download when a modal is closed + */ +function cleanupArtistDownload(virtualPlaylistId) { + console.log(`🔍 [CLEANUP] Looking for download to cleanup: ${virtualPlaylistId}`); + console.log(`🔍 [CLEANUP] Current artist bubbles:`, Object.keys(artistDownloadBubbles)); + + // Find which artist this download belongs to + for (const artistId in artistDownloadBubbles) { + const downloads = artistDownloadBubbles[artistId].downloads; + const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId); + + console.log(`🔍 [CLEANUP] Checking artist ${artistId}: ${downloads.length} downloads`); + downloads.forEach(d => console.log(` - ${d.album.name} (${d.virtualPlaylistId}): ${d.status}`)); + + if (downloadIndex !== -1) { + const downloadToRemove = downloads[downloadIndex]; + console.log(`🧹 [CLEANUP] Found download to cleanup: ${downloadToRemove.album.name} (status: ${downloadToRemove.status})`); + + // Remove this download from the artist's downloads + downloads.splice(downloadIndex, 1); + console.log(`✅ [CLEANUP] Removed download from artist ${artistId}. Remaining: ${downloads.length}`); + + // If no more downloads for this artist, remove the bubble + if (downloads.length === 0) { + delete artistDownloadBubbles[artistId]; + console.log(`🧹 [CLEANUP] No more downloads - removed artist bubble: ${artistId}`); + } else { + console.log(`📊 [CLEANUP] Artist ${artistId} still has ${downloads.length} downloads remaining`); + } + + // Update the downloads section + console.log(`🔄 [CLEANUP] Updating artist downloads section...`); + updateArtistDownloadsSection(); + + // Save snapshot of updated state + saveArtistBubbleSnapshot(); + break; + } + } + console.log(`✅ [CLEANUP] Cleanup process completed for ${virtualPlaylistId}`); +} + +/** + * Force refresh all artist download statuses (useful for debugging) + */ +function refreshAllArtistDownloadStatuses() { + console.log('🔄 Force refreshing all artist download statuses...'); + + for (const artistId in artistDownloadBubbles) { + const artistData = artistDownloadBubbles[artistId]; + let hasChanges = false; + + artistData.downloads.forEach(download => { + const process = activeDownloadProcesses[download.virtualPlaylistId]; + if (process) { + const expectedStatus = process.status === 'complete' ? 'view_results' : 'in_progress'; + if (download.status !== expectedStatus) { + console.log(`🔧 Fixing status for ${download.album.name}: ${download.status} → ${expectedStatus}`); + download.status = expectedStatus; + hasChanges = true; + } + } + }); + + if (hasChanges) { + console.log(`✅ Updated statuses for ${artistData.artist.name}`); + } + } + + // Force update the downloads section + showArtistDownloadsSection(); +} + +/** + * Extract dominant colors from an image for dynamic glow effects + */ +async function extractImageColors(imageUrl, callback) { + if (!imageUrl) { + callback(getAccentFallbackColors()); // Fallback to Spotify green + return; + } + + // Check cache first for performance + if (artistsPageState.cache.colors[imageUrl]) { + callback(artistsPageState.cache.colors[imageUrl]); + return; + } + + try { + // Create a canvas to analyze the image + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const img = new Image(); + + img.crossOrigin = 'anonymous'; + + img.onload = function () { + // Resize to small dimensions for faster processing + const size = 50; + canvas.width = size; + canvas.height = size; + + // Draw image to canvas + ctx.drawImage(img, 0, 0, size, size); + + try { + // Get image data + const imageData = ctx.getImageData(0, 0, size, size); + const data = imageData.data; + + // Extract colors (sample every few pixels for performance) + const colors = []; + for (let i = 0; i < data.length; i += 16) { // Sample every 4th pixel + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + const alpha = data[i + 3]; + + // Skip transparent or very dark pixels + if (alpha > 128 && (r + g + b) > 150) { + colors.push({ r, g, b }); + } + } + + if (colors.length === 0) { + callback(getAccentFallbackColors()); // Fallback + return; + } + + // Find dominant colors using a simple clustering approach + const dominantColors = findDominantColors(colors, 2); + + // Convert to CSS hex colors + const hexColors = dominantColors.map(color => + `#${((1 << 24) + (color.r << 16) + (color.g << 8) + color.b).toString(16).slice(1)}` + ); + + // Cache the colors for future use + artistsPageState.cache.colors[imageUrl] = hexColors; + + callback(hexColors); + + } catch (e) { + console.warn('Color extraction failed, using fallback colors:', e); + callback(getAccentFallbackColors()); + } + }; + + img.onerror = function () { + callback(getAccentFallbackColors()); // Fallback on error + }; + + img.src = imageUrl; + + } catch (error) { + console.warn('Image color extraction error:', error); + callback(getAccentFallbackColors()); + } +} + +/** + * Simple color clustering to find dominant colors + */ +function findDominantColors(colors, numColors = 2) { + if (colors.length === 0) return [{ r: 29, g: 185, b: 84 }]; + + // Simple k-means clustering + let centroids = []; + + // Initialize centroids randomly + for (let i = 0; i < numColors; i++) { + centroids.push(colors[Math.floor(Math.random() * colors.length)]); + } + + // Run a few iterations of k-means + for (let iteration = 0; iteration < 5; iteration++) { + const clusters = Array(numColors).fill().map(() => []); + + // Assign each color to nearest centroid + colors.forEach(color => { + let minDistance = Infinity; + let nearestCluster = 0; + + centroids.forEach((centroid, i) => { + const distance = Math.sqrt( + Math.pow(color.r - centroid.r, 2) + + Math.pow(color.g - centroid.g, 2) + + Math.pow(color.b - centroid.b, 2) + ); + + if (distance < minDistance) { + minDistance = distance; + nearestCluster = i; + } + }); + + clusters[nearestCluster].push(color); + }); + + // Update centroids + centroids = clusters.map(cluster => { + if (cluster.length === 0) return centroids[0]; // Fallback + + const avgR = cluster.reduce((sum, c) => sum + c.r, 0) / cluster.length; + const avgG = cluster.reduce((sum, c) => sum + c.g, 0) / cluster.length; + const avgB = cluster.reduce((sum, c) => sum + c.b, 0) / cluster.length; + + return { r: Math.round(avgR), g: Math.round(avgG), b: Math.round(avgB) }; + }); + } + + // Ensure we have vibrant colors by boosting saturation + return centroids.map(color => { + const max = Math.max(color.r, color.g, color.b); + const min = Math.min(color.r, color.g, color.b); + const saturation = max === 0 ? 0 : (max - min) / max; + + // Boost low saturation colors + if (saturation < 0.4) { + const factor = 1.3; + return { + r: Math.min(255, Math.round(color.r * factor)), + g: Math.min(255, Math.round(color.g * factor)), + b: Math.min(255, Math.round(color.b * factor)) + }; + } + + return color; + }); +} + +/** + * Apply dynamic glow effect to a card element + */ +function applyDynamicGlow(cardElement, colors) { + if (!cardElement || colors.length < 2) return; + + const color1 = colors[0]; + const color2 = colors[1]; + + // Add a small delay to make the effect feel more natural + setTimeout(() => { + // Create CSS custom properties for the dynamic colors + cardElement.style.setProperty('--glow-color-1', color1); + cardElement.style.setProperty('--glow-color-2', color2); + cardElement.classList.add('has-dynamic-glow'); + + console.log(`🎨 Applied dynamic glow: ${color1}, ${color2}`); + }, Math.random() * 200 + 100); // Random delay between 100-300ms +} + +/** + * Utility function to escape HTML + */ +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// --- Service Status and System Stats Functions --- + +async function _forceServiceStatusRefresh() { + // Force an immediate status refresh (bypasses WebSocket check) — used after settings save + try { + const response = await fetch('/status'); + if (!response.ok) return; + const data = await response.json(); + handleServiceStatusUpdate(data); + } catch (error) { + console.warn('Could not force service status refresh:', error); + } +} + +async function fetchAndUpdateServiceStatus() { + if (document.hidden) return; // Skip polling when tab is not visible + if (socketConnected) return; // WebSocket is pushing updates — skip HTTP poll + try { + const response = await fetch('/status'); + if (!response.ok) return; + + const data = await response.json(); + + // Cache for library status card + _lastServiceStatus = data; + + // Update service status indicators and text (dashboard) + updateServiceStatus('spotify', data.spotify); + updateServiceStatus('media-server', data.media_server); + updateServiceStatus('soulseek', data.soulseek); + + // Update sidebar service status indicators + updateSidebarServiceStatus('spotify', data.spotify); + updateSidebarServiceStatus('media-server', data.media_server); + updateSidebarServiceStatus('soulseek', data.soulseek); + + // Update downloads nav badge + if (data.active_downloads !== undefined) _updateDlNavBadge(data.active_downloads); + + // Hide sync buttons (not the page) for standalone mode + const isSoulsyncStandalone2 = data.media_server?.type === 'soulsync'; + _isSoulsyncStandalone = isSoulsyncStandalone2; + document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => { + if (isSoulsyncStandalone2) { + btn.dataset.hiddenByStandalone = '1'; + btn.style.display = 'none'; + } else if (btn.dataset.hiddenByStandalone) { + delete btn.dataset.hiddenByStandalone; + btn.style.display = ''; + } + }); + + // Update enrichment service cards + if (data.enrichment) renderEnrichmentCards(data.enrichment); + + // 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); + } +} + +function updateServiceStatus(service, statusData) { + const indicator = document.getElementById(`${service}-status-indicator`); + const statusText = document.getElementById(`${service}-status-text`); + + if (indicator && statusText) { + 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'; + 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 { + indicator.className = 'service-card-indicator disconnected'; + statusText.textContent = 'Disconnected'; + statusText.className = 'service-card-status-text disconnected'; + } + } + + // Update music source title based on active source + if (service === 'spotify' && statusData.source) { + const musicSourceTitleElement = document.getElementById('music-source-title'); + if (musicSourceTitleElement) { + const sourceName = statusData.source === 'spotify' ? 'Spotify' : statusData.source === 'deezer' ? 'Deezer' : statusData.source === 'discogs' ? 'Discogs' : 'iTunes'; + musicSourceTitleElement.textContent = sourceName; + currentMusicSourceName = sourceName; + } + + // Show/hide Spotify disconnect button based on connection state + const disconnectBtn = document.getElementById('spotify-disconnect-btn'); + if (disconnectBtn) { + disconnectBtn.style.display = statusData.source === 'spotify' ? '' : 'none'; + } + } + + // Update download source title on dashboard card + if (service === 'soulseek' && statusData.source) { + const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Hybrid' }; + const displayName = sourceNames[statusData.source] || 'Soulseek'; + const titleEl = document.getElementById('download-source-title'); + if (titleEl) titleEl.textContent = displayName; + } +} + +function updateSidebarServiceStatus(service, statusData) { + const indicator = document.getElementById(`${service}-indicator`); + if (indicator) { + const dot = indicator.querySelector('.status-dot'); + const nameElement = indicator.querySelector('.status-name'); + + if (dot) { + 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 = ''; + } else { + dot.className = 'status-dot disconnected'; + dot.title = ''; + } + } + + // Update media server name if it's the media server indicator + if (service === 'media-server' && statusData.type) { + const mediaServerNameElement = document.getElementById('media-server-name'); + if (mediaServerNameElement) { + const serverName = statusData.type.charAt(0).toUpperCase() + statusData.type.slice(1); + mediaServerNameElement.textContent = serverName; + } + } + + // Update music source name in sidebar based on active source + if (service === 'spotify' && statusData.source) { + const musicSourceNameElement = document.getElementById('music-source-name'); + if (musicSourceNameElement) { + const sourceName = statusData.source === 'spotify' ? 'Spotify' : statusData.source === 'deezer' ? 'Deezer' : statusData.source === 'discogs' ? 'Discogs' : 'iTunes'; + musicSourceNameElement.textContent = sourceName; + } + } + + // Update download source name based on configured mode + if (service === 'soulseek' && statusData.source) { + const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Hybrid' }; + const displayName = sourceNames[statusData.source] || 'Soulseek'; + const sidebarName = document.getElementById('download-source-name'); + if (sidebarName) sidebarName.textContent = displayName; + } + } +} + +function renderEnrichmentCards(enrichment) { + const grid = document.getElementById('enrichment-status-grid'); + if (!grid || !enrichment) return; + + // Service display order + const serviceOrder = [ + 'musicbrainz', 'spotify_enrichment', 'itunes_enrichment', 'deezer_enrichment', + 'tidal_enrichment', 'qobuz_enrichment', 'lastfm', 'genius', 'audiodb', + 'acoustid', 'listenbrainz' + ]; + + // Map service keys to their settings page selector for click-to-configure + const settingsSelectors = { + 'spotify_enrichment': '.spotify-title', + 'tidal_enrichment': '.tidal-title', + 'qobuz_enrichment': '.qobuz-title', + 'lastfm': '.lastfm-title', + 'genius': '.genius-title', + 'acoustid': '.acoustid-title', + 'listenbrainz': '.listenbrainz-title', + }; + + const chips = []; + for (const key of serviceOrder) { + const svc = enrichment[key]; + if (!svc) continue; + + // Determine status class and text + let statusClass, statusLabel; + if ('running' in svc) { + if (!svc.configured) { + statusClass = 'not-configured'; + statusLabel = 'Set up'; + } else if (svc.paused) { + statusClass = 'paused'; + statusLabel = svc.yield_reason === 'downloads' ? 'Yielding' : 'Paused'; + } else if (svc.running) { + statusClass = svc.idle ? 'idle' : 'running'; + statusLabel = svc.idle ? 'Idle' : 'Running'; + } else { + statusClass = 'stopped'; + statusLabel = 'Stopped'; + } + } else { + statusClass = svc.configured ? 'running' : 'not-configured'; + statusLabel = svc.configured ? 'Ready' : 'Set up'; + } + + const selector = settingsSelectors[key]; + const clickAttr = selector + ? `onclick="navigateToPage('settings'); setTimeout(() => { switchSettingsTab('connections'); setTimeout(() => { const el = document.querySelector('${selector}'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 100); }, 50);"` + : ''; + + // Build activity display — human-readable, not cryptic numbers + let activityHtml = ''; + let metaHtml = ''; + const isSpotify = key === 'spotify_enrichment'; + + if ('running' in svc && svc.configured) { + const c1h = svc.calls_1h || 0; + const c24h = svc.calls_24h || 0; + + if (isSpotify && svc.daily_budget) { + // Spotify: show budget usage prominently + const b = svc.daily_budget; + const pct = Math.min(100, Math.round((b.used / b.limit) * 100)); + const barClass = b.exhausted ? 'exhausted' : pct > 80 ? 'high' : ''; + activityHtml = `${b.used.toLocaleString()} / ${b.limit.toLocaleString()}`; + metaHtml = `
+
+
`; + } else if (c24h > 0) { + // Other services: show 24h count + activityHtml = `${c24h.toLocaleString()} / 24h`; + } + } + + // Tooltip: full details including 1h breakdown + let tooltipLines = [svc.name + ' — ' + statusLabel]; + if ('running' in svc && svc.configured) { + const c1h = svc.calls_1h || 0; + const c24h = svc.calls_24h || 0; + if (c24h > 0 || c1h > 0) tooltipLines.push('Last hour: ' + c1h + ' · Last 24h: ' + c24h); + } + if (isSpotify && svc.daily_budget) { + const b = svc.daily_budget; + tooltipLines.push('Daily budget: ' + b.used + ' / ' + b.limit + (b.exhausted ? ' (exhausted)' : '')); + } + if (selector && statusClass === 'not-configured') { + tooltipLines = ['Click to configure in Settings']; + } + + const statusDisplay = statusClass === 'not-configured' && selector ? 'Configure →' : statusLabel; + + chips.push(` +
+ + ${svc.name} + ${activityHtml} + ${statusDisplay} + ${metaHtml} +
+ `); + } + + grid.innerHTML = chips.join(''); +} + +// =============================== From 89754480beb441ae855d51e126a94b23dc0adbac Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:28:28 -0700 Subject: [PATCH 13/41] Source-aware /api/artist-detail: fall back to metadata source when not in library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part A of the deferred unification cleanup. The standalone artist- detail endpoint used to 404 whenever `artist_id` wasn't a local library primary key, which is exactly what source artists (Deezer/Spotify/ iTunes/etc.) have. That forced the Phase 4a revert: source artists had to use the inline Artists page because this endpoint couldn't handle them. New behaviour: - Library PK path — unchanged. Existing callers see the same response. - `/api/artist-detail/?source=&name=` with source in (spotify, itunes, deezer, discogs, hydrabase, musicbrainz) — when the library DB lookup misses, synthesize a response by: • fetching artist image via metadata_service.get_artist_image_url with source_override (the helper already backing /api/artist/ /image) • fetching discography via metadata_service.get_artist_detail_ discography with MetadataLookupOptions(source_override=source, artist_source_ids={source: artist_id}) • returning { success, artist: {id, name, image_url, server_source: null, genres: []}, discography, enrichment_coverage: {} } - Library PK missing AND no source — preserves the 404 (caller didn't give enough info to fall back). Frontend plumbing: library.js loadArtistDetailData now appends ?source=&name= to the fetch URL when artistDetailPageState.currentArtistSource is set. The field is already seeded by navigateToArtistDetail's third arg (added during the earlier unification work), so no new state plumbing is needed. populateArtistDetailPage gracefully handles the missing-library-data case per earlier exploration — owned_releases empty is fine, enrichment_coverage optional, spotify_artist_id optional. Part B will re-route the source-artist callsites (Search / Discover / Watchlist / etc.) back through navigateToArtistDetail so they actually exercise this new fallback path. --- web_server.py | 93 ++++++++++++++++++++++++++++++++++++++++- webui/static/library.js | 17 +++++++- 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/web_server.py b/web_server.py index 77ae5381..58914c45 100644 --- a/web_server.py +++ b/web_server.py @@ -11316,11 +11316,93 @@ def test_artist_endpoint(artist_id): "message": f"Test endpoint working for artist ID: {artist_id}" }) +_SOURCE_ONLY_ARTIST_SOURCES = frozenset({ + 'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz', +}) + + +def _build_source_only_artist_detail(artist_id, artist_name, source): + """Synthesize an artist-detail response from a single metadata source for an + artist that isn't in the local library. Used when `/api/artist-detail/` + is called with a `source` param and the library DB lookup misses.""" + from core.metadata_service import ( + MetadataLookupOptions, + get_artist_detail_discography as _get_artist_detail_discography, + get_artist_image_url as _get_artist_image_url, + ) + + # Resolve artist image via the same helper that powers /api/artist//image + image_url = None + try: + image_url = _get_artist_image_url(artist_id, source_override=source) + except Exception as e: + logger.debug(f"Artist image lookup failed for {source}:{artist_id}: {e}") + + # Fetch discography from the specified source, with source_override pinned so + # the fallback chain starts with the caller-requested provider. + discography_result = _get_artist_detail_discography( + artist_id, + artist_name=artist_name or artist_id, + options=MetadataLookupOptions( + source_override=source, + allow_fallback=True, + skip_cache=False, + max_pages=0, + limit=50, + artist_source_ids={source: artist_id}, + ), + ) + + if not discography_result.get('success'): + return jsonify({ + "success": False, + "error": discography_result.get('error', 'Could not load discography'), + "source": source, + }), 404 + + artist_info = { + "id": artist_id, + "name": artist_name or artist_id, + "image_url": image_url, + "server_source": None, # not in library + "genres": [], + } + + logger.info( + f"Source-only artist-detail: {artist_info['name']} from {source} — " + f"albums={len(discography_result.get('albums', []))}, " + f"eps={len(discography_result.get('eps', []))}, " + f"singles={len(discography_result.get('singles', []))}" + ) + + return jsonify({ + "success": True, + "artist": artist_info, + "discography": discography_result, + "enrichment_coverage": {}, + }) + + @app.route('/api/artist-detail/') def get_artist_detail(artist_id): - """Get artist detail data""" + """Get artist detail data. + + For library artists, `artist_id` is the local DB primary key and the full + library-aware path runs (owned releases + merged source discography + per- + service enrichment coverage). + + For source artists (Spotify/Deezer/iTunes/etc. that aren't in the library + yet), pass `?source=&name=` and the endpoint synthesizes + a response directly from the metadata source — no owned releases, just name + + image + discography so the artist-detail page can still render. + """ try: - logger.info(f"Getting artist detail for ID: {artist_id}") + source_param = (request.args.get('source', '') or '').strip().lower() + artist_name_arg = (request.args.get('name', '') or '').strip() + logger.info( + f"Getting artist detail for ID: {artist_id} " + f"(source={source_param or 'library'})" + ) # Get database instance database = get_database() @@ -11329,6 +11411,13 @@ def get_artist_detail(artist_id): db_result = database.get_artist_discography(artist_id) if not db_result.get('success'): + # Library lookup failed. If a metadata source was specified, fall back + # to a source-only response so the page can render a non-library artist. + if source_param in _SOURCE_ONLY_ARTIST_SOURCES: + return _build_source_only_artist_detail( + artist_id, artist_name_arg, source_param + ) + logger.error(f"Database returned error: {db_result}") return jsonify({ "success": False, diff --git a/webui/static/library.js b/webui/static/library.js index 3be4d03c..33e58955 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -764,8 +764,21 @@ async function loadArtistDetailData(artistId, artistName) { // Don't update header until data loads to avoid showing stale data try { - // Call API to get artist discography data - const response = await fetch(`/api/artist-detail/${artistId}`); + // Call API to get artist discography data. If this artist came from a + // metadata source (not the library), pass source + name so the backend + // can synthesize a response from that source instead of 404ing on the + // local DB lookup. + const params = new URLSearchParams(); + if (artistDetailPageState.currentArtistSource) { + params.set('source', artistDetailPageState.currentArtistSource); + } + if (artistName) { + params.set('name', artistName); + } + const qs = params.toString(); + const response = await fetch( + `/api/artist-detail/${encodeURIComponent(artistId)}${qs ? '?' + qs : ''}` + ); if (!response.ok) { throw new Error(`Failed to load artist data: ${response.statusText}`); From 1c345e4eb591e747b987eac99de0ee1e588e26bd Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:30:40 -0700 Subject: [PATCH 14/41] Route source-artist clicks to standalone /artist-detail page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part B of the deferred unification cleanup. Now that Part A teaches /api/artist-detail/ to fall back to a metadata-source lookup when the library DB lookup misses, source-artist clicks can finally land on the standalone page without 404ing — the goal Phase 4a aimed for and had to roll back in commit 19e9174. Re-migrating the seven callsites reverted earlier in this session: - search.js enhanced-search source-artist onClick - downloads.js _gsClickArtist (global widget non-library branch) - downloads.js _navigateToArtistFromModal fallback - discover.js viewRecommendedArtistDiscography - discover.js viewDiscoverHeroDiscography - discover.js 'Your Artists' card navAction inline onclick - discover.js 'Your Artists' info-modal 'View All' button - discover.js artist-map context menu - discover.js genre-deep-dive artist click - api-monitor.js watchlist discography view Each replaces the navigateToPage('artists')+setTimeout+selectArtist- ForDetail dance with a single navigateToArtistDetail(id, name, source) call. The third arg seeds artistDetailPageState.currentArtist- Source, which library.js now reads and forwards as ?source= to the backend (added in Part A). Effect: clicking an artist in any of these surfaces now lands on the standalone /artist-detail page with a stable URL, source context preserved, and owned-library data merged in when available. Library artist clicks (unchanged) and media-player / stats links (unchanged) all continue to use navigateToArtistDetail too, so they now consistently share one destination. The inline Artists page (#artists-page + selectArtistForDetail in artists.js) still exists but has no external callers left — only the page's own internal search-result click handler references the function now. Parts D + E will delete the dead inline page and finally remove artists.js. --- webui/static/api-monitor.js | 10 +--------- webui/static/discover.js | 34 +++++++--------------------------- webui/static/downloads.js | 25 +++---------------------- webui/static/search.js | 12 +----------- 4 files changed, 12 insertions(+), 69 deletions(-) diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js index bc86b223..c900d824 100644 --- a/webui/static/api-monitor.js +++ b/webui/static/api-monitor.js @@ -2385,16 +2385,8 @@ async function openWatchlistArtistDetailView(artistId, artistName) { source = spotify_artist_id ? 'spotify' : discogs_artist_id ? 'discogs' : deezer_artist_id ? 'deezer' : 'itunes'; } if (discogId) { - // Watchlist discogId is a metadata-source id (Spotify/Deezer/iTunes), - // not a library PK — route through the Artists page inline view. closeWatchlistArtistDetailView(); - navigateToPage('artists'); - setTimeout(() => { - selectArtistForDetail( - { id: discogId, name: artistName, image_url: artist.image_url || '' }, - { source: source } - ); - }, 200); + navigateToArtistDetail(discogId, artistName, source); } }); diff --git a/webui/static/discover.js b/webui/static/discover.js index e0293a60..e70e3b65 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -739,14 +739,7 @@ async function checkRecommendedWatchlistStatuses(artists) { async function viewRecommendedArtistDiscography(artistId, artistName) { closeRecommendedArtistsModal(); - - const artist = { id: artistId, name: artistName }; - - // Recommended artists come from the metadata source — route through the - // Artists page's inline view so the source-provided id resolves correctly. - navigateToPage('artists'); - await new Promise(resolve => setTimeout(resolve, 100)); - await selectArtistForDetail(artist); + navigateToArtistDetail(artistId, artistName); } async function checkAllHeroWatchlistStatus() { @@ -828,21 +821,8 @@ async function viewDiscoverHeroDiscography() { return; } - const artist = { - id: artistId, - name: artistName, - image_url: discoverHeroArtists[discoverHeroIndex]?.image_url || '', - genres: discoverHeroArtists[discoverHeroIndex]?.genres || [], - popularity: discoverHeroArtists[discoverHeroIndex]?.popularity || 0 - }; - console.log(`🎵 Navigating to artist detail for: ${artistName}`); - - // Hero artists are source-provided recommendations — route through the - // Artists page's inline view so the source id resolves correctly. - navigateToPage('artists'); - await new Promise(resolve => setTimeout(resolve, 100)); - await selectArtistForDetail(artist); + navigateToArtistDetail(artistId, artistName); } function showDiscoverHeroEmpty() { @@ -4633,9 +4613,9 @@ function _renderYourArtistCard(artist) { const watchlistClass = artist.on_watchlist ? 'active' : ''; const hasId = artist.active_source_id && artist.active_source_id !== ''; - // Navigate to Artists page (name click) — source artist id, needs inline view + // Navigate to standalone artist detail page (name click) const navAction = hasId - ? `event.stopPropagation(); navigateToPage('artists'); setTimeout(() => selectArtistForDetail({id:'${escapeForInlineJs(artist.active_source_id)}', name:'${escapeForInlineJs(artist.artist_name)}', image_url:'${escapeForInlineJs(img)}'}), 200)` + ? `event.stopPropagation(); navigateToArtistDetail('${escapeForInlineJs(artist.active_source_id)}', '${escapeForInlineJs(artist.artist_name)}')` : ''; // Open info modal (card body click) — pass pool ID so we can look up all data @@ -4814,7 +4794,7 @@ async function openYourArtistInfoModal(poolId) { Explore - @@ -6714,7 +6694,7 @@ function _artMapSetupInteraction(canvas) {
Artist Info
-
+
💿 View Discography
@@ -7402,7 +7382,7 @@ async function openGenreDeepDive(genre) { // Always open on Artists page with discography — pass source for correct routing const imgUrl = _esc(a.image_url || ''); const artSource = _esc(a.source || ''); - const clickAction = `onclick="document.getElementById('genre-deep-dive-modal').remove();navigateToPage('artists');setTimeout(()=>selectArtistForDetail({id:'${_esc(a.entity_id)}',name:'${_esc(a.name)}',image_url:'${imgUrl}'},{source:'${artSource}'}),300)"`; + const clickAction = `onclick="document.getElementById('genre-deep-dive-modal').remove();navigateToArtistDetail('${_esc(a.entity_id)}','${_esc(a.name)}','${artSource}' || null)"`; const srcClass = (a.source || '').toLowerCase(); return `
diff --git a/webui/static/downloads.js b/webui/static/downloads.js index 86d0ad24..f13bfb2e 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -634,15 +634,7 @@ function _navigateToArtistFromModal(artistId, artistName, imageUrl, source, play if (!artistName) return; // Close the download modal if (playlistId) closeDownloadMissingModal(playlistId); - // The id from a download modal is typically a metadata-source id; route via - // the Artists page inline view so the source-aware discography endpoint runs. - navigateToPage('artists'); - setTimeout(() => { - selectArtistForDetail( - { id: artistId || artistName, name: artistName, image_url: imageUrl || '' }, - source ? { source: source } : undefined - ); - }, 200); + navigateToArtistDetail(artistId || artistName, artistName, source || null); } async function closeDownloadMissingModal(playlistId) { @@ -5433,19 +5425,8 @@ function _gsSwitchSource(src) { function _gsClickArtist(id, name, isLibrary) { _gsDeactivate(); - if (isLibrary) { - // Library artists: id is a local DB PK — use the standalone artist-detail page. - navigateToArtistDetail(id, name); - } else { - // Source artists: id is a Deezer/Spotify/iTunes id — route to the Artists - // page's inline view which fetches discography from the source. - navigateToPage('artists'); - setTimeout(() => { - selectArtistForDetail({ id, name, image_url: '' }, { - source: _gsState.activeSource || '', - }); - }, 150); - } + const source = isLibrary ? null : (_gsState.activeSource || null); + navigateToArtistDetail(id, name, source); } async function _gsClickAlbum(albumId, albumName, artistName, imageUrl, source) { diff --git a/webui/static/search.js b/webui/static/search.js index fd8b0b37..be9147c5 100644 --- a/webui/static/search.js +++ b/webui/static/search.js @@ -340,17 +340,7 @@ function initializeSearchModeToggle() { const sourceOverride = _activeSearchSource; console.log(`🎵 Opening artist detail: ${artist.name} (ID: ${artist.id}, source: ${sourceOverride})`); hideDropdown(); - - // Source artists are NOT library entries — their id is a Deezer/ - // Spotify/iTunes id, not a library PK. Route to the Artists page's - // inline selectArtistForDetail which fetches discography from the - // source directly, not the library's /api/artist-detail endpoint. - navigateToPage('artists'); - await new Promise(resolve => setTimeout(resolve, 100)); - await selectArtistForDetail(artist, { - source: sourceOverride, - plugin: artist.external_urls?.hydrabase_plugin, - }); + navigateToArtistDetail(artist.id, artist.name, sourceOverride || null); } }) ); From 71ff5cb5c3e7d59d33573757b49d7ec679f56fd3 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:38:32 -0700 Subject: [PATCH 15/41] Retire artists.js and inline Artists page, ship unification at 2.40 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part D + E of the deferred cleanup + the final version bump that publishes the whole Search/Artists unification project. Deletions: - webui/static/artists.js (1903 lines) — removed entirely. The 2 remaining externally-referenced helpers (lazyLoadArtistImages + showCompletionError) moved into shared-helpers.js first. - webui/index.html — 140-line #artists-page HTML block and the - diff --git a/webui/static/artists.js b/webui/static/artists.js deleted file mode 100644 index 7d70c875..00000000 --- a/webui/static/artists.js +++ /dev/null @@ -1,1903 +0,0 @@ -// ARTISTS PAGE FUNCTIONALITY - ELEGANT SEARCH & DISCOVERY -// ============================================================================ - -/** - * Initialize the artists page when navigated to (only runs once) - */ -function initializeArtistsPage() { - console.log('🎵 Initializing Artists Page (first time)'); - - // Get DOM elements - const searchInput = document.getElementById('artists-search-input'); - const headerSearchInput = document.getElementById('artists-header-search-input'); - const searchStatus = document.getElementById('artists-search-status'); - const backButton = document.getElementById('artists-back-button'); - const detailBackButton = document.getElementById('artist-detail-back-button'); - - // Set up event listeners (only need to do this once) - if (searchInput) { - searchInput.addEventListener('input', handleArtistsSearchInput); - searchInput.addEventListener('keypress', handleArtistsSearchKeypress); - } - - if (headerSearchInput) { - headerSearchInput.addEventListener('input', handleArtistsHeaderSearchInput); - headerSearchInput.addEventListener('keypress', handleArtistsSearchKeypress); - } - - if (backButton) { - backButton.addEventListener('click', () => showArtistsSearchState()); - } - - if (detailBackButton) { - detailBackButton.addEventListener('click', () => { - // If the user searched within the Artists page, back returns to the - // results list so they can pick a different artist. - if (artistsPageState.searchResults && artistsPageState.searchResults.length > 0) { - showArtistsResultsState(); - return; - } - // Otherwise the user reached this detail view from elsewhere (Search, - // Discover, watchlist, etc.). The Artists page is no longer a sidebar - // entry, so there's nothing useful to fall back to here — let the - // browser take them back to wherever they came from, or drop them on - // Search (the go-forward way to find another artist). - if (window.history.length > 1) { - window.history.back(); - } else { - navigateToPage('search'); - } - }); - } - - // Initialize tabs (only need to do this once) - initializeArtistTabs(); - - // Mark as initialized - artistsPageState.isInitialized = true; - - // Restore previous state instead of always resetting to search - restoreArtistsPageState(); - console.log('✅ Artists Page initialized successfully (ready for navigation)'); -} - -/** - * Restore the artists page to its previous state - */ -function restoreArtistsPageState() { - console.log(`🔄 Restoring artists page state: ${artistsPageState.currentView}`); - - switch (artistsPageState.currentView) { - case 'results': - // Restore search results state - if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) { - console.log(`📦 Restoring search results for: "${artistsPageState.searchQuery}"`); - - // Restore search input values - const searchInput = document.getElementById('artists-search-input'); - const headerSearchInput = document.getElementById('artists-header-search-input'); - - if (searchInput) searchInput.value = artistsPageState.searchQuery; - if (headerSearchInput) headerSearchInput.value = artistsPageState.searchQuery; - - // Display the cached results - displayArtistsResults(artistsPageState.searchQuery, artistsPageState.searchResults); - } else { - // No valid results state, fall back to search - showArtistsSearchState(); - } - break; - - case 'detail': - // Restore artist detail state - if (artistsPageState.selectedArtist && artistsPageState.artistDiscography) { - console.log(`🎤 Restoring artist detail for: ${artistsPageState.selectedArtist.name}`); - - // First restore search results if they exist - if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) { - const searchInput = document.getElementById('artists-search-input'); - const headerSearchInput = document.getElementById('artists-header-search-input'); - - if (searchInput) searchInput.value = artistsPageState.searchQuery; - if (headerSearchInput) headerSearchInput.value = artistsPageState.searchQuery; - } - - // Show artist detail state - showArtistDetailState(); - - // Update artist info in header - updateArtistDetailHeader(artistsPageState.selectedArtist); - - // Display cached discography - if (artistsPageState.artistDiscography.albums || artistsPageState.artistDiscography.singles) { - displayArtistDiscography(artistsPageState.artistDiscography); - // Restore cached completion data instead of re-scanning - restoreCachedCompletionData(artistsPageState.selectedArtist.id); - } - } else { - // No valid detail state, fall back to search or results - if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) { - displayArtistsResults(artistsPageState.searchQuery, artistsPageState.searchResults); - } else { - showArtistsSearchState(); - } - } - break; - - default: - case 'search': - // Show search state (but preserve any existing search query) - if (artistsPageState.searchQuery) { - const searchInput = document.getElementById('artists-search-input'); - if (searchInput) searchInput.value = artistsPageState.searchQuery; - } - showArtistsSearchState(); - break; - } -} - -/** - * Handle search input with debouncing - */ -function handleArtistsSearchInput(event) { - const query = event.target.value.trim(); - updateArtistsSearchStatus('searching'); - - // Clear existing timeout - if (artistsSearchTimeout) { - clearTimeout(artistsSearchTimeout); - } - - // Cancel any active search - if (artistsSearchController) { - artistsSearchController.abort(); - } - - if (query === '') { - updateArtistsSearchStatus('default'); - return; - } - - // Set up new debounced search - artistsSearchTimeout = setTimeout(() => { - performArtistsSearch(query); - }, 1000); // 1 second debounce -} - -/** - * Handle header search input (already in results state) - */ -function handleArtistsHeaderSearchInput(event) { - const query = event.target.value.trim(); - - // Update main search input to match - const mainInput = document.getElementById('artists-search-input'); - if (mainInput) { - mainInput.value = query; - } - - // Trigger search with same debouncing logic - handleArtistsSearchInput(event); -} - -/** - * Handle Enter key press in search inputs - */ -function handleArtistsSearchKeypress(event) { - if (event.key === 'Enter') { - event.preventDefault(); - const query = event.target.value.trim(); - - if (query && query !== artistsPageState.searchQuery) { - // Clear timeout and search immediately - if (artistsSearchTimeout) { - clearTimeout(artistsSearchTimeout); - } - performArtistsSearch(query); - } - } -} - -/** - * Perform artist search with API call - */ -async function performArtistsSearch(query) { - console.log(`🔍 Searching for artists: "${query}"`); - - // Check cache first - if (artistsPageState.cache.searches[query]) { - console.log('📦 Using cached search results'); - displayArtistsResults(query, artistsPageState.cache.searches[query]); - return; - } - - // Update status - updateArtistsSearchStatus('searching'); - - // Show loading cards immediately if we're in results view - if (artistsPageState.currentView === 'results') { - showSearchLoadingCards(); - } - - try { - // Set up abort controller - artistsSearchController = new AbortController(); - - const response = await fetch('/api/match/search', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - query: query, - context: 'artist' - }), - signal: artistsSearchController.signal - }); - - if (!response.ok) { - throw new Error(`Search failed: ${response.status}`); - } - - const data = await response.json(); - console.log(`✅ Found ${data.results?.length || 0} artists`); - - // Transform the results to flatten the nested artist data - const transformedResults = (data.results || []).map(result => { - // Extract artist data from the nested structure - const artist = result.artist || result; - return { - id: artist.id, - name: artist.name, - image_url: artist.image_url, - genres: artist.genres, - popularity: artist.popularity, - confidence: result.confidence || 0 - }; - }); - - console.log('🔧 Transformed results:', transformedResults); - - // Cache the transformed results - artistsPageState.cache.searches[query] = transformedResults; - - // Display results - displayArtistsResults(query, transformedResults); - - } catch (error) { - if (error.name !== 'AbortError') { - console.error('❌ Artist search failed:', error); - - // Provide specific error messages based on the error type - let errorMessage = 'Search failed. Please try again.'; - if (error.message.includes('401') || error.message.includes('authentication')) { - errorMessage = 'Spotify not authenticated. Please check your API settings.'; - } else if (error.message.includes('network') || error.message.includes('fetch')) { - errorMessage = 'Network error. Please check your connection.'; - } else if (error.message.includes('timeout')) { - errorMessage = 'Search timed out. Please try again.'; - } - - updateArtistsSearchStatus('error', errorMessage); - } - } finally { - artistsSearchController = null; - } -} - -/** - * Display artist search results - */ -function displayArtistsResults(query, results) { - console.log(`📊 Displaying ${results.length} artist results`); - - // Update state - artistsPageState.searchQuery = query; - artistsPageState.searchResults = results; - artistsPageState.currentView = 'results'; - - // Update header search input if different - const headerInput = document.getElementById('artists-header-search-input'); - if (headerInput && headerInput.value !== query) { - headerInput.value = query; - } - - // Show results state - showArtistsResultsState(); - - // Populate results - const container = document.getElementById('artists-cards-container'); - if (!container) return; - - if (results.length === 0) { - container.innerHTML = ` -
-
🔍
-
No artists found
-
Try a different search term
-
- `; - return; - } - - // Create artist cards - container.innerHTML = results.map(result => createArtistCardHTML(result)).join(''); - observeLazyBackgrounds(container); - - // Add event listeners to cards - container.querySelectorAll('.artist-card').forEach((card, index) => { - card.addEventListener('click', () => selectArtistForDetail(results[index])); - - // Extract colors from artist image for dynamic glow - const artist = results[index]; - if (artist.image_url) { - extractImageColors(artist.image_url, (colors) => { - applyDynamicGlow(card, colors); - }); - } - }); - - // Update watchlist status for all cards - updateArtistCardWatchlistStatus(); - - // Lazy load missing artist images - console.log('🖼️ Starting lazy load for artist images on Artists page...'); - if (typeof lazyLoadArtistImages === 'function') { - lazyLoadArtistImages(container); - } else if (typeof window.lazyLoadArtistImages === 'function') { - window.lazyLoadArtistImages(container); - } else { - console.error('❌ lazyLoadArtistImages function not found!'); - } - - // Add mouse wheel horizontal scrolling - container.addEventListener('wheel', (event) => { - if (event.deltaY !== 0) { - event.preventDefault(); - container.scrollLeft += event.deltaY; - } - }); -} - -/** - * Lazy load artist images for cards that don't have images yet. - * Fetches images asynchronously so search results appear immediately. - */ -async function lazyLoadArtistImages(container) { - if (!container) { - console.error('❌ lazyLoadArtistImages: container is null'); - return; - } - - // Find all cards that need images - const cardsNeedingImages = container.querySelectorAll('[data-needs-image="true"]'); - - if (cardsNeedingImages.length === 0) { - console.log('✅ All artist cards have images'); - return; - } - - console.log(`🖼️ Lazy loading images for ${cardsNeedingImages.length} artist cards`); - - // Load images in parallel (but with a small batch to avoid overwhelming the server) - const batchSize = 5; - const cards = Array.from(cardsNeedingImages); - - for (let i = 0; i < cards.length; i += batchSize) { - const batch = cards.slice(i, i + batchSize); - - await Promise.all(batch.map(async (card) => { - const artistId = card.dataset.artistId; - if (!artistId) { - console.warn('⚠️ Card missing artistId:', card); - return; - } - - try { - console.log(`🔄 Fetching image for artist ${artistId}...`); - const response = await fetch(`/api/artist/${artistId}/image`); - const data = await response.json(); - - console.log(`📥 Got response for ${artistId}:`, data); - - if (data.success && data.image_url) { - // Update the card's background image - // Handle both card types (suggestion-card and artist-card) - if (card.classList.contains('suggestion-card')) { - card.style.backgroundImage = `url(${data.image_url})`; - card.style.backgroundSize = 'cover'; - card.style.backgroundPosition = 'center'; - } else if (card.classList.contains('artist-card')) { - const bgElement = card.querySelector('.artist-card-background'); - if (bgElement) { - // Clear the gradient first, then set the image - bgElement.style.cssText = `background-image: url('${data.image_url}'); background-size: cover; background-position: center;`; - } - } - - card.dataset.needsImage = 'false'; - console.log(`✅ Loaded image for artist ${artistId}`); - } - } catch (error) { - console.error(`❌ Failed to load image for artist ${artistId}:`, error); - } - })); - } - - console.log('✅ Finished lazy loading artist images'); -} - -// Make function globally accessible -window.lazyLoadArtistImages = lazyLoadArtistImages; - -/** - * Create HTML for an artist card - */ -function createArtistCardHTML(artist) { - const imageUrl = artist.image_url || ''; - const genres = artist.genres && artist.genres.length > 0 ? - artist.genres.slice(0, 3).join(', ') : 'Various genres'; - const popularity = artist.popularity || 0; - - // Use data-bg-src for lazy background loading via IntersectionObserver - const backgroundAttr = imageUrl ? - `data-bg-src="${imageUrl}"` : - `style="background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);"`; - - // Format popularity as a percentage for better UX - const popularityText = popularity > 0 ? `${popularity}% Popular` : 'Popularity Unknown'; - - // Track if image needs to be lazy loaded - const needsImage = imageUrl ? 'false' : 'true'; - - // Check for MusicBrainz ID - let mbIconHTML = ''; - if (artist.musicbrainz_id) { - mbIconHTML = ` -
- -
- `; - } - - return ` -
- ${mbIconHTML} -
-
-
-
${escapeHtml(artist.name)}
-
${escapeHtml(genres)}
-
- 🔥 - ${popularityText} -
-
-
- - -
-
-
-
- `; -} - -/** - * Select an artist and show their discography - */ -async function selectArtistForDetail(artist, options = {}) { - console.log(`🎤 Selected artist: ${artist.name}`); - - // Cancel any ongoing completion check from previous artist - if (artistCompletionController) { - console.log('⏹️ Canceling previous artist completion check'); - artistCompletionController.abort(); - artistCompletionController = null; - } - - // Cancel any ongoing similar artists stream from previous artist - if (similarArtistsController) { - console.log('⏹️ Canceling previous similar artists stream'); - similarArtistsController.abort(); - similarArtistsController = null; - } - - // Update state - artistsPageState.selectedArtist = artist; - artistsPageState.currentView = 'detail'; - artistsPageState.sourceOverride = options.source || artist.source || null; - artistsPageState.pluginOverride = options.plugin || null; - - // Show detail state - showArtistDetailState(); - - // Update artist info in header - updateArtistDetailHeader(artist); - - // Load discography (pass artist name for cross-source fallback) - await loadArtistDiscography(artist.id, artist.name, artistsPageState.sourceOverride, options.plugin); -} - -/** - * Load artist's discography from Spotify or iTunes - * @param {string} artistId - Artist ID (Spotify or iTunes format) - * @param {string} [artistName] - Optional artist name for fallback searches - */ -async function loadArtistDiscography(artistId, artistName = null, sourceOverride = null, pluginOverride = null) { - console.log(`💿 Loading discography for artist: ${artistId} (name: ${artistName}, source: ${sourceOverride || 'auto'})`); - - // Use source-prefixed cache key to avoid ID collisions between sources - const cacheKey = sourceOverride ? `${sourceOverride}:${artistId}` : artistId; - - // Check cache first - if (artistsPageState.cache.discography[cacheKey]) { - console.log('📦 Using cached discography'); - const cachedDiscography = artistsPageState.cache.discography[cacheKey]; - if (artistsPageState.selectedArtist) { - artistsPageState.selectedArtist = { - ...artistsPageState.selectedArtist, - source: cachedDiscography.source || sourceOverride || artistsPageState.selectedArtist.source || null, - }; - } - artistsPageState.sourceOverride = cachedDiscography.source || sourceOverride || artistsPageState.sourceOverride || null; - displayArtistDiscography(cachedDiscography); - - // Load similar artists in parallel (don't wait) — always uses primary source - loadSimilarArtists(artistsPageState.selectedArtist?.name).catch(err => { - console.error('❌ Error loading similar artists:', err); - }); - - // Still check completion status for cached data - await checkDiscographyCompletion(artistId, cachedDiscography); - return; - } - - try { - // Show loading states - showDiscographyLoading(); - - // Build URL with optional artist name and source override for fallback - let url = `/api/artist/${artistId}/discography`; - const params = new URLSearchParams(); - if (artistName) params.set('artist_name', artistName); - if (sourceOverride) params.set('source', sourceOverride); - if (pluginOverride) params.set('plugin', pluginOverride); - if (params.toString()) url += `?${params.toString()}`; - - // Call the real API endpoint - const response = await fetch(url); - - if (!response.ok) { - if (response.status === 401) { - throw new Error('Spotify not authenticated. Please check your API settings.'); - } - throw new Error(`Failed to load discography: ${response.status}`); - } - - const data = await response.json(); - - if (data.error) { - throw new Error(data.error); - } - - const discography = { - albums: data.albums || [], - singles: data.singles || [], - source: data.source || sourceOverride || null, - }; - - // Keep the resolved metadata source on the selected artist so album clicks - // can pass it through to /api/album//tracks. - if (artistsPageState.selectedArtist) { - artistsPageState.selectedArtist = { - ...artistsPageState.selectedArtist, - source: discography.source, - }; - } - artistsPageState.sourceOverride = discography.source || artistsPageState.sourceOverride || null; - - // Update selected artist with full details from backend (includes MusicBrainz ID) - if (data.artist) { - console.log('✨ Updating artist details with fresh data from backend'); - artistsPageState.selectedArtist = { - ...artistsPageState.selectedArtist, - ...data.artist - }; - } - - // Merge artist_info enrichment from discography response - if (data.artist_info) { - artistsPageState.selectedArtist = { - ...artistsPageState.selectedArtist, - artist_info: data.artist_info, - }; - } - - // Refresh header with all available data - updateArtistDetailHeader(artistsPageState.selectedArtist); - - console.log(`✅ Loaded ${discography.albums.length} albums and ${discography.singles.length} singles`); - - // Cache the results (use source-prefixed key if source override active) - artistsPageState.cache.discography[cacheKey] = discography; - artistsPageState.artistDiscography = discography; - - // Display results - displayArtistDiscography(discography); - - // Load similar artists and check completion in parallel (don't wait) - loadSimilarArtists(artistsPageState.selectedArtist?.name).catch(err => { - console.error('❌ Error loading similar artists:', err); - }); - - // Check completion status for all albums and singles - await checkDiscographyCompletion(artistId, discography); - - } catch (error) { - console.error('❌ Failed to load discography:', error); - showDiscographyError(error.message); - } -} - -/** - * Display artist's discography in tabs - */ -function displayArtistDiscography(discography) { - console.log(`📀 Displaying discography: ${discography.albums?.length || 0} albums, ${discography.singles?.length || 0} singles`); - - // Show Download Discography button(s) if there are any releases - const _totalReleases = (discography.albums?.length || 0) + (discography.eps?.length || 0) + (discography.singles?.length || 0); - const _discogWrap = document.getElementById('discog-download-wrap'); - if (_discogWrap) _discogWrap.style.display = _totalReleases > 0 ? '' : 'none'; - const _discogBtnArtists = document.getElementById('discog-download-btn-artists'); - if (_discogBtnArtists) _discogBtnArtists.style.display = _totalReleases > 0 ? '' : 'none'; - - // Populate albums - const albumsContainer = document.getElementById('album-cards-container'); - if (albumsContainer) { - if (discography.albums?.length > 0) { - albumsContainer.innerHTML = discography.albums.map(album => createAlbumCardHTML(album)).join(''); - observeLazyBackgrounds(albumsContainer); - - // Add dynamic glow effects and click handlers to album cards - albumsContainer.querySelectorAll('.album-card').forEach((card, index) => { - const album = discography.albums[index]; - if (album.image_url) { - extractImageColors(album.image_url, (colors) => { - applyDynamicGlow(card, colors); - }); - } - - // Add click handler for download missing tracks modal - card.addEventListener('click', () => handleArtistAlbumClick(album, 'albums')); - card.style.cursor = 'pointer'; - }); - } else { - albumsContainer.innerHTML = ` -
-
💿
-
No albums found
-
- `; - } - } - - // Populate singles - const singlesContainer = document.getElementById('singles-cards-container'); - if (singlesContainer) { - if (discography.singles?.length > 0) { - singlesContainer.innerHTML = discography.singles.map(single => createAlbumCardHTML(single)).join(''); - observeLazyBackgrounds(singlesContainer); - - // Add dynamic glow effects and click handlers to singles cards - singlesContainer.querySelectorAll('.album-card').forEach((card, index) => { - const single = discography.singles[index]; - if (single.image_url) { - extractImageColors(single.image_url, (colors) => { - applyDynamicGlow(card, colors); - }); - } - - // Add click handler for download missing tracks modal - card.addEventListener('click', () => handleArtistAlbumClick(single, 'singles')); - card.style.cursor = 'pointer'; - }); - } else { - singlesContainer.innerHTML = ` -
-
🎵
-
No singles or EPs found
-
- `; - } - } - - // Auto-switch to Singles tab if no albums but has singles - if ((!discography.albums || discography.albums.length === 0) && - discography.singles && discography.singles.length > 0) { - console.log('📀 No albums found, auto-switching to Singles & EPs tab'); - - // Switch to singles tab - const albumsTab = document.getElementById('albums-tab'); - const singlesTab = document.getElementById('singles-tab'); - const albumsContent = document.getElementById('albums-content'); - const singlesContent = document.getElementById('singles-content'); - - if (albumsTab && singlesTab && albumsContent && singlesContent) { - // Remove active from albums - albumsTab.classList.remove('active'); - albumsContent.classList.remove('active'); - - // Add active to singles - singlesTab.classList.add('active'); - singlesContent.classList.add('active'); - } - } -} - -/** - * Load similar artists from MusicMap - */ -async function loadSimilarArtists(artistName) { - if (!artistName) { - console.warn('⚠️ No artist name provided for similar artists'); - return; - } - - console.log(`🔍 Loading similar artists for: ${artistName}`); - - // Get DOM elements - const section = document.getElementById('similar-artists-section'); - const loadingEl = document.getElementById('similar-artists-loading'); - const errorEl = document.getElementById('similar-artists-error'); - const container = document.getElementById('similar-artists-bubbles-container'); - - if (!section || !loadingEl || !errorEl || !container) { - console.warn('⚠️ Similar artists section elements not found'); - return; - } - - // Show loading state - loadingEl.classList.remove('hidden'); - errorEl.classList.add('hidden'); - container.innerHTML = ''; - section.style.display = 'block'; - - try { - // Create new abort controller for this similar artists stream - similarArtistsController = new AbortController(); - - // Use streaming endpoint for real-time bubble creation - const url = `/api/artist/similar/${encodeURIComponent(artistName)}/stream`; - console.log(`📡 Streaming from: ${url}`); - - const response = await fetch(url, { - signal: similarArtistsController.signal - }); - - if (!response.ok) { - throw new Error(`Failed to fetch similar artists: ${response.status}`); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let artistCount = 0; - - // Read the stream - while (true) { - const { done, value } = await reader.read(); - - if (done) { - console.log('✅ Stream complete'); - break; - } - - // Decode the chunk and add to buffer - buffer += decoder.decode(value, { stream: true }); - - // Process complete messages (separated by \n\n) - const messages = buffer.split('\n\n'); - buffer = messages.pop() || ''; // Keep incomplete message in buffer - - for (const message of messages) { - if (!message.trim() || !message.startsWith('data: ')) continue; - - try { - const jsonData = JSON.parse(message.substring(6)); // Remove 'data: ' prefix - - if (jsonData.error) { - throw new Error(jsonData.error); - } - - if (jsonData.artist) { - // Hide loading on first artist - if (artistCount === 0) { - loadingEl.classList.add('hidden'); - } - - // Create and append bubble immediately - const bubble = createSimilarArtistBubble(jsonData.artist); - container.appendChild(bubble); - artistCount++; - - console.log(`✅ Added bubble for: ${jsonData.artist.name} (${artistCount})`); - } - - if (jsonData.complete) { - console.log(`🎉 Streaming complete: ${jsonData.total} artists`); - - if (artistCount === 0) { - loadingEl.classList.add('hidden'); - container.innerHTML = ` -
-
🎵
-
No similar artists found
-
- `; - } else { - // Lazy load images for similar artists that don't have them - lazyLoadSimilarArtistImages(container); - } - } - } catch (parseError) { - console.error('❌ Error parsing stream message:', parseError); - } - } - } - - // Clear the controller when done - similarArtistsController = null; - - } catch (error) { - // Don't show error if it was aborted (user navigated away) - if (error.name === 'AbortError') { - console.log('⏹️ Similar artists stream aborted (user navigated to new artist)'); - loadingEl.classList.add('hidden'); - return; - } - - console.error('❌ Error loading similar artists:', error); - - // Hide loading, show error - loadingEl.classList.add('hidden'); - errorEl.classList.remove('hidden'); - - // Also show error message in container - container.innerHTML = ` -
-
⚠️
-
${error.message}
-
- `; - } finally { - // Always clear the controller - similarArtistsController = null; - } -} - -/** - * Lazy load images for similar artist bubbles that don't have images - */ -async function lazyLoadSimilarArtistImages(container) { - if (!container) return; - - const bubblesNeedingImages = container.querySelectorAll('.similar-artist-bubble[data-needs-image="true"]'); - - if (bubblesNeedingImages.length === 0) { - console.log('✅ All similar artist bubbles have images'); - return; - } - - console.log(`🖼️ Lazy loading images for ${bubblesNeedingImages.length} similar artists`); - - // Load images in parallel batches - const batchSize = 5; - const bubbles = Array.from(bubblesNeedingImages); - - for (let i = 0; i < bubbles.length; i += batchSize) { - const batch = bubbles.slice(i, i + batchSize); - - await Promise.all(batch.map(async (bubble) => { - const artistId = bubble.getAttribute('data-artist-id'); - const artistSource = bubble.getAttribute('data-artist-source') || ''; - const artistPlugin = bubble.getAttribute('data-artist-plugin') || ''; - if (!artistId) return; - - try { - const params = new URLSearchParams(); - if (artistSource) params.set('source', artistSource); - if (artistPlugin) params.set('plugin', artistPlugin); - - const imageUrl = params.toString() - ? `/api/artist/${encodeURIComponent(artistId)}/image?${params.toString()}` - : `/api/artist/${encodeURIComponent(artistId)}/image`; - - const response = await fetch(imageUrl); - const data = await response.json(); - - if (data.success && data.image_url) { - const imageContainer = bubble.querySelector('.similar-artist-bubble-image'); - if (imageContainer) { - const artistName = bubble.querySelector('.similar-artist-bubble-name')?.textContent || 'Artist'; - imageContainer.innerHTML = `${artistName}`; - bubble.setAttribute('data-needs-image', 'false'); - console.log(`✅ Loaded image for similar artist ${artistId}`); - } - } - } catch (error) { - console.warn(`⚠️ Failed to load image for similar artist ${artistId}:`, error); - } - })); - } - - console.log('✅ Finished lazy loading similar artist images'); -} - -/** - * Display similar artist bubble cards progressively (one at a time with delay) - */ -function displaySimilarArtistsProgressively(artists) { - const container = document.getElementById('similar-artists-bubbles-container'); - - if (!container) { - console.warn('⚠️ Similar artists container not found'); - return; - } - - // Clear container - container.innerHTML = ''; - - // Add each bubble with a delay to simulate progressive loading - artists.forEach((artist, index) => { - setTimeout(() => { - const bubble = createSimilarArtistBubble(artist); - container.appendChild(bubble); - }, index * 100); // 100ms delay between each bubble - }); - - console.log(`✅ Displaying ${artists.length} similar artist bubbles progressively`); -} - -/** - * Display similar artist bubble cards (all at once - legacy) - */ -function displaySimilarArtists(artists) { - const container = document.getElementById('similar-artists-bubbles-container'); - - if (!container) { - console.warn('⚠️ Similar artists container not found'); - return; - } - - // Clear container - container.innerHTML = ''; - - // Create bubble cards with staggered animation - artists.forEach((artist, index) => { - const bubble = createSimilarArtistBubble(artist); - - // Add staggered animation delay (50ms per bubble) - bubble.style.animationDelay = `${index * 0.05}s`; - - container.appendChild(bubble); - }); - - console.log(`✅ Displayed ${artists.length} similar artist bubbles`); -} - -/** - * Create a similar artist bubble card element - */ -function createSimilarArtistBubble(artist) { - // Create bubble container - const bubble = document.createElement('div'); - bubble.className = 'similar-artist-bubble'; - bubble.setAttribute('data-artist-id', artist.id); - bubble.setAttribute('data-artist-source', artist.source || ''); - if (artist.plugin) { - bubble.setAttribute('data-artist-plugin', artist.plugin); - } - - // Track if image needs lazy loading - const hasImage = artist.image_url && artist.image_url.trim() !== ''; - bubble.setAttribute('data-needs-image', hasImage ? 'false' : 'true'); - - // Create image container - const imageContainer = document.createElement('div'); - imageContainer.className = 'similar-artist-bubble-image'; - - if (hasImage) { - const img = document.createElement('img'); - img.src = artist.image_url; - img.alt = artist.name; - - // Handle image load error - img.onerror = () => { - console.log(`Failed to load image for ${artist.name}`); - imageContainer.innerHTML = `
🎵
`; - bubble.setAttribute('data-needs-image', 'true'); - }; - - imageContainer.appendChild(img); - } else { - // No image - show fallback (will be lazy loaded) - imageContainer.innerHTML = `
🎵
`; - } - - // Create name element - const name = document.createElement('div'); - name.className = 'similar-artist-bubble-name'; - name.textContent = artist.name; - name.title = artist.name; // Tooltip for full name - - // Optional: Create genres element (hidden by default in CSS) - const genres = document.createElement('div'); - genres.className = 'similar-artist-bubble-genres'; - if (artist.genres && artist.genres.length > 0) { - genres.textContent = artist.genres.slice(0, 2).join(', '); - } - - // Assemble bubble - bubble.appendChild(imageContainer); - bubble.appendChild(name); - if (artist.genres && artist.genres.length > 0) { - bubble.appendChild(genres); - } - - // Add click handler to navigate to artist detail page - bubble.addEventListener('click', () => { - console.log(`🎵 Clicked similar artist: ${artist.name} (ID: ${artist.id})`); - // Navigate to this artist's detail page (same as clicking from search results) - selectArtistForDetail( - artist, - artist.source ? { source: artist.source, plugin: artist.plugin } : {} - ); - }); - - return bubble; -} - -/** - * Restore cached completion data without re-scanning the database - */ -function restoreCachedCompletionData(artistId) { - console.log(`📦 Restoring cached completion data for artist: ${artistId}`); - - const cachedData = artistsPageState.cache.completionData[artistId]; - if (!cachedData) { - console.log('⚠️ No cached completion data found, skipping restoration'); - return; - } - - // Restore album completion overlays - if (cachedData.albums) { - cachedData.albums.forEach(albumCompletion => { - updateAlbumCompletionOverlay(albumCompletion, 'albums'); - }); - console.log(`✅ Restored ${cachedData.albums.length} album completion overlays`); - } - - // Restore singles completion overlays - if (cachedData.singles) { - cachedData.singles.forEach(singleCompletion => { - updateAlbumCompletionOverlay(singleCompletion, 'singles'); - }); - console.log(`✅ Restored ${cachedData.singles.length} single completion overlays`); - } -} - -/** - * Check completion status for entire discography with streaming updates - */ - -/** - * Show error state on all completion overlays - */ -function showCompletionError() { - const allOverlays = document.querySelectorAll('.completion-overlay.checking'); - allOverlays.forEach(overlay => { - overlay.classList.remove('checking'); - overlay.classList.add('error'); - overlay.innerHTML = 'Error'; - overlay.title = 'Failed to check completion status'; - }); -} - -/** - * Create HTML for an album/single card - */ -function createAlbumCardHTML(album) { - const imageUrl = album.image_url || ''; - const year = album.release_date ? new Date(album.release_date).getFullYear() : ''; - const type = album.album_type === 'album' ? 'Album' : - album.album_type === 'single' ? 'Single' : 'EP'; - - // Use data-bg-src for lazy background loading via IntersectionObserver - const backgroundAttr = imageUrl ? - `data-bg-src="${imageUrl}"` : - `style="background: linear-gradient(135deg, rgba(29, 185, 84, 0.2) 0%, rgba(24, 156, 71, 0.1) 100%);"`; - - return ` -
-
-
- Checking... -
-
-
${escapeHtml(album.name)}
-
${year || 'Unknown'}
-
${type}
-
-
- `; -} - -/** - * Initialize artist detail tabs - */ -function initializeArtistTabs() { - const tabButtons = document.querySelectorAll('.artist-tab'); - const tabContents = document.querySelectorAll('.tab-content'); - - tabButtons.forEach(button => { - button.addEventListener('click', () => { - const tabName = button.getAttribute('data-tab'); - - // Update button states - tabButtons.forEach(btn => btn.classList.remove('active')); - button.classList.add('active'); - - // Update content states - tabContents.forEach(content => { - content.classList.remove('active'); - if (content.id === `${tabName}-content`) { - content.classList.add('active'); - } - }); - - console.log(`🔄 Switched to ${tabName} tab`); - }); - }); -} - -/** - * State management functions - */ -function showArtistsSearchState() { - console.log('🔄 Showing search state'); - - // Cancel any ongoing completion check when navigating back to search - if (artistCompletionController) { - console.log('⏹️ Canceling completion check (navigating back to search)'); - artistCompletionController.abort(); - artistCompletionController = null; - } - - // Cancel any ongoing similar artists stream when navigating back to search - if (similarArtistsController) { - console.log('⏹️ Canceling similar artists stream (navigating back to search)'); - similarArtistsController.abort(); - similarArtistsController = null; - } - - const searchState = document.getElementById('artists-search-state'); - const resultsState = document.getElementById('artists-results-state'); - const detailState = document.getElementById('artist-detail-state'); - - if (searchState) { - searchState.classList.remove('hidden', 'fade-out'); - } - if (resultsState) { - resultsState.classList.add('hidden'); - resultsState.classList.remove('show'); - } - if (detailState) { - detailState.classList.add('hidden'); - detailState.classList.remove('show'); - } - - artistsPageState.currentView = 'search'; - updateArtistsSearchStatus('default'); - - // Show artist downloads section if there are active downloads - showArtistDownloadsSection(); -} - -function showArtistsResultsState() { - console.log('🔄 Showing results state'); - - // Cancel any ongoing completion check when navigating back - if (artistCompletionController) { - console.log('⏹️ Canceling completion check (navigating back to results)'); - artistCompletionController.abort(); - artistCompletionController = null; - } - - // Cancel any ongoing similar artists stream when navigating back - if (similarArtistsController) { - console.log('⏹️ Canceling similar artists stream (navigating back to results)'); - similarArtistsController.abort(); - similarArtistsController = null; - } - - // Clear artist-specific data when navigating back to results - // This ensures that selecting the same artist again will trigger a fresh scan - if (artistsPageState.selectedArtist) { - const artistId = artistsPageState.selectedArtist.id; - console.log(`🗑️ Clearing cached data for artist: ${artistsPageState.selectedArtist.name}`); - - // Clear artist-specific cache data - delete artistsPageState.cache.completionData[artistId]; - delete artistsPageState.cache.discography[artistId]; - - // Clear artist state - artistsPageState.selectedArtist = null; - artistsPageState.artistDiscography = { albums: [], singles: [] }; - } - - const searchState = document.getElementById('artists-search-state'); - const resultsState = document.getElementById('artists-results-state'); - const detailState = document.getElementById('artist-detail-state'); - - if (searchState) { - searchState.classList.add('fade-out'); - setTimeout(() => searchState.classList.add('hidden'), 200); - } - if (resultsState) { - resultsState.classList.remove('hidden'); - setTimeout(() => resultsState.classList.add('show'), 50); - } - if (detailState) { - detailState.classList.add('hidden'); - detailState.classList.remove('show'); - } - - artistsPageState.currentView = 'results'; -} - -function showArtistDetailState() { - console.log('🔄 Showing detail state'); - - const searchState = document.getElementById('artists-search-state'); - const resultsState = document.getElementById('artists-results-state'); - const detailState = document.getElementById('artist-detail-state'); - - if (searchState) { - searchState.classList.add('hidden', 'fade-out'); - } - if (resultsState) { - resultsState.classList.add('hidden'); - resultsState.classList.remove('show'); - } - if (detailState) { - detailState.classList.remove('hidden'); - setTimeout(() => detailState.classList.add('show'), 50); - } - - artistsPageState.currentView = 'detail'; -} - -/** - * Update search status text and styling - */ -function updateArtistsSearchStatus(status, message = null) { - const statusElement = document.getElementById('artists-search-status'); - if (!statusElement) return; - - // Clear all status classes - statusElement.classList.remove('searching', 'error'); - - switch (status) { - case 'default': - statusElement.textContent = 'Start typing to search for artists'; - break; - case 'searching': - statusElement.classList.add('searching'); - statusElement.textContent = 'Searching for artists...'; - break; - case 'error': - statusElement.classList.add('error'); - statusElement.innerHTML = ` -
${message || 'Search failed. Please try again.'}
- - `; - break; - } -} - -/** - * Retry the last search query - */ -function retryLastSearch() { - const searchInput = document.getElementById('artists-search-input'); - const headerSearchInput = document.getElementById('artists-header-search-input'); - - // Get the last search query from either input - const query = searchInput?.value?.trim() || headerSearchInput?.value?.trim() || artistsPageState.searchQuery; - - if (query) { - console.log(`🔄 Retrying search for: "${query}"`); - performArtistsSearch(query); - } -} - -/** - * Update artist detail header with artist info - */ -function updateArtistDetailHeader(artist) { - const _esc = (s) => (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - const info = artist.artist_info || {}; - const imageUrl = artist.image_url || info.image_url || ''; - - // Background blur - const heroBg = document.getElementById('artists-hero-bg'); - if (heroBg) { - heroBg.style.backgroundImage = imageUrl ? `url('${imageUrl}')` : 'none'; - } - - // Artist image - const heroImage = document.getElementById('artists-hero-image'); - if (heroImage) { - if (imageUrl) { - heroImage.style.backgroundImage = `url('${imageUrl}')`; - heroImage.innerHTML = ''; - } else { - heroImage.style.backgroundImage = 'none'; - heroImage.innerHTML = '🎤'; - // Lazy load - fetch(`/api/artist/${artist.id}/image`) - .then(r => r.json()) - .then(d => { - if (d.success && d.image_url) { - heroImage.style.backgroundImage = `url('${d.image_url}')`; - heroImage.innerHTML = ''; - if (heroBg) heroBg.style.backgroundImage = `url('${d.image_url}')`; - artist.image_url = d.image_url; - } - }).catch(() => { }); - } - } - - // Name - const heroName = document.getElementById('artists-hero-name'); - if (heroName) heroName.textContent = artist.name || 'Unknown Artist'; - - // Badges (service links — real logos matching library page) - const badgesEl = document.getElementById('artists-hero-badges'); - if (badgesEl) { - const _hb = (logo, fallback, title, url) => { - const inner = logo - ? `${fallback}` - : `${fallback}`; - if (url) return `${inner}`; - return `
${inner}
`; - }; - const badges = []; - if (info.spotify_artist_id) badges.push(_hb(SPOTIFY_LOGO_URL, 'SP', 'Spotify', `https://open.spotify.com/artist/${info.spotify_artist_id}`)); - if (info.musicbrainz_id || artist.musicbrainz_id) badges.push(_hb(MUSICBRAINZ_LOGO_URL, 'MB', 'MusicBrainz', `https://musicbrainz.org/artist/${info.musicbrainz_id || artist.musicbrainz_id}`)); - if (info.deezer_id) badges.push(_hb(DEEZER_LOGO_URL, 'Dz', 'Deezer', `https://www.deezer.com/artist/${info.deezer_id}`)); - if (info.itunes_artist_id) badges.push(_hb(ITUNES_LOGO_URL, 'IT', 'Apple Music', `https://music.apple.com/artist/${info.itunes_artist_id}`)); - if (info.lastfm_url) badges.push(_hb(LASTFM_LOGO_URL, 'LFM', 'Last.fm', info.lastfm_url)); - if (info.genius_url) badges.push(_hb(GENIUS_LOGO_URL, 'GEN', 'Genius', info.genius_url)); - if (info.tidal_id) badges.push(_hb(TIDAL_LOGO_URL, 'TD', 'Tidal', `https://tidal.com/browse/artist/${info.tidal_id}`)); - if (info.qobuz_id) badges.push(_hb(QOBUZ_LOGO_URL, 'Qz', 'Qobuz', `https://www.qobuz.com/artist/${info.qobuz_id}`)); - if (info.discogs_id) badges.push(_hb(DISCOGS_LOGO_URL, 'DC', 'Discogs', `https://www.discogs.com/artist/${info.discogs_id}`)); - badgesEl.innerHTML = badges.join(''); - } - - // Genres (pill tags — merge with Last.fm tags, deduplicated) - const genresEl = document.getElementById('artists-hero-genres'); - if (genresEl) { - let genres = info.genres || artist.genres || []; - // Merge Last.fm tags - const lfmTags = info.lastfm_tags || []; - if (Array.isArray(lfmTags) && lfmTags.length > 0) { - const existing = new Set(genres.map(g => g.toLowerCase())); - const newTags = lfmTags.filter(t => !existing.has(t.toLowerCase())); - genres = [...genres, ...newTags]; - } - if (genres.length > 0) { - genresEl.innerHTML = genres.slice(0, 8).map(g => - `${_esc(g)}` - ).join(''); - } else { - genresEl.innerHTML = ''; - } - } - - // Bio (Last.fm bio or summary fallback — matching library page pattern) - const bioEl = document.getElementById('artists-hero-bio'); - if (bioEl) { - const bio = info.lastfm_bio || info.bio || ''; - if (bio) { - // Strip HTML tags and "Read more on Last.fm" links - let cleanBio = bio.replace(/]*>.*?<\/a>/gi, '').replace(/<[^>]+>/g, '').trim(); - if (cleanBio) { - bioEl.innerHTML = `${_esc(cleanBio)} - Read more`; - bioEl.style.display = ''; - } else { - bioEl.style.display = 'none'; - } - } else { - bioEl.style.display = 'none'; - } - } - - // Stats (Last.fm listeners + playcount, with followers fallback) - const statsEl = document.getElementById('artists-hero-stats'); - if (statsEl) { - const _fmtNum = (n) => { - if (!n || n <= 0) return '0'; - if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; - if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; - return n.toLocaleString(); - }; - let stats = ''; - if (info.lastfm_listeners) { - stats += `${_fmtNum(info.lastfm_listeners)} listeners`; - } - if (info.lastfm_playcount) { - stats += `${_fmtNum(info.lastfm_playcount)} plays`; - } - if (!stats && info.followers) { - stats += `${_fmtNum(info.followers)} followers`; - } - statsEl.innerHTML = stats; - } - - // Also update old hidden elements for any JS that references them - const oldImage = document.getElementById('search-artist-detail-image'); - if (oldImage && imageUrl) oldImage.style.backgroundImage = `url('${imageUrl}')`; - const oldName = document.getElementById('search-artist-detail-name'); - if (oldName) oldName.textContent = artist.name; - - // Initialize watchlist button - initializeArtistDetailWatchlistButton(artist); -} - -/** - * Initialize watchlist button for artist detail page - */ -async function initializeArtistDetailWatchlistButton(artist) { - const button = document.getElementById('artist-detail-watchlist-btn'); - if (!button) return; - - console.log(`🔧 Initializing watchlist button for artist: ${artist.name} (${artist.id})`); - - // Store artist info on the button for settings gear access - button.dataset.artistId = artist.id; - button.dataset.artistName = artist.name; - - // Reset button state completely - button.disabled = false; - button.classList.remove('watching'); - button.style.background = ''; - button.style.cursor = ''; - - // Remove any existing click handlers to prevent duplicates - button.onclick = null; - - // Set up new click handler - button.onclick = (event) => toggleArtistDetailWatchlist(event, artist.id, artist.name); - - // Check and update current status - await updateArtistDetailWatchlistButton(artist.id, artist.name); -} - -/** - * Toggle watchlist status for artist detail page - */ -async function toggleArtistDetailWatchlist(event, artistId, artistName) { - event.preventDefault(); - - const button = document.getElementById('artist-detail-watchlist-btn'); - const icon = button.querySelector('.watchlist-icon'); - const text = button.querySelector('.watchlist-text'); - - // Show loading state - const originalText = text.textContent; - text.textContent = 'Loading...'; - button.disabled = true; - - try { - // Check current status - const checkResponse = await fetch('/api/watchlist/check', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_id: artistId }) - }); - - const checkData = await checkResponse.json(); - if (!checkData.success) { - throw new Error(checkData.error || 'Failed to check watchlist status'); - } - - const isWatching = checkData.is_watching; - - // Toggle watchlist status - const endpoint = isWatching ? '/api/watchlist/remove' : '/api/watchlist/add'; - const payload = isWatching ? - { artist_id: artistId } : - { artist_id: artistId, artist_name: artistName }; - - const response = await fetch(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - - const data = await response.json(); - if (!data.success) { - throw new Error(data.error || 'Failed to update watchlist'); - } - - // Update button appearance - if (isWatching) { - // Was watching, now removed - icon.textContent = '👁️'; - text.textContent = 'Add to Watchlist'; - button.classList.remove('watching'); - console.log(`❌ Removed ${artistName} from watchlist`); - } else { - // Was not watching, now added - icon.textContent = '👁️'; - text.textContent = 'Remove from Watchlist'; - button.classList.add('watching'); - console.log(`✅ Added ${artistName} to watchlist`); - } - - // Show/hide watchlist settings gear - const settingsBtn = document.getElementById('artist-detail-watchlist-settings-btn'); - if (settingsBtn) { - if (!isWatching) { - // Just added to watchlist — show gear - settingsBtn.classList.remove('hidden'); - settingsBtn.onclick = () => openWatchlistArtistConfigModal(artistId, artistName); - } else { - // Just removed from watchlist — hide gear - settingsBtn.classList.add('hidden'); - settingsBtn.onclick = null; - } - } - - // Update dashboard watchlist count - updateWatchlistButtonCount(); - - // Update any visible artist cards - updateArtistCardWatchlistStatus(); - - } catch (error) { - console.error('Error toggling watchlist:', error); - text.textContent = originalText; - - // Show error feedback - const originalBackground = button.style.background; - button.style.background = 'rgba(255, 59, 48, 0.3)'; - setTimeout(() => { - button.style.background = originalBackground; - }, 2000); - } finally { - button.disabled = false; - } -} - -/** - * Update artist detail watchlist button status - */ -async function updateArtistDetailWatchlistButton(artistId, artistName) { - const button = document.getElementById('artist-detail-watchlist-btn'); - if (!button) { - console.warn('⚠️ Artist detail watchlist button not found'); - return; - } - - // Use passed name or fall back to stored data attribute - const name = artistName || button.dataset.artistName || ''; - - try { - console.log(`🔍 Checking watchlist status for artist: ${artistId}`); - - const response = await fetch('/api/watchlist/check', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_id: artistId }) - }); - - const data = await response.json(); - if (data.success) { - const icon = button.querySelector('.watchlist-icon'); - const text = button.querySelector('.watchlist-text'); - - console.log(`📊 Watchlist status for ${artistId}: ${data.is_watching ? 'WATCHING' : 'NOT WATCHING'}`); - - // Ensure button is enabled - button.disabled = false; - - // Show/hide watchlist settings gear - const settingsBtn = document.getElementById('artist-detail-watchlist-settings-btn'); - if (settingsBtn) { - if (data.is_watching) { - settingsBtn.classList.remove('hidden'); - settingsBtn.onclick = () => openWatchlistArtistConfigModal(artistId, name); - } else { - settingsBtn.classList.add('hidden'); - settingsBtn.onclick = null; - } - } - - if (data.is_watching) { - icon.textContent = '👁️'; - text.textContent = 'Remove from Watchlist'; - button.classList.add('watching'); - } else { - icon.textContent = '👁️'; - text.textContent = 'Add to Watchlist'; - button.classList.remove('watching'); - } - } else { - console.error('❌ Failed to check watchlist status:', data.error); - } - } catch (error) { - console.error('❌ Error checking watchlist status:', error); - // Ensure button doesn't get stuck in bad state - button.disabled = false; - } -} - -/** - * Show loading state for discography - */ -function showDiscographyLoading() { - const albumsContainer = document.getElementById('album-cards-container'); - const singlesContainer = document.getElementById('singles-cards-container'); - - const loadingHtml = ` -
-
-
-
Loading...
-
-
-
-
-
-
- `.repeat(4); - - if (albumsContainer) albumsContainer.innerHTML = loadingHtml; - if (singlesContainer) singlesContainer.innerHTML = loadingHtml; -} - -/** - * Show error state for discography - */ -function showDiscographyError(message = 'Failed to load discography') { - const albumsContainer = document.getElementById('album-cards-container'); - const singlesContainer = document.getElementById('singles-cards-container'); - - const errorHtml = ` -
-
⚠️
-
Failed to load discography
-
${escapeHtml(message)}
-
- `; - - if (albumsContainer) albumsContainer.innerHTML = errorHtml; - if (singlesContainer) singlesContainer.innerHTML = errorHtml; -} - -/** - * Show loading cards while searching - */ -function showSearchLoadingCards() { - const container = document.getElementById('artists-cards-container'); - if (!container) return; - - const loadingCardHtml = ` -
-
-
-
-
Loading...
-
Fetching data...
-
- - Loading... -
-
-
- `; - - // Show 6 loading cards - container.innerHTML = loadingCardHtml.repeat(6); -} - -// =============================== -// ARTIST ALBUM DOWNLOAD MISSING TRACKS INTEGRATION -// =============================== - -/** - * Get the completion status of an album from cached data or DOM - * @param {string} albumId - The album ID - * @param {string} albumType - The album type ('albums' or 'singles') - * @returns {Object|null} - Completion status object or null - */ -function getAlbumCompletionStatus(albumId, albumType) { - try { - // First, check cached completion data - const artistId = artistsPageState.selectedArtist?.id; - if (artistId && artistsPageState.cache.completionData[artistId]) { - const cachedData = artistsPageState.cache.completionData[artistId]; - const dataArray = albumType === 'albums' ? cachedData.albums : cachedData.singles; - - if (dataArray) { - const completionData = dataArray.find(item => item.album_id === albumId || item.id === albumId); - if (completionData) { - console.log(`📊 Found cached completion data for album ${albumId}:`, completionData); - return completionData; - } - } - } - - // Fallback: Check DOM completion overlay - const containerId = albumType === 'albums' ? 'album-cards-container' : 'singles-cards-container'; - const container = document.getElementById(containerId); - - if (container) { - const albumCard = container.querySelector(`[data-album-id="${albumId}"]`); - if (albumCard) { - const overlay = albumCard.querySelector('.completion-overlay'); - if (overlay) { - // Extract status from overlay classes - const classList = Array.from(overlay.classList); - const statusClasses = ['completed', 'nearly_complete', 'partial', 'missing', 'downloading', 'downloaded', 'error']; - const status = statusClasses.find(cls => classList.includes(cls)); - - if (status) { - console.log(`📊 Found DOM completion status for album ${albumId}: ${status}`); - return { status, completion_percentage: status === 'completed' ? 100 : 0 }; - } - } - } - } - - console.warn(`⚠️ No completion status found for album ${albumId}`); - return null; - - } catch (error) { - console.error(`❌ Error getting album completion status for ${albumId}:`, error); - return null; - } -} - -/** - * Handle album/single/EP click to open download missing tracks modal - */ -async function handleArtistAlbumClick(album, albumType) { - console.log(`🎵 Album clicked: ${album.name} (${album.album_type}) from artist: ${artistsPageState.selectedArtist?.name}`); - - if (!artistsPageState.selectedArtist) { - console.error('❌ No selected artist found'); - showToast('Error: No artist selected', 'error'); - return; - } - - showLoadingOverlay('Loading album...'); - - try { - // Check completion status of the album - const completionStatus = getAlbumCompletionStatus(album.id, albumType); - console.log(`📊 Album completion status: ${completionStatus?.status || 'unknown'} (${completionStatus?.completion_percentage || 0}%)`); - - // For Artists page, always use Download Missing Tracks modal to analyze and download - console.log(`🔄 Opening download missing tracks modal for album analysis`); - - // Create virtual playlist ID - const virtualPlaylistId = `artist_album_${artistsPageState.selectedArtist.id}_${album.id}`; - - // Check if modal already exists and show it - if (activeDownloadProcesses[virtualPlaylistId]) { - console.log(`📱 Reopening existing modal for ${album.name}`); - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process.modalElement) { - if (process.status === 'complete') { - showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); - } - process.modalElement.style.display = 'flex'; - hideLoadingOverlay(); - return; - } - } - - // Create virtual playlist and open modal - // Note: Don't hide loading overlay here - let the flow continue through to the modal - await createArtistAlbumVirtualPlaylist(album, albumType); - - } catch (error) { - hideLoadingOverlay(); - console.error('❌ Error handling album click:', error); - showToast(`Error opening download modal: ${error.message}`, 'error'); - } -} - -/** - * Create virtual playlist for artist album and open download missing tracks modal - */ -async function createArtistAlbumVirtualPlaylist(album, albumType) { - const artist = artistsPageState.selectedArtist; - const virtualPlaylistId = `artist_album_${artist.id}_${album.id}`; - - console.log(`🎵 Creating virtual playlist for: ${artist.name} - ${album.name}`); - - try { - // Loading overlay already shown by handleArtistAlbumClick - - // Fetch album tracks from backend (pass name/artist for Hydrabase support) - const _aat1 = new URLSearchParams({ name: album.name || '', artist: artist.name || '' }); - const albumSource = artistsPageState.sourceOverride || album.source || artist.source || artistsPageState.artistDiscography?.source || null; - if (albumSource) { - _aat1.set('source', albumSource); - } - if (artistsPageState.pluginOverride) { - _aat1.set('plugin', artistsPageState.pluginOverride); - } - const response = await fetch(`/api/album/${album.id}/tracks?${_aat1}`); - - if (!response.ok) { - if (response.status === 401) { - throw new Error('Spotify not authenticated. Please check your API settings.'); - } - const errData = await response.json().catch(() => ({})); - throw new Error(errData.error || `Failed to load album tracks: ${response.status}`); - } - - const data = await response.json(); - - if (!data.success || !data.tracks || data.tracks.length === 0) { - throw new Error('No tracks found for this album'); - } - - console.log(`✅ Loaded ${data.tracks.length} tracks for ${data.album.name}`); - - // Use album data from API response (has complete data including images array) - const fullAlbumData = data.album; - - // Format playlist name with artist and album info - const playlistName = `[${artist.name}] ${fullAlbumData.name}`; - - // Open download missing tracks modal with formatted tracks - // Pass false for showLoadingOverlay since we already have one from handleArtistAlbumClick - // Use fullAlbumData from API response instead of album parameter - await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, data.tracks, fullAlbumData, artist, false); - - // Track this download for artist bubble management - registerArtistDownload(artist, album, virtualPlaylistId, albumType); - - } catch (error) { - console.error('❌ Error creating virtual playlist:', error); - showToast(`Failed to load album: ${error.message}`, 'error'); - throw error; - } -} - -/** - * Open download missing tracks modal specifically for artist albums - * Similar to openDownloadMissingModalForYouTube but for artist albums - */ diff --git a/webui/static/helper.js b/webui/static/helper.js index bafe250c..dcbb56af 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3546,21 +3546,18 @@ function closeHelperSearch() { // WHAT'S NEW (Phase 6) // ═══════════════════════════════════════════════════════════════════════════ -// Entries tagged with `unreleased: true` are accumulating under a version label -// but won't display until the build version catches up. The Search/Artists -// unification project stays folded here at 2.40 until the whole thing ships. const WHATS_NEW = { '2.40': [ - // --- Search & Artists unification (in progress, not yet released) --- - { date: 'Unreleased — Search & Artists unification', unreleased: true }, - { title: 'Search Source Picker', desc: 'The Search page\'s Enhanced/Basic toggle is replaced by a single "Search from" dropdown at the top — pick All sources (Auto), Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz, or Soulseek (raw files). Auto keeps today\'s multi-source fan-out; picking a specific source hits only that provider so there are no more surprise Spotify rate-limit hits from flows that didn\'t need Spotify. "Soulseek" routes to the raw-file search (what "Basic" used to do), so one picker now covers both old modes. Loading text reflects the selected source', page: 'search', unreleased: true }, - { title: 'Explicit Source Selection on /api/enhanced-search', desc: 'The enhanced-search endpoint now accepts an optional `source` body param (spotify, itunes, deezer, discogs, hydrabase, musicbrainz, auto). When a specific source is chosen, only that provider is queried and db_artists (local library matches) still come back. Cache keys isolate per-source so single-source and multi-source results don\'t collide. Omitted or `auto` preserves the old multi-source fan-out behavior unchanged — nothing breaks for existing callers', page: 'search', unreleased: true }, - { title: 'Shared Enhanced-Search Fetch Helper', desc: 'Internal refactor — the Search page dropdown and the global search widget now route through one shared enhancedSearchFetch helper in search.js instead of duplicating the POST boilerplate. Zero UX change, but it means any future source-picker tweak only needs wiring in one place', page: 'search', unreleased: true }, - { title: 'Search Page Renamed to /search', desc: 'The Search page\'s internal id is now "search" instead of the confusing "downloads" (which clashed with the actual Downloads page). Sidebar label unchanged. URL is now /search; /downloads still resolves so old bookmarks keep working. Profile ACL "Page Access" now saves as "search"; existing profiles with "downloads" in allowed_pages still resolve through a legacy-compat check', page: 'search', unreleased: true }, - { title: 'Embedded Download Manager Removed from Search Page', desc: 'The Search page used to carry a second copy of the Download Manager (active + finished queues, clear/cancel-all buttons) that was hidden by default and duplicated the dedicated Downloads page. That duplicate is gone — toggle button, side-panel HTML, and its 1-second polling loop all removed. About 330 lines of dead code cleaned up. The dedicated Downloads sidebar page is now the single downloads UI', page: 'search', unreleased: true }, - { title: 'Artists Sidebar Entry Retired — Use Search Instead', desc: 'Cin flagged that "Artists" in the sidebar read like a library section but was actually a dedicated artist-search page, duplicating what the unified Search already does. The sidebar entry is gone. New flow: Sidebar → Search → type artist name → click their result. "Browse Artists" on the empty Watchlist page and "View artist from Wishlist" now open Search pre-filled with the artist\'s name. Removed "Artists" from profile Home Page + Page Access options. Deep link to /artists still resolves so old bookmarks keep working — the page just isn\'t promoted anywhere', page: 'search', unreleased: true }, - { title: 'Artist Detail Back Button Fallback', desc: 'The back button on the Artists-page inline detail view used to dump users on an empty "Search for an artist..." screen when they arrived from outside the Artists page — a dead end now that Artists isn\'t in the sidebar. If you searched inside the Artists page, back still returns to your results list. Otherwise (arriving from Search, Discover, Watchlist, etc.), back uses the browser history to land you on whichever page you came from. Falls back to the Search page only when there\'s no browser history to go back to (the natural place to find another artist)', page: 'search', unreleased: true }, - { title: 'Interactive Help Updated for Unified Search', desc: 'The click-for-help annotations and the "Your First Download" guided tour were rewritten for the new Search page. Stale annotations pointing at removed elements (Basic/Enhanced toggle button, side-panel queues, download-manager controls) are deleted. The first-download tour now runs on /search and opens with the source picker. PAGE_TOUR_MAP accepts both "search" and the legacy "downloads" id so old bookmarks still match a tour. Retired the standalone "Browse Artists" tour', page: 'help', unreleased: true }, + // --- April 24, 2026 — Search & Artists unification --- + { date: 'April 24, 2026 — Search & Artists unification' }, + { title: 'Unified Artist Detail Page', desc: 'Clicking any artist anywhere — Search results, Discover, Watchlist, Stats, Library, API monitor, Wishlist, Media player — now lands on the same standalone /artist-detail page. Before, library artists and metadata-source artists went to two different pages (one inline, one standalone). The backend /api/artist-detail endpoint now accepts an optional ?source= param and falls back to a metadata-source lookup when the local DB lookup misses, so Deezer/Spotify/iTunes/Discogs/Hydrabase/MusicBrainz artists work on the same page as library artists. Stable deep-link URL; source context carried through cleanly', page: 'library' }, + { title: 'Artists Page Retired — artists.js Deleted', desc: 'The Artists sidebar entry was already retired earlier; this release finishes the job by deleting the inline page itself (4600-line file, 140 HTML lines). Shared helpers the rest of the app depended on (escapeHtml used 229 times, service-status polling, image-colour extraction, download-bubble infrastructure, discography completion checking, enrichment card rendering) were extracted into a new webui/static/shared-helpers.js so other modules keep working. Legacy /artists URL now aliases to /search for bookmark compatibility', page: 'library' }, + { title: 'Search Source Picker', desc: 'The Search page\'s Enhanced/Basic toggle is replaced by a single "Search from" dropdown at the top — pick All sources (Auto), Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz, or Soulseek (raw files). Auto keeps today\'s multi-source fan-out; picking a specific source hits only that provider so there are no more surprise Spotify rate-limit hits from flows that didn\'t need Spotify. "Soulseek" routes to the raw-file search (what "Basic" used to do), so one picker now covers both old modes. Loading text reflects the selected source', page: 'search' }, + { title: 'Explicit Source Selection on /api/enhanced-search', desc: 'The enhanced-search endpoint now accepts an optional `source` body param (spotify, itunes, deezer, discogs, hydrabase, musicbrainz, auto). When a specific source is chosen, only that provider is queried and db_artists (local library matches) still come back. Cache keys isolate per-source so single-source and multi-source results don\'t collide. Omitted or `auto` preserves the old multi-source fan-out behavior unchanged — nothing breaks for existing callers', page: 'search' }, + { title: 'Shared Enhanced-Search Fetch Helper', desc: 'Internal refactor — the Search page dropdown and the global search widget now route through one shared enhancedSearchFetch helper in search.js instead of duplicating the POST boilerplate. Zero UX change, but it means any future source-picker tweak only needs wiring in one place', page: 'search' }, + { title: 'Search Page Renamed to /search', desc: 'The Search page\'s internal id is now "search" instead of the confusing "downloads" (which clashed with the actual Downloads page). Sidebar label unchanged. URL is now /search; /downloads still resolves so old bookmarks keep working. Profile ACL "Page Access" now saves as "search"; existing profiles with "downloads" in allowed_pages still resolve through a legacy-compat check', page: 'search' }, + { title: 'Embedded Download Manager Removed from Search Page', desc: 'The Search page used to carry a second copy of the Download Manager (active + finished queues, clear/cancel-all buttons) that was hidden by default and duplicated the dedicated Downloads page. That duplicate is gone — toggle button, side-panel HTML, and its 1-second polling loop all removed. About 330 lines of dead code cleaned up. The dedicated Downloads sidebar page is now the single downloads UI', page: 'search' }, + { title: 'Interactive Help Updated for Unified Search', desc: 'The click-for-help annotations and the "Your First Download" guided tour were rewritten for the new Search page. Stale annotations pointing at removed elements (Basic/Enhanced toggle button, side-panel queues, download-manager controls) are deleted. The first-download tour now runs on /search and opens with the source picker. PAGE_TOUR_MAP accepts both "search" and the legacy "downloads" id so old bookmarks still match a tour. Retired the standalone "Browse Artists" tour', page: 'help' }, ], '2.39': [ // --- April 22, 2026 --- @@ -3765,13 +3762,7 @@ function _getCurrentVersion() { } function _getLatestWhatsNewVersion() { - // Only surface entries whose version number is <= the current build. Entries - // sitting at higher versions are unreleased work-in-progress and shouldn't - // flag as "new" in the helper badge until the build catches up. - const buildVer = parseFloat(_getCurrentVersion()) || 2.39; - const versions = Object.keys(WHATS_NEW) - .filter(v => (parseFloat(v) || 0) <= buildVer) - .sort((a, b) => parseFloat(b) - parseFloat(a)); + const versions = Object.keys(WHATS_NEW).sort((a, b) => parseFloat(b) - parseFloat(a)); return versions[0] || '2.39'; } @@ -3860,11 +3851,8 @@ function _openFullChangelog() { } function _showOlderNotes() { - // Cycle to next older version in the what's new panel (skip unreleased entries) - const buildVer = parseFloat(_getCurrentVersion()) || 2.39; - const versions = Object.keys(WHATS_NEW) - .filter(v => (parseFloat(v) || 0) <= buildVer) - .sort((a, b) => parseFloat(b) - parseFloat(a)); + // Cycle to next older version in the what's new panel + const versions = Object.keys(WHATS_NEW).sort((a, b) => parseFloat(b) - parseFloat(a)); const panel = _helperPopover; if (!panel) return; const currentTitle = panel.querySelector('.helper-popover-title'); diff --git a/webui/static/init.js b/webui/static/init.js index e8e38463..2c28c897 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -2010,7 +2010,7 @@ function _getPageFromPath() { const basePage = path.split('/')[0]; if (!_DEEPLINK_VALID_PAGES.has(basePage)) return 'dashboard'; // Context-dependent pages fall back to a sensible parent - if (basePage === 'artist-detail') return 'artists'; + if (basePage === 'artist-detail') return 'library'; if (basePage === 'playlist-explorer') return 'library'; return basePage; } @@ -2115,8 +2115,10 @@ function initializeWatchlist() { } function navigateToPage(pageId, options = {}) { - // Backwards-compat alias — the Search page used to live under id 'downloads'. - if (pageId === 'downloads') pageId = 'search'; + // Backwards-compat aliases — the Search page used to live under id + // 'downloads', and the Artists page was retired and folded into Search. + // Legacy bookmarks to /downloads or /artists all land on /search. + if (pageId === 'downloads' || pageId === 'artists') pageId = 'search'; if (pageId === currentPage) return; @@ -2218,15 +2220,7 @@ async function loadPageData(pageId) { initializeSearchModeToggle(); initializeFilters(); break; - case 'artists': - // Only fully initialize if not already initialized - if (!artistsPageState.isInitialized) { - initializeArtistsPage(); - } else { - // Just restore state if already initialized - restoreArtistsPageState(); - } - break; + // 'artists' page retired — aliased to 'search' at the top of navigateToPage case 'active-downloads': loadActiveDownloadsPage(); break; diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index d45737f5..66320cea 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -2759,3 +2759,79 @@ function renderEnrichmentCards(enrichment) { } // =============================== + + +// ---------------------------------------------------------------------------- +// Additional helpers from the retired Artists page still needed by other files +// ---------------------------------------------------------------------------- + +async function lazyLoadArtistImages(container) { + if (!container) { + console.error('❌ lazyLoadArtistImages: container is null'); + return; + } + + const cardsNeedingImages = container.querySelectorAll('[data-needs-image="true"]'); + + if (cardsNeedingImages.length === 0) { + console.log('✅ All artist cards have images'); + return; + } + + console.log(`🖼️ Lazy loading images for ${cardsNeedingImages.length} artist cards`); + + const batchSize = 5; + const cards = Array.from(cardsNeedingImages); + + for (let i = 0; i < cards.length; i += batchSize) { + const batch = cards.slice(i, i + batchSize); + + await Promise.all(batch.map(async (card) => { + const artistId = card.dataset.artistId; + if (!artistId) { + console.warn('⚠️ Card missing artistId:', card); + return; + } + + try { + const response = await fetch(`/api/artist/${artistId}/image`); + const data = await response.json(); + + if (data.success && data.image_url) { + if (card.classList.contains('suggestion-card')) { + card.style.backgroundImage = `url(${data.image_url})`; + card.style.backgroundSize = 'cover'; + card.style.backgroundPosition = 'center'; + } else if (card.classList.contains('artist-card')) { + const bgElement = card.querySelector('.artist-card-background'); + if (bgElement) { + bgElement.style.cssText = `background-image: url('${data.image_url}'); background-size: cover; background-position: center;`; + } + } + + card.dataset.needsImage = 'false'; + } + } catch (error) { + console.error(`❌ Failed to load image for artist ${artistId}:`, error); + } + })); + } +} + +// Legacy global alias — wishlist-tools.js falls back to window.lazyLoadArtistImages +window.lazyLoadArtistImages = lazyLoadArtistImages; + + +// ---------------------------------------------------------------------------- +// Completion error overlay — called from checkDiscographyCompletion above +// ---------------------------------------------------------------------------- + +function showCompletionError() { + const allOverlays = document.querySelectorAll('.completion-overlay.checking'); + allOverlays.forEach(overlay => { + overlay.classList.remove('checking'); + overlay.classList.add('error'); + overlay.innerHTML = 'Error'; + overlay.title = 'Failed to check completion status'; + }); +} From 1a8071d6ec19d9c4c21138c937e2de76b1d467d5 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:52:53 -0700 Subject: [PATCH 16/41] Revert "Retire artists.js and inline Artists page, ship unification at 2.40" This reverts commit 71ff5cb5c3e7d59d33573757b49d7ec679f56fd3. --- tests/test_script_split_integrity.py | 8 +- web_server.py | 16 +- webui/index.html | 141 ++ webui/static/artists.js | 1903 ++++++++++++++++++++++++++ webui/static/helper.js | 38 +- webui/static/init.js | 18 +- webui/static/shared-helpers.js | 76 - 7 files changed, 2086 insertions(+), 114 deletions(-) create mode 100644 webui/static/artists.js diff --git a/tests/test_script_split_integrity.py b/tests/test_script_split_integrity.py index 01767d68..d5982c9e 100644 --- a/tests/test_script_split_integrity.py +++ b/tests/test_script_split_integrity.py @@ -27,9 +27,8 @@ _ROOT = Path(__file__).resolve().parent.parent _STATIC = _ROOT / "webui" / "static" _INDEX = _ROOT / "webui" / "index.html" -# The modules that replaced script.js, minus artists.js (which was retired -# after the Search/Artists unification) plus shared-helpers.js (extracted -# from artists.js). Order matters for first/last checks. +# The 17 modules that replaced script.js + shared-helpers.js extracted from +# artists.js (order matters for first/last checks) SPLIT_MODULES = [ "core.js", "shared-helpers.js", @@ -40,6 +39,7 @@ SPLIT_MODULES = [ "downloads.js", "wishlist-tools.js", "sync-services.js", + "artists.js", "api-monitor.js", "library.js", "beatport-ui.js", @@ -57,7 +57,7 @@ NON_SPLIT_JS = {"setup-wizard.js", "docs.js", "helper.js", "particles.js", "work # In a plain + diff --git a/webui/static/artists.js b/webui/static/artists.js new file mode 100644 index 00000000..7d70c875 --- /dev/null +++ b/webui/static/artists.js @@ -0,0 +1,1903 @@ +// ARTISTS PAGE FUNCTIONALITY - ELEGANT SEARCH & DISCOVERY +// ============================================================================ + +/** + * Initialize the artists page when navigated to (only runs once) + */ +function initializeArtistsPage() { + console.log('🎵 Initializing Artists Page (first time)'); + + // Get DOM elements + const searchInput = document.getElementById('artists-search-input'); + const headerSearchInput = document.getElementById('artists-header-search-input'); + const searchStatus = document.getElementById('artists-search-status'); + const backButton = document.getElementById('artists-back-button'); + const detailBackButton = document.getElementById('artist-detail-back-button'); + + // Set up event listeners (only need to do this once) + if (searchInput) { + searchInput.addEventListener('input', handleArtistsSearchInput); + searchInput.addEventListener('keypress', handleArtistsSearchKeypress); + } + + if (headerSearchInput) { + headerSearchInput.addEventListener('input', handleArtistsHeaderSearchInput); + headerSearchInput.addEventListener('keypress', handleArtistsSearchKeypress); + } + + if (backButton) { + backButton.addEventListener('click', () => showArtistsSearchState()); + } + + if (detailBackButton) { + detailBackButton.addEventListener('click', () => { + // If the user searched within the Artists page, back returns to the + // results list so they can pick a different artist. + if (artistsPageState.searchResults && artistsPageState.searchResults.length > 0) { + showArtistsResultsState(); + return; + } + // Otherwise the user reached this detail view from elsewhere (Search, + // Discover, watchlist, etc.). The Artists page is no longer a sidebar + // entry, so there's nothing useful to fall back to here — let the + // browser take them back to wherever they came from, or drop them on + // Search (the go-forward way to find another artist). + if (window.history.length > 1) { + window.history.back(); + } else { + navigateToPage('search'); + } + }); + } + + // Initialize tabs (only need to do this once) + initializeArtistTabs(); + + // Mark as initialized + artistsPageState.isInitialized = true; + + // Restore previous state instead of always resetting to search + restoreArtistsPageState(); + console.log('✅ Artists Page initialized successfully (ready for navigation)'); +} + +/** + * Restore the artists page to its previous state + */ +function restoreArtistsPageState() { + console.log(`🔄 Restoring artists page state: ${artistsPageState.currentView}`); + + switch (artistsPageState.currentView) { + case 'results': + // Restore search results state + if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) { + console.log(`📦 Restoring search results for: "${artistsPageState.searchQuery}"`); + + // Restore search input values + const searchInput = document.getElementById('artists-search-input'); + const headerSearchInput = document.getElementById('artists-header-search-input'); + + if (searchInput) searchInput.value = artistsPageState.searchQuery; + if (headerSearchInput) headerSearchInput.value = artistsPageState.searchQuery; + + // Display the cached results + displayArtistsResults(artistsPageState.searchQuery, artistsPageState.searchResults); + } else { + // No valid results state, fall back to search + showArtistsSearchState(); + } + break; + + case 'detail': + // Restore artist detail state + if (artistsPageState.selectedArtist && artistsPageState.artistDiscography) { + console.log(`🎤 Restoring artist detail for: ${artistsPageState.selectedArtist.name}`); + + // First restore search results if they exist + if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) { + const searchInput = document.getElementById('artists-search-input'); + const headerSearchInput = document.getElementById('artists-header-search-input'); + + if (searchInput) searchInput.value = artistsPageState.searchQuery; + if (headerSearchInput) headerSearchInput.value = artistsPageState.searchQuery; + } + + // Show artist detail state + showArtistDetailState(); + + // Update artist info in header + updateArtistDetailHeader(artistsPageState.selectedArtist); + + // Display cached discography + if (artistsPageState.artistDiscography.albums || artistsPageState.artistDiscography.singles) { + displayArtistDiscography(artistsPageState.artistDiscography); + // Restore cached completion data instead of re-scanning + restoreCachedCompletionData(artistsPageState.selectedArtist.id); + } + } else { + // No valid detail state, fall back to search or results + if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) { + displayArtistsResults(artistsPageState.searchQuery, artistsPageState.searchResults); + } else { + showArtistsSearchState(); + } + } + break; + + default: + case 'search': + // Show search state (but preserve any existing search query) + if (artistsPageState.searchQuery) { + const searchInput = document.getElementById('artists-search-input'); + if (searchInput) searchInput.value = artistsPageState.searchQuery; + } + showArtistsSearchState(); + break; + } +} + +/** + * Handle search input with debouncing + */ +function handleArtistsSearchInput(event) { + const query = event.target.value.trim(); + updateArtistsSearchStatus('searching'); + + // Clear existing timeout + if (artistsSearchTimeout) { + clearTimeout(artistsSearchTimeout); + } + + // Cancel any active search + if (artistsSearchController) { + artistsSearchController.abort(); + } + + if (query === '') { + updateArtistsSearchStatus('default'); + return; + } + + // Set up new debounced search + artistsSearchTimeout = setTimeout(() => { + performArtistsSearch(query); + }, 1000); // 1 second debounce +} + +/** + * Handle header search input (already in results state) + */ +function handleArtistsHeaderSearchInput(event) { + const query = event.target.value.trim(); + + // Update main search input to match + const mainInput = document.getElementById('artists-search-input'); + if (mainInput) { + mainInput.value = query; + } + + // Trigger search with same debouncing logic + handleArtistsSearchInput(event); +} + +/** + * Handle Enter key press in search inputs + */ +function handleArtistsSearchKeypress(event) { + if (event.key === 'Enter') { + event.preventDefault(); + const query = event.target.value.trim(); + + if (query && query !== artistsPageState.searchQuery) { + // Clear timeout and search immediately + if (artistsSearchTimeout) { + clearTimeout(artistsSearchTimeout); + } + performArtistsSearch(query); + } + } +} + +/** + * Perform artist search with API call + */ +async function performArtistsSearch(query) { + console.log(`🔍 Searching for artists: "${query}"`); + + // Check cache first + if (artistsPageState.cache.searches[query]) { + console.log('📦 Using cached search results'); + displayArtistsResults(query, artistsPageState.cache.searches[query]); + return; + } + + // Update status + updateArtistsSearchStatus('searching'); + + // Show loading cards immediately if we're in results view + if (artistsPageState.currentView === 'results') { + showSearchLoadingCards(); + } + + try { + // Set up abort controller + artistsSearchController = new AbortController(); + + const response = await fetch('/api/match/search', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + query: query, + context: 'artist' + }), + signal: artistsSearchController.signal + }); + + if (!response.ok) { + throw new Error(`Search failed: ${response.status}`); + } + + const data = await response.json(); + console.log(`✅ Found ${data.results?.length || 0} artists`); + + // Transform the results to flatten the nested artist data + const transformedResults = (data.results || []).map(result => { + // Extract artist data from the nested structure + const artist = result.artist || result; + return { + id: artist.id, + name: artist.name, + image_url: artist.image_url, + genres: artist.genres, + popularity: artist.popularity, + confidence: result.confidence || 0 + }; + }); + + console.log('🔧 Transformed results:', transformedResults); + + // Cache the transformed results + artistsPageState.cache.searches[query] = transformedResults; + + // Display results + displayArtistsResults(query, transformedResults); + + } catch (error) { + if (error.name !== 'AbortError') { + console.error('❌ Artist search failed:', error); + + // Provide specific error messages based on the error type + let errorMessage = 'Search failed. Please try again.'; + if (error.message.includes('401') || error.message.includes('authentication')) { + errorMessage = 'Spotify not authenticated. Please check your API settings.'; + } else if (error.message.includes('network') || error.message.includes('fetch')) { + errorMessage = 'Network error. Please check your connection.'; + } else if (error.message.includes('timeout')) { + errorMessage = 'Search timed out. Please try again.'; + } + + updateArtistsSearchStatus('error', errorMessage); + } + } finally { + artistsSearchController = null; + } +} + +/** + * Display artist search results + */ +function displayArtistsResults(query, results) { + console.log(`📊 Displaying ${results.length} artist results`); + + // Update state + artistsPageState.searchQuery = query; + artistsPageState.searchResults = results; + artistsPageState.currentView = 'results'; + + // Update header search input if different + const headerInput = document.getElementById('artists-header-search-input'); + if (headerInput && headerInput.value !== query) { + headerInput.value = query; + } + + // Show results state + showArtistsResultsState(); + + // Populate results + const container = document.getElementById('artists-cards-container'); + if (!container) return; + + if (results.length === 0) { + container.innerHTML = ` +
+
🔍
+
No artists found
+
Try a different search term
+
+ `; + return; + } + + // Create artist cards + container.innerHTML = results.map(result => createArtistCardHTML(result)).join(''); + observeLazyBackgrounds(container); + + // Add event listeners to cards + container.querySelectorAll('.artist-card').forEach((card, index) => { + card.addEventListener('click', () => selectArtistForDetail(results[index])); + + // Extract colors from artist image for dynamic glow + const artist = results[index]; + if (artist.image_url) { + extractImageColors(artist.image_url, (colors) => { + applyDynamicGlow(card, colors); + }); + } + }); + + // Update watchlist status for all cards + updateArtistCardWatchlistStatus(); + + // Lazy load missing artist images + console.log('🖼️ Starting lazy load for artist images on Artists page...'); + if (typeof lazyLoadArtistImages === 'function') { + lazyLoadArtistImages(container); + } else if (typeof window.lazyLoadArtistImages === 'function') { + window.lazyLoadArtistImages(container); + } else { + console.error('❌ lazyLoadArtistImages function not found!'); + } + + // Add mouse wheel horizontal scrolling + container.addEventListener('wheel', (event) => { + if (event.deltaY !== 0) { + event.preventDefault(); + container.scrollLeft += event.deltaY; + } + }); +} + +/** + * Lazy load artist images for cards that don't have images yet. + * Fetches images asynchronously so search results appear immediately. + */ +async function lazyLoadArtistImages(container) { + if (!container) { + console.error('❌ lazyLoadArtistImages: container is null'); + return; + } + + // Find all cards that need images + const cardsNeedingImages = container.querySelectorAll('[data-needs-image="true"]'); + + if (cardsNeedingImages.length === 0) { + console.log('✅ All artist cards have images'); + return; + } + + console.log(`🖼️ Lazy loading images for ${cardsNeedingImages.length} artist cards`); + + // Load images in parallel (but with a small batch to avoid overwhelming the server) + const batchSize = 5; + const cards = Array.from(cardsNeedingImages); + + for (let i = 0; i < cards.length; i += batchSize) { + const batch = cards.slice(i, i + batchSize); + + await Promise.all(batch.map(async (card) => { + const artistId = card.dataset.artistId; + if (!artistId) { + console.warn('⚠️ Card missing artistId:', card); + return; + } + + try { + console.log(`🔄 Fetching image for artist ${artistId}...`); + const response = await fetch(`/api/artist/${artistId}/image`); + const data = await response.json(); + + console.log(`📥 Got response for ${artistId}:`, data); + + if (data.success && data.image_url) { + // Update the card's background image + // Handle both card types (suggestion-card and artist-card) + if (card.classList.contains('suggestion-card')) { + card.style.backgroundImage = `url(${data.image_url})`; + card.style.backgroundSize = 'cover'; + card.style.backgroundPosition = 'center'; + } else if (card.classList.contains('artist-card')) { + const bgElement = card.querySelector('.artist-card-background'); + if (bgElement) { + // Clear the gradient first, then set the image + bgElement.style.cssText = `background-image: url('${data.image_url}'); background-size: cover; background-position: center;`; + } + } + + card.dataset.needsImage = 'false'; + console.log(`✅ Loaded image for artist ${artistId}`); + } + } catch (error) { + console.error(`❌ Failed to load image for artist ${artistId}:`, error); + } + })); + } + + console.log('✅ Finished lazy loading artist images'); +} + +// Make function globally accessible +window.lazyLoadArtistImages = lazyLoadArtistImages; + +/** + * Create HTML for an artist card + */ +function createArtistCardHTML(artist) { + const imageUrl = artist.image_url || ''; + const genres = artist.genres && artist.genres.length > 0 ? + artist.genres.slice(0, 3).join(', ') : 'Various genres'; + const popularity = artist.popularity || 0; + + // Use data-bg-src for lazy background loading via IntersectionObserver + const backgroundAttr = imageUrl ? + `data-bg-src="${imageUrl}"` : + `style="background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);"`; + + // Format popularity as a percentage for better UX + const popularityText = popularity > 0 ? `${popularity}% Popular` : 'Popularity Unknown'; + + // Track if image needs to be lazy loaded + const needsImage = imageUrl ? 'false' : 'true'; + + // Check for MusicBrainz ID + let mbIconHTML = ''; + if (artist.musicbrainz_id) { + mbIconHTML = ` +
+ +
+ `; + } + + return ` +
+ ${mbIconHTML} +
+
+
+
${escapeHtml(artist.name)}
+
${escapeHtml(genres)}
+
+ 🔥 + ${popularityText} +
+
+
+ + +
+
+
+
+ `; +} + +/** + * Select an artist and show their discography + */ +async function selectArtistForDetail(artist, options = {}) { + console.log(`🎤 Selected artist: ${artist.name}`); + + // Cancel any ongoing completion check from previous artist + if (artistCompletionController) { + console.log('⏹️ Canceling previous artist completion check'); + artistCompletionController.abort(); + artistCompletionController = null; + } + + // Cancel any ongoing similar artists stream from previous artist + if (similarArtistsController) { + console.log('⏹️ Canceling previous similar artists stream'); + similarArtistsController.abort(); + similarArtistsController = null; + } + + // Update state + artistsPageState.selectedArtist = artist; + artistsPageState.currentView = 'detail'; + artistsPageState.sourceOverride = options.source || artist.source || null; + artistsPageState.pluginOverride = options.plugin || null; + + // Show detail state + showArtistDetailState(); + + // Update artist info in header + updateArtistDetailHeader(artist); + + // Load discography (pass artist name for cross-source fallback) + await loadArtistDiscography(artist.id, artist.name, artistsPageState.sourceOverride, options.plugin); +} + +/** + * Load artist's discography from Spotify or iTunes + * @param {string} artistId - Artist ID (Spotify or iTunes format) + * @param {string} [artistName] - Optional artist name for fallback searches + */ +async function loadArtistDiscography(artistId, artistName = null, sourceOverride = null, pluginOverride = null) { + console.log(`💿 Loading discography for artist: ${artistId} (name: ${artistName}, source: ${sourceOverride || 'auto'})`); + + // Use source-prefixed cache key to avoid ID collisions between sources + const cacheKey = sourceOverride ? `${sourceOverride}:${artistId}` : artistId; + + // Check cache first + if (artistsPageState.cache.discography[cacheKey]) { + console.log('📦 Using cached discography'); + const cachedDiscography = artistsPageState.cache.discography[cacheKey]; + if (artistsPageState.selectedArtist) { + artistsPageState.selectedArtist = { + ...artistsPageState.selectedArtist, + source: cachedDiscography.source || sourceOverride || artistsPageState.selectedArtist.source || null, + }; + } + artistsPageState.sourceOverride = cachedDiscography.source || sourceOverride || artistsPageState.sourceOverride || null; + displayArtistDiscography(cachedDiscography); + + // Load similar artists in parallel (don't wait) — always uses primary source + loadSimilarArtists(artistsPageState.selectedArtist?.name).catch(err => { + console.error('❌ Error loading similar artists:', err); + }); + + // Still check completion status for cached data + await checkDiscographyCompletion(artistId, cachedDiscography); + return; + } + + try { + // Show loading states + showDiscographyLoading(); + + // Build URL with optional artist name and source override for fallback + let url = `/api/artist/${artistId}/discography`; + const params = new URLSearchParams(); + if (artistName) params.set('artist_name', artistName); + if (sourceOverride) params.set('source', sourceOverride); + if (pluginOverride) params.set('plugin', pluginOverride); + if (params.toString()) url += `?${params.toString()}`; + + // Call the real API endpoint + const response = await fetch(url); + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Spotify not authenticated. Please check your API settings.'); + } + throw new Error(`Failed to load discography: ${response.status}`); + } + + const data = await response.json(); + + if (data.error) { + throw new Error(data.error); + } + + const discography = { + albums: data.albums || [], + singles: data.singles || [], + source: data.source || sourceOverride || null, + }; + + // Keep the resolved metadata source on the selected artist so album clicks + // can pass it through to /api/album//tracks. + if (artistsPageState.selectedArtist) { + artistsPageState.selectedArtist = { + ...artistsPageState.selectedArtist, + source: discography.source, + }; + } + artistsPageState.sourceOverride = discography.source || artistsPageState.sourceOverride || null; + + // Update selected artist with full details from backend (includes MusicBrainz ID) + if (data.artist) { + console.log('✨ Updating artist details with fresh data from backend'); + artistsPageState.selectedArtist = { + ...artistsPageState.selectedArtist, + ...data.artist + }; + } + + // Merge artist_info enrichment from discography response + if (data.artist_info) { + artistsPageState.selectedArtist = { + ...artistsPageState.selectedArtist, + artist_info: data.artist_info, + }; + } + + // Refresh header with all available data + updateArtistDetailHeader(artistsPageState.selectedArtist); + + console.log(`✅ Loaded ${discography.albums.length} albums and ${discography.singles.length} singles`); + + // Cache the results (use source-prefixed key if source override active) + artistsPageState.cache.discography[cacheKey] = discography; + artistsPageState.artistDiscography = discography; + + // Display results + displayArtistDiscography(discography); + + // Load similar artists and check completion in parallel (don't wait) + loadSimilarArtists(artistsPageState.selectedArtist?.name).catch(err => { + console.error('❌ Error loading similar artists:', err); + }); + + // Check completion status for all albums and singles + await checkDiscographyCompletion(artistId, discography); + + } catch (error) { + console.error('❌ Failed to load discography:', error); + showDiscographyError(error.message); + } +} + +/** + * Display artist's discography in tabs + */ +function displayArtistDiscography(discography) { + console.log(`📀 Displaying discography: ${discography.albums?.length || 0} albums, ${discography.singles?.length || 0} singles`); + + // Show Download Discography button(s) if there are any releases + const _totalReleases = (discography.albums?.length || 0) + (discography.eps?.length || 0) + (discography.singles?.length || 0); + const _discogWrap = document.getElementById('discog-download-wrap'); + if (_discogWrap) _discogWrap.style.display = _totalReleases > 0 ? '' : 'none'; + const _discogBtnArtists = document.getElementById('discog-download-btn-artists'); + if (_discogBtnArtists) _discogBtnArtists.style.display = _totalReleases > 0 ? '' : 'none'; + + // Populate albums + const albumsContainer = document.getElementById('album-cards-container'); + if (albumsContainer) { + if (discography.albums?.length > 0) { + albumsContainer.innerHTML = discography.albums.map(album => createAlbumCardHTML(album)).join(''); + observeLazyBackgrounds(albumsContainer); + + // Add dynamic glow effects and click handlers to album cards + albumsContainer.querySelectorAll('.album-card').forEach((card, index) => { + const album = discography.albums[index]; + if (album.image_url) { + extractImageColors(album.image_url, (colors) => { + applyDynamicGlow(card, colors); + }); + } + + // Add click handler for download missing tracks modal + card.addEventListener('click', () => handleArtistAlbumClick(album, 'albums')); + card.style.cursor = 'pointer'; + }); + } else { + albumsContainer.innerHTML = ` +
+
💿
+
No albums found
+
+ `; + } + } + + // Populate singles + const singlesContainer = document.getElementById('singles-cards-container'); + if (singlesContainer) { + if (discography.singles?.length > 0) { + singlesContainer.innerHTML = discography.singles.map(single => createAlbumCardHTML(single)).join(''); + observeLazyBackgrounds(singlesContainer); + + // Add dynamic glow effects and click handlers to singles cards + singlesContainer.querySelectorAll('.album-card').forEach((card, index) => { + const single = discography.singles[index]; + if (single.image_url) { + extractImageColors(single.image_url, (colors) => { + applyDynamicGlow(card, colors); + }); + } + + // Add click handler for download missing tracks modal + card.addEventListener('click', () => handleArtistAlbumClick(single, 'singles')); + card.style.cursor = 'pointer'; + }); + } else { + singlesContainer.innerHTML = ` +
+
🎵
+
No singles or EPs found
+
+ `; + } + } + + // Auto-switch to Singles tab if no albums but has singles + if ((!discography.albums || discography.albums.length === 0) && + discography.singles && discography.singles.length > 0) { + console.log('📀 No albums found, auto-switching to Singles & EPs tab'); + + // Switch to singles tab + const albumsTab = document.getElementById('albums-tab'); + const singlesTab = document.getElementById('singles-tab'); + const albumsContent = document.getElementById('albums-content'); + const singlesContent = document.getElementById('singles-content'); + + if (albumsTab && singlesTab && albumsContent && singlesContent) { + // Remove active from albums + albumsTab.classList.remove('active'); + albumsContent.classList.remove('active'); + + // Add active to singles + singlesTab.classList.add('active'); + singlesContent.classList.add('active'); + } + } +} + +/** + * Load similar artists from MusicMap + */ +async function loadSimilarArtists(artistName) { + if (!artistName) { + console.warn('⚠️ No artist name provided for similar artists'); + return; + } + + console.log(`🔍 Loading similar artists for: ${artistName}`); + + // Get DOM elements + const section = document.getElementById('similar-artists-section'); + const loadingEl = document.getElementById('similar-artists-loading'); + const errorEl = document.getElementById('similar-artists-error'); + const container = document.getElementById('similar-artists-bubbles-container'); + + if (!section || !loadingEl || !errorEl || !container) { + console.warn('⚠️ Similar artists section elements not found'); + return; + } + + // Show loading state + loadingEl.classList.remove('hidden'); + errorEl.classList.add('hidden'); + container.innerHTML = ''; + section.style.display = 'block'; + + try { + // Create new abort controller for this similar artists stream + similarArtistsController = new AbortController(); + + // Use streaming endpoint for real-time bubble creation + const url = `/api/artist/similar/${encodeURIComponent(artistName)}/stream`; + console.log(`📡 Streaming from: ${url}`); + + const response = await fetch(url, { + signal: similarArtistsController.signal + }); + + if (!response.ok) { + throw new Error(`Failed to fetch similar artists: ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let artistCount = 0; + + // Read the stream + while (true) { + const { done, value } = await reader.read(); + + if (done) { + console.log('✅ Stream complete'); + break; + } + + // Decode the chunk and add to buffer + buffer += decoder.decode(value, { stream: true }); + + // Process complete messages (separated by \n\n) + const messages = buffer.split('\n\n'); + buffer = messages.pop() || ''; // Keep incomplete message in buffer + + for (const message of messages) { + if (!message.trim() || !message.startsWith('data: ')) continue; + + try { + const jsonData = JSON.parse(message.substring(6)); // Remove 'data: ' prefix + + if (jsonData.error) { + throw new Error(jsonData.error); + } + + if (jsonData.artist) { + // Hide loading on first artist + if (artistCount === 0) { + loadingEl.classList.add('hidden'); + } + + // Create and append bubble immediately + const bubble = createSimilarArtistBubble(jsonData.artist); + container.appendChild(bubble); + artistCount++; + + console.log(`✅ Added bubble for: ${jsonData.artist.name} (${artistCount})`); + } + + if (jsonData.complete) { + console.log(`🎉 Streaming complete: ${jsonData.total} artists`); + + if (artistCount === 0) { + loadingEl.classList.add('hidden'); + container.innerHTML = ` +
+
🎵
+
No similar artists found
+
+ `; + } else { + // Lazy load images for similar artists that don't have them + lazyLoadSimilarArtistImages(container); + } + } + } catch (parseError) { + console.error('❌ Error parsing stream message:', parseError); + } + } + } + + // Clear the controller when done + similarArtistsController = null; + + } catch (error) { + // Don't show error if it was aborted (user navigated away) + if (error.name === 'AbortError') { + console.log('⏹️ Similar artists stream aborted (user navigated to new artist)'); + loadingEl.classList.add('hidden'); + return; + } + + console.error('❌ Error loading similar artists:', error); + + // Hide loading, show error + loadingEl.classList.add('hidden'); + errorEl.classList.remove('hidden'); + + // Also show error message in container + container.innerHTML = ` +
+
⚠️
+
${error.message}
+
+ `; + } finally { + // Always clear the controller + similarArtistsController = null; + } +} + +/** + * Lazy load images for similar artist bubbles that don't have images + */ +async function lazyLoadSimilarArtistImages(container) { + if (!container) return; + + const bubblesNeedingImages = container.querySelectorAll('.similar-artist-bubble[data-needs-image="true"]'); + + if (bubblesNeedingImages.length === 0) { + console.log('✅ All similar artist bubbles have images'); + return; + } + + console.log(`🖼️ Lazy loading images for ${bubblesNeedingImages.length} similar artists`); + + // Load images in parallel batches + const batchSize = 5; + const bubbles = Array.from(bubblesNeedingImages); + + for (let i = 0; i < bubbles.length; i += batchSize) { + const batch = bubbles.slice(i, i + batchSize); + + await Promise.all(batch.map(async (bubble) => { + const artistId = bubble.getAttribute('data-artist-id'); + const artistSource = bubble.getAttribute('data-artist-source') || ''; + const artistPlugin = bubble.getAttribute('data-artist-plugin') || ''; + if (!artistId) return; + + try { + const params = new URLSearchParams(); + if (artistSource) params.set('source', artistSource); + if (artistPlugin) params.set('plugin', artistPlugin); + + const imageUrl = params.toString() + ? `/api/artist/${encodeURIComponent(artistId)}/image?${params.toString()}` + : `/api/artist/${encodeURIComponent(artistId)}/image`; + + const response = await fetch(imageUrl); + const data = await response.json(); + + if (data.success && data.image_url) { + const imageContainer = bubble.querySelector('.similar-artist-bubble-image'); + if (imageContainer) { + const artistName = bubble.querySelector('.similar-artist-bubble-name')?.textContent || 'Artist'; + imageContainer.innerHTML = `${artistName}`; + bubble.setAttribute('data-needs-image', 'false'); + console.log(`✅ Loaded image for similar artist ${artistId}`); + } + } + } catch (error) { + console.warn(`⚠️ Failed to load image for similar artist ${artistId}:`, error); + } + })); + } + + console.log('✅ Finished lazy loading similar artist images'); +} + +/** + * Display similar artist bubble cards progressively (one at a time with delay) + */ +function displaySimilarArtistsProgressively(artists) { + const container = document.getElementById('similar-artists-bubbles-container'); + + if (!container) { + console.warn('⚠️ Similar artists container not found'); + return; + } + + // Clear container + container.innerHTML = ''; + + // Add each bubble with a delay to simulate progressive loading + artists.forEach((artist, index) => { + setTimeout(() => { + const bubble = createSimilarArtistBubble(artist); + container.appendChild(bubble); + }, index * 100); // 100ms delay between each bubble + }); + + console.log(`✅ Displaying ${artists.length} similar artist bubbles progressively`); +} + +/** + * Display similar artist bubble cards (all at once - legacy) + */ +function displaySimilarArtists(artists) { + const container = document.getElementById('similar-artists-bubbles-container'); + + if (!container) { + console.warn('⚠️ Similar artists container not found'); + return; + } + + // Clear container + container.innerHTML = ''; + + // Create bubble cards with staggered animation + artists.forEach((artist, index) => { + const bubble = createSimilarArtistBubble(artist); + + // Add staggered animation delay (50ms per bubble) + bubble.style.animationDelay = `${index * 0.05}s`; + + container.appendChild(bubble); + }); + + console.log(`✅ Displayed ${artists.length} similar artist bubbles`); +} + +/** + * Create a similar artist bubble card element + */ +function createSimilarArtistBubble(artist) { + // Create bubble container + const bubble = document.createElement('div'); + bubble.className = 'similar-artist-bubble'; + bubble.setAttribute('data-artist-id', artist.id); + bubble.setAttribute('data-artist-source', artist.source || ''); + if (artist.plugin) { + bubble.setAttribute('data-artist-plugin', artist.plugin); + } + + // Track if image needs lazy loading + const hasImage = artist.image_url && artist.image_url.trim() !== ''; + bubble.setAttribute('data-needs-image', hasImage ? 'false' : 'true'); + + // Create image container + const imageContainer = document.createElement('div'); + imageContainer.className = 'similar-artist-bubble-image'; + + if (hasImage) { + const img = document.createElement('img'); + img.src = artist.image_url; + img.alt = artist.name; + + // Handle image load error + img.onerror = () => { + console.log(`Failed to load image for ${artist.name}`); + imageContainer.innerHTML = `
🎵
`; + bubble.setAttribute('data-needs-image', 'true'); + }; + + imageContainer.appendChild(img); + } else { + // No image - show fallback (will be lazy loaded) + imageContainer.innerHTML = `
🎵
`; + } + + // Create name element + const name = document.createElement('div'); + name.className = 'similar-artist-bubble-name'; + name.textContent = artist.name; + name.title = artist.name; // Tooltip for full name + + // Optional: Create genres element (hidden by default in CSS) + const genres = document.createElement('div'); + genres.className = 'similar-artist-bubble-genres'; + if (artist.genres && artist.genres.length > 0) { + genres.textContent = artist.genres.slice(0, 2).join(', '); + } + + // Assemble bubble + bubble.appendChild(imageContainer); + bubble.appendChild(name); + if (artist.genres && artist.genres.length > 0) { + bubble.appendChild(genres); + } + + // Add click handler to navigate to artist detail page + bubble.addEventListener('click', () => { + console.log(`🎵 Clicked similar artist: ${artist.name} (ID: ${artist.id})`); + // Navigate to this artist's detail page (same as clicking from search results) + selectArtistForDetail( + artist, + artist.source ? { source: artist.source, plugin: artist.plugin } : {} + ); + }); + + return bubble; +} + +/** + * Restore cached completion data without re-scanning the database + */ +function restoreCachedCompletionData(artistId) { + console.log(`📦 Restoring cached completion data for artist: ${artistId}`); + + const cachedData = artistsPageState.cache.completionData[artistId]; + if (!cachedData) { + console.log('⚠️ No cached completion data found, skipping restoration'); + return; + } + + // Restore album completion overlays + if (cachedData.albums) { + cachedData.albums.forEach(albumCompletion => { + updateAlbumCompletionOverlay(albumCompletion, 'albums'); + }); + console.log(`✅ Restored ${cachedData.albums.length} album completion overlays`); + } + + // Restore singles completion overlays + if (cachedData.singles) { + cachedData.singles.forEach(singleCompletion => { + updateAlbumCompletionOverlay(singleCompletion, 'singles'); + }); + console.log(`✅ Restored ${cachedData.singles.length} single completion overlays`); + } +} + +/** + * Check completion status for entire discography with streaming updates + */ + +/** + * Show error state on all completion overlays + */ +function showCompletionError() { + const allOverlays = document.querySelectorAll('.completion-overlay.checking'); + allOverlays.forEach(overlay => { + overlay.classList.remove('checking'); + overlay.classList.add('error'); + overlay.innerHTML = 'Error'; + overlay.title = 'Failed to check completion status'; + }); +} + +/** + * Create HTML for an album/single card + */ +function createAlbumCardHTML(album) { + const imageUrl = album.image_url || ''; + const year = album.release_date ? new Date(album.release_date).getFullYear() : ''; + const type = album.album_type === 'album' ? 'Album' : + album.album_type === 'single' ? 'Single' : 'EP'; + + // Use data-bg-src for lazy background loading via IntersectionObserver + const backgroundAttr = imageUrl ? + `data-bg-src="${imageUrl}"` : + `style="background: linear-gradient(135deg, rgba(29, 185, 84, 0.2) 0%, rgba(24, 156, 71, 0.1) 100%);"`; + + return ` +
+
+
+ Checking... +
+
+
${escapeHtml(album.name)}
+
${year || 'Unknown'}
+
${type}
+
+
+ `; +} + +/** + * Initialize artist detail tabs + */ +function initializeArtistTabs() { + const tabButtons = document.querySelectorAll('.artist-tab'); + const tabContents = document.querySelectorAll('.tab-content'); + + tabButtons.forEach(button => { + button.addEventListener('click', () => { + const tabName = button.getAttribute('data-tab'); + + // Update button states + tabButtons.forEach(btn => btn.classList.remove('active')); + button.classList.add('active'); + + // Update content states + tabContents.forEach(content => { + content.classList.remove('active'); + if (content.id === `${tabName}-content`) { + content.classList.add('active'); + } + }); + + console.log(`🔄 Switched to ${tabName} tab`); + }); + }); +} + +/** + * State management functions + */ +function showArtistsSearchState() { + console.log('🔄 Showing search state'); + + // Cancel any ongoing completion check when navigating back to search + if (artistCompletionController) { + console.log('⏹️ Canceling completion check (navigating back to search)'); + artistCompletionController.abort(); + artistCompletionController = null; + } + + // Cancel any ongoing similar artists stream when navigating back to search + if (similarArtistsController) { + console.log('⏹️ Canceling similar artists stream (navigating back to search)'); + similarArtistsController.abort(); + similarArtistsController = null; + } + + const searchState = document.getElementById('artists-search-state'); + const resultsState = document.getElementById('artists-results-state'); + const detailState = document.getElementById('artist-detail-state'); + + if (searchState) { + searchState.classList.remove('hidden', 'fade-out'); + } + if (resultsState) { + resultsState.classList.add('hidden'); + resultsState.classList.remove('show'); + } + if (detailState) { + detailState.classList.add('hidden'); + detailState.classList.remove('show'); + } + + artistsPageState.currentView = 'search'; + updateArtistsSearchStatus('default'); + + // Show artist downloads section if there are active downloads + showArtistDownloadsSection(); +} + +function showArtistsResultsState() { + console.log('🔄 Showing results state'); + + // Cancel any ongoing completion check when navigating back + if (artistCompletionController) { + console.log('⏹️ Canceling completion check (navigating back to results)'); + artistCompletionController.abort(); + artistCompletionController = null; + } + + // Cancel any ongoing similar artists stream when navigating back + if (similarArtistsController) { + console.log('⏹️ Canceling similar artists stream (navigating back to results)'); + similarArtistsController.abort(); + similarArtistsController = null; + } + + // Clear artist-specific data when navigating back to results + // This ensures that selecting the same artist again will trigger a fresh scan + if (artistsPageState.selectedArtist) { + const artistId = artistsPageState.selectedArtist.id; + console.log(`🗑️ Clearing cached data for artist: ${artistsPageState.selectedArtist.name}`); + + // Clear artist-specific cache data + delete artistsPageState.cache.completionData[artistId]; + delete artistsPageState.cache.discography[artistId]; + + // Clear artist state + artistsPageState.selectedArtist = null; + artistsPageState.artistDiscography = { albums: [], singles: [] }; + } + + const searchState = document.getElementById('artists-search-state'); + const resultsState = document.getElementById('artists-results-state'); + const detailState = document.getElementById('artist-detail-state'); + + if (searchState) { + searchState.classList.add('fade-out'); + setTimeout(() => searchState.classList.add('hidden'), 200); + } + if (resultsState) { + resultsState.classList.remove('hidden'); + setTimeout(() => resultsState.classList.add('show'), 50); + } + if (detailState) { + detailState.classList.add('hidden'); + detailState.classList.remove('show'); + } + + artistsPageState.currentView = 'results'; +} + +function showArtistDetailState() { + console.log('🔄 Showing detail state'); + + const searchState = document.getElementById('artists-search-state'); + const resultsState = document.getElementById('artists-results-state'); + const detailState = document.getElementById('artist-detail-state'); + + if (searchState) { + searchState.classList.add('hidden', 'fade-out'); + } + if (resultsState) { + resultsState.classList.add('hidden'); + resultsState.classList.remove('show'); + } + if (detailState) { + detailState.classList.remove('hidden'); + setTimeout(() => detailState.classList.add('show'), 50); + } + + artistsPageState.currentView = 'detail'; +} + +/** + * Update search status text and styling + */ +function updateArtistsSearchStatus(status, message = null) { + const statusElement = document.getElementById('artists-search-status'); + if (!statusElement) return; + + // Clear all status classes + statusElement.classList.remove('searching', 'error'); + + switch (status) { + case 'default': + statusElement.textContent = 'Start typing to search for artists'; + break; + case 'searching': + statusElement.classList.add('searching'); + statusElement.textContent = 'Searching for artists...'; + break; + case 'error': + statusElement.classList.add('error'); + statusElement.innerHTML = ` +
${message || 'Search failed. Please try again.'}
+ + `; + break; + } +} + +/** + * Retry the last search query + */ +function retryLastSearch() { + const searchInput = document.getElementById('artists-search-input'); + const headerSearchInput = document.getElementById('artists-header-search-input'); + + // Get the last search query from either input + const query = searchInput?.value?.trim() || headerSearchInput?.value?.trim() || artistsPageState.searchQuery; + + if (query) { + console.log(`🔄 Retrying search for: "${query}"`); + performArtistsSearch(query); + } +} + +/** + * Update artist detail header with artist info + */ +function updateArtistDetailHeader(artist) { + const _esc = (s) => (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + const info = artist.artist_info || {}; + const imageUrl = artist.image_url || info.image_url || ''; + + // Background blur + const heroBg = document.getElementById('artists-hero-bg'); + if (heroBg) { + heroBg.style.backgroundImage = imageUrl ? `url('${imageUrl}')` : 'none'; + } + + // Artist image + const heroImage = document.getElementById('artists-hero-image'); + if (heroImage) { + if (imageUrl) { + heroImage.style.backgroundImage = `url('${imageUrl}')`; + heroImage.innerHTML = ''; + } else { + heroImage.style.backgroundImage = 'none'; + heroImage.innerHTML = '🎤'; + // Lazy load + fetch(`/api/artist/${artist.id}/image`) + .then(r => r.json()) + .then(d => { + if (d.success && d.image_url) { + heroImage.style.backgroundImage = `url('${d.image_url}')`; + heroImage.innerHTML = ''; + if (heroBg) heroBg.style.backgroundImage = `url('${d.image_url}')`; + artist.image_url = d.image_url; + } + }).catch(() => { }); + } + } + + // Name + const heroName = document.getElementById('artists-hero-name'); + if (heroName) heroName.textContent = artist.name || 'Unknown Artist'; + + // Badges (service links — real logos matching library page) + const badgesEl = document.getElementById('artists-hero-badges'); + if (badgesEl) { + const _hb = (logo, fallback, title, url) => { + const inner = logo + ? `${fallback}` + : `${fallback}`; + if (url) return `${inner}`; + return `
${inner}
`; + }; + const badges = []; + if (info.spotify_artist_id) badges.push(_hb(SPOTIFY_LOGO_URL, 'SP', 'Spotify', `https://open.spotify.com/artist/${info.spotify_artist_id}`)); + if (info.musicbrainz_id || artist.musicbrainz_id) badges.push(_hb(MUSICBRAINZ_LOGO_URL, 'MB', 'MusicBrainz', `https://musicbrainz.org/artist/${info.musicbrainz_id || artist.musicbrainz_id}`)); + if (info.deezer_id) badges.push(_hb(DEEZER_LOGO_URL, 'Dz', 'Deezer', `https://www.deezer.com/artist/${info.deezer_id}`)); + if (info.itunes_artist_id) badges.push(_hb(ITUNES_LOGO_URL, 'IT', 'Apple Music', `https://music.apple.com/artist/${info.itunes_artist_id}`)); + if (info.lastfm_url) badges.push(_hb(LASTFM_LOGO_URL, 'LFM', 'Last.fm', info.lastfm_url)); + if (info.genius_url) badges.push(_hb(GENIUS_LOGO_URL, 'GEN', 'Genius', info.genius_url)); + if (info.tidal_id) badges.push(_hb(TIDAL_LOGO_URL, 'TD', 'Tidal', `https://tidal.com/browse/artist/${info.tidal_id}`)); + if (info.qobuz_id) badges.push(_hb(QOBUZ_LOGO_URL, 'Qz', 'Qobuz', `https://www.qobuz.com/artist/${info.qobuz_id}`)); + if (info.discogs_id) badges.push(_hb(DISCOGS_LOGO_URL, 'DC', 'Discogs', `https://www.discogs.com/artist/${info.discogs_id}`)); + badgesEl.innerHTML = badges.join(''); + } + + // Genres (pill tags — merge with Last.fm tags, deduplicated) + const genresEl = document.getElementById('artists-hero-genres'); + if (genresEl) { + let genres = info.genres || artist.genres || []; + // Merge Last.fm tags + const lfmTags = info.lastfm_tags || []; + if (Array.isArray(lfmTags) && lfmTags.length > 0) { + const existing = new Set(genres.map(g => g.toLowerCase())); + const newTags = lfmTags.filter(t => !existing.has(t.toLowerCase())); + genres = [...genres, ...newTags]; + } + if (genres.length > 0) { + genresEl.innerHTML = genres.slice(0, 8).map(g => + `${_esc(g)}` + ).join(''); + } else { + genresEl.innerHTML = ''; + } + } + + // Bio (Last.fm bio or summary fallback — matching library page pattern) + const bioEl = document.getElementById('artists-hero-bio'); + if (bioEl) { + const bio = info.lastfm_bio || info.bio || ''; + if (bio) { + // Strip HTML tags and "Read more on Last.fm" links + let cleanBio = bio.replace(/]*>.*?<\/a>/gi, '').replace(/<[^>]+>/g, '').trim(); + if (cleanBio) { + bioEl.innerHTML = `${_esc(cleanBio)} + Read more`; + bioEl.style.display = ''; + } else { + bioEl.style.display = 'none'; + } + } else { + bioEl.style.display = 'none'; + } + } + + // Stats (Last.fm listeners + playcount, with followers fallback) + const statsEl = document.getElementById('artists-hero-stats'); + if (statsEl) { + const _fmtNum = (n) => { + if (!n || n <= 0) return '0'; + if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; + if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; + return n.toLocaleString(); + }; + let stats = ''; + if (info.lastfm_listeners) { + stats += `${_fmtNum(info.lastfm_listeners)} listeners`; + } + if (info.lastfm_playcount) { + stats += `${_fmtNum(info.lastfm_playcount)} plays`; + } + if (!stats && info.followers) { + stats += `${_fmtNum(info.followers)} followers`; + } + statsEl.innerHTML = stats; + } + + // Also update old hidden elements for any JS that references them + const oldImage = document.getElementById('search-artist-detail-image'); + if (oldImage && imageUrl) oldImage.style.backgroundImage = `url('${imageUrl}')`; + const oldName = document.getElementById('search-artist-detail-name'); + if (oldName) oldName.textContent = artist.name; + + // Initialize watchlist button + initializeArtistDetailWatchlistButton(artist); +} + +/** + * Initialize watchlist button for artist detail page + */ +async function initializeArtistDetailWatchlistButton(artist) { + const button = document.getElementById('artist-detail-watchlist-btn'); + if (!button) return; + + console.log(`🔧 Initializing watchlist button for artist: ${artist.name} (${artist.id})`); + + // Store artist info on the button for settings gear access + button.dataset.artistId = artist.id; + button.dataset.artistName = artist.name; + + // Reset button state completely + button.disabled = false; + button.classList.remove('watching'); + button.style.background = ''; + button.style.cursor = ''; + + // Remove any existing click handlers to prevent duplicates + button.onclick = null; + + // Set up new click handler + button.onclick = (event) => toggleArtistDetailWatchlist(event, artist.id, artist.name); + + // Check and update current status + await updateArtistDetailWatchlistButton(artist.id, artist.name); +} + +/** + * Toggle watchlist status for artist detail page + */ +async function toggleArtistDetailWatchlist(event, artistId, artistName) { + event.preventDefault(); + + const button = document.getElementById('artist-detail-watchlist-btn'); + const icon = button.querySelector('.watchlist-icon'); + const text = button.querySelector('.watchlist-text'); + + // Show loading state + const originalText = text.textContent; + text.textContent = 'Loading...'; + button.disabled = true; + + try { + // Check current status + const checkResponse = await fetch('/api/watchlist/check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_id: artistId }) + }); + + const checkData = await checkResponse.json(); + if (!checkData.success) { + throw new Error(checkData.error || 'Failed to check watchlist status'); + } + + const isWatching = checkData.is_watching; + + // Toggle watchlist status + const endpoint = isWatching ? '/api/watchlist/remove' : '/api/watchlist/add'; + const payload = isWatching ? + { artist_id: artistId } : + { artist_id: artistId, artist_name: artistName }; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || 'Failed to update watchlist'); + } + + // Update button appearance + if (isWatching) { + // Was watching, now removed + icon.textContent = '👁️'; + text.textContent = 'Add to Watchlist'; + button.classList.remove('watching'); + console.log(`❌ Removed ${artistName} from watchlist`); + } else { + // Was not watching, now added + icon.textContent = '👁️'; + text.textContent = 'Remove from Watchlist'; + button.classList.add('watching'); + console.log(`✅ Added ${artistName} to watchlist`); + } + + // Show/hide watchlist settings gear + const settingsBtn = document.getElementById('artist-detail-watchlist-settings-btn'); + if (settingsBtn) { + if (!isWatching) { + // Just added to watchlist — show gear + settingsBtn.classList.remove('hidden'); + settingsBtn.onclick = () => openWatchlistArtistConfigModal(artistId, artistName); + } else { + // Just removed from watchlist — hide gear + settingsBtn.classList.add('hidden'); + settingsBtn.onclick = null; + } + } + + // Update dashboard watchlist count + updateWatchlistButtonCount(); + + // Update any visible artist cards + updateArtistCardWatchlistStatus(); + + } catch (error) { + console.error('Error toggling watchlist:', error); + text.textContent = originalText; + + // Show error feedback + const originalBackground = button.style.background; + button.style.background = 'rgba(255, 59, 48, 0.3)'; + setTimeout(() => { + button.style.background = originalBackground; + }, 2000); + } finally { + button.disabled = false; + } +} + +/** + * Update artist detail watchlist button status + */ +async function updateArtistDetailWatchlistButton(artistId, artistName) { + const button = document.getElementById('artist-detail-watchlist-btn'); + if (!button) { + console.warn('⚠️ Artist detail watchlist button not found'); + return; + } + + // Use passed name or fall back to stored data attribute + const name = artistName || button.dataset.artistName || ''; + + try { + console.log(`🔍 Checking watchlist status for artist: ${artistId}`); + + const response = await fetch('/api/watchlist/check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_id: artistId }) + }); + + const data = await response.json(); + if (data.success) { + const icon = button.querySelector('.watchlist-icon'); + const text = button.querySelector('.watchlist-text'); + + console.log(`📊 Watchlist status for ${artistId}: ${data.is_watching ? 'WATCHING' : 'NOT WATCHING'}`); + + // Ensure button is enabled + button.disabled = false; + + // Show/hide watchlist settings gear + const settingsBtn = document.getElementById('artist-detail-watchlist-settings-btn'); + if (settingsBtn) { + if (data.is_watching) { + settingsBtn.classList.remove('hidden'); + settingsBtn.onclick = () => openWatchlistArtistConfigModal(artistId, name); + } else { + settingsBtn.classList.add('hidden'); + settingsBtn.onclick = null; + } + } + + if (data.is_watching) { + icon.textContent = '👁️'; + text.textContent = 'Remove from Watchlist'; + button.classList.add('watching'); + } else { + icon.textContent = '👁️'; + text.textContent = 'Add to Watchlist'; + button.classList.remove('watching'); + } + } else { + console.error('❌ Failed to check watchlist status:', data.error); + } + } catch (error) { + console.error('❌ Error checking watchlist status:', error); + // Ensure button doesn't get stuck in bad state + button.disabled = false; + } +} + +/** + * Show loading state for discography + */ +function showDiscographyLoading() { + const albumsContainer = document.getElementById('album-cards-container'); + const singlesContainer = document.getElementById('singles-cards-container'); + + const loadingHtml = ` +
+
+
+
Loading...
+
-
+
-
+
+
+ `.repeat(4); + + if (albumsContainer) albumsContainer.innerHTML = loadingHtml; + if (singlesContainer) singlesContainer.innerHTML = loadingHtml; +} + +/** + * Show error state for discography + */ +function showDiscographyError(message = 'Failed to load discography') { + const albumsContainer = document.getElementById('album-cards-container'); + const singlesContainer = document.getElementById('singles-cards-container'); + + const errorHtml = ` +
+
⚠️
+
Failed to load discography
+
${escapeHtml(message)}
+
+ `; + + if (albumsContainer) albumsContainer.innerHTML = errorHtml; + if (singlesContainer) singlesContainer.innerHTML = errorHtml; +} + +/** + * Show loading cards while searching + */ +function showSearchLoadingCards() { + const container = document.getElementById('artists-cards-container'); + if (!container) return; + + const loadingCardHtml = ` +
+
+
+
+
Loading...
+
Fetching data...
+
+ + Loading... +
+
+
+ `; + + // Show 6 loading cards + container.innerHTML = loadingCardHtml.repeat(6); +} + +// =============================== +// ARTIST ALBUM DOWNLOAD MISSING TRACKS INTEGRATION +// =============================== + +/** + * Get the completion status of an album from cached data or DOM + * @param {string} albumId - The album ID + * @param {string} albumType - The album type ('albums' or 'singles') + * @returns {Object|null} - Completion status object or null + */ +function getAlbumCompletionStatus(albumId, albumType) { + try { + // First, check cached completion data + const artistId = artistsPageState.selectedArtist?.id; + if (artistId && artistsPageState.cache.completionData[artistId]) { + const cachedData = artistsPageState.cache.completionData[artistId]; + const dataArray = albumType === 'albums' ? cachedData.albums : cachedData.singles; + + if (dataArray) { + const completionData = dataArray.find(item => item.album_id === albumId || item.id === albumId); + if (completionData) { + console.log(`📊 Found cached completion data for album ${albumId}:`, completionData); + return completionData; + } + } + } + + // Fallback: Check DOM completion overlay + const containerId = albumType === 'albums' ? 'album-cards-container' : 'singles-cards-container'; + const container = document.getElementById(containerId); + + if (container) { + const albumCard = container.querySelector(`[data-album-id="${albumId}"]`); + if (albumCard) { + const overlay = albumCard.querySelector('.completion-overlay'); + if (overlay) { + // Extract status from overlay classes + const classList = Array.from(overlay.classList); + const statusClasses = ['completed', 'nearly_complete', 'partial', 'missing', 'downloading', 'downloaded', 'error']; + const status = statusClasses.find(cls => classList.includes(cls)); + + if (status) { + console.log(`📊 Found DOM completion status for album ${albumId}: ${status}`); + return { status, completion_percentage: status === 'completed' ? 100 : 0 }; + } + } + } + } + + console.warn(`⚠️ No completion status found for album ${albumId}`); + return null; + + } catch (error) { + console.error(`❌ Error getting album completion status for ${albumId}:`, error); + return null; + } +} + +/** + * Handle album/single/EP click to open download missing tracks modal + */ +async function handleArtistAlbumClick(album, albumType) { + console.log(`🎵 Album clicked: ${album.name} (${album.album_type}) from artist: ${artistsPageState.selectedArtist?.name}`); + + if (!artistsPageState.selectedArtist) { + console.error('❌ No selected artist found'); + showToast('Error: No artist selected', 'error'); + return; + } + + showLoadingOverlay('Loading album...'); + + try { + // Check completion status of the album + const completionStatus = getAlbumCompletionStatus(album.id, albumType); + console.log(`📊 Album completion status: ${completionStatus?.status || 'unknown'} (${completionStatus?.completion_percentage || 0}%)`); + + // For Artists page, always use Download Missing Tracks modal to analyze and download + console.log(`🔄 Opening download missing tracks modal for album analysis`); + + // Create virtual playlist ID + const virtualPlaylistId = `artist_album_${artistsPageState.selectedArtist.id}_${album.id}`; + + // Check if modal already exists and show it + if (activeDownloadProcesses[virtualPlaylistId]) { + console.log(`📱 Reopening existing modal for ${album.name}`); + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process.modalElement) { + if (process.status === 'complete') { + showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); + } + process.modalElement.style.display = 'flex'; + hideLoadingOverlay(); + return; + } + } + + // Create virtual playlist and open modal + // Note: Don't hide loading overlay here - let the flow continue through to the modal + await createArtistAlbumVirtualPlaylist(album, albumType); + + } catch (error) { + hideLoadingOverlay(); + console.error('❌ Error handling album click:', error); + showToast(`Error opening download modal: ${error.message}`, 'error'); + } +} + +/** + * Create virtual playlist for artist album and open download missing tracks modal + */ +async function createArtistAlbumVirtualPlaylist(album, albumType) { + const artist = artistsPageState.selectedArtist; + const virtualPlaylistId = `artist_album_${artist.id}_${album.id}`; + + console.log(`🎵 Creating virtual playlist for: ${artist.name} - ${album.name}`); + + try { + // Loading overlay already shown by handleArtistAlbumClick + + // Fetch album tracks from backend (pass name/artist for Hydrabase support) + const _aat1 = new URLSearchParams({ name: album.name || '', artist: artist.name || '' }); + const albumSource = artistsPageState.sourceOverride || album.source || artist.source || artistsPageState.artistDiscography?.source || null; + if (albumSource) { + _aat1.set('source', albumSource); + } + if (artistsPageState.pluginOverride) { + _aat1.set('plugin', artistsPageState.pluginOverride); + } + const response = await fetch(`/api/album/${album.id}/tracks?${_aat1}`); + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Spotify not authenticated. Please check your API settings.'); + } + const errData = await response.json().catch(() => ({})); + throw new Error(errData.error || `Failed to load album tracks: ${response.status}`); + } + + const data = await response.json(); + + if (!data.success || !data.tracks || data.tracks.length === 0) { + throw new Error('No tracks found for this album'); + } + + console.log(`✅ Loaded ${data.tracks.length} tracks for ${data.album.name}`); + + // Use album data from API response (has complete data including images array) + const fullAlbumData = data.album; + + // Format playlist name with artist and album info + const playlistName = `[${artist.name}] ${fullAlbumData.name}`; + + // Open download missing tracks modal with formatted tracks + // Pass false for showLoadingOverlay since we already have one from handleArtistAlbumClick + // Use fullAlbumData from API response instead of album parameter + await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, data.tracks, fullAlbumData, artist, false); + + // Track this download for artist bubble management + registerArtistDownload(artist, album, virtualPlaylistId, albumType); + + } catch (error) { + console.error('❌ Error creating virtual playlist:', error); + showToast(`Failed to load album: ${error.message}`, 'error'); + throw error; + } +} + +/** + * Open download missing tracks modal specifically for artist albums + * Similar to openDownloadMissingModalForYouTube but for artist albums + */ diff --git a/webui/static/helper.js b/webui/static/helper.js index dcbb56af..bafe250c 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3546,18 +3546,21 @@ function closeHelperSearch() { // WHAT'S NEW (Phase 6) // ═══════════════════════════════════════════════════════════════════════════ +// Entries tagged with `unreleased: true` are accumulating under a version label +// but won't display until the build version catches up. The Search/Artists +// unification project stays folded here at 2.40 until the whole thing ships. const WHATS_NEW = { '2.40': [ - // --- April 24, 2026 — Search & Artists unification --- - { date: 'April 24, 2026 — Search & Artists unification' }, - { title: 'Unified Artist Detail Page', desc: 'Clicking any artist anywhere — Search results, Discover, Watchlist, Stats, Library, API monitor, Wishlist, Media player — now lands on the same standalone /artist-detail page. Before, library artists and metadata-source artists went to two different pages (one inline, one standalone). The backend /api/artist-detail endpoint now accepts an optional ?source= param and falls back to a metadata-source lookup when the local DB lookup misses, so Deezer/Spotify/iTunes/Discogs/Hydrabase/MusicBrainz artists work on the same page as library artists. Stable deep-link URL; source context carried through cleanly', page: 'library' }, - { title: 'Artists Page Retired — artists.js Deleted', desc: 'The Artists sidebar entry was already retired earlier; this release finishes the job by deleting the inline page itself (4600-line file, 140 HTML lines). Shared helpers the rest of the app depended on (escapeHtml used 229 times, service-status polling, image-colour extraction, download-bubble infrastructure, discography completion checking, enrichment card rendering) were extracted into a new webui/static/shared-helpers.js so other modules keep working. Legacy /artists URL now aliases to /search for bookmark compatibility', page: 'library' }, - { title: 'Search Source Picker', desc: 'The Search page\'s Enhanced/Basic toggle is replaced by a single "Search from" dropdown at the top — pick All sources (Auto), Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz, or Soulseek (raw files). Auto keeps today\'s multi-source fan-out; picking a specific source hits only that provider so there are no more surprise Spotify rate-limit hits from flows that didn\'t need Spotify. "Soulseek" routes to the raw-file search (what "Basic" used to do), so one picker now covers both old modes. Loading text reflects the selected source', page: 'search' }, - { title: 'Explicit Source Selection on /api/enhanced-search', desc: 'The enhanced-search endpoint now accepts an optional `source` body param (spotify, itunes, deezer, discogs, hydrabase, musicbrainz, auto). When a specific source is chosen, only that provider is queried and db_artists (local library matches) still come back. Cache keys isolate per-source so single-source and multi-source results don\'t collide. Omitted or `auto` preserves the old multi-source fan-out behavior unchanged — nothing breaks for existing callers', page: 'search' }, - { title: 'Shared Enhanced-Search Fetch Helper', desc: 'Internal refactor — the Search page dropdown and the global search widget now route through one shared enhancedSearchFetch helper in search.js instead of duplicating the POST boilerplate. Zero UX change, but it means any future source-picker tweak only needs wiring in one place', page: 'search' }, - { title: 'Search Page Renamed to /search', desc: 'The Search page\'s internal id is now "search" instead of the confusing "downloads" (which clashed with the actual Downloads page). Sidebar label unchanged. URL is now /search; /downloads still resolves so old bookmarks keep working. Profile ACL "Page Access" now saves as "search"; existing profiles with "downloads" in allowed_pages still resolve through a legacy-compat check', page: 'search' }, - { title: 'Embedded Download Manager Removed from Search Page', desc: 'The Search page used to carry a second copy of the Download Manager (active + finished queues, clear/cancel-all buttons) that was hidden by default and duplicated the dedicated Downloads page. That duplicate is gone — toggle button, side-panel HTML, and its 1-second polling loop all removed. About 330 lines of dead code cleaned up. The dedicated Downloads sidebar page is now the single downloads UI', page: 'search' }, - { title: 'Interactive Help Updated for Unified Search', desc: 'The click-for-help annotations and the "Your First Download" guided tour were rewritten for the new Search page. Stale annotations pointing at removed elements (Basic/Enhanced toggle button, side-panel queues, download-manager controls) are deleted. The first-download tour now runs on /search and opens with the source picker. PAGE_TOUR_MAP accepts both "search" and the legacy "downloads" id so old bookmarks still match a tour. Retired the standalone "Browse Artists" tour', page: 'help' }, + // --- Search & Artists unification (in progress, not yet released) --- + { date: 'Unreleased — Search & Artists unification', unreleased: true }, + { title: 'Search Source Picker', desc: 'The Search page\'s Enhanced/Basic toggle is replaced by a single "Search from" dropdown at the top — pick All sources (Auto), Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz, or Soulseek (raw files). Auto keeps today\'s multi-source fan-out; picking a specific source hits only that provider so there are no more surprise Spotify rate-limit hits from flows that didn\'t need Spotify. "Soulseek" routes to the raw-file search (what "Basic" used to do), so one picker now covers both old modes. Loading text reflects the selected source', page: 'search', unreleased: true }, + { title: 'Explicit Source Selection on /api/enhanced-search', desc: 'The enhanced-search endpoint now accepts an optional `source` body param (spotify, itunes, deezer, discogs, hydrabase, musicbrainz, auto). When a specific source is chosen, only that provider is queried and db_artists (local library matches) still come back. Cache keys isolate per-source so single-source and multi-source results don\'t collide. Omitted or `auto` preserves the old multi-source fan-out behavior unchanged — nothing breaks for existing callers', page: 'search', unreleased: true }, + { title: 'Shared Enhanced-Search Fetch Helper', desc: 'Internal refactor — the Search page dropdown and the global search widget now route through one shared enhancedSearchFetch helper in search.js instead of duplicating the POST boilerplate. Zero UX change, but it means any future source-picker tweak only needs wiring in one place', page: 'search', unreleased: true }, + { title: 'Search Page Renamed to /search', desc: 'The Search page\'s internal id is now "search" instead of the confusing "downloads" (which clashed with the actual Downloads page). Sidebar label unchanged. URL is now /search; /downloads still resolves so old bookmarks keep working. Profile ACL "Page Access" now saves as "search"; existing profiles with "downloads" in allowed_pages still resolve through a legacy-compat check', page: 'search', unreleased: true }, + { title: 'Embedded Download Manager Removed from Search Page', desc: 'The Search page used to carry a second copy of the Download Manager (active + finished queues, clear/cancel-all buttons) that was hidden by default and duplicated the dedicated Downloads page. That duplicate is gone — toggle button, side-panel HTML, and its 1-second polling loop all removed. About 330 lines of dead code cleaned up. The dedicated Downloads sidebar page is now the single downloads UI', page: 'search', unreleased: true }, + { title: 'Artists Sidebar Entry Retired — Use Search Instead', desc: 'Cin flagged that "Artists" in the sidebar read like a library section but was actually a dedicated artist-search page, duplicating what the unified Search already does. The sidebar entry is gone. New flow: Sidebar → Search → type artist name → click their result. "Browse Artists" on the empty Watchlist page and "View artist from Wishlist" now open Search pre-filled with the artist\'s name. Removed "Artists" from profile Home Page + Page Access options. Deep link to /artists still resolves so old bookmarks keep working — the page just isn\'t promoted anywhere', page: 'search', unreleased: true }, + { title: 'Artist Detail Back Button Fallback', desc: 'The back button on the Artists-page inline detail view used to dump users on an empty "Search for an artist..." screen when they arrived from outside the Artists page — a dead end now that Artists isn\'t in the sidebar. If you searched inside the Artists page, back still returns to your results list. Otherwise (arriving from Search, Discover, Watchlist, etc.), back uses the browser history to land you on whichever page you came from. Falls back to the Search page only when there\'s no browser history to go back to (the natural place to find another artist)', page: 'search', unreleased: true }, + { title: 'Interactive Help Updated for Unified Search', desc: 'The click-for-help annotations and the "Your First Download" guided tour were rewritten for the new Search page. Stale annotations pointing at removed elements (Basic/Enhanced toggle button, side-panel queues, download-manager controls) are deleted. The first-download tour now runs on /search and opens with the source picker. PAGE_TOUR_MAP accepts both "search" and the legacy "downloads" id so old bookmarks still match a tour. Retired the standalone "Browse Artists" tour', page: 'help', unreleased: true }, ], '2.39': [ // --- April 22, 2026 --- @@ -3762,7 +3765,13 @@ function _getCurrentVersion() { } function _getLatestWhatsNewVersion() { - const versions = Object.keys(WHATS_NEW).sort((a, b) => parseFloat(b) - parseFloat(a)); + // Only surface entries whose version number is <= the current build. Entries + // sitting at higher versions are unreleased work-in-progress and shouldn't + // flag as "new" in the helper badge until the build catches up. + const buildVer = parseFloat(_getCurrentVersion()) || 2.39; + const versions = Object.keys(WHATS_NEW) + .filter(v => (parseFloat(v) || 0) <= buildVer) + .sort((a, b) => parseFloat(b) - parseFloat(a)); return versions[0] || '2.39'; } @@ -3851,8 +3860,11 @@ function _openFullChangelog() { } function _showOlderNotes() { - // Cycle to next older version in the what's new panel - const versions = Object.keys(WHATS_NEW).sort((a, b) => parseFloat(b) - parseFloat(a)); + // Cycle to next older version in the what's new panel (skip unreleased entries) + const buildVer = parseFloat(_getCurrentVersion()) || 2.39; + const versions = Object.keys(WHATS_NEW) + .filter(v => (parseFloat(v) || 0) <= buildVer) + .sort((a, b) => parseFloat(b) - parseFloat(a)); const panel = _helperPopover; if (!panel) return; const currentTitle = panel.querySelector('.helper-popover-title'); diff --git a/webui/static/init.js b/webui/static/init.js index 2c28c897..e8e38463 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -2010,7 +2010,7 @@ function _getPageFromPath() { const basePage = path.split('/')[0]; if (!_DEEPLINK_VALID_PAGES.has(basePage)) return 'dashboard'; // Context-dependent pages fall back to a sensible parent - if (basePage === 'artist-detail') return 'library'; + if (basePage === 'artist-detail') return 'artists'; if (basePage === 'playlist-explorer') return 'library'; return basePage; } @@ -2115,10 +2115,8 @@ function initializeWatchlist() { } function navigateToPage(pageId, options = {}) { - // Backwards-compat aliases — the Search page used to live under id - // 'downloads', and the Artists page was retired and folded into Search. - // Legacy bookmarks to /downloads or /artists all land on /search. - if (pageId === 'downloads' || pageId === 'artists') pageId = 'search'; + // Backwards-compat alias — the Search page used to live under id 'downloads'. + if (pageId === 'downloads') pageId = 'search'; if (pageId === currentPage) return; @@ -2220,7 +2218,15 @@ async function loadPageData(pageId) { initializeSearchModeToggle(); initializeFilters(); break; - // 'artists' page retired — aliased to 'search' at the top of navigateToPage + case 'artists': + // Only fully initialize if not already initialized + if (!artistsPageState.isInitialized) { + initializeArtistsPage(); + } else { + // Just restore state if already initialized + restoreArtistsPageState(); + } + break; case 'active-downloads': loadActiveDownloadsPage(); break; diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 66320cea..d45737f5 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -2759,79 +2759,3 @@ function renderEnrichmentCards(enrichment) { } // =============================== - - -// ---------------------------------------------------------------------------- -// Additional helpers from the retired Artists page still needed by other files -// ---------------------------------------------------------------------------- - -async function lazyLoadArtistImages(container) { - if (!container) { - console.error('❌ lazyLoadArtistImages: container is null'); - return; - } - - const cardsNeedingImages = container.querySelectorAll('[data-needs-image="true"]'); - - if (cardsNeedingImages.length === 0) { - console.log('✅ All artist cards have images'); - return; - } - - console.log(`🖼️ Lazy loading images for ${cardsNeedingImages.length} artist cards`); - - const batchSize = 5; - const cards = Array.from(cardsNeedingImages); - - for (let i = 0; i < cards.length; i += batchSize) { - const batch = cards.slice(i, i + batchSize); - - await Promise.all(batch.map(async (card) => { - const artistId = card.dataset.artistId; - if (!artistId) { - console.warn('⚠️ Card missing artistId:', card); - return; - } - - try { - const response = await fetch(`/api/artist/${artistId}/image`); - const data = await response.json(); - - if (data.success && data.image_url) { - if (card.classList.contains('suggestion-card')) { - card.style.backgroundImage = `url(${data.image_url})`; - card.style.backgroundSize = 'cover'; - card.style.backgroundPosition = 'center'; - } else if (card.classList.contains('artist-card')) { - const bgElement = card.querySelector('.artist-card-background'); - if (bgElement) { - bgElement.style.cssText = `background-image: url('${data.image_url}'); background-size: cover; background-position: center;`; - } - } - - card.dataset.needsImage = 'false'; - } - } catch (error) { - console.error(`❌ Failed to load image for artist ${artistId}:`, error); - } - })); - } -} - -// Legacy global alias — wishlist-tools.js falls back to window.lazyLoadArtistImages -window.lazyLoadArtistImages = lazyLoadArtistImages; - - -// ---------------------------------------------------------------------------- -// Completion error overlay — called from checkDiscographyCompletion above -// ---------------------------------------------------------------------------- - -function showCompletionError() { - const allOverlays = document.querySelectorAll('.completion-overlay.checking'); - allOverlays.forEach(overlay => { - overlay.classList.remove('checking'); - overlay.classList.add('error'); - overlay.innerHTML = 'Error'; - overlay.title = 'Failed to check completion status'; - }); -} From 18146098a7e084de6624b195897905c7c646632c Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:52:54 -0700 Subject: [PATCH 17/41] Revert "Route source-artist clicks to standalone /artist-detail page" This reverts commit 1c345e4eb591e747b987eac99de0ee1e588e26bd. --- webui/static/api-monitor.js | 10 +++++++++- webui/static/discover.js | 34 +++++++++++++++++++++++++++------- webui/static/downloads.js | 25 ++++++++++++++++++++++--- webui/static/search.js | 12 +++++++++++- 4 files changed, 69 insertions(+), 12 deletions(-) diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js index c900d824..bc86b223 100644 --- a/webui/static/api-monitor.js +++ b/webui/static/api-monitor.js @@ -2385,8 +2385,16 @@ async function openWatchlistArtistDetailView(artistId, artistName) { source = spotify_artist_id ? 'spotify' : discogs_artist_id ? 'discogs' : deezer_artist_id ? 'deezer' : 'itunes'; } if (discogId) { + // Watchlist discogId is a metadata-source id (Spotify/Deezer/iTunes), + // not a library PK — route through the Artists page inline view. closeWatchlistArtistDetailView(); - navigateToArtistDetail(discogId, artistName, source); + navigateToPage('artists'); + setTimeout(() => { + selectArtistForDetail( + { id: discogId, name: artistName, image_url: artist.image_url || '' }, + { source: source } + ); + }, 200); } }); diff --git a/webui/static/discover.js b/webui/static/discover.js index e70e3b65..e0293a60 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -739,7 +739,14 @@ async function checkRecommendedWatchlistStatuses(artists) { async function viewRecommendedArtistDiscography(artistId, artistName) { closeRecommendedArtistsModal(); - navigateToArtistDetail(artistId, artistName); + + const artist = { id: artistId, name: artistName }; + + // Recommended artists come from the metadata source — route through the + // Artists page's inline view so the source-provided id resolves correctly. + navigateToPage('artists'); + await new Promise(resolve => setTimeout(resolve, 100)); + await selectArtistForDetail(artist); } async function checkAllHeroWatchlistStatus() { @@ -821,8 +828,21 @@ async function viewDiscoverHeroDiscography() { return; } + const artist = { + id: artistId, + name: artistName, + image_url: discoverHeroArtists[discoverHeroIndex]?.image_url || '', + genres: discoverHeroArtists[discoverHeroIndex]?.genres || [], + popularity: discoverHeroArtists[discoverHeroIndex]?.popularity || 0 + }; + console.log(`🎵 Navigating to artist detail for: ${artistName}`); - navigateToArtistDetail(artistId, artistName); + + // Hero artists are source-provided recommendations — route through the + // Artists page's inline view so the source id resolves correctly. + navigateToPage('artists'); + await new Promise(resolve => setTimeout(resolve, 100)); + await selectArtistForDetail(artist); } function showDiscoverHeroEmpty() { @@ -4613,9 +4633,9 @@ function _renderYourArtistCard(artist) { const watchlistClass = artist.on_watchlist ? 'active' : ''; const hasId = artist.active_source_id && artist.active_source_id !== ''; - // Navigate to standalone artist detail page (name click) + // Navigate to Artists page (name click) — source artist id, needs inline view const navAction = hasId - ? `event.stopPropagation(); navigateToArtistDetail('${escapeForInlineJs(artist.active_source_id)}', '${escapeForInlineJs(artist.artist_name)}')` + ? `event.stopPropagation(); navigateToPage('artists'); setTimeout(() => selectArtistForDetail({id:'${escapeForInlineJs(artist.active_source_id)}', name:'${escapeForInlineJs(artist.artist_name)}', image_url:'${escapeForInlineJs(img)}'}), 200)` : ''; // Open info modal (card body click) — pass pool ID so we can look up all data @@ -4794,7 +4814,7 @@ async function openYourArtistInfoModal(poolId) { Explore - @@ -6694,7 +6714,7 @@ function _artMapSetupInteraction(canvas) {
Artist Info
-
+
💿 View Discography
@@ -7382,7 +7402,7 @@ async function openGenreDeepDive(genre) { // Always open on Artists page with discography — pass source for correct routing const imgUrl = _esc(a.image_url || ''); const artSource = _esc(a.source || ''); - const clickAction = `onclick="document.getElementById('genre-deep-dive-modal').remove();navigateToArtistDetail('${_esc(a.entity_id)}','${_esc(a.name)}','${artSource}' || null)"`; + const clickAction = `onclick="document.getElementById('genre-deep-dive-modal').remove();navigateToPage('artists');setTimeout(()=>selectArtistForDetail({id:'${_esc(a.entity_id)}',name:'${_esc(a.name)}',image_url:'${imgUrl}'},{source:'${artSource}'}),300)"`; const srcClass = (a.source || '').toLowerCase(); return `
diff --git a/webui/static/downloads.js b/webui/static/downloads.js index f13bfb2e..86d0ad24 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -634,7 +634,15 @@ function _navigateToArtistFromModal(artistId, artistName, imageUrl, source, play if (!artistName) return; // Close the download modal if (playlistId) closeDownloadMissingModal(playlistId); - navigateToArtistDetail(artistId || artistName, artistName, source || null); + // The id from a download modal is typically a metadata-source id; route via + // the Artists page inline view so the source-aware discography endpoint runs. + navigateToPage('artists'); + setTimeout(() => { + selectArtistForDetail( + { id: artistId || artistName, name: artistName, image_url: imageUrl || '' }, + source ? { source: source } : undefined + ); + }, 200); } async function closeDownloadMissingModal(playlistId) { @@ -5425,8 +5433,19 @@ function _gsSwitchSource(src) { function _gsClickArtist(id, name, isLibrary) { _gsDeactivate(); - const source = isLibrary ? null : (_gsState.activeSource || null); - navigateToArtistDetail(id, name, source); + if (isLibrary) { + // Library artists: id is a local DB PK — use the standalone artist-detail page. + navigateToArtistDetail(id, name); + } else { + // Source artists: id is a Deezer/Spotify/iTunes id — route to the Artists + // page's inline view which fetches discography from the source. + navigateToPage('artists'); + setTimeout(() => { + selectArtistForDetail({ id, name, image_url: '' }, { + source: _gsState.activeSource || '', + }); + }, 150); + } } async function _gsClickAlbum(albumId, albumName, artistName, imageUrl, source) { diff --git a/webui/static/search.js b/webui/static/search.js index be9147c5..fd8b0b37 100644 --- a/webui/static/search.js +++ b/webui/static/search.js @@ -340,7 +340,17 @@ function initializeSearchModeToggle() { const sourceOverride = _activeSearchSource; console.log(`🎵 Opening artist detail: ${artist.name} (ID: ${artist.id}, source: ${sourceOverride})`); hideDropdown(); - navigateToArtistDetail(artist.id, artist.name, sourceOverride || null); + + // Source artists are NOT library entries — their id is a Deezer/ + // Spotify/iTunes id, not a library PK. Route to the Artists page's + // inline selectArtistForDetail which fetches discography from the + // source directly, not the library's /api/artist-detail endpoint. + navigateToPage('artists'); + await new Promise(resolve => setTimeout(resolve, 100)); + await selectArtistForDetail(artist, { + source: sourceOverride, + plugin: artist.external_urls?.hydrabase_plugin, + }); } }) ); From 6a76405444a9ecd01bb08ea8b9f418186acbdb7c Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:19:25 -0700 Subject: [PATCH 18/41] Add Similar Artists to standalone /artist-detail page, hide library-only UI for source artists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First increment of the artist-detail unification redesign. Delivers the two most-visible missing pieces for source artists without touching the hero layout — that's a later commit. Changes: - HTML: new #ad-similar-artists-section inside #artist-detail-main (scoped IDs with 'ad-' prefix so they don't collide with the inline Artists page, which has the same section using base IDs). - shared-helpers.js: similar-artists helpers (loadSimilarArtists + display/progressive/createBubble + lazy image loader) moved out of artists.js. New _resolveSimilarArtistsTargets() resolver picks whichever candidate set has a `.page.active` ancestor, so the same function works on both the inline Artists page and the standalone artist-detail page without caller changes. - library.js populateArtistDetailPage: sets document.body.dataset.artistSource = 'library' | 'source' before rendering, and fires loadSimilarArtists(artist.name) after populating the rest of the page. - style.css: body[data-artist-source='source'] rules hide library-only UI on the artist-detail page — Enhanced view toggle, Status (owned/missing) filter, completion bars, enrichment coverage, Top Tracks sidebar, Radio / Enhance Quality buttons, "X owned / Y missing" section-stats counts. CSS-only, additive, library artists completely unaffected. Impact today: - Library artists: Similar Artists section now appears at the bottom of their detail page (previously only the inline Artists page had it). All other UI unchanged. - Source artists: still route to the inline Artists page (Part B reverted earlier this session). The standalone page is now source-ready infrastructure-wise, but source artists don't reach it yet. A later commit will re-migrate source callers to the standalone page once the hero rendering is also source-ready. artists.js shrinks from 1903 -> 1584 lines (similar-artists block extracted). shared-helpers.js grows correspondingly. 357/357 tests still pass. No version bump — this is still 2.39 pending. --- webui/index.html | 21 ++ webui/static/artists.js | 319 ----------------------------- webui/static/library.js | 12 ++ webui/static/shared-helpers.js | 363 +++++++++++++++++++++++++++++++++ webui/static/style.css | 36 ++++ 5 files changed, 432 insertions(+), 319 deletions(-) diff --git a/webui/index.html b/webui/index.html index fed35624..29aec63a 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2667,6 +2667,27 @@
+ +
+
+

Similar Artists

+

Discover artists with a similar sound

+
+ + +
+ +
+
+