From 3c47281ce50d4dddb7d0572cb710fb46aa79a7ba Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:12:42 -0700 Subject: [PATCH] Redesign Watch All Unwatched as polished preview modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the fire-and-forget button with a premium modal that shows exactly which artists will be added before confirming. Features: - Glassmorphic modal with stat cards, two-column artist grid, search filter, collapsible ineligible section, and loading spinner - Source-aware filtering: only shows artists with the active source's ID (Spotify/iTunes/Deezer) as eligible - Frontend and backend both paginate at 400 to avoid SQLite variable limit (SQLITE_MAX_VARIABLE_NUMBER=999) that silently broke queries above ~500 artists - Backend source detection aligned with frontend — uses only the active source's ID, falls back to configured metadata source --- web_server.py | 65 ++++++------ webui/index.html | 2 +- webui/static/helper.js | 1 + webui/static/mobile.css | 10 ++ webui/static/script.js | 219 +++++++++++++++++++++++++++++++++------- webui/static/style.css | 188 ++++++++++++++++++++++++++++++++++ 6 files changed, 421 insertions(+), 64 deletions(-) diff --git a/web_server.py b/web_server.py index 92820feb..2a9e7741 100644 --- a/web_server.py +++ b/web_server.py @@ -18999,6 +18999,17 @@ def get_version_info(): "title": "What's New in SoulSync", "subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes", "sections": [ + { + "title": "👁️ Watch All Unwatched Preview Modal", + "description": "The Watch All Unwatched button now opens a modal showing exactly which artists will be added", + "features": [ + "• Preview list shows all eligible artists with images, track counts, and matched sources", + "• Clear separation of eligible vs ineligible artists (no external ID)", + "• Collapsible section explains why some artists can't be added yet", + "• Confirm before adding — no more silent 'Added 0' surprises", + "• Results summary shown after completion" + ] + }, { "title": "🔧 Fix Watch All Unwatched Skipping Deezer Artists", "description": "Watch All Unwatched now supports Deezer as an ID source", @@ -34246,34 +34257,36 @@ def watchlist_all_unwatched_library_artists(): database = get_database() active_source = _get_active_discovery_source() - # Get ALL unwatched artists from library (no pagination) - result = database.get_library_artists( - search_query='', - letter='all', - page=1, - limit=99999, - watchlist_filter='unwatched', - profile_id=get_current_profile_id() - ) - - unwatched_artists = result.get('artists', []) + # Fetch all unwatched artists in pages (SQLite variable limit safe) + unwatched_artists = [] + page = 1 + page_size = 400 + while True: + result = database.get_library_artists( + search_query='', + letter='all', + page=page, + limit=page_size, + watchlist_filter='unwatched', + profile_id=get_current_profile_id() + ) + unwatched_artists.extend(result.get('artists', [])) + if not result.get('pagination', {}).get('has_next', False): + break + page += 1 added = 0 skipped_no_id = 0 skipped_already = 0 for artist in unwatched_artists: - # Determine the external ID to use based on active source + # Use only the active source's ID — matches frontend modal filtering artist_id = None - if active_source == 'spotify' and artist.get('spotify_artist_id'): - artist_id = artist['spotify_artist_id'] - elif active_source == 'deezer' and artist.get('deezer_id'): - artist_id = artist['deezer_id'] - elif artist.get('spotify_artist_id'): - artist_id = artist['spotify_artist_id'] - elif artist.get('itunes_artist_id'): - artist_id = artist['itunes_artist_id'] - elif artist.get('deezer_id'): - artist_id = artist['deezer_id'] + if active_source == 'spotify': + artist_id = artist.get('spotify_artist_id') + elif active_source == 'itunes': + artist_id = artist.get('itunes_artist_id') + elif active_source == 'deezer': + artist_id = artist.get('deezer_id') if not artist_id: skipped_no_id += 1 @@ -34288,13 +34301,7 @@ def watchlist_all_unwatched_library_artists(): skipped_already += 1 continue - # Determine source based on which ID field we picked - if artist_id == artist.get('spotify_artist_id'): - src = 'spotify' - elif artist_id == artist.get('deezer_id'): - src = 'deezer' - else: - src = _get_metadata_fallback_source() + src = active_source if active_source in ('spotify', 'itunes', 'deezer') else _get_metadata_fallback_source() success = database.add_artist_to_watchlist(artist_id, artist_name, profile_id=get_current_profile_id(), source=src) if success: added += 1 diff --git a/webui/index.html b/webui/index.html index a61279f3..84f6af87 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2484,7 +2484,7 @@ All Watched Unwatched - + 👁️ Watch All Unwatched diff --git a/webui/static/helper.js b/webui/static/helper.js index 1d53327a..defe47d7 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3403,6 +3403,7 @@ function closeHelperSearch() { const WHATS_NEW = { '2.1': [ // Newest features first + { title: 'Watch All Preview Modal', desc: 'Watch All Unwatched now opens a modal showing which artists will be added before confirming', page: 'library', selector: '#library-watchlist-all-btn' }, { title: 'Fix Watch All Unwatched', desc: 'Watch All Unwatched now works for Deezer users — was silently skipping artists with only Deezer IDs' }, { title: 'Fix Path Mismatch Fixes', desc: 'Library Maintenance path fixes now use fresh config and show error reasons in the toast' }, { title: 'Fix Wrong Spotify IDs', desc: 'Manual match no longer stores iTunes IDs as Spotify IDs — detects actual provider from results' }, diff --git a/webui/static/mobile.css b/webui/static/mobile.css index 912fbffe..a72ce854 100644 --- a/webui/static/mobile.css +++ b/webui/static/mobile.css @@ -1000,6 +1000,16 @@ padding: 5px 12px; } + .watch-all-modal { max-width: 95vw; width: 95vw; } + .watch-all-grid { grid-template-columns: 1fr; } + .watch-all-stats { flex-wrap: wrap; gap: 8px; padding: 12px 14px; } + .watch-all-stat-card { padding: 8px 12px; } + .watch-all-stat-value { font-size: 18px; } + .watch-all-cell { padding: 6px 8px; } + .watch-all-cell-img, .watch-all-cell-placeholder { width: 32px; height: 32px; } + .watch-all-header { padding: 16px 18px; } + .watch-all-search-wrap { padding: 8px 14px 4px; } + .library-card-watchlist-btn { opacity: 1; } diff --git a/webui/static/script.js b/webui/static/script.js index 2fc09367..321a6e57 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -38975,13 +38975,171 @@ function showLibraryEmpty(show) { } } -async function addAllUnwatchedToWatchlist(btn) { - if (btn.classList.contains('adding') || btn.classList.contains('all-added')) return; +async function openWatchAllUnwatchedModal() { + if (document.getElementById('watch-all-modal-overlay')) return; - btn.classList.add('adding'); - const textSpan = btn.querySelector('.watchlist-all-text'); - const originalText = textSpan.textContent; - textSpan.textContent = 'Adding...'; + const sourceIdField = currentMusicSourceName === 'Apple Music' ? 'itunes_artist_id' + : currentMusicSourceName === 'Deezer' ? 'deezer_id' : 'spotify_artist_id'; + const sourceName = currentMusicSourceName || 'Spotify'; + + const overlay = document.createElement('div'); + overlay.id = 'watch-all-modal-overlay'; + overlay.className = 'modal-overlay'; + overlay.onclick = (e) => { if (e.target === overlay) closeWatchAllUnwatchedModal(); }; + + overlay.innerHTML = ` + + + + 👁 + + Watch All Unwatched + Add unwatched artists with ${_esc(sourceName)} IDs to your watchlist + + + × + + + + + Loading unwatched artists... + + + + + + `; + document.body.appendChild(overlay); + + // Fetch all unwatched artists paginated (SQLite variable limit safe) + try { + const eligible = []; + const ineligible = []; + let page = 1; + const pageSize = 400; + const countEl = document.getElementById('watch-all-load-count'); + + while (true) { + if (!document.getElementById('watch-all-modal-overlay')) return; + if (countEl) countEl.textContent = `${eligible.length + ineligible.length} artists loaded...`; + + const params = new URLSearchParams({ search: '', letter: 'all', page, limit: pageSize, watchlist: 'unwatched' }); + const response = await fetch(`/api/library/artists?${params}`); + const data = await response.json(); + if (!data.success) throw new Error(data.error || 'Failed to load artists'); + + for (const a of (data.artists || [])) { + if (a[sourceIdField]) eligible.push(a); + else ineligible.push(a); + } + + if (!data.pagination.has_next) break; + page++; + } + + _renderWatchAllModalContent(overlay, eligible, ineligible, sourceName); + } catch (error) { + console.error('Error loading unwatched artists:', error); + const body = overlay.querySelector('.watch-all-body'); + if (body) body.innerHTML = `⚠Failed to load artistsRetry`; + } +} + +function _renderWatchAllModalContent(overlay, eligible, ineligible, sourceName) { + const body = overlay.querySelector('.watch-all-body'); + const confirmBtn = overlay.querySelector('#watch-all-confirm-btn'); + + if (eligible.length === 0 && ineligible.length === 0) { + body.innerHTML = '🎵No unwatched artists found'; + return; + } + + // Store data for search filtering + overlay._watchAllEligible = eligible; + overlay._watchAllIneligible = ineligible; + + let html = ''; + + // Summary bar (sticky) + html += ''; + html += `${eligible.length}Ready to watch`; + html += `${ineligible.length}No ${_esc(sourceName)} ID`; + html += `${eligible.length + ineligible.length}Total unwatched`; + html += ''; + + // Search filter + if (eligible.length > 10) { + html += ''; + } + + // Eligible grid + if (eligible.length > 0) { + html += 'Artists to be watched'; + html += ''; + html += _buildWatchAllRows(eligible, false); + html += ''; + } + + // Ineligible section + if (ineligible.length > 0) { + html += ` + + + ⚠ + ${ineligible.length} artist${ineligible.length !== 1 ? 's' : ''} without ${_esc(sourceName)} ID + + ▼ + + + These artists haven't been matched to ${_esc(sourceName)} yet. The background enrichment worker will match them over time. + ${_buildWatchAllRows(ineligible, true)} + + `; + } + + if (eligible.length === 0) { + html += `🔌None of your unwatched artists have a ${_esc(sourceName)} ID yetThe background enrichment worker will match them over time.`; + } + + body.innerHTML = html; + + if (eligible.length > 0 && confirmBtn) { + confirmBtn.textContent = `Watch All (${eligible.length})`; + confirmBtn.disabled = false; + confirmBtn.onclick = () => _confirmWatchAllUnwatched(overlay, eligible.length); + } +} + +function _buildWatchAllRows(artists, dimmed) { + let html = ''; + for (const a of artists) { + const img = a.image_url + ? `🎵` + : `🎵`; + html += ` + ${img} + ${_esc(a.name)} + ${a.track_count || 0} tracks + `; + } + return html; +} + +function _filterWatchAllList(query) { + const q = query.toLowerCase().trim(); + document.querySelectorAll('#watch-all-eligible-grid .watch-all-cell').forEach(cell => { + cell.style.display = !q || cell.dataset.name.includes(q) ? '' : 'none'; + }); +} + +async function _confirmWatchAllUnwatched(overlay, expectedCount) { + const confirmBtn = overlay.querySelector('#watch-all-confirm-btn'); + const cancelBtn = overlay.querySelector('.watch-all-btn-cancel'); + if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = 'Adding...'; } + if (cancelBtn) cancelBtn.disabled = true; try { const response = await fetch('/api/library/watchlist-all-unwatched', { @@ -38991,43 +39149,36 @@ async function addAllUnwatchedToWatchlist(btn) { const data = await response.json(); if (data.success) { - btn.classList.remove('adding'); - btn.classList.add('all-added'); - - let resultText = `Added ${data.added}`; - if (data.skipped_no_id > 0) { - resultText += ` (${data.skipped_no_id} unmatched)`; - } - textSpan.textContent = resultText; - - if (data.added > 0) { - showToast(data.message, 'success'); - // Reload the library to reflect changes - setTimeout(() => { - loadLibraryArtists(); - }, 1500); - } else if (data.skipped_no_id > 0) { - showToast(`No artists could be added — ${data.skipped_no_id} don't have matching IDs yet. Background workers will match them over time.`, 'warning'); - } else { - showToast('No unwatched artists found', 'info'); - } + const body = overlay.querySelector('.watch-all-body'); + body.innerHTML = ` + ✓ + Added ${data.added} artist${data.added !== 1 ? 's' : ''} to watchlist + ${data.skipped_already > 0 ? `${data.skipped_already} already watched` : ''} + ${data.skipped_no_id > 0 ? `${data.skipped_no_id} skipped (no external ID)` : ''} + `; - // Reset button after a delay - setTimeout(() => { - btn.classList.remove('all-added'); - textSpan.textContent = originalText; - }, 5000); + if (confirmBtn) confirmBtn.style.display = 'none'; + if (cancelBtn) { cancelBtn.disabled = false; cancelBtn.textContent = 'Close'; } + overlay.dataset.needsRefresh = 'true'; } else { throw new Error(data.error || 'Failed to add artists'); } } catch (error) { - console.error('Error bulk adding unwatched artists:', error); - btn.classList.remove('adding'); - textSpan.textContent = originalText; + console.error('Error in watch all:', error); + if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.textContent = `Watch All (${expectedCount})`; } + if (cancelBtn) cancelBtn.disabled = false; showToast('Failed to add artists to watchlist', 'error'); } } +function closeWatchAllUnwatchedModal() { + const overlay = document.getElementById('watch-all-modal-overlay'); + if (!overlay) return; + const needsRefresh = overlay.dataset.needsRefresh === 'true'; + overlay.remove(); + if (needsRefresh) loadLibraryArtists(); +} + async function toggleLibraryCardWatchlist(btn, artist) { if (btn.disabled) return; btn.disabled = true; diff --git a/webui/static/style.css b/webui/static/style.css index 94511574..f1df20d3 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -20560,6 +20560,194 @@ body.helper-mode-active #dashboard-activity-feed:hover { display: none; } +/* ── Watch All Unwatched Modal ──────────────────────────── */ +.watch-all-modal { + width: 880px; max-width: 95vw; max-height: 85vh; + background: linear-gradient(135deg, rgba(20,20,20,0.97) 0%, rgba(12,12,12,0.99) 100%); + backdrop-filter: blur(20px) saturate(1.2); + border-radius: 20px; + border: 1px solid rgba(255,255,255,0.1); + border-top: 1px solid rgba(255,255,255,0.15); + box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 8px 32px rgba(0,0,0,0.4), + 0 0 40px rgba(var(--accent-rgb), 0.08), inset 0 1px 0 rgba(255,255,255,0.1); + display: flex; flex-direction: column; overflow: hidden; +} +.watch-all-header { + display: flex; align-items: center; justify-content: space-between; + padding: 20px 24px; + background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.06) 0%, transparent 60%); + border-bottom: 1px solid rgba(var(--accent-rgb), 0.12); +} +.watch-all-header-content { display: flex; align-items: center; gap: 14px; } +.watch-all-header-icon { font-size: 28px; opacity: 0.9; } +.watch-all-title { + font-size: 20px; font-weight: 700; color: #fff; letter-spacing: -0.3px; + font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif; +} +.watch-all-subtitle { font-size: 13px; color: rgba(255,255,255,0.45); margin-top: 2px; } +.watch-all-close { + background: rgba(255,255,255,0.08); border: none; color: rgba(255,255,255,0.5); + width: 36px; height: 36px; border-radius: 50%; font-size: 20px; cursor: pointer; + display: flex; align-items: center; justify-content: center; transition: all 0.2s; +} +.watch-all-close:hover { background: rgba(255,255,255,0.15); color: #fff; transform: scale(1.1); } +.watch-all-body { + flex: 1; overflow-y: auto; padding: 0; + scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.15) transparent; +} +.watch-all-body::-webkit-scrollbar { width: 6px; } +.watch-all-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; } +.watch-all-footer { + display: flex; justify-content: flex-end; gap: 10px; + padding: 16px 24px; + border-top: 1px solid rgba(255,255,255,0.06); + background: rgba(0,0,0,0.2); +} +.watch-all-btn { + padding: 10px 24px; border-radius: 10px; font-size: 14px; font-weight: 600; + border: none; cursor: pointer; transition: all 0.2s; +} +.watch-all-btn-cancel { + background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.7); +} +.watch-all-btn-cancel:hover { background: rgba(255,255,255,0.12); color: #fff; } +.watch-all-btn-primary { + background: rgb(var(--accent-rgb)); color: #fff; + box-shadow: 0 4px 12px rgba(var(--accent-rgb), 0.3); +} +.watch-all-btn-primary:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(var(--accent-rgb), 0.4); +} +.watch-all-btn-primary:disabled { opacity: 0.4; cursor: default; } + +/* Loading state */ +.watch-all-loading-state { + display: flex; flex-direction: column; align-items: center; justify-content: center; + padding: 64px 24px; gap: 16px; +} +.watch-all-loading-spinner { + width: 36px; height: 36px; border-radius: 50%; + border: 3px solid rgba(255,255,255,0.1); border-top-color: rgb(var(--accent-rgb)); + animation: watch-all-spin 0.8s linear infinite; +} +@keyframes watch-all-spin { to { transform: rotate(360deg); } } +.watch-all-loading-text { color: rgba(255,255,255,0.6); font-size: 14px; } +.watch-all-loading-count { color: rgba(255,255,255,0.3); font-size: 13px; } + +/* Stats cards */ +.watch-all-stats { + display: flex; gap: 12px; padding: 16px 20px; + position: sticky; top: 0; z-index: 2; + background: linear-gradient(135deg, rgba(20,20,20,0.98) 0%, rgba(16,16,16,0.98) 100%); + border-bottom: 1px solid rgba(255,255,255,0.06); +} +.watch-all-stat-card { + flex: 1; padding: 12px 16px; border-radius: 12px; + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.06); + text-align: center; transition: all 0.2s; +} +.watch-all-stat-card:hover { border-color: rgba(255,255,255,0.1); background: rgba(255,255,255,0.05); } +.watch-all-stat-card.eligible { border-color: rgba(var(--accent-rgb), 0.2); } +.watch-all-stat-card.eligible .watch-all-stat-value { color: rgb(var(--accent-rgb)); } +.watch-all-stat-card.ineligible .watch-all-stat-value { color: rgba(255,180,80,0.8); } +.watch-all-stat-value { font-size: 22px; font-weight: 700; color: #fff; } +.watch-all-stat-label { font-size: 11px; color: rgba(255,255,255,0.4); margin-top: 2px; text-transform: uppercase; letter-spacing: 0.5px; } + +/* Search */ +.watch-all-search-wrap { padding: 8px 20px 4px; position: sticky; top: 78px; z-index: 2; background: rgba(16,16,16,0.95); } +.watch-all-search { + width: 100%; padding: 10px 16px; border-radius: 10px; + background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); + color: #fff; font-size: 14px; outline: none; transition: border-color 0.2s; + box-sizing: border-box; +} +.watch-all-search:focus { border-color: rgba(var(--accent-rgb), 0.4); } +.watch-all-search::placeholder { color: rgba(255,255,255,0.3); } + +/* Section labels */ +.watch-all-section-label { + padding: 12px 20px 6px; font-size: 11px; font-weight: 600; + color: rgba(255,255,255,0.35); text-transform: uppercase; letter-spacing: 1px; +} + +/* Artist grid — two columns */ +.watch-all-grid { + display: grid; grid-template-columns: 1fr 1fr; gap: 2px; + padding: 4px 12px 12px; +} +.watch-all-cell { + display: flex; align-items: center; gap: 10px; + padding: 8px 12px; border-radius: 10px; + transition: background 0.15s; +} +.watch-all-cell:hover { background: rgba(255,255,255,0.04); } +.watch-all-cell.dimmed { opacity: 0.4; } +.watch-all-cell-img { + width: 38px; height: 38px; flex-shrink: 0; border-radius: 50%; overflow: hidden; + background: rgba(255,255,255,0.06); +} +.watch-all-cell-img img { width: 100%; height: 100%; object-fit: cover; display: block; } +.watch-all-cell-placeholder { + width: 38px; height: 38px; border-radius: 50%; + background: rgba(255,255,255,0.06); + display: flex; align-items: center; justify-content: center; font-size: 16px; +} +.watch-all-cell-name { + font-size: 13px; font-weight: 500; color: #fff; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + flex: 1; min-width: 0; +} +.watch-all-cell-meta { font-size: 11px; color: rgba(255,255,255,0.3); white-space: nowrap; } + +/* Ineligible collapsible section */ +.watch-all-ineligible { + border-top: 1px solid rgba(255,255,255,0.06); margin-top: 8px; +} +.watch-all-ineligible-header { + display: flex; justify-content: space-between; align-items: center; + padding: 12px 20px; cursor: pointer; transition: background 0.15s; +} +.watch-all-ineligible-header:hover { background: rgba(255,255,255,0.03); } +.watch-all-ineligible-label { display: flex; align-items: center; gap: 8px; font-size: 13px; color: rgba(255,180,80,0.7); } +.watch-all-ineligible-icon { font-size: 14px; } +.watch-all-chevron { font-size: 10px; color: rgba(255,255,255,0.3); transition: transform 0.25s; } +.watch-all-ineligible.expanded .watch-all-chevron { transform: rotate(180deg); } +.watch-all-ineligible-body { + max-height: 0; overflow: hidden; transition: max-height 0.35s ease; +} +.watch-all-ineligible.expanded .watch-all-ineligible-body { max-height: 400px; overflow-y: auto; } +.watch-all-ineligible-hint { + padding: 8px 20px 4px; font-size: 12px; color: rgba(255,255,255,0.3); + border-left: 3px solid rgba(255,180,80,0.3); margin: 0 20px 8px; padding-left: 12px; +} + +/* Results state */ +.watch-all-results { + display: flex; flex-direction: column; align-items: center; justify-content: center; + padding: 64px 24px; gap: 8px; +} +.watch-all-results-icon { + width: 64px; height: 64px; border-radius: 50%; + background: rgba(var(--accent-rgb), 0.12); color: rgb(var(--accent-rgb)); + display: flex; align-items: center; justify-content: center; + font-size: 32px; margin-bottom: 8px; + box-shadow: 0 0 24px rgba(var(--accent-rgb), 0.15); +} +.watch-all-results-title { font-size: 20px; font-weight: 600; color: #fff; } +.watch-all-results-detail { font-size: 13px; color: rgba(255,255,255,0.4); } + +/* Empty / error state */ +.watch-all-empty-state { + display: flex; flex-direction: column; align-items: center; justify-content: center; + padding: 64px 24px; gap: 8px; color: rgba(255,255,255,0.5); font-size: 14px; +} +.watch-all-empty-icon { font-size: 36px; margin-bottom: 4px; opacity: 0.5; } +.watch-all-empty-hint { font-size: 12px; color: rgba(255,255,255,0.3); margin-top: 4px; } +.watch-all-retry-link { color: rgb(var(--accent-rgb)); text-decoration: none; margin-top: 8px; } +.watch-all-retry-link:hover { text-decoration: underline; } + .alphabet-selector { overflow-x: auto; -webkit-overflow-scrolling: touch;
Add unwatched artists with ${_esc(sourceName)} IDs to your watchlist