From 7c0f9510c88dce764c5b0f625ce305bdff997cba Mon Sep 17 00:00:00 2001 From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com> Date: Thu, 23 Apr 2026 09:54:51 -0700 Subject: [PATCH 01/80] Skip Docker/publish workflows on forks Add repository guard (github.repository == Nezreka/SoulSync) to cleanup-dev-images, dev-nightly, and docker-publish workflows. build-and-test stays available for fork contributors. --- .github/workflows/cleanup-dev-images.yml | 1 + .github/workflows/dev-nightly.yml | 1 + .github/workflows/docker-publish.yml | 1 + 3 files changed, 3 insertions(+) diff --git a/.github/workflows/cleanup-dev-images.yml b/.github/workflows/cleanup-dev-images.yml index 19597992..e065c9ce 100644 --- a/.github/workflows/cleanup-dev-images.yml +++ b/.github/workflows/cleanup-dev-images.yml @@ -8,6 +8,7 @@ on: jobs: cleanup: + if: github.repository == 'Nezreka/SoulSync' runs-on: ubuntu-latest permissions: packages: write diff --git a/.github/workflows/dev-nightly.yml b/.github/workflows/dev-nightly.yml index d5312d29..6165350d 100644 --- a/.github/workflows/dev-nightly.yml +++ b/.github/workflows/dev-nightly.yml @@ -13,6 +13,7 @@ on: jobs: nightly: + if: github.repository == 'Nezreka/SoulSync' runs-on: ubuntu-latest # Skip scheduled runs if dev branch has no new commits in the last 24h # (pushes and manual triggers always run) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b18d1b26..44e766dd 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -15,6 +15,7 @@ on: jobs: build-and-push: + if: github.repository == 'Nezreka/SoulSync' runs-on: ubuntu-latest permissions: From 4b619951ff5e79752cdb31aac9659fb28d7d864e Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:52:13 -0700 Subject: [PATCH 02/80] Fix UnboundLocalError in _check_and_remove_from_wishlist Method 4 When a completed download's track_info has neither an `id` field nor a `wishlist_id`, Methods 1-3 of _check_and_remove_from_wishlist() all skip without defining `wishlist_tracks`. Method 4 (fuzzy match) then hits `if not wishlist_tracks:` and raises UnboundLocalError, which the call sites catch + log but silently skip the wishlist removal for that track. Path became more common after the batch-queue-system refactor started routing non-Spotify-id completions (e.g. discover sync tracks downloaded under a non-Spotify primary source) through the same completion handler. Fix: initialize `wishlist_tracks = []` at the top of the try block so Method 3's reassignment still works and Method 4's `if not wishlist_tracks` guard always has a defined value to test. Credit to RENOxDECEPTION (JohnBaumb) for pinpointing the variable-scope issue during PR #357 testing. --- web_server.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web_server.py b/web_server.py index 05d577c4..32ba02dc 100644 --- a/web_server.py +++ b/web_server.py @@ -22619,7 +22619,11 @@ def _check_and_remove_from_wishlist(context): # Try to extract Spotify track ID from various sources in the context spotify_track_id = None - + # Populated lazily by Method 3 or Method 4. Initialized here so Method 4's + # `if not wishlist_tracks` guard doesn't UnboundLocalError when Methods 1/2 + # found nothing and Method 3 never ran (no wishlist_id in track_info). + wishlist_tracks = [] + # Method 1: Direct track_info with id track_info = context.get('track_info', {}) if track_info.get('id'): From f85564a2deb8a6150350f4a783830d8f8d1211b9 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:13:26 -0700 Subject: [PATCH 03/80] Move enhancedSearchFetch, SOURCE_LABELS, renderCompactSection to shared-helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These three utilities lived inside search.js — the fetch helper at module scope, and SOURCE_LABELS plus renderCompactSection as closures inside initializeSearchModeToggle. The global search widget in downloads.js already depends on enhancedSearchFetch via global scope and re-implements the rendering inline. Hoist all three to shared-helpers.js so both surfaces share the same implementations. No behavior change — this is the refactor step that precedes the source-picker redesign. Also adds a 'soulseek' entry to SOURCE_LABELS for the upcoming icon row. --- webui/static/search.js | 142 ++----------------------------- webui/static/shared-helpers.js | 149 +++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 137 deletions(-) diff --git a/webui/static/search.js b/webui/static/search.js index 0cae5787..42f9b45a 100644 --- a/webui/static/search.js +++ b/webui/static/search.js @@ -1,21 +1,8 @@ // 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(); -} +// `enhancedSearchFetch`, `SOURCE_LABELS`, and `renderCompactSection` live in +// shared-helpers.js so the Search page and the global widget share the same +// implementations. function initializeSearch() { // --- FIX: Corrected the element IDs to match the HTML --- @@ -107,15 +94,7 @@ function initializeSearchModeToggle() { let _activeSearchSource = null; // Currently displayed source tab let _altSourceController = null; // AbortController for alternate source fetches - const SOURCE_LABELS = { - spotify: { text: 'Spotify', tabClass: 'enh-tab-spotify', badgeClass: 'enh-badge-spotify' }, - itunes: { text: 'Apple Music', tabClass: 'enh-tab-itunes', badgeClass: 'enh-badge-itunes' }, - deezer: { text: 'Deezer', tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer' }, - discogs: { text: 'Discogs', tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs' }, - hydrabase: { text: 'Hydrabase', tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase' }, - youtube_videos: { text: 'Music Videos', tabClass: 'enh-tab-youtube', badgeClass: 'enh-badge-youtube' }, - musicbrainz: { text: 'MusicBrainz', tabClass: 'enh-tab-musicbrainz', badgeClass: 'enh-badge-musicbrainz' }, - }; + // SOURCE_LABELS now lives in shared-helpers.js. // Live search with debouncing if (enhancedInput) { @@ -808,118 +787,7 @@ function initializeSearchModeToggle() { return `${minutes}:${seconds.toString().padStart(2, '0')}`; } - function renderCompactSection(sectionId, listId, countId, items, mapItem) { - const section = document.getElementById(sectionId); - const list = document.getElementById(listId); - const count = document.getElementById(countId); - - if (!list) return; - - list.innerHTML = ''; - - if (!items || items.length === 0) { - section.classList.add('hidden'); - return; - } - - section.classList.remove('hidden'); - count.textContent = items.length; - - // Determine type based on section ID - const isArtist = sectionId.includes('artists'); - const isAlbum = sectionId.includes('albums') || sectionId.includes('singles'); - const isTrack = sectionId.includes('tracks'); - - // Add appropriate grid class to list - if (isArtist) { - list.classList.add('enh-artists-grid'); - } else if (isAlbum) { - list.classList.add('enh-albums-grid'); - } else if (isTrack) { - list.classList.add('enh-tracks-list'); - } - - items.forEach(item => { - const config = mapItem(item); - const elem = document.createElement('div'); - - // Add appropriate card class - if (isArtist) { - elem.className = 'enh-compact-item artist-card'; - // Add data attributes for lazy loading - if (item.id) { - elem.dataset.artistId = item.id; - elem.dataset.needsImage = config.image ? 'false' : 'true'; - } - } else if (isAlbum) { - elem.className = 'enh-compact-item album-card'; - } else if (isTrack) { - elem.className = 'enh-compact-item track-item'; - } - - // Build image HTML with type-specific classes - let imageClass = 'enh-item-image'; - let placeholderClass = 'enh-item-image-placeholder'; - - if (isArtist) { - imageClass += ' artist-image'; - placeholderClass += ' artist-placeholder'; - } else if (isAlbum) { - imageClass += ' album-cover'; - placeholderClass += ' album-placeholder'; - } else if (isTrack) { - imageClass += ' track-cover'; - placeholderClass += ' track-placeholder'; - } - - const imageHtml = config.image - ? `${escapeHtml(config.name)}` - : `
${config.placeholder}
`; - - const badgeHtml = config.badge - ? `
${config.badge.text}
` - : ''; - - const durationHtml = config.duration && isTrack - ? `
- ${escapeHtml(config.duration)} - -
` - : ''; - - elem.innerHTML = ` - ${imageHtml} -
-
${escapeHtml(config.name)}
-
${escapeHtml(config.meta)}
-
- ${durationHtml} - ${badgeHtml} - `; - - elem.addEventListener('click', config.onClick); - - // Add play button handler for tracks - if (isTrack && config.onPlay) { - const playBtn = elem.querySelector('.enh-item-play-btn'); - if (playBtn) { - playBtn.addEventListener('click', (e) => { - e.stopPropagation(); // Don't trigger main onClick - config.onPlay(); - }); - } - } - - list.appendChild(elem); - - // Extract colors from image for dynamic glow effect - if (config.image) { - extractImageColors(config.image, (colors) => { - applyDynamicGlow(elem, colors); - }); - } - }); - } + // renderCompactSection now lives in shared-helpers.js. async function handleEnhancedSearchAlbumClick(album) { console.log(`💿 Enhanced search album clicked: ${album.name} by ${album.artist}`); diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 312278fb..1a565973 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -12,6 +12,155 @@ // ============================================================================ +// ---------------------------------------------------------------------------- +// Enhanced search shared utilities (used by Search page + 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(); +} + +// Per-source labels + tab/badge CSS classes. Referenced by both the Search +// page and the global search widget for consistent badge/icon rendering. +const SOURCE_LABELS = { + spotify: { text: 'Spotify', tabClass: 'enh-tab-spotify', badgeClass: 'enh-badge-spotify' }, + itunes: { text: 'Apple Music', tabClass: 'enh-tab-itunes', badgeClass: 'enh-badge-itunes' }, + deezer: { text: 'Deezer', tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer' }, + discogs: { text: 'Discogs', tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs' }, + hydrabase: { text: 'Hydrabase', tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase' }, + youtube_videos: { text: 'Music Videos', tabClass: 'enh-tab-youtube', badgeClass: 'enh-badge-youtube' }, + musicbrainz: { text: 'MusicBrainz', tabClass: 'enh-tab-musicbrainz', badgeClass: 'enh-badge-musicbrainz' }, + soulseek: { text: 'Soulseek', tabClass: 'enh-tab-soulseek', badgeClass: 'enh-badge-soulseek' }, +}; + +// Render a single enhanced-search result section (artists / albums / tracks). +// Shared between the Search page and the global widget. The mapItem callback +// projects each backend item to the card config consumed here. +function renderCompactSection(sectionId, listId, countId, items, mapItem) { + const section = document.getElementById(sectionId); + const list = document.getElementById(listId); + const count = document.getElementById(countId); + + if (!list) return; + + list.innerHTML = ''; + + if (!items || items.length === 0) { + section.classList.add('hidden'); + return; + } + + section.classList.remove('hidden'); + count.textContent = items.length; + + // Determine type based on section ID + const isArtist = sectionId.includes('artists'); + const isAlbum = sectionId.includes('albums') || sectionId.includes('singles'); + const isTrack = sectionId.includes('tracks'); + + // Add appropriate grid class to list + if (isArtist) { + list.classList.add('enh-artists-grid'); + } else if (isAlbum) { + list.classList.add('enh-albums-grid'); + } else if (isTrack) { + list.classList.add('enh-tracks-list'); + } + + items.forEach(item => { + const config = mapItem(item); + const elem = document.createElement('div'); + + // Add appropriate card class + if (isArtist) { + elem.className = 'enh-compact-item artist-card'; + // Add data attributes for lazy loading + if (item.id) { + elem.dataset.artistId = item.id; + elem.dataset.needsImage = config.image ? 'false' : 'true'; + } + } else if (isAlbum) { + elem.className = 'enh-compact-item album-card'; + } else if (isTrack) { + elem.className = 'enh-compact-item track-item'; + } + + // Build image HTML with type-specific classes + let imageClass = 'enh-item-image'; + let placeholderClass = 'enh-item-image-placeholder'; + + if (isArtist) { + imageClass += ' artist-image'; + placeholderClass += ' artist-placeholder'; + } else if (isAlbum) { + imageClass += ' album-cover'; + placeholderClass += ' album-placeholder'; + } else if (isTrack) { + imageClass += ' track-cover'; + placeholderClass += ' track-placeholder'; + } + + const imageHtml = config.image + ? `${escapeHtml(config.name)}` + : `
${config.placeholder}
`; + + const badgeHtml = config.badge + ? `
${config.badge.text}
` + : ''; + + const durationHtml = config.duration && isTrack + ? `
+ ${escapeHtml(config.duration)} + +
` + : ''; + + elem.innerHTML = ` + ${imageHtml} +
+
${escapeHtml(config.name)}
+
${escapeHtml(config.meta)}
+
+ ${durationHtml} + ${badgeHtml} + `; + + elem.addEventListener('click', config.onClick); + + // Add play button handler for tracks + if (isTrack && config.onPlay) { + const playBtn = elem.querySelector('.enh-item-play-btn'); + if (playBtn) { + playBtn.addEventListener('click', (e) => { + e.stopPropagation(); // Don't trigger main onClick + config.onPlay(); + }); + } + } + + list.appendChild(elem); + + // Extract colors from image for dynamic glow effect + if (config.image) { + extractImageColors(config.image, (colors) => { + applyDynamicGlow(elem, colors); + }); + } + }); +} + + // ---------------------------------------------------------------------------- // Discography completion checking (for artist-detail pages, library page) // ---------------------------------------------------------------------------- From a72810ce22d22267a563680c7a155e24f26cabec Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:23:17 -0700 Subject: [PATCH 04/80] Search page: replace fan-out with source-picker icon row + per-source cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Search page previously fired a primary /api/enhanced-search request plus a fan-out loop (_queueAlternateSourceFetches / _fetchAlternateSource) that streamed NDJSON from /api/enhanced-search/source/ for every other configured source. One search = 7 API calls across Spotify, iTunes, Deezer, Discogs, Hydrabase, MusicBrainz, and YouTube Music Videos. The post-search tab bar then let users switch views between the results that had already been fetched. This changes the default to explicit per-source selection: - The old - - - - - - - - - - - - + +
@@ -2017,8 +2004,9 @@ diff --git a/webui/static/helper.js b/webui/static/helper.js index de289e17..37d89cf3 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3456,6 +3456,8 @@ const WHATS_NEW = { { 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 }, { title: 'Unified Source-Picker Controller (Search Page + Global Widget)', desc: 'Internal refactor — the source picker state machine (query, active source, per-query cache, fallbacks, loading state, configured-source discovery) is now a single createSearchController factory in shared-helpers.js. Both the full Search page and the sidebar global search popover consume the same controller with per-surface wiring (DOM elements, Soulseek handoff, unconfigured-source click). About 380 lines of near-duplicated state + fetch + render code consolidated into one implementation, so a bug fix or behavior tweak to the picker lands everywhere at once. Zero UX change — every keystroke, icon click, cache hit, rate-limit fallback, and unconfigured-source redirect behaves identically to before', page: 'search', unreleased: true }, { title: 'Fix Clean Search History Automation Failing with AttributeError', desc: 'The hourly Clean Search History maintenance automation was crashing with "DownloadOrchestrator object has no attribute base_url". Root cause: the check `soulseek_client.base_url` was written before the orchestrator refactor — `soulseek_client` is now a DownloadOrchestrator that wraps individual download clients, with the real Soulseek client at `.soulseek`. Two other call sites in web_server.py already used the correct `soulseek_client.soulseek.base_url` pattern; this one was missed. Now matches the same getattr-guarded pattern and the hourly cleanup runs again', page: 'stats' }, + { title: 'Search Results Always Visible — Show/Hide Button Removed', desc: 'The "Show Results / Hide Results" toggle next to the search bar is gone. There was nothing else on the page worth seeing instead of results, so toggling visibility never made sense. Cin flagged it during PR review. Dropdown visibility is now a pure function of query state — empty input hides it, results show it', page: 'search', unreleased: true }, + { title: 'Cached Search Results Restore on Navigate-Back', desc: 'Previously, navigating away from /search via a sidebar link dismissed the dropdown (the click registered as outside-click). When you came back, the input still held your query but the results were hidden until you typed again or clicked Show Results. Now the per-query cache renders automatically when you re-enter /search, so your results are right where you left them. Cin flagged the round-trip during PR review', page: 'search', unreleased: true }, ], '2.39': [ // --- April 22, 2026 --- diff --git a/webui/static/mobile.css b/webui/static/mobile.css index bb0428c5..75ff8337 100644 --- a/webui/static/mobile.css +++ b/webui/static/mobile.css @@ -329,11 +329,6 @@ width: 100%; } - .enhanced-search-bar-container button { - width: 100%; - min-height: 44px; - } - .filter-group { flex-wrap: wrap; } diff --git a/webui/static/search.js b/webui/static/search.js index a9d4c28a..f9c3e9d1 100644 --- a/webui/static/search.js +++ b/webui/static/search.js @@ -35,11 +35,18 @@ function initializeSearch() { // =============================== let searchModeToggleInitialized = false; +// Set by the closure on first init; called by subsequent invocations to +// re-display the search dropdown from the controller's cached state. +// Solves the "results vanish on navigate-back" UX issue — a sidebar nav +// click is treated as outside-click and dismisses the dropdown, so when +// the user returns to /search we need to re-render whatever was cached. +let _searchPageRestoreOnEnter = null; function initializeSearchModeToggle() { - // Only initialize once to prevent duplicate event listeners + // Subsequent invocations: just re-display cached results so they don't + // vanish on navigate-back. Skip the duplicate-listener setup. if (searchModeToggleInitialized) { - console.log('Search mode toggle already initialized, skipping...'); + if (_searchPageRestoreOnEnter) _searchPageRestoreOnEnter(); return; } @@ -61,7 +68,6 @@ function initializeSearchModeToggle() { // the controller up with Search-page-specific DOM + callbacks. const enhancedInput = document.getElementById('enhanced-search-input'); - const enhancedSearchBtn = document.getElementById('enhanced-search-btn'); const enhancedCancelBtn = document.getElementById('enhanced-cancel-btn'); const enhancedDropdown = document.getElementById('enhanced-dropdown'); const loadingState = document.getElementById('enhanced-loading'); @@ -202,6 +208,12 @@ function initializeSearchModeToggle() { }); searchController.init(); + // Expose a re-render hook so navigate-back to /search restores cached + // results instead of leaving the dropdown hidden. + _searchPageRestoreOnEnter = () => { + if (searchController.state.query) _renderFromState(searchController.state); + }; + // Live search with debouncing if (enhancedInput) { enhancedInput.addEventListener('input', (e) => { @@ -238,35 +250,6 @@ function initializeSearchModeToggle() { }); } - if (enhancedSearchBtn) { - enhancedSearchBtn.addEventListener('click', (e) => { - // Prevent click from bubbling to document (which would close the dropdown) - e.stopPropagation(); - - // Get fresh references (in case we navigated away and back) - const dropdown = document.getElementById('enhanced-dropdown'); - const results = document.getElementById('enhanced-results-container'); - - if (!dropdown) return; - - // Toggle the dropdown visibility to show/hide previous search results - if (dropdown.classList.contains('hidden')) { - // Check if there are results to show by looking for actual content - const hasResults = results && - !results.classList.contains('hidden') && - results.children.length > 0; - - if (hasResults) { - showDropdown(); - } else { - showToast('No previous results to show. Type to search!', 'info'); - } - } else { - hideDropdown(); - } - }); - } - if (enhancedCancelBtn) { enhancedCancelBtn.addEventListener('click', () => { enhancedInput.value = ''; @@ -1017,10 +1000,7 @@ function initializeSearchModeToggle() { function showDropdown() { const dropdown = document.getElementById('enhanced-dropdown'); - if (dropdown) { - dropdown.classList.remove('hidden'); - updateToggleButtonState(); - } + if (dropdown) dropdown.classList.remove('hidden'); // Hide the page header + source picker to reclaim space const header = document.querySelector('#search-page .downloads-header'); const modeToggle = document.querySelector('.search-source-picker-container'); @@ -1032,10 +1012,7 @@ function initializeSearchModeToggle() { function hideDropdown() { const dropdown = document.getElementById('enhanced-dropdown'); - if (dropdown) { - dropdown.classList.add('hidden'); - updateToggleButtonState(); - } + if (dropdown) dropdown.classList.add('hidden'); // Restore hidden elements const header = document.querySelector('#search-page .downloads-header'); const modeToggle = document.querySelector('.search-source-picker-container'); @@ -1044,27 +1021,6 @@ function initializeSearchModeToggle() { if (modeToggle) modeToggle.classList.remove('enh-results-active-hide'); if (slskdPlaceholder) slskdPlaceholder.classList.remove('enh-results-active-hide'); } - - function updateToggleButtonState() { - // Get fresh references - const btn = document.getElementById('enhanced-search-btn'); - const dropdown = document.getElementById('enhanced-dropdown'); - - if (!btn || !dropdown) return; - - const btnIcon = btn.querySelector('.btn-icon'); - const btnText = btn.querySelector('.btn-text'); - - if (dropdown.classList.contains('hidden')) { - // Dropdown is hidden - button should say "Show Results" - if (btnIcon) btnIcon.textContent = '👁️'; - if (btnText) btnText.textContent = 'Show Results'; - } else { - // Dropdown is visible - button should say "Hide Results" - if (btnIcon) btnIcon.textContent = '🙈'; - if (btnText) btnText.textContent = 'Hide Results'; - } - } } async function performSearch() { diff --git a/webui/static/style.css b/webui/static/style.css index c7743e65..fedf54cd 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -33338,38 +33338,6 @@ div.artist-hero-badge { color: #fff; } -.enhanced-search-btn { - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 10px; - padding: 10px 20px; - color: rgba(255, 255, 255, 0.8); - font-family: 'Segoe UI', sans-serif; - font-size: 13px; - font-weight: 600; - cursor: pointer; - display: flex; - align-items: center; - gap: 6px; - transition: all 0.2s ease; - flex-shrink: 0; -} - -.enhanced-search-btn:hover { - background: rgba(255, 255, 255, 0.12); - color: #fff; - transform: none; - box-shadow: none; -} - -.enhanced-search-btn:active { - transform: scale(0.97); -} - -.btn-icon { - font-size: 14px; -} - /* Enhanced Search Status */ .enhanced-search-status { display: flex; @@ -33576,12 +33544,6 @@ div.artist-hero-badge { gap: 8px; } - .enhanced-search-btn { - width: 100%; - justify-content: center; - padding: 10px 16px; - } - #enhanced-search-input { font-size: 14px; } @@ -33602,11 +33564,6 @@ div.artist-hero-badge { margin-right: 6px; } - .enhanced-search-btn { - padding: 9px 14px; - font-size: 12px; - } - /* Better album/track results on mobile */ .album-result-item { margin-bottom: 8px; From ab7aeb302c626299ac7273e4187d92e29b2cb35a Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:17:33 -0700 Subject: [PATCH 17/80] Defer search-restore render so it survives nav-button click bubble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The navigate-back fix from the previous commit was being immediately undone by the document outside-click handler. Race: 1. Click on sidebar nav-button → button handler runs synchronously, eventually calling _searchPageRestoreOnEnter → _renderFromState → showDropdown removes `hidden` class 2. Click event bubbles up to document 3. Document outside-click handler sees dropdown is now visible, sees the click target is a nav-button (not inside the search wrapper or the source row), calls hideDropdown → instantly hidden again Fix: defer the _renderFromState call to setTimeout(0). The macrotask runs AFTER the click event finishes propagating, so by the time the dropdown becomes visible, the document outside-click handler has already short-circuited (it saw the dropdown still hidden). User reported having to delete + retype the last character of the query to force a re-render — which worked because the input event listener fires submitQuery, which routes through the controller without going through the deferred path. --- webui/static/search.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/webui/static/search.js b/webui/static/search.js index f9c3e9d1..e8d93f52 100644 --- a/webui/static/search.js +++ b/webui/static/search.js @@ -209,9 +209,13 @@ function initializeSearchModeToggle() { searchController.init(); // Expose a re-render hook so navigate-back to /search restores cached - // results instead of leaving the dropdown hidden. + // results instead of leaving the dropdown hidden. Deferred to the next + // tick so the render happens AFTER the nav-button click finishes + // bubbling to the document outside-click handler — otherwise that + // handler sees the just-shown dropdown and immediately dismisses it. _searchPageRestoreOnEnter = () => { - if (searchController.state.query) _renderFromState(searchController.state); + if (!searchController.state.query) return; + setTimeout(() => _renderFromState(searchController.state), 0); }; // Live search with debouncing From 005c6ad73aef34c166b7b576a3a103f3599202ac Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:24:46 -0700 Subject: [PATCH 18/80] Fix Soulseek handoff routing + stale-request flash on fast retype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two AI-review findings from Cin (kettui) on the source-picker PR: 1. Soulseek handoff from global widget went through metadata flow _gsNavigateToSearchPage(query, 'soulseek') wrote the query into #enhanced-search-input and dispatched an input event. The Search page controller's activeSource was whatever its default was (spotify, deezer, etc.), so the debounced submitQuery ran the enhanced /api/enhanced-search flow instead of the raw Soulseek file search. The `src` parameter was effectively ignored. Fix: when src === 'soulseek', pre-fill #downloads-search-input directly and click the Search page's Soulseek icon. The icon click triggers the controller's onSoulseekSelected callback, which owns the section swap and re-runs performDownloadsSearch against the value we just wrote to the basic input. 2. Stale in-flight requests cleared loadingSources after fast retype createSearchController._fetchSource awaits the fetch result, then unconditionally mutates state.loadingSources / state.sources in the settle and catch blocks. When a user typed "abc" → fetch started → typed "abcd" before the first fetch returned, the second submitQuery aborted the first fetch and started its own. The first fetch's catch (AbortError) then ran and cleared loadingSources for that source — wiping the spinner the new request had just set, and causing a brief flash of empty/error state while the new fetch was still in flight. Fix: monotonic _requestSeq token. Each _fetchSource call captures the next value (++_requestSeq). Settle / catch blocks (and the YouTube NDJSON streaming loop) bail before mutating shared state if requestId !== _requestSeq. Existing abortCtrl behavior unchanged — this is a layered defense for the catch-clobber pattern that abort alone can't prevent. --- webui/static/downloads.js | 20 +++++++++++++++++--- webui/static/helper.js | 2 ++ webui/static/shared-helpers.js | 26 ++++++++++++++++++++++++-- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/webui/static/downloads.js b/webui/static/downloads.js index 75e1607d..2ad5aa49 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -5181,10 +5181,24 @@ function _gsNavigateToSearchPage(query, src) { _gsDeactivate(); if (typeof navigateToPage !== 'function') return; navigateToPage('search'); - // After the page mounts, mirror the query into the enhanced input so the - // user doesn't have to retype it. The Search page's source picker will - // pick up `src` via its own default-source flow. + // After the page mounts, mirror the query into whichever input drives the + // requested source. Soulseek goes through the basic-search file flow, not + // the enhanced metadata flow — without this branch the Search page would + // run /api/enhanced-search instead of /api/search and the user would get + // metadata results when they clicked the Soulseek icon. setTimeout(() => { + if (src === 'soulseek') { + // Pre-fill the basic input first, then click the Search page's + // Soulseek icon. The icon's click triggers the controller's + // onSoulseekSelected callback, which owns the section swap and + // re-runs performDownloadsSearch with whatever's in the basic + // input (i.e., the value we just wrote). + const basicInput = document.getElementById('downloads-search-input'); + if (basicInput && query) basicInput.value = query; + const soulseekIcon = document.querySelector('#enh-source-row [data-source="soulseek"]'); + if (soulseekIcon) soulseekIcon.click(); + return; + } const input = document.getElementById('enhanced-search-input'); if (input && query) { input.value = query; diff --git a/webui/static/helper.js b/webui/static/helper.js index 37d89cf3..7d46ddce 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3458,6 +3458,8 @@ const WHATS_NEW = { { title: 'Fix Clean Search History Automation Failing with AttributeError', desc: 'The hourly Clean Search History maintenance automation was crashing with "DownloadOrchestrator object has no attribute base_url". Root cause: the check `soulseek_client.base_url` was written before the orchestrator refactor — `soulseek_client` is now a DownloadOrchestrator that wraps individual download clients, with the real Soulseek client at `.soulseek`. Two other call sites in web_server.py already used the correct `soulseek_client.soulseek.base_url` pattern; this one was missed. Now matches the same getattr-guarded pattern and the hourly cleanup runs again', page: 'stats' }, { title: 'Search Results Always Visible — Show/Hide Button Removed', desc: 'The "Show Results / Hide Results" toggle next to the search bar is gone. There was nothing else on the page worth seeing instead of results, so toggling visibility never made sense. Cin flagged it during PR review. Dropdown visibility is now a pure function of query state — empty input hides it, results show it', page: 'search', unreleased: true }, { title: 'Cached Search Results Restore on Navigate-Back', desc: 'Previously, navigating away from /search via a sidebar link dismissed the dropdown (the click registered as outside-click). When you came back, the input still held your query but the results were hidden until you typed again or clicked Show Results. Now the per-query cache renders automatically when you re-enter /search, so your results are right where you left them. Cin flagged the round-trip during PR review', page: 'search', unreleased: true }, + { title: 'Fix Soulseek Handoff from Global Search Going Through Metadata Flow', desc: 'When you clicked the Soulseek icon in the sidebar global search popover, it navigated to /search and wrote the query into the enhanced-search input — which then ran the metadata flow against whatever your default source was (Spotify, Deezer, etc.) instead of the raw Soulseek file search you actually wanted. Cin flagged it during PR review. Now the handoff pre-fills the basic-search input directly and clicks the Search page\'s Soulseek icon so the controller\'s onSoulseekSelected callback owns the section swap and runs performDownloadsSearch with the right query', page: 'search', unreleased: true }, + { title: 'Stale Search Requests No Longer Flash Empty Results on Fast Retype', desc: 'Cin flagged a race in createSearchController: when you typed a query then quickly re-typed before the first fetch returned, the first fetch\'s catch block (firing on AbortError after the second submitQuery aborted it) cleared loadingSources and notified the UI, causing a brief flash of empty/error state while the new query\'s fetch was still mid-flight. Added a monotonic _requestSeq token — each fetch captures the next value, and stale completions bail before mutating shared state. The controller still aborts in-flight fetches on supersession; this just keeps the abort-cleanup of the old request from clobbering the new one\'s spinner', page: 'search', unreleased: true }, ], '2.39': [ // --- April 22, 2026 --- diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 80ac7ecf..ece7efca 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -172,6 +172,13 @@ function createSearchController({ for (const src of SOURCE_ORDER) state.configuredSources[src] = true; let abortCtrl = null; + // Monotonic request token. Each _fetchSource call captures the next + // value; settle/error blocks bail before mutating shared state if a + // newer request has superseded them. Without this, a fast retype lets + // the in-flight fetch's catch (or settle) clear loadingSources / write + // stale data into state.sources, causing a flash of empty/error UI + // while the new query's fetch is still running. + let _requestSeq = 0; function _notify() { if (onStateChange) onStateChange(state); } @@ -305,6 +312,8 @@ function createSearchController({ const query = state.query; if (!query) return; + const requestId = ++_requestSeq; + state.loadingSources.add(src); renderSourceRow(); _notify(); @@ -314,12 +323,14 @@ function createSearchController({ try { if (src === 'youtube_videos') { - await _fetchYouTubeVideos(query, abortCtrl.signal); + await _fetchYouTubeVideos(query, abortCtrl.signal, requestId); } else { const data = await enhancedSearchFetch(query, { source: src, signal: abortCtrl.signal, }); + // Bail without writing if a newer query has superseded us. + if (requestId !== _requestSeq) return; state.sources[src] = { artists: data.spotify_artists || [], albums: data.spotify_albums || [], @@ -331,10 +342,15 @@ function createSearchController({ if (served && served !== src) state.fallbacks[src] = served; } + // Only the latest request gets to clear loadingSources + notify. + // A stale completion would otherwise wipe the spinner the new + // request just set. + if (requestId !== _requestSeq) return; state.loadingSources.delete(src); renderSourceRow(); _notify(); } catch (err) { + if (requestId !== _requestSeq) return; state.loadingSources.delete(src); renderSourceRow(); _notify(); @@ -344,7 +360,7 @@ function createSearchController({ } } - async function _fetchYouTubeVideos(query, signal) { + async function _fetchYouTubeVideos(query, signal, requestId) { const res = await fetch('/api/enhanced-search/source/youtube_videos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -353,6 +369,9 @@ function createSearchController({ }); if (!res.ok) throw new Error(`YouTube search failed: ${res.status}`); + // Bail before allocating cache entry if superseded by a newer request. + if (requestId !== _requestSeq) return; + state.sources['youtube_videos'] = { artists: [], albums: [], tracks: [], videos: [], db_artists: [], }; @@ -364,6 +383,9 @@ function createSearchController({ while (true) { const { done, value } = await reader.read(); if (done) break; + // Mid-stream supersession check — abort cleanly without writing + // additional chunks into stale cache. + if (requestId !== _requestSeq) return; buffer += decoder.decode(value, { stream: true }); let idx; while ((idx = buffer.indexOf('\n')) !== -1) { From 325292ce5a167100513f7663b672eb4a3c7b16fe Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:28:47 -0700 Subject: [PATCH 19/80] Treat Soulseek as configurable in source picker (require slskd_url) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cin flagged that Soulseek was always rendered as configured in the source picker, even on dev instances with no slskd set up — letting users click it and fire searches that could never succeed. Three coordinated changes: 1. web_server.py SERVICE_CONFIG_REGISTRY: add Soulseek entry requiring `slskd_url`. /api/settings/config-status now reports its real state alongside every other service. 2. shared-helpers.js _ALWAYS_CONFIGURED_SOURCES: drop 'soulseek'. The set is now just MusicBrainz + YouTube Music Videos (sources that genuinely don't need user creds). Soulseek goes through the normal config-status code path. 3. shared-helpers.js openSettingsForSource: special-case Soulseek to route to Settings → Downloads tab (where slskd URL field lives, gated behind the download-source-mode dropdown) and scroll to the #soulseek-url input. Every other source still routes to Connections and scrolls to its .stg-service card. Without this, Soulseek's "click to configure" landed on a Connections card that doesn't exist (Soulseek's URL/key fields are scoped to the download-source selection on the Downloads tab). --- web_server.py | 6 ++++++ webui/static/helper.js | 1 + webui/static/shared-helpers.js | 31 ++++++++++++++++++++++--------- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/web_server.py b/web_server.py index aa4ee8ea..c835715d 100644 --- a/web_server.py +++ b/web_server.py @@ -4455,6 +4455,12 @@ SERVICE_CONFIG_REGISTRY = { 'acoustid': {'required': ['api_key']}, 'listenbrainz': {'required': ['token']}, 'hydrabase': {'required': ['url', 'api_key']}, + # Soulseek (slskd) needs a base URL. Used by the search source picker + # to dim Soulseek and redirect to Settings when the user has no slskd + # configured — clicking it would otherwise fire searches that always + # fail. URL field lives on Settings → Downloads, gated behind the + # download-source-mode dropdown. + 'soulseek': {'required': ['slskd_url']}, } diff --git a/webui/static/helper.js b/webui/static/helper.js index 7d46ddce..411bbe1b 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3460,6 +3460,7 @@ const WHATS_NEW = { { title: 'Cached Search Results Restore on Navigate-Back', desc: 'Previously, navigating away from /search via a sidebar link dismissed the dropdown (the click registered as outside-click). When you came back, the input still held your query but the results were hidden until you typed again or clicked Show Results. Now the per-query cache renders automatically when you re-enter /search, so your results are right where you left them. Cin flagged the round-trip during PR review', page: 'search', unreleased: true }, { title: 'Fix Soulseek Handoff from Global Search Going Through Metadata Flow', desc: 'When you clicked the Soulseek icon in the sidebar global search popover, it navigated to /search and wrote the query into the enhanced-search input — which then ran the metadata flow against whatever your default source was (Spotify, Deezer, etc.) instead of the raw Soulseek file search you actually wanted. Cin flagged it during PR review. Now the handoff pre-fills the basic-search input directly and clicks the Search page\'s Soulseek icon so the controller\'s onSoulseekSelected callback owns the section swap and runs performDownloadsSearch with the right query', page: 'search', unreleased: true }, { title: 'Stale Search Requests No Longer Flash Empty Results on Fast Retype', desc: 'Cin flagged a race in createSearchController: when you typed a query then quickly re-typed before the first fetch returned, the first fetch\'s catch block (firing on AbortError after the second submitQuery aborted it) cleared loadingSources and notified the UI, causing a brief flash of empty/error state while the new query\'s fetch was still mid-flight. Added a monotonic _requestSeq token — each fetch captures the next value, and stale completions bail before mutating shared state. The controller still aborts in-flight fetches on supersession; this just keeps the abort-cleanup of the old request from clobbering the new one\'s spinner', page: 'search', unreleased: true }, + { title: 'Source Picker Dims Soulseek When slskd Isn\'t Configured', desc: 'Cin pointed out that the Soulseek icon was always rendered as configured, so users without slskd set up could click it and fire searches that would never succeed. Soulseek is now in the backend config-status registry as `required: [slskd_url]` and removed from the frontend\'s always-configured set. Without slskd, the icon dims and clicking it routes to Settings → Downloads tab (where the slskd URL field lives, gated behind the download-source dropdown) instead of Settings → Connections', page: 'search', unreleased: true }, ], '2.39': [ // --- April 22, 2026 --- diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index ece7efca..d9206ec8 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -87,7 +87,10 @@ const SOURCE_ORDER = [ // Sources the config-status endpoint doesn't cover because they don't need // user-supplied credentials — they always render as "configured" in the picker. -const _ALWAYS_CONFIGURED_SOURCES = new Set(['musicbrainz', 'youtube_videos', 'soulseek']); +// Soulseek IS configurable (needs slskd URL), so it's intentionally not here: +// /api/settings/config-status reports its real state and the picker dims it +// when no slskd is set up, redirecting clicks to Settings → Downloads. +const _ALWAYS_CONFIGURED_SOURCES = new Set(['musicbrainz', 'youtube_videos']); // Fetch /api/settings/config-status and return a map { src -> bool } // covering every source in SOURCE_ORDER. Sources not present in the backend @@ -438,22 +441,32 @@ function createSearchController({ } -// Navigate to Settings → Connections tab and scroll to the service card that +// Navigate to Settings → relevant tab and scroll to the service card that // matches the picker's source id. Called when a user clicks an unconfigured -// source icon. +// source icon. Soulseek is special-cased to land on the Downloads tab where +// its slskd URL field lives (gated behind the download-source-mode select); +// every other source has a card on Connections. function openSettingsForSource(src) { if (typeof navigateToPage !== 'function') return; navigateToPage('settings'); + const targetTab = src === 'soulseek' ? 'downloads' : 'connections'; setTimeout(() => { try { - if (typeof switchSettingsTab === 'function') switchSettingsTab('connections'); + if (typeof switchSettingsTab === 'function') switchSettingsTab(targetTab); } catch (_) { /* best-effort */ } setTimeout(() => { - const card = document.querySelector(`#settings-page .stg-service[data-service="${src}"]`); - if (card) { - card.scrollIntoView({ behavior: 'smooth', block: 'center' }); - card.classList.add('stg-service-flash'); - setTimeout(() => card.classList.remove('stg-service-flash'), 2200); + // Soulseek doesn't have a .stg-service card — scroll to the + // slskd URL input instead so the user lands on the right field. + const target = src === 'soulseek' + ? document.querySelector('#settings-page #soulseek-url') + : document.querySelector(`#settings-page .stg-service[data-service="${src}"]`); + if (!target) return; + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + if (src === 'soulseek') { + try { target.focus(); } catch (_) { /* best-effort */ } + } else { + target.classList.add('stg-service-flash'); + setTimeout(() => target.classList.remove('stg-service-flash'), 2200); } }, 120); }, 60); From 527b51d69bd3991c8f9de4f172eee99e474a791c Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:37:07 -0700 Subject: [PATCH 20/80] Tighten Soulseek handoff + per-source request tokens after self-audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the previous review-fix commits, found during a Cin-standard re-audit: A) Soulseek handoff stale state.query overrode the global widget's query The previous fix pre-set basicInput.value before clicking the Search page's Soulseek icon. But the click triggers onSoulseekSelected with the controller's CURRENT state.query — which is whatever the user last typed on /search, not the global widget's query. The Search page callback then ran `if (query) basicInput.value = query;` and overwrote the just-set value with the stale one before firing performDownloadsSearch. Fix: expose searchController as `_searchPageController` (mirrors `_searchPageRestoreOnEnter` already at module scope). Global widget's _gsNavigateToSearchPage syncs `_searchPageController.state.query` to its own query before clicking the icon. Also added a fallback for the case where the icon doesn't exist yet (controller still mid-init): swap sections + run performDownloadsSearch directly. B) Single _requestSeq token leaked loadingSources across sources The earlier "stale request" fix used one global _requestSeq. But when the user switched Spotify → Deezer mid-fetch, the Spotify abort's catch block bailed (1 !== 2), leaving 'spotify' in loadingSources forever — permanent spinner on the Spotify icon even though no fetch was running for it. Fix: per-source `_sourceRequestIds[src]` map. Same-source supersession bails (correct), cross-source supersession still clears the old source's loadingSources entry (correct). Bonus defensive: submitQuery now invalidates every per-source token and aborts the in-flight fetch when the query string changes. Catches the residual edge case where user clears the input — the in-flight fetch's settle would otherwise write stale data into the just-cleared state.sources. --- webui/static/downloads.js | 32 +++++++++++++---- webui/static/search.js | 6 ++++ webui/static/shared-helpers.js | 64 ++++++++++++++++++++++------------ 3 files changed, 74 insertions(+), 28 deletions(-) diff --git a/webui/static/downloads.js b/webui/static/downloads.js index 2ad5aa49..ce20e659 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -5188,15 +5188,35 @@ function _gsNavigateToSearchPage(query, src) { // metadata results when they clicked the Soulseek icon. setTimeout(() => { if (src === 'soulseek') { - // Pre-fill the basic input first, then click the Search page's - // Soulseek icon. The icon's click triggers the controller's - // onSoulseekSelected callback, which owns the section swap and - // re-runs performDownloadsSearch with whatever's in the basic - // input (i.e., the value we just wrote). const basicInput = document.getElementById('downloads-search-input'); if (basicInput && query) basicInput.value = query; + + // Sync the Search page controller's state.query to the widget's + // query BEFORE clicking the Soulseek icon. Otherwise the icon + // click fires onSoulseekSelected(state.query) where state.query + // is whatever the user last typed on /search (often stale), and + // the callback would overwrite basicInput.value with that stale + // value before running performDownloadsSearch. + if (typeof _searchPageController !== 'undefined' && _searchPageController) { + _searchPageController.state.query = query || ''; + } + const soulseekIcon = document.querySelector('#enh-source-row [data-source="soulseek"]'); - if (soulseekIcon) soulseekIcon.click(); + if (soulseekIcon) { + soulseekIcon.click(); + return; + } + // Fallback: controller hasn't initialized yet (slow /api/settings + // fetches at first /search visit). Run the search directly + swap + // sections so the user still gets results. Icon row will catch up + // visually on the next render. + const basicSection = document.getElementById('basic-search-section'); + const enhancedSection = document.getElementById('enhanced-search-section'); + if (basicSection) basicSection.classList.add('active'); + if (enhancedSection) enhancedSection.classList.remove('active'); + if (basicInput && basicInput.value && typeof performDownloadsSearch === 'function') { + performDownloadsSearch(); + } return; } const input = document.getElementById('enhanced-search-input'); diff --git a/webui/static/search.js b/webui/static/search.js index e8d93f52..4d47436e 100644 --- a/webui/static/search.js +++ b/webui/static/search.js @@ -41,6 +41,11 @@ let searchModeToggleInitialized = false; // click is treated as outside-click and dismisses the dropdown, so when // the user returns to /search we need to re-render whatever was cached. let _searchPageRestoreOnEnter = null; +// Exposed so the global-search widget's Soulseek handoff can sync the +// controller's state.query to the widget's query before clicking the +// Soulseek icon — otherwise onSoulseekSelected fires with whatever the +// user last typed on /search and overwrites the basic input. +let _searchPageController = null; function initializeSearchModeToggle() { // Subsequent invocations: just re-display cached results so they don't @@ -207,6 +212,7 @@ function initializeSearchModeToggle() { }, }); searchController.init(); + _searchPageController = searchController; // Expose a re-render hook so navigate-back to /search restores cached // results instead of leaving the dropdown hidden. Deferred to the next diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index d9206ec8..660ef01c 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -175,13 +175,20 @@ function createSearchController({ for (const src of SOURCE_ORDER) state.configuredSources[src] = true; let abortCtrl = null; - // Monotonic request token. Each _fetchSource call captures the next - // value; settle/error blocks bail before mutating shared state if a - // newer request has superseded them. Without this, a fast retype lets - // the in-flight fetch's catch (or settle) clear loadingSources / write - // stale data into state.sources, causing a flash of empty/error UI - // while the new query's fetch is still running. + // Per-source request tokens. Each _fetchSource call increments the + // monotonic _requestSeq and stamps it into _sourceRequestIds[src]. + // Settle/error blocks bail before mutating shared state if their + // requestId no longer matches the latest id for THAT source — + // protecting against the fast-retype race (same-source supersession) + // without dropping cleanup for cross-source supersession. + // + // A single global token would mishandle cross-source: switching + // Spotify → Deezer aborts Spotify's fetch, but Spotify's catch needs + // to clear 'spotify' from loadingSources (Deezer's request hasn't + // touched it). Per-source tracking lets each source's catch own its + // own loadingSources entry. let _requestSeq = 0; + const _sourceRequestIds = Object.create(null); function _notify() { if (onStateChange) onStateChange(state); } @@ -316,6 +323,7 @@ function createSearchController({ if (!query) return; const requestId = ++_requestSeq; + _sourceRequestIds[src] = requestId; state.loadingSources.add(src); renderSourceRow(); @@ -332,8 +340,11 @@ function createSearchController({ source: src, signal: abortCtrl.signal, }); - // Bail without writing if a newer query has superseded us. - if (requestId !== _requestSeq) return; + // Bail without writing if a newer request for THIS source + // has superseded us. Cross-source supersession (different + // src entirely) is handled by the loadingSources cleanup + // below — each source's catch owns its own entry. + if (_sourceRequestIds[src] !== requestId) return; state.sources[src] = { artists: data.spotify_artists || [], albums: data.spotify_albums || [], @@ -345,18 +356,20 @@ function createSearchController({ if (served && served !== src) state.fallbacks[src] = served; } - // Only the latest request gets to clear loadingSources + notify. - // A stale completion would otherwise wipe the spinner the new - // request just set. - if (requestId !== _requestSeq) return; + if (_sourceRequestIds[src] !== requestId) return; state.loadingSources.delete(src); renderSourceRow(); _notify(); } catch (err) { - if (requestId !== _requestSeq) return; - state.loadingSources.delete(src); - renderSourceRow(); - _notify(); + // Only clear loadingSources if no newer request for THIS source + // is in flight. Cross-source supersession (e.g. user switched + // Spotify → Deezer) still falls through here so Spotify's + // spinner gets cleared on its own AbortError. + if (_sourceRequestIds[src] === requestId) { + state.loadingSources.delete(src); + renderSourceRow(); + _notify(); + } if (err.name !== 'AbortError') { console.debug(`Source fetch failed for ${src}:`, err); } @@ -372,8 +385,9 @@ function createSearchController({ }); if (!res.ok) throw new Error(`YouTube search failed: ${res.status}`); - // Bail before allocating cache entry if superseded by a newer request. - if (requestId !== _requestSeq) return; + // Bail before allocating cache entry if a newer YouTube request + // has superseded us. + if (_sourceRequestIds['youtube_videos'] !== requestId) return; state.sources['youtube_videos'] = { artists: [], albums: [], tracks: [], videos: [], db_artists: [], @@ -386,9 +400,7 @@ function createSearchController({ while (true) { const { done, value } = await reader.read(); if (done) break; - // Mid-stream supersession check — abort cleanly without writing - // additional chunks into stale cache. - if (requestId !== _requestSeq) return; + if (_sourceRequestIds['youtube_videos'] !== requestId) return; buffer += decoder.decode(value, { stream: true }); let idx; while ((idx = buffer.indexOf('\n')) !== -1) { @@ -399,7 +411,6 @@ function createSearchController({ const chunk = JSON.parse(line); if (chunk.type === 'videos') { cache.videos = chunk.data; - // Live-render if still the active source. if (state.activeSource === 'youtube_videos') _notify(); } } catch (_) { /* best-effort NDJSON parse */ } @@ -413,6 +424,15 @@ function createSearchController({ state.sources = {}; state.fallbacks = {}; state.loadingSources = new Set(); + // Invalidate every in-flight per-source token. Without this, a + // settle that arrives AFTER a query reset (e.g. user typed 'a', + // fetch started, then user cleared the input) would still + // pass the per-source token check and write stale data back + // into the just-cleared state.sources. Setting fresh tokens + // when each new _fetchSource fires re-stamps as needed. + for (const k in _sourceRequestIds) delete _sourceRequestIds[k]; + // Abort the active fetch — its results are useless now. + if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; } renderSourceRow(); } From 253e4d1e4a0e22bdf7143e4516f4e24c940d770a Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 23 Apr 2026 23:04:39 -0700 Subject: [PATCH 21/80] Fix Discover hero 'View Discography' 404ing on source-only artists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking 'View Discography' on the Discover hero slideshow was calling navigateToArtistDetail(id, name) without the third 'source' argument. loadArtistDetailData then omits the `source` query param, so /api/artist-detail falls through to a local DB lookup and returns 404 for artists that don't exist in the library — which is nearly every hero artist, since they come from discover similar-artists. Regression from the unification PR (93f1941) that rewrote the click handler to route through the standalone /artist-detail page instead of the old inline Artists view. The rewrite didn't thread the source. Backend already includes `artist.source` on each hero entry. Fix: - Stash artist.source as data-source on #discover-hero-discography when displayDiscoverHeroArtist populates the card. - Read data-source in viewDiscoverHeroDiscography and pass it as the third arg to navigateToArtistDetail, so the eventual API call includes `?source=itunes/deezer/etc.` and returns the synthesized discography. Reproduced by clicking View Discography on a non-Spotify hero artist (log showed `GET /api/artist-detail/76258852?name=ДЕТИ+RAVE → 404, Getting artist detail for ID: 76258852 (source=library)`). --- webui/static/discover.js | 14 ++++++++++++-- webui/static/helper.js | 1 + 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index d322cc81..eb50d2ba 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -278,6 +278,12 @@ function displayDiscoverHeroArtist(artist) { if (discographyBtn && artistId) { discographyBtn.setAttribute('data-artist-id', artistId); discographyBtn.setAttribute('data-artist-name', artist.artist_name); + // Source the click handler will pass to navigateToArtistDetail. Without + // this, source-only hero artists (which is the typical case — they + // come from discover similar-artists, not the library) get looked up + // as library IDs and 404. Backend always includes artist.source. + if (artist.source) discographyBtn.setAttribute('data-source', artist.source); + else discographyBtn.removeAttribute('data-source'); // Also store both IDs for cross-source operations if (artist.spotify_artist_id) discographyBtn.setAttribute('data-spotify-id', artist.spotify_artist_id); if (artist.itunes_artist_id) discographyBtn.setAttribute('data-itunes-id', artist.itunes_artist_id); @@ -815,14 +821,18 @@ async function viewDiscoverHeroDiscography() { const artistId = button.getAttribute('data-artist-id'); const artistName = button.getAttribute('data-artist-name'); + // Pass the source so /api/artist-detail knows to synthesize from that + // metadata provider instead of doing a local DB lookup. Hero similar + // artists are almost always source-only (not in the library). + const source = button.getAttribute('data-source') || null; if (!artistId || !artistName) { console.error('No artist data found for discography view'); return; } - console.log(`🎵 Navigating to artist detail for: ${artistName}`); - navigateToArtistDetail(artistId, artistName); + console.log(`🎵 Navigating to artist detail for: ${artistName} (source: ${source || 'library'})`); + navigateToArtistDetail(artistId, artistName, source); } function showDiscoverHeroEmpty() { diff --git a/webui/static/helper.js b/webui/static/helper.js index 411bbe1b..8a13ad18 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3461,6 +3461,7 @@ const WHATS_NEW = { { title: 'Fix Soulseek Handoff from Global Search Going Through Metadata Flow', desc: 'When you clicked the Soulseek icon in the sidebar global search popover, it navigated to /search and wrote the query into the enhanced-search input — which then ran the metadata flow against whatever your default source was (Spotify, Deezer, etc.) instead of the raw Soulseek file search you actually wanted. Cin flagged it during PR review. Now the handoff pre-fills the basic-search input directly and clicks the Search page\'s Soulseek icon so the controller\'s onSoulseekSelected callback owns the section swap and runs performDownloadsSearch with the right query', page: 'search', unreleased: true }, { title: 'Stale Search Requests No Longer Flash Empty Results on Fast Retype', desc: 'Cin flagged a race in createSearchController: when you typed a query then quickly re-typed before the first fetch returned, the first fetch\'s catch block (firing on AbortError after the second submitQuery aborted it) cleared loadingSources and notified the UI, causing a brief flash of empty/error state while the new query\'s fetch was still mid-flight. Added a monotonic _requestSeq token — each fetch captures the next value, and stale completions bail before mutating shared state. The controller still aborts in-flight fetches on supersession; this just keeps the abort-cleanup of the old request from clobbering the new one\'s spinner', page: 'search', unreleased: true }, { title: 'Source Picker Dims Soulseek When slskd Isn\'t Configured', desc: 'Cin pointed out that the Soulseek icon was always rendered as configured, so users without slskd set up could click it and fire searches that would never succeed. Soulseek is now in the backend config-status registry as `required: [slskd_url]` and removed from the frontend\'s always-configured set. Without slskd, the icon dims and clicking it routes to Settings → Downloads tab (where the slskd URL field lives, gated behind the download-source dropdown) instead of Settings → Connections', page: 'search', unreleased: true }, + { title: 'Fix Discover Hero "View Discography" 404ing on Source Artists', desc: 'Clicking "View Discography" on the Discover page hero slideshow was calling navigateToArtistDetail without a source, so /api/artist-detail defaulted to a library lookup and returned 404 for artists that don\'t exist in your library (which is nearly every hero artist — they come from discover similar-artists, not the library). Regression from the unification PR that rewrote the click handler to route to /artist-detail but forgot to pass the source. Backend already sends artist.source on each hero entry; we now stash it as data-source on the discography button and thread it through to navigateToArtistDetail so the API call includes source=itunes/deezer/etc. and returns the synthesized discography', page: 'discover', unreleased: true }, ], '2.39': [ // --- April 22, 2026 --- From 569c827ab46e57678e6dfaf1ebf44fdc6f54196a Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Fri, 24 Apr 2026 10:11:56 +0300 Subject: [PATCH 22/80] chore: don't include hidden files or folders in docker images --- .dockerignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.dockerignore b/.dockerignore index cd63220a..4b77bb74 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,8 @@ # Docker ignore file for SoulSync WebUI +# Hidden folders and files +.* + # Git .git .gitignore From 7285c6f55a623c6bc0d36ba26efc2e1275a8bb4d Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Fri, 24 Apr 2026 10:12:52 +0300 Subject: [PATCH 23/80] fix: more thorough handling for internal image url fixes --- web_server.py | 68 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 11 deletions(-) diff --git a/web_server.py b/web_server.py index c835715d..1e9c6196 100644 --- a/web_server.py +++ b/web_server.py @@ -18,7 +18,7 @@ import sqlite3 import types import collections from pathlib import Path -from urllib.parse import urljoin +from urllib.parse import quote, urljoin, urlparse from concurrent.futures import ThreadPoolExecutor, as_completed from flask import Flask, render_template, request, jsonify, redirect, send_file, Response, session, g, abort @@ -11127,7 +11127,7 @@ def maintain_search_history(): return jsonify({"success": False, "error": str(e)}), 500 def fix_artist_image_url(thumb_url): - """Convert localhost URLs to proper server URLs using config""" + """Convert media-server image URLs into browser-safe URLs.""" if not thumb_url: return None @@ -11136,6 +11136,11 @@ def fix_artist_image_url(thumb_url): needs_fixing = ( thumb_url.startswith('http://localhost:') or thumb_url.startswith('https://localhost:') or + thumb_url.startswith('http://127.0.0.1:') or + thumb_url.startswith('https://127.0.0.1:') or + thumb_url.startswith('http://host.docker.internal:') or + thumb_url.startswith('https://host.docker.internal:') or + (thumb_url.startswith('http://') and _is_internal_image_host(thumb_url)) or thumb_url.startswith('/library/') or # Plex relative paths thumb_url.startswith('/Items/') or # Jellyfin relative paths thumb_url.startswith('/api/') or # Old Navidrome API paths @@ -11166,7 +11171,7 @@ def fix_artist_image_url(thumb_url): # Construct proper Plex URL with token fixed_url = f"{plex_base_url.rstrip('/')}{path}?X-Plex-Token={plex_token}" logger.info(f"Fixed URL: {fixed_url}") - return fixed_url + return _browser_safe_image_url(fixed_url) elif active_server == 'jellyfin': jellyfin_config = config_manager.get_jellyfin_config() @@ -11192,7 +11197,7 @@ def fix_artist_image_url(thumb_url): else: fixed_url = f"{jellyfin_base_url.rstrip('/')}{path}" logger.info(f"Fixed URL: {fixed_url}") - return fixed_url + return _browser_safe_image_url(fixed_url) elif active_server == 'navidrome': navidrome_config = config_manager.get_navidrome_config() @@ -11225,16 +11230,57 @@ def fix_artist_image_url(thumb_url): # Construct proper Navidrome Subsonic URL fixed_url = f"{navidrome_base_url.rstrip('/')}{path}{separator}{auth_params}" logger.info(f"Fixed URL: {fixed_url}") - return fixed_url + return _browser_safe_image_url(fixed_url) logger.warning(f"No configuration found for {active_server} or unsupported server type") - # Return original URL if no fixing needed/possible - return thumb_url + # Return a browser-safe URL even if no server-specific rebuild was possible. + return _browser_safe_image_url(thumb_url) except Exception as e: logger.error(f"Error fixing image URL '{thumb_url}': {e}") - return thumb_url + return _browser_safe_image_url(thumb_url) + + +def _is_internal_image_host(url: str) -> bool: + """Return True when an image URL points at a host the browser likely cannot reach directly.""" + try: + parsed = urlparse(url) + host = (parsed.hostname or '').strip('[]').lower() + if not host: + return False + + if host in {'localhost', '127.0.0.1', '::1', 'host.docker.internal'}: + return True + + # Single-label hosts are usually Docker service names or local LAN aliases. + if '.' not in host: + return True + + try: + ip = ipaddress.ip_address(host) + return ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_reserved + except ValueError: + return False + except Exception: + return False + + +def _browser_safe_image_url(url: str) -> str: + """Return a browser-safe image URL, proxying internal hosts through SoulSync.""" + if not url: + return url + + if url.startswith('/api/image-proxy?url='): + return url + + if url.startswith('http://') or url.startswith('https://'): + if _is_internal_image_host(url): + return f"/api/image-proxy?url={quote(url, safe='')}" + return url + + # Relative media-server paths should already have been expanded before this point. + return url @app.route('/api/library/history') def get_library_history(): @@ -45220,8 +45266,7 @@ def image_proxy(): url = request.args.get('url', '') if not url or not url.startswith('http'): return '', 400 - # Only allow known image CDNs - from urllib.parse import urlparse + host = urlparse(url).hostname or '' allowed_hosts = [ 'i.scdn.co', 'mosaic.scdn.co', # Spotify @@ -45230,8 +45275,9 @@ def image_proxy(): 'is1-ssl.mzstatic.com', 'is2-ssl.mzstatic.com', 'is3-ssl.mzstatic.com', 'is4-ssl.mzstatic.com', 'is5-ssl.mzstatic.com', # iTunes/Apple 'img.discogs.com', 'i.discogs.com', # Discogs + 'localhost', '127.0.0.1', 'host.docker.internal', # Local/Docker media servers ] - if not any(host == h or host.endswith('.' + h) for h in allowed_hosts): + if not any(host == h or host.endswith('.' + h) for h in allowed_hosts) and not _is_internal_image_host(url): return '', 403 try: resp = requests.get(url, timeout=10, stream=True, headers={ From 23b02147120856ad59680d0dd4f28c2dbdb1f40e Mon Sep 17 00:00:00 2001 From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:56:25 -0700 Subject: [PATCH 24/80] feat: add SoulSync Discover sync tab with ListenBrainz, progress tracking, and Navidrome push Adds a full Discover Sync tab to the Sync page with: - Core UI scaffolding, playlist modal, empty-state handling - ListenBrainz playlist integration with auto-update toggle persistence - Sync progress tracking with matched/total counts on cards - Navidrome playlist push on batch completion (V1 and V2 paths) - Active download state display with polling resume on page reload - Stuck-download detection for downloading and catch-all states - Serialized sync queue to prevent concurrent backend contention - Source badges, compact card layout, URL fixes --- web_server.py | 499 +++++++++++++++++++++++- webui/index.html | 17 +- webui/static/discover.js | 618 +++++++++++++++++++++++++++++- webui/static/pages-extra.js | 7 +- webui/static/stats-automations.js | 1 + webui/static/style.css | 313 ++++++++++++++- webui/static/sync-services.js | 5 + 7 files changed, 1437 insertions(+), 23 deletions(-) diff --git a/web_server.py b/web_server.py index 1e9c6196..3acf4143 100644 --- a/web_server.py +++ b/web_server.py @@ -28923,8 +28923,16 @@ def _on_download_completed(batch_id, task_id, success=True): except Exception: pass - # Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist + # Push discover playlists to media server after downloads complete playlist_id = batch.get('playlist_id') + if playlist_id and playlist_id.startswith('discover_'): + threading.Thread( + target=_push_discover_playlist_to_server, + args=(batch_id, batch), + daemon=True + ).start() + + # Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist if playlist_id and playlist_id.startswith('youtube_'): url_hash = playlist_id.replace('youtube_', '') if url_hash in youtube_playlist_states: @@ -31632,6 +31640,7 @@ def get_all_downloads_unified(): 'source_page': batch.get('source_page') or batch.get('initiated_from') or '', 'phase': batch.get('phase', 'unknown'), 'total': len(queue), + 'analysis_total': batch.get('analysis_total', len(queue)), 'completed': sum(1 for s in statuses if s in ('completed', 'skipped', 'already_owned')), 'failed': sum(1 for s in statuses if s in ('failed', 'not_found', 'cancelled')), 'active': sum(1 for s in statuses if s in ('downloading', 'searching', 'post_processing')), @@ -32109,8 +32118,27 @@ def _check_batch_completion_v2(batch_id): finished_count += 1 else: retrying_count += 1 + elif task_status == 'downloading': + task_age = current_time - task.get('status_change_time', current_time) + if no_active_workers and task_age > 300: # 5 minutes with no worker running + logger.info(f"⏰ [Stuck Detection V2] Task {task_id} stuck in downloading for {task_age:.0f}s with no active workers - forcing failed") + task['status'] = 'failed' + task['error_message'] = f'Download stuck for {int(task_age // 60)} minutes with no active worker — timed out' + finished_count += 1 + else: + retrying_count += 1 elif task_status in ['completed', 'failed', 'cancelled', 'not_found']: finished_count += 1 + else: + # Catch-all for any other non-terminal state (queued, retrying, etc.) + task_age = current_time - task.get('status_change_time', current_time) + if no_active_workers and task_age > 600: # 10 minutes with no worker + logger.info(f"⏰ [Stuck Detection V2] Task {task_id} stuck in '{task_status}' for {task_age:.0f}s with no active workers - forcing failed") + task['status'] = 'failed' + task['error_message'] = f'Task stuck in {task_status} for {int(task_age // 60)} minutes with no active worker — timed out' + finished_count += 1 + else: + retrying_count += 1 else: # Task ID in queue but not in download_tasks - treat as completed to prevent blocking logger.warning(f"[Orphaned Task V2] Task {task_id} in queue but not in download_tasks - counting as finished") @@ -32133,6 +32161,9 @@ def _check_batch_completion_v2(batch_id): batch['phase'] = 'complete' batch['completion_time'] = time.time() # Track when batch completed + # Record sync history completion + _record_sync_history_completion(batch_id, batch) + # Add activity for batch completion playlist_name = batch.get('playlist_name', 'Unknown Playlist') failed_count = len(batch.get('permanently_failed_tracks', [])) @@ -32151,6 +32182,15 @@ def _check_batch_completion_v2(batch_id): }) except Exception: pass + + # Push discover playlists to media server after downloads complete + playlist_id = batch.get('playlist_id') + if playlist_id and playlist_id.startswith('discover_'): + threading.Thread( + target=_push_discover_playlist_to_server, + args=(batch_id, batch), + daemon=True + ).start() else: logger.warning(f"[Completion Check V2] Batch {batch_id} already marked complete - skipping duplicate processing") return True # Already complete @@ -32524,7 +32564,7 @@ def _detect_sync_source(playlist_id): ('auto_mirror_', 'mirrored'), ('youtube_mirrored_', 'mirrored'), ('youtube_', 'youtube'), ('beatport_', 'beatport'), ('tidal_', 'tidal'), ('deezer_', 'deezer'), ('listenbrainz_', 'listenbrainz'), - ('spotify_public_', 'spotify_public'), ('discover_album_', 'discover'), + ('spotify_public_', 'spotify_public'), ('discover_', 'discover'), ('seasonal_album_', 'discover'), ('library_redownload_', 'library'), ('issue_download_', 'library'), ('artist_album_', 'spotify'), ('enhanced_search_', 'spotify'), ('spotify_library_', 'spotify'), @@ -32625,6 +32665,10 @@ def _record_sync_history_completion(batch_id, batch): completed_count = 0 failed_count = len(batch.get('permanently_failed_tracks', [])) + logger.warning(f"[SyncHistory] Recording completion for batch {batch_id}: " + f"analysis_results={len(analysis_results)}, tracks_found={tracks_found}, " + f"queue_len={len(queue)}, failed={failed_count}") + # Build download status map: track_index → status download_status_map = {} for task_id in queue: @@ -32635,6 +32679,9 @@ def _record_sync_history_completion(batch_id, batch): if task.get('status') == 'completed': completed_count += 1 + logger.warning(f"[SyncHistory] Batch {batch_id}: completed_downloads={completed_count}, " + f"download_status_map_size={len(download_status_map)}") + # Build per-track results from analysis track_results = [] for res in analysis_results: @@ -32674,14 +32721,118 @@ def _record_sync_history_completion(batch_id, batch): track_results.append(entry) db = MusicDatabase() - db.update_sync_history_completion(batch_id, tracks_found, completed_count, failed_count) + updated = db.update_sync_history_completion(batch_id, tracks_found, completed_count, failed_count) + logger.warning(f"[SyncHistory] DB update for batch {batch_id}: updated={updated}") # Save per-track results if track_results: - db.update_sync_history_track_results(batch_id, json.dumps(track_results)) + tr_updated = db.update_sync_history_track_results(batch_id, json.dumps(track_results)) + logger.warning(f"[SyncHistory] Track results saved for batch {batch_id}: updated={tr_updated}, count={len(track_results)}") except Exception as e: logger.warning(f"Failed to record sync history completion: {e}") + import traceback + traceback.print_exc() + + +def _push_discover_playlist_to_server(batch_id, batch): + """After a discover batch completes, push the playlist to the media server. + Runs in a background thread. Triggers a scan, waits, then searches for tracks and creates/updates the playlist.""" + try: + playlist_id = batch.get('playlist_id', '') + playlist_name = batch.get('playlist_name', '') + if not playlist_name: + return + + analysis_results = batch.get('analysis_results', []) + if not analysis_results: + logger.info(f"[DiscoverPush] No analysis results for {playlist_name} - skipping server push") + return + + # Build list of tracks that should be in the playlist (found in library OR successfully downloaded) + queue = batch.get('queue', []) + download_status_map = {} + with tasks_lock: + for task_id in queue: + task = download_tasks.get(task_id, {}) + ti = task.get('track_index') + if ti is not None: + download_status_map[ti] = task.get('status', 'unknown') + + tracks_to_find = [] + for res in analysis_results: + idx = res.get('track_index', 0) + found = res.get('found', False) + dl_status = download_status_map.get(idx) + if found or dl_status == 'completed': + track_data = res.get('track', {}) + artists = track_data.get('artists', []) + if artists: + first = artists[0] + artist_name = first.get('name', first) if isinstance(first, dict) else str(first) + else: + artist_name = '' + tracks_to_find.append({ + 'index': idx, + 'title': track_data.get('name', ''), + 'artist': artist_name, + }) + + if not tracks_to_find: + logger.info(f"[DiscoverPush] No tracks to push for {playlist_name}") + return + + logger.info(f"[DiscoverPush] {playlist_name}: {len(tracks_to_find)} tracks to push to server, triggering scan first") + + # Trigger a library scan so newly downloaded tracks are indexed + if navidrome_client and navidrome_client.is_connected(): + navidrome_client.trigger_library_scan() + elif hasattr(web_scan_manager, 'request_scan'): + web_scan_manager.request_scan(f"Discover playlist push: {playlist_name}") + + # Wait for scan to finish (poll every 5s, up to 90s) + if navidrome_client and navidrome_client.is_connected(): + for _ in range(18): + time.sleep(5) + if not navidrome_client.is_library_scanning(): + break + logger.info(f"[DiscoverPush] Scan complete, searching for tracks") + else: + time.sleep(30) + + # Search for each track on the media server + matched_server_tracks = [] + if navidrome_client and navidrome_client.is_connected(): + for t in tracks_to_find: + results = navidrome_client.search_tracks(t['title'], t['artist'], limit=5) + if results: + # Use the first result's underlying NavidromeTrack for playlist creation + best = results[0] + nav_track = getattr(best, '_original_navidrome_track', None) + if nav_track: + matched_server_tracks.append(nav_track) + logger.debug(f"[DiscoverPush] Matched: '{t['title']}' by '{t['artist']}' → {best.id}") + else: + matched_server_tracks.append(best) + else: + logger.info(f"[DiscoverPush] No match for: '{t['title']}' by '{t['artist']}'") + + if not matched_server_tracks: + logger.warning(f"[DiscoverPush] No tracks matched on server for {playlist_name}") + return + + logger.info(f"[DiscoverPush] Pushing {len(matched_server_tracks)}/{len(tracks_to_find)} tracks to '{playlist_name}' on server") + success = navidrome_client.update_playlist(playlist_name, matched_server_tracks) + if success: + logger.info(f"[DiscoverPush] Successfully pushed '{playlist_name}' to server with {len(matched_server_tracks)} tracks") + else: + logger.warning(f"[DiscoverPush] Failed to push '{playlist_name}' to server") + + except Exception as e: + logger.error(f"[DiscoverPush] Error pushing playlist to server: {e}") + import traceback + traceback.print_exc() + # =============================== # == SERVER PLAYLIST MANAGER == @@ -33346,6 +33497,8 @@ def start_missing_tracks_process(playlist_id): _source_page = 'wishlist' elif is_album_download: _source_page = 'album' + elif playlist_id.startswith('discover_') or playlist_id.startswith('seasonal_'): + _source_page = 'discover' elif playlist_id.startswith('youtube_'): _source_page = 'sync' else: @@ -39488,6 +39641,13 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, except (IndexError, ValueError): pass else: + # Derive source_page from playlist_id prefix + if playlist_id.startswith('discover_') or playlist_id.startswith('seasonal_'): + _source_page = 'discover' + elif playlist_id.startswith('listenbrainz_') or playlist_id.startswith('discover_listenbrainz_'): + _source_page = 'discover' + else: + _source_page = 'sync' _record_sync_history_start( batch_id=sync_batch_id, playlist_id=playlist_id, @@ -39497,7 +39657,7 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, album_context=None, artist_context=None, playlist_folder_mode=False, - source_page='sync' + source_page=_source_page ) try: @@ -43848,6 +44008,9 @@ def refresh_discover_data(): logger.info(f"[Discover Refresh] Complete! Recent albums: {len(recent_albums)}, Release Radar: {len(release_radar)} tracks, Discovery Weekly: {len(discovery_weekly)} tracks") + # Auto-sync any "Keep it updated" playlists + _auto_sync_discover_playlists(refresh_pid, active_source) + return jsonify({ "success": True, "message": "Discover data refreshed", @@ -43864,6 +44027,269 @@ def refresh_discover_data(): return jsonify({"success": False, "error": str(e)}), 500 +def _auto_sync_discover_playlists(profile_id, active_source): + """Auto-sync Discover playlists that have 'Keep it updated' enabled.""" + try: + playlist_configs = { + 'release_radar': 'Fresh Tape', + 'discovery_weekly': 'The Archives', + 'seasonal_playlist': 'Seasonal Mix', + 'popular_picks': 'Popular Picks', + 'hidden_gems': 'Hidden Gems', + 'discovery_shuffle': 'Discovery Shuffle', + 'familiar_favorites': 'Familiar Favorites', + } + + for ptype, pname in playlist_configs.items(): + if not config_manager.get(f'discover.auto_sync.{ptype}', False): + continue + + logger.info(f"[Auto-Sync] {pname} has 'Keep it updated' enabled, triggering sync...") + + try: + database = get_database() + tracks = [] + + if ptype in ('release_radar', 'discovery_weekly'): + curated_ids = database.get_curated_playlist(f'{ptype}_{active_source}', profile_id=profile_id) + if not curated_ids: + curated_ids = database.get_curated_playlist(ptype, profile_id=profile_id) + if curated_ids: + pool_tracks = database.get_discovery_pool_tracks(limit=5000, new_releases_only=False, source=active_source, profile_id=profile_id) + tracks_by_id = {} + for track in pool_tracks: + tid = None + if active_source == 'spotify' and track.spotify_track_id: + tid = track.spotify_track_id + elif active_source == 'deezer' and getattr(track, 'deezer_track_id', None): + tid = track.deezer_track_id + elif active_source == 'itunes' and track.itunes_track_id: + tid = track.itunes_track_id + if tid: + tracks_by_id[tid] = track + + for track_id in curated_ids: + if track_id in tracks_by_id: + t = tracks_by_id[track_id] + tracks.append({ + 'id': t.spotify_track_id or getattr(t, 'deezer_track_id', None) or t.itunes_track_id or '', + 'name': t.track_name, + 'artists': [t.artist_name], + 'album': t.album_name, + 'duration_ms': t.duration_ms or 0 + }) + elif ptype == 'seasonal_playlist': + from core.seasonal_discovery import SeasonalDiscoveryService + seasonal_svc = SeasonalDiscoveryService(database) + season_data = seasonal_svc.get_current_season_playlist() + if season_data and season_data.get('tracks'): + tracks = [{ + 'id': t.get('spotify_track_id', ''), + 'name': t.get('track_name', ''), + 'artists': [t.get('artist_name', '')], + 'album': t.get('album_name', ''), + 'duration_ms': t.get('duration_ms', 0) + } for t in season_data['tracks']] + else: + from core.personalized_playlists import PersonalizedPlaylistsService + service = PersonalizedPlaylistsService(database) + method_map = { + 'popular_picks': service.get_popular_picks, + 'hidden_gems': service.get_hidden_gems, + 'discovery_shuffle': service.get_discovery_shuffle, + 'familiar_favorites': service.get_familiar_favorites, + } + if ptype in method_map: + raw_tracks = method_map[ptype](limit=50) + tracks = [{ + 'id': t.get('spotify_track_id', ''), + 'name': t.get('track_name', ''), + 'artists': [t.get('artist_name', '')], + 'album': t.get('album_name', ''), + 'duration_ms': t.get('duration_ms', 0) + } for t in raw_tracks] + + if tracks: + virtual_id = f'discover_{ptype}' + with sync_lock: + if virtual_id in active_sync_workers and not active_sync_workers[virtual_id].done(): + logger.info(f"[Auto-Sync] {pname} already syncing, skipping") + continue + sync_states[virtual_id] = {"status": "starting", "progress": {}} + future = sync_executor.submit(_run_sync_task, virtual_id, pname, tracks, None, profile_id, '') + active_sync_workers[virtual_id] = future + logger.info(f"[Auto-Sync] Started sync for {pname} with {len(tracks)} tracks") + else: + logger.info(f"[Auto-Sync] No tracks available for {pname}, skipping") + + except Exception as e: + logger.error(f"[Auto-Sync] Error syncing {pname}: {e}") + + except Exception as e: + logger.error(f"[Auto-Sync] Error in auto-sync: {e}") + + +@app.route('/api/discover/synced-playlists', methods=['GET']) +def get_discover_synced_playlists(): + """Get all Discover playlist types with sync status and auto-update config.""" + try: + database = get_database() + active_source = _get_active_discovery_source() + pid = get_current_profile_id() + + playlist_types = [ + {'type': 'release_radar', 'name': 'Fresh Tape', 'description': 'New drops from recent releases', 'icon': '🎵'}, + {'type': 'discovery_weekly', 'name': 'The Archives', 'description': 'Curated from your collection', 'icon': '📚'}, + {'type': 'seasonal_playlist', 'name': 'Seasonal Mix', 'description': 'Seasonal curated playlist', 'icon': '🌿'}, + {'type': 'popular_picks', 'name': 'Popular Picks', 'description': 'Most popular from your discovery pool', 'icon': '🔥'}, + {'type': 'hidden_gems', 'name': 'Hidden Gems', 'description': 'Underappreciated gems from your pool', 'icon': '💎'}, + {'type': 'discovery_shuffle', 'name': 'Discovery Shuffle', 'description': 'Random tracks from discovery', 'icon': '🔀'}, + {'type': 'familiar_favorites', 'name': 'Familiar Favorites', 'description': 'Familiar tracks you love', 'icon': '❤️'}, + ] + + # Check if discovery pool has any data (needed for personalized playlists) + try: + with database._get_connection() as conn: + pool_count = conn.execute( + "SELECT COUNT(*) FROM discovery_pool WHERE source = ?", (active_source,) + ).fetchone()[0] + except Exception: + pool_count = 0 + + results = [] + for pt in playlist_types: + ptype = pt['type'] + + # Get track count + track_count = 0 + if ptype in ('release_radar', 'discovery_weekly'): + curated_ids = database.get_curated_playlist(f'{ptype}_{active_source}', profile_id=pid) + if not curated_ids: + curated_ids = database.get_curated_playlist(ptype, profile_id=pid) + track_count = len(curated_ids) if curated_ids else 0 + elif ptype == 'seasonal_playlist': + from core.seasonal_discovery import SeasonalDiscoveryService + try: + seasonal_svc = SeasonalDiscoveryService(database) + season_data = seasonal_svc.get_current_season_playlist() + track_count = len(season_data.get('tracks', [])) if season_data else 0 + except Exception: + track_count = 0 + else: + # Personalized playlists come from the discovery pool + # familiar_favorites is not implemented — always report 0 + if ptype == 'familiar_favorites': + track_count = 0 + elif pool_count > 0: + track_count = min(50, pool_count) + else: + track_count = 0 + + # Get last sync info + virtual_id = f'discover_{ptype}' + sync_status = 'never' + last_synced = None + matched_tracks = 0 + total_sync_tracks = 0 + + with sync_lock: + state = sync_states.get(virtual_id) + if state and state.get('status') in ('syncing', 'starting'): + sync_status = 'syncing' + + # Also check download_batches for active discover batches + active_batch_id = None + if sync_status == 'never': + with tasks_lock: + for bid, b in download_batches.items(): + if b.get('playlist_id') == virtual_id and b.get('phase') not in ('complete', 'error', 'cancelled'): + sync_status = 'syncing' + active_batch_id = bid + break + + if sync_status == 'never': + try: + entries, _ = database.get_sync_history(source='discover', page=1, limit=100) + for entry in entries: + if entry.get('playlist_name') == pt['name'] or entry.get('playlist_id', '').startswith(virtual_id): + sync_status = 'synced' + last_synced = entry.get('completed_at') or entry.get('started_at') + matched_tracks = (entry.get('tracks_found') or 0) + (entry.get('tracks_downloaded') or 0) + total_sync_tracks = entry.get('total_tracks') or 0 + break + except Exception: + pass + + auto_update = config_manager.get(f'discover.auto_sync.{ptype}', False) + + # Use actual track count from last sync if available (curated_playlist can be stale) + if total_sync_tracks > 0: + track_count = total_sync_tracks + + results.append({ + **pt, + 'track_count': track_count, + 'sync_status': sync_status, + 'last_synced': last_synced, + 'matched_tracks': matched_tracks, + 'total_sync_tracks': total_sync_tracks, + 'auto_update': bool(auto_update), + 'virtual_id': virtual_id, + 'active_batch_id': active_batch_id, + }) + + source_labels = { + 'spotify': 'Spotify', 'deezer': 'Deezer', 'itunes': 'iTunes/Apple Music', + 'discogs': 'Discogs', 'hydrabase': 'Hydrabase' + } + has_any_data = pool_count > 0 or any(r['track_count'] > 0 for r in results) + + return jsonify({ + "success": True, + "playlists": results, + "source": active_source, + "source_label": source_labels.get(active_source, active_source), + "has_data": has_any_data, + }) + except Exception as e: + logger.error(f"Error getting discover synced playlists: {e}") + import traceback + traceback.print_exc() + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/discover/auto-update', methods=['POST', 'GET']) +def manage_discover_auto_update(): + """Toggle or get auto-update settings for Discover playlists.""" + valid_types = ['release_radar', 'discovery_weekly', 'seasonal_playlist', + 'popular_picks', 'hidden_gems', 'discovery_shuffle', 'familiar_favorites'] + + if request.method == 'GET': + settings = {} + for ptype in valid_types: + settings[ptype] = bool(config_manager.get(f'discover.auto_sync.{ptype}', False)) + # Also include any listenbrainz_* auto-sync settings + all_config = config_manager.get('discover.auto_sync', {}) + if isinstance(all_config, dict): + for key, val in all_config.items(): + if key.startswith('listenbrainz_'): + settings[key] = bool(val) + return jsonify({"success": True, "settings": settings}) + + data = request.get_json() + playlist_type = data.get('playlist_type') + enabled = data.get('enabled', False) + + is_lb_type = playlist_type and playlist_type.startswith('listenbrainz_') + if playlist_type not in valid_types and not is_lb_type: + return jsonify({"success": False, "error": f"Invalid playlist type: {playlist_type}"}), 400 + + config_manager.set(f'discover.auto_sync.{playlist_type}', bool(enabled)) + logger.info(f"Discover auto-sync for {playlist_type}: {'enabled' if enabled else 'disabled'}") + + return jsonify({"success": True, "playlist_type": playlist_type, "enabled": bool(enabled)}) + + @app.route('/api/discover/diagnose', methods=['GET']) def diagnose_discover_data(): """ @@ -43937,6 +44363,66 @@ def diagnose_discover_data(): # SEASONAL DISCOVERY ENDPOINTS # ======================================== +@app.route('/api/discover/seasonal/current-playlist', methods=['GET']) +def get_current_seasonal_playlist(): + """Auto-detect current season and return its playlist tracks""" + try: + from core.seasonal_discovery import get_seasonal_discovery_service, SEASONAL_CONFIG + + database = get_database() + seasonal_service = get_seasonal_discovery_service(spotify_client, database) + current_season = seasonal_service.get_current_season() + + if not current_season or current_season not in SEASONAL_CONFIG: + return jsonify({"success": True, "tracks": []}) + + active_source = _get_active_discovery_source() + track_ids = seasonal_service.get_curated_seasonal_playlist(current_season, source=active_source) + + if not track_ids: + return jsonify({"success": True, "tracks": []}) + + track_id_col = 'spotify_track_id' if active_source == 'spotify' else 'itunes_track_id' + tracks = [] + with database._get_connection() as conn: + cursor = conn.cursor() + for track_id in track_ids: + cursor.execute(""" + SELECT spotify_track_id, track_name, artist_name, album_name, + album_cover_url, duration_ms, popularity, track_data_json + FROM seasonal_tracks WHERE spotify_track_id = ? AND source = ? + """, (track_id, active_source)) + result = cursor.fetchone() + if not result: + cursor.execute(f""" + SELECT {track_id_col} as spotify_track_id, track_name, artist_name, album_name, + album_cover_url, duration_ms, popularity, track_data_json + FROM discovery_pool WHERE {track_id_col} = ? AND source = ? + """, (track_id, active_source)) + result = cursor.fetchone() + if result: + track_dict = dict(result) + if track_dict.get('track_data_json'): + try: + import json + track_dict['track_data_json'] = json.loads(track_dict['track_data_json']) + except: + pass + tracks.append(track_dict) + + config = SEASONAL_CONFIG[current_season] + return jsonify({ + "success": True, + "season": current_season, + "name": config['name'], + "tracks": tracks + }) + except Exception as e: + logger.error(f"Error getting current seasonal playlist: {e}") + import traceback + traceback.print_exc() + return jsonify({"success": False, "error": str(e)}), 500 + @app.route('/api/discover/seasonal/current', methods=['GET']) def get_current_seasonal_content(): """Auto-detect and return current season's content""" @@ -46523,7 +47009,8 @@ def _get_lb_discover_playlists(playlist_type): "title": playlist['title'], "creator": playlist['creator'], "annotation": playlist.get('annotation', {}), - "track": [] + "track": [], + "track_count": playlist.get('track_count', 0), } }) diff --git a/webui/index.html b/webui/index.html index c73140d7..eb289636 100644 --- a/webui/index.html +++ b/webui/index.html @@ -837,8 +837,7 @@

Playlist Sync

-

Synchronize your Spotify, Tidal, and YouTube playlists with your media - server

+

Sync playlists from Spotify, Tidal, YouTube, Beatport, Deezer, and ListenBrainz to your media server

@@ -874,6 +873,9 @@ + @@ -1747,6 +1749,17 @@
+ +
+
+

SoulSync Discover

+

Playlists generated from your Discover page. Toggle "Keep it updated" to auto-sync when playlists refresh.

+
+
+
Loading Discover playlists...
+
+
+
diff --git a/webui/static/discover.js b/webui/static/discover.js index eb50d2ba..52be47c7 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -2314,8 +2314,17 @@ function startDecadeSyncPolling(decade, virtualPlaylistId) { delete _syncProgressCallbacks[virtualPlaylistId]; const syncButton = el(`decade-${decade}-sync-btn`); if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; } - showToast(`${decade}s Classics sync complete!`, 'success'); - setTimeout(() => { const sd = el(`decade-${decade}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000); + const _m2 = progress.matched_tracks || matched || 0; + const _t2 = progress.total_tracks || total || 0; + const _miss2 = _t2 - _m2; + if (_miss2 > 0) { + showToast(`${decade}s Classics: ${_m2}/${_t2} matched, ${_miss2} missing`, 'warning'); + } else { + showToast(`${decade}s Classics: all ${_t2} tracks matched!`, 'success'); + } + if (el(`decade-${decade}-sync-percentage`)) el(`decade-${decade}-sync-percentage`).textContent = '100'; + if (el(`decade-${decade}-sync-pending`)) el(`decade-${decade}-sync-pending`).textContent = '0'; + setTimeout(() => { const sd = el(`decade-${decade}-sync-status`); if (sd) sd.style.display = 'none'; }, 5000); } }; } @@ -2357,12 +2366,19 @@ function startDecadeSyncPolling(decade, virtualPlaylistId) { syncButton.style.cursor = 'pointer'; } - showToast(`${decade}s Classics sync complete!`, 'success'); + const missing = total - matched; + if (missing > 0) { + showToast(`${decade}s Classics: ${matched}/${total} matched, ${missing} missing`, 'warning'); + } else { + showToast(`${decade}s Classics: all ${total} tracks matched!`, 'success'); + } + if (percentageEl) percentageEl.textContent = '100'; + if (pendingEl) pendingEl.textContent = '0'; setTimeout(() => { const statusDisplay = document.getElementById(`decade-${decade}-sync-status`); if (statusDisplay) statusDisplay.style.display = 'none'; - }, 3000); + }, 5000); } } catch (error) { console.error(`Error polling sync status for decade ${decade}:`, error); @@ -2715,8 +2731,17 @@ function startGenreSyncPolling(genreName, genreId, virtualPlaylistId) { delete _syncProgressCallbacks[virtualPlaylistId]; const syncButton = el(`genre-${genreId}-sync-btn`); if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; } - showToast(`${capitalizeGenre(genreName)} Mix sync complete!`, 'success'); - setTimeout(() => { const sd = el(`genre-${genreId}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000); + const _m3 = progress.matched_tracks || matched || 0; + const _t3 = progress.total_tracks || total || 0; + const _miss3 = _t3 - _m3; + if (_miss3 > 0) { + showToast(`${capitalizeGenre(genreName)} Mix: ${_m3}/${_t3} matched, ${_miss3} missing`, 'warning'); + } else { + showToast(`${capitalizeGenre(genreName)} Mix: all ${_t3} tracks matched!`, 'success'); + } + if (el(`genre-${genreId}-sync-percentage`)) el(`genre-${genreId}-sync-percentage`).textContent = '100'; + if (el(`genre-${genreId}-sync-pending`)) el(`genre-${genreId}-sync-pending`).textContent = '0'; + setTimeout(() => { const sd = el(`genre-${genreId}-sync-status`); if (sd) sd.style.display = 'none'; }, 5000); } }; } @@ -2758,12 +2783,19 @@ function startGenreSyncPolling(genreName, genreId, virtualPlaylistId) { syncButton.style.cursor = 'pointer'; } - showToast(`${capitalizeGenre(genreName)} Mix sync complete!`, 'success'); + const missing = total - matched; + if (missing > 0) { + showToast(`${capitalizeGenre(genreName)} Mix: ${matched}/${total} matched, ${missing} missing`, 'warning'); + } else { + showToast(`${capitalizeGenre(genreName)} Mix: all ${total} tracks matched!`, 'success'); + } + if (percentageEl) percentageEl.textContent = '100'; + if (pendingEl) pendingEl.textContent = '0'; setTimeout(() => { const statusDisplay = document.getElementById(`genre-${genreId}-sync-status`); if (statusDisplay) statusDisplay.style.display = 'none'; - }, 3000); + }, 5000); } } catch (error) { console.error(`Error polling sync status for genre ${genreName}:`, error); @@ -7977,8 +8009,18 @@ function startDiscoverSyncPolling(playlistType, virtualPlaylistId) { 'hidden_gems': 'Hidden Gems', 'discovery_shuffle': 'Discovery Shuffle', 'familiar_favorites': 'Familiar Favorites', 'build_playlist': 'Custom Playlist' }; - showToast(`${playlistNames[playlistType] || playlistType} sync complete!`, 'success'); - setTimeout(() => { const sd = el(`${prefix}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000); + const dn = playlistNames[playlistType] || playlistType; + const _m = progress.matched_tracks || matched || 0; + const _t = progress.total_tracks || total || 0; + const _miss = _t - _m; + if (_miss > 0) { + showToast(`${dn}: ${_m}/${_t} matched, ${_miss} missing`, 'warning'); + } else { + showToast(`${dn}: all ${_t} tracks matched!`, 'success'); + } + if (el(`${prefix}-sync-percentage`)) el(`${prefix}-sync-percentage`).textContent = '100'; + if (el(`${prefix}-sync-pending`)) el(`${prefix}-sync-pending`).textContent = '0'; + setTimeout(() => { const sd = el(`${prefix}-sync-status`); if (sd) sd.style.display = 'none'; }, 5000); } }; } @@ -8045,15 +8087,22 @@ function startDiscoverSyncPolling(playlistType, virtualPlaylistId) { 'build_playlist': 'Custom Playlist' }; const displayName = playlistNames[playlistType] || playlistType; - showToast(`${displayName} sync complete!`, 'success'); + const missing = total - matched; + if (missing > 0) { + showToast(`${displayName}: ${matched}/${total} matched, ${missing} missing`, 'warning'); + } else { + showToast(`${displayName}: all ${total} tracks matched!`, 'success'); + } - // Hide status display after 3 seconds + // Update status display to show final result, then hide after 5s + if (percentageEl) percentageEl.textContent = '100'; + if (pendingEl) pendingEl.textContent = '0'; setTimeout(() => { const statusDisplay = document.getElementById(`${prefix}-sync-status`); if (statusDisplay) { statusDisplay.style.display = 'none'; } - }, 3000); + }, 5000); } } catch (error) { @@ -8903,3 +8952,546 @@ if (document.readyState === 'loading') { // ============================================================================ + +// ── SoulSync Discover Sync Tab ───────────────────────────────────────── + +async function loadDiscoverSyncPlaylists() { + if (discoverSyncPlaylistsLoaded) return; + discoverSyncPlaylistsLoaded = true; + const container = document.getElementById('discover-sync-playlist-container'); + if (!container) return; + container.innerHTML = '
Loading Discover playlists...
'; + + try { + const response = await fetch('/api/discover/synced-playlists'); + const data = await response.json(); + + if (!data.success || !data.playlists || data.playlists.length === 0) { + container.innerHTML = '
No Discover playlists available. Visit the Discover page to generate playlists first.
'; + return; + } + + container.innerHTML = ''; + + // Show source info and empty-state hint if no playlists have data + if (!data.has_data) { + const hint = document.createElement('div'); + hint.className = 'discover-sync-empty-hint'; + hint.innerHTML = ` +

Your Discover playlists don't have any tracks yet.

+

Go to the Discover page and let it build your playlist pool — it uses your ${data.source_label || 'configured source'} data and watchlist to generate personalized playlists.

+ `; + container.appendChild(hint); + } + + data.playlists.forEach(playlist => { + renderDiscoverSyncCard(playlist, container, data.source_label || data.source); + // Resume polling if there's an active batch for this playlist + if (playlist.active_batch_id && playlist.sync_status === 'syncing') { + const btn = document.getElementById(`discover-sync-btn-${playlist.type}`); + if (btn) { btn.disabled = true; btn.textContent = 'Syncing...'; } + pollDiscoverBatchFromTab(playlist.type, playlist.active_batch_id, playlist.name); + } + }); + + // Also fetch ListenBrainz playlists and add them + try { + // Fetch saved auto-update settings so LB toggles persist across restarts + let lbAutoSettings = {}; + try { + const settingsRes = await fetch('/api/discover/auto-update'); + if (settingsRes.ok) { + const settingsData = await settingsRes.json(); + if (settingsData.success) lbAutoSettings = settingsData.settings || {}; + } + } catch (_) {} + + const lbRes = await fetch('/api/discover/listenbrainz/created-for'); + if (lbRes.ok) { + const lbData = await lbRes.json(); + if (lbData.success && lbData.playlists && lbData.playlists.length > 0) { + // Fetch sync history once for all LB playlists + let historyEntries = []; + try { + const histRes = await fetch('/api/sync/history?source=discover&limit=50'); + if (histRes.ok) { + const histData = await histRes.json(); + historyEntries = histData.entries || []; + } + } catch (_) {} + + // Deduplicate by base name — only show the latest of each type + const seen = new Map(); + for (const p of lbData.playlists) { + const pl = p.playlist || p; + const rawTitle = pl.title || 'ListenBrainz Playlist'; + // Strip ", week of YYYY-MM-DD ..." suffix for a stable display name + const baseName = rawTitle.replace(/,\s*week of .+$/i, '').trim(); + // Keep only the first (latest) for each base name + if (!seen.has(baseName)) seen.set(baseName, { pl, rawTitle, baseName }); + } + + for (const { pl, rawTitle, baseName } of seen.values()) { + const identifier = pl.identifier || ''; + const mbid = identifier.split('/').pop(); + const trackCount = pl.track_count || (pl.track || []).length; + // Determine icon from title + let icon = '🧠'; + if (rawTitle.toLowerCase().includes('jam')) icon = '🎸'; + else if (rawTitle.toLowerCase().includes('explor')) icon = '🔭'; + + const lbType = `listenbrainz_${mbid}`; + + // Check sync history for this playlist by matching the base name + let syncStatus = 'never'; + let lastSynced = null; + let matchedTracks = 0; + let totalSyncTracks = 0; + for (const entry of historyEntries) { + const eName = entry.playlist_name || ''; + if (eName === baseName || eName.startsWith(baseName)) { + syncStatus = 'synced'; + lastSynced = entry.completed_at || entry.started_at || entry.created_at; + matchedTracks = entry.tracks_found || 0; + totalSyncTracks = entry.total_tracks || 0; + break; + } + } + + renderDiscoverSyncCard({ + type: lbType, + name: baseName, + description: '', + icon: icon, + track_count: trackCount, + sync_status: syncStatus, + last_synced: lastSynced, + matched_tracks: matchedTracks, + total_sync_tracks: totalSyncTracks, + auto_update: !!lbAutoSettings[lbType], + virtual_id: `discover_listenbrainz_${mbid}`, + _lb_mbid: mbid, + }, container, 'ListenBrainz'); + } + } + } + } catch (lbErr) { + console.warn('Could not load ListenBrainz playlists for discover sync tab:', lbErr); + } + + } catch (error) { + console.error('Error loading discover sync playlists:', error); + container.innerHTML = '
Error loading Discover playlists.
'; + } +} + +function renderDiscoverSyncCard(playlist, container, sourceLabel) { + const card = document.createElement('div'); + const isEmpty = playlist.track_count === 0; + card.className = `discover-sync-card${isEmpty ? ' discover-sync-card-empty' : ''}`; + card.id = `discover-sync-card-${playlist.type}`; + + const lastSyncedText = playlist.last_synced + ? `Last synced ${timeAgo(playlist.last_synced)}` + : 'Never synced'; + + const statusClass = playlist.sync_status === 'syncing' ? 'syncing' : + playlist.sync_status === 'synced' ? 'synced' : 'not-synced'; + let statusText = playlist.sync_status === 'syncing' ? 'Syncing...' : + playlist.sync_status === 'synced' ? 'Synced' : 'Not synced'; + + // Show matched/total counts if available (only when matched > 0, meaning completion was recorded) + if (playlist.sync_status === 'synced' && playlist.matched_tracks > 0 && playlist.total_sync_tracks > 0) { + statusText = `Synced ${playlist.matched_tracks}/${playlist.total_sync_tracks}`; + } + + const trackLabel = isEmpty ? 'No tracks yet' : `${playlist.track_count} tracks`; + + card.innerHTML = ` +
${playlist.icon}
+
+
${playlist.name} + + ${sourceLabel || 'unknown'} + \u00b7 + ${trackLabel} + \u00b7 + ${statusText} + \u00b7 + ${lastSyncedText} + +
+
+
+
+ + +
+ +
+ `; + + // Make the icon + info area clickable to view tracks + if (!isEmpty) { + const clickArea = card.querySelector('.discover-sync-card-info'); + const iconArea = card.querySelector('.discover-sync-card-icon'); + [clickArea, iconArea].forEach(el => { + el.style.cursor = 'pointer'; + el.addEventListener('click', () => openDiscoverPlaylistModal(playlist.type, playlist.name, playlist.icon)); + }); + } + + container.appendChild(card); +} + +async function toggleDiscoverAutoUpdate(playlistType, enabled) { + try { + const response = await fetch('/api/discover/auto-update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ playlist_type: playlistType, enabled: enabled }) + }); + const data = await response.json(); + if (data.success) { + showToast(`Auto-update ${enabled ? 'enabled' : 'disabled'} for ${playlistType.replace(/_/g, ' ')}`, 'success'); + } else { + showToast(`Failed to update setting: ${data.error}`, 'error'); + } + } catch (error) { + console.error('Error toggling auto-update:', error); + showToast('Failed to update setting', 'error'); + } +} + +const _discoverSyncQueue = []; +let _discoverSyncRunning = false; + +async function syncDiscoverPlaylistFromTab(playlistType, playlistName) { + // Serialize sync operations to avoid concurrent backend contention + return new Promise((resolve) => { + _discoverSyncQueue.push({ playlistType, playlistName, resolve }); + _processDiscoverSyncQueue(); + }); +} + +async function _processDiscoverSyncQueue() { + if (_discoverSyncRunning || _discoverSyncQueue.length === 0) return; + _discoverSyncRunning = true; + const { playlistType, playlistName, resolve } = _discoverSyncQueue.shift(); + try { + await _doSyncDiscoverPlaylist(playlistType, playlistName); + } finally { + _discoverSyncRunning = false; + resolve(); + _processDiscoverSyncQueue(); + } +} + +async function _doSyncDiscoverPlaylist(playlistType, playlistName) { + const btn = document.getElementById(`discover-sync-btn-${playlistType}`); + if (btn) { + btn.disabled = true; + btn.textContent = 'Syncing...'; + } + + try { + let tracksResponse; + + // Use unified URL helper (handles ListenBrainz + standard discover types) + const apiUrl = _discoverPlaylistApiUrl(playlistType); + if (apiUrl) { + tracksResponse = await fetch(apiUrl); + } + + let tracks = []; + if (tracksResponse && tracksResponse.ok) { + const data = await tracksResponse.json(); + tracks = data.tracks || []; + } + + if (!tracks.length) { + showToast(`No tracks available for ${playlistName}. Visit the Discover page first.`, 'warning'); + if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; } + return; + } + + const syncTracks = tracks.map(track => { + if (track.track_data_json) { + const t = track.track_data_json; + if (t.artists && Array.isArray(t.artists)) { + t.artists = t.artists.map(a => a.name || a); + } + return t; + } + return { + id: track.spotify_track_id || track.track_id || '', + name: track.track_name || track.name || '', + artists: [track.artist_name || 'Unknown Artist'], + album: track.album_name || '', + duration_ms: track.duration_ms || 0, + image_url: track.album_cover_url || track.image_url || '' + }; + }); + + const virtualPlaylistId = `discover_${playlistType}`; + + // Use the download batch endpoint directly so the batch is labeled + // as "Discover" instead of going through sync → wishlist → "Wishlist" batch. + // Omit force_download_all so it checks the library first and only downloads missing tracks. + const batchResponse = await fetch(`/api/playlists/${virtualPlaylistId}/start-missing-process`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tracks: syncTracks, + playlist_name: playlistName + }) + }); + + const result = await batchResponse.json(); + if (result.success) { + showToast(`Downloading ${playlistName} (${syncTracks.length} tracks)...`, 'info'); + const card = document.getElementById(`discover-sync-card-${playlistType}`); + if (card) { + const statusEl = card.querySelector('.discover-sync-status'); + if (statusEl) { + statusEl.className = 'discover-sync-status syncing'; + statusEl.textContent = 'Downloading...'; + } + } + // Poll the download batch status + pollDiscoverBatchFromTab(playlistType, result.batch_id, playlistName); + } else { + showToast(`Download failed: ${result.error || 'Unknown error'}`, 'error'); + if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; } + } + } catch (error) { + console.error(`Error syncing ${playlistName}:`, error); + showToast(`Failed to sync ${playlistName}`, 'error'); + if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; } + } +} + +function pollDiscoverSyncFromTab(playlistType, virtualPlaylistId, playlistName) { + const pollInterval = setInterval(async () => { + try { + const resp = await fetch(`/api/sync/status/${virtualPlaylistId}`); + if (!resp.ok) { clearInterval(pollInterval); return; } + const data = await resp.json(); + + if (data.status === 'finished' || data.status === 'error') { + clearInterval(pollInterval); + const btn = document.getElementById(`discover-sync-btn-${playlistType}`); + if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; } + + const card = document.getElementById(`discover-sync-card-${playlistType}`); + if (card) { + const statusEl = card.querySelector('.discover-sync-status'); + if (statusEl) { + if (data.status === 'finished') { + const progress = data.progress || data.result || {}; + const matched = progress.matched_tracks || 0; + const total = progress.total_tracks || 0; + statusEl.className = 'discover-sync-status synced'; + statusEl.textContent = matched > 0 && total > 0 ? `Synced ${matched}/${total}` : 'Synced'; + } else { + statusEl.className = 'discover-sync-status not-synced'; + statusEl.textContent = 'Failed'; + } + } + const lastSyncedEl = card.querySelector('.discover-sync-last-synced'); + if (lastSyncedEl && data.status === 'finished') { + lastSyncedEl.textContent = 'Last synced just now'; + } + } + + if (data.status === 'finished') { + showToast(`${playlistName} synced successfully!`, 'success'); + } else { + showToast(`${playlistName} sync failed`, 'error'); + } + } + } catch (error) { + clearInterval(pollInterval); + } + }, 2000); +} + +function pollDiscoverBatchFromTab(playlistType, batchId, playlistName) { + const pollInterval = setInterval(async () => { + try { + const resp = await fetch(`/api/playlists/${batchId}/download_status`); + if (!resp.ok) { clearInterval(pollInterval); return; } + const data = await resp.json(); + const phase = data.phase || data.status; + + if (phase === 'complete' || phase === 'error' || phase === 'cancelled') { + clearInterval(pollInterval); + const btn = document.getElementById(`discover-sync-btn-${playlistType}`); + if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; } + + // Extract matched/total from analysis_results + const analysisResults = data.analysis_results || []; + const totalTracks = analysisResults.length; + const matchedTracks = analysisResults.filter(r => r.found).length; + const tasks = data.tasks || []; + const downloaded = tasks.filter(t => t.status === 'completed').length; + const failed = tasks.filter(t => t.status === 'failed' || t.status === 'not_found').length; + + const card = document.getElementById(`discover-sync-card-${playlistType}`); + const syncedCount = matchedTracks + downloaded; + if (card) { + const statusEl = card.querySelector('.discover-sync-status'); + if (statusEl) { + statusEl.className = `discover-sync-status ${phase === 'complete' ? 'synced' : 'not-synced'}`; + if (phase === 'complete' && totalTracks > 0) { + statusEl.textContent = `Synced ${syncedCount}/${totalTracks}`; + } else { + statusEl.textContent = phase === 'complete' ? 'Synced' : (phase === 'cancelled' ? 'Cancelled' : 'Failed'); + } + } + const lastSyncedEl = card.querySelector('.discover-sync-last-synced'); + if (lastSyncedEl && phase === 'complete') { + lastSyncedEl.textContent = 'Last synced just now'; + } + } + + if (phase === 'complete') { + if (totalTracks > 0) { + const missing = totalTracks - syncedCount; + let msg = `${playlistName}: ${syncedCount}/${totalTracks} in library`; + if (downloaded > 0) msg += `, ${downloaded} downloaded`; + if (failed > 0) msg += `, ${failed} failed`; + if (missing === 0) msg += ' - all owned!'; + showToast(msg, 'success'); + } else { + showToast(`${playlistName} download complete!`, 'success'); + } + } else if (phase !== 'cancelled') { + showToast(`${playlistName} download failed`, 'error'); + } + } + } catch (error) { + clearInterval(pollInterval); + } + }, 3000); +} + +/** + * Map a discover playlist type to its API endpoint for fetching tracks. + */ +function _discoverPlaylistApiUrl(playlistType) { + // ListenBrainz playlists + if (playlistType.startsWith('listenbrainz_')) { + const mbid = playlistType.replace('listenbrainz_', ''); + return `/api/discover/listenbrainz/playlist/${mbid}`; + } + const map = { + release_radar: '/api/discover/release-radar', + discovery_weekly: '/api/discover/weekly', + seasonal_playlist: '/api/discover/seasonal/current-playlist', + popular_picks: '/api/discover/personalized/popular-picks', + hidden_gems: '/api/discover/personalized/hidden-gems', + discovery_shuffle: '/api/discover/personalized/discovery-shuffle', + familiar_favorites: '/api/discover/personalized/familiar-favorites', + }; + return map[playlistType] || null; +} + +/** + * Open a modal showing all tracks in a Discover playlist (mirrored-modal style). + */ +async function openDiscoverPlaylistModal(playlistType, playlistName, icon) { + const apiUrl = _discoverPlaylistApiUrl(playlistType); + if (!apiUrl) { showToast('Unknown playlist type', 'error'); return; } + + showLoadingOverlay(`Loading ${playlistName}...`); + try { + const res = await fetch(apiUrl); + const data = await res.json(); + const tracks = data.tracks || []; + + hideLoadingOverlay(); + + if (!tracks.length) { + showToast(`No tracks in ${playlistName}. Visit the Discover page first.`, 'warning'); + return; + } + + // Remove any existing modal + const old = document.getElementById('discover-playlist-modal'); + if (old) old.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'discover-playlist-modal'; + overlay.className = 'mirrored-modal-overlay'; + + const trackRows = tracks.map((t, idx) => { + const name = t.track_name || t.name || ''; + const artist = t.artist_name || (t.artists ? (Array.isArray(t.artists) ? t.artists.map(a => a.name || a).join(', ') : t.artists) : ''); + const album = t.album_name || t.album || ''; + const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : ''; + const coverUrl = t.album_cover_url || ''; + const coverHtml = coverUrl + ? `` + : `
`; + return `
+ ${idx + 1} + ${coverHtml} + ${_esc(name)} + ${_esc(artist)} + ${_esc(album)} + ${dur} +
`; + }).join(''); + + overlay.innerHTML = ` +
+
+
+
${icon || '🎵'}
+
+

${_esc(playlistName)}

+
+ discover + ${tracks.length} tracks +
+
+
+ × +
+
+
+ #TrackArtistAlbumTime +
+ ${trackRows} +
+ +
+ `; + + overlay.addEventListener('click', e => { if (e.target === overlay) closeDiscoverPlaylistModal(); }); + document.body.appendChild(overlay); + } catch (err) { + hideLoadingOverlay(); + showToast(`Error loading ${playlistName}: ${err.message}`, 'error'); + } +} + +function closeDiscoverPlaylistModal() { + const m = document.getElementById('discover-playlist-modal'); + if (m) m.remove(); +} diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index 333b1de6..1ac5f919 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -2514,7 +2514,12 @@ function _adlRenderBatchPanel() { phaseText = `${batch.completed}/${total} tracks`; if (batch.active > 0) phaseIcon = ''; } else if (batch.phase === 'complete') { - phaseText = `Done \u2014 ${batch.completed} tracks`; + const analysisTotal = batch.analysis_total || 0; + const alreadyOwned = analysisTotal > 0 ? analysisTotal - total : 0; + let parts = [`${batch.completed} downloaded`]; + if (alreadyOwned > 0) parts.push(`${alreadyOwned} owned`); + if (batch.failed > 0) parts.push(`${batch.failed} failed`); + phaseText = parts.join(', '); phaseIcon = '\u2713'; } else if (batch.phase === 'cancelled') { phaseText = 'Cancelled'; diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 7b48c34c..612cffe3 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -2157,6 +2157,7 @@ function importFileSubmit() { // ── Mirrored Playlists ──────────────────────────────────────────────── let mirroredPlaylistsLoaded = false; +let discoverSyncPlaylistsLoaded = false; /** * Fire-and-forget helper: send parsed playlist data to be mirrored on the backend. diff --git a/webui/static/style.css b/webui/static/style.css index fedf54cd..bc5b9d4a 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -2490,7 +2490,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { .helper-first-launch-tip { position: fixed; bottom: 34px; - right: 84px; + right: 136px; padding: 8px 16px; background: rgba(16, 16, 16, 0.95); border: 1px solid rgba(var(--accent-rgb), 0.3); @@ -12141,6 +12141,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { overflow: hidden; border-top-left-radius: 20px; border-top-right-radius: 20px; + flex-shrink: 0; } .mirrored-modal-hero { @@ -12341,6 +12342,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); border-bottom-left-radius: 20px; border-bottom-right-radius: 20px; + flex-shrink: 0; } .mirrored-modal-footer button { @@ -59937,4 +59939,313 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt #artist-detail-page .release-card.album-card .mb-card-icon:hover { opacity: 1; + +/* ── SoulSync Discover Sync Tab ───────────────────────────────────────── */ + +.discover-icon { + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + +.sync-tab-button[data-tab="discover"].active { + background: linear-gradient(135deg, #a78bfa, #7c3aed); + color: #fff; + box-shadow: 0 4px 15px rgba(167, 139, 250, 0.3); +} + +.sync-tab-button.active .discover-icon { + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + +.discover-sync-subtitle { + color: rgba(255, 255, 255, 0.5); + font-size: 13px; + margin: 4px 0 0 0; + font-weight: 400; + line-height: 1.4; +} + +.discover-sync-card { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + margin: 3px 6px; + padding: 8px 14px; + display: flex; + align-items: center; + gap: 10px; + transition: all 0.25s ease; +} + +.discover-sync-card:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(167, 139, 250, 0.2); +} + +.discover-sync-card-icon { + font-size: 20px; + flex-shrink: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; +} + +.discover-sync-card-info { + flex: 1; + min-width: 0; +} + +.discover-sync-card-name { + font-size: 13px; + font-weight: 600; + color: #fff; + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.discover-sync-card-meta-inline { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 400; + color: rgba(255, 255, 255, 0.4); +} + +.discover-sync-source-badge { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 1px 7px; + border-radius: 4px; + color: rgb(var(--accent-light-rgb)); + background: rgba(var(--accent-rgb), 0.1); + border: 1px solid rgba(var(--accent-rgb), 0.2); +} + +.discover-sync-card-desc { + display: none; +} + +.discover-sync-card-meta { + display: none; +} + color: rgba(255, 255, 255, 0.4); +} + +.discover-sync-separator { + opacity: 0.4; +} + +.discover-sync-track-count { + color: rgba(255, 255, 255, 0.55); +} + +.discover-sync-status { + font-weight: 500; +} + +.discover-sync-status.synced { + color: #22c55e; +} + +.discover-sync-status.syncing { + color: #facc15; +} + +.discover-sync-status.not-synced { + color: rgba(255, 255, 255, 0.35); +} + +.discover-sync-last-synced { + color: rgba(255, 255, 255, 0.35); +} + +.discover-sync-card-actions { + display: flex; + align-items: center; + gap: 16px; + flex-shrink: 0; +} + +.discover-sync-toggle-wrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.discover-sync-toggle-label { + font-size: 10.5px; + color: rgba(255, 255, 255, 0.4); + white-space: nowrap; +} + +.discover-sync-toggle { + position: relative; + display: inline-block; + width: 40px; + height: 22px; + cursor: pointer; +} + +.discover-sync-toggle input { + opacity: 0; + width: 0; + height: 0; +} + +.discover-sync-toggle-slider { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.12); + border-radius: 22px; + transition: all 0.25s ease; +} + +.discover-sync-toggle-slider::before { + content: ''; + position: absolute; + width: 16px; + height: 16px; + left: 3px; + bottom: 3px; + background: #fff; + border-radius: 50%; + transition: all 0.25s ease; +} + +.discover-sync-toggle input:checked + .discover-sync-toggle-slider { + background: #a78bfa; +} + +.discover-sync-toggle input:checked + .discover-sync-toggle-slider::before { + transform: translateX(18px); +} + +.discover-sync-btn { + padding: 8px 16px; + background: linear-gradient(135deg, #a78bfa, #7c3aed); + color: #fff; + border: none; + border-radius: 8px; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: all 0.25s ease; +} + +.discover-sync-btn:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(167, 139, 250, 0.35); +} + +.discover-sync-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +/* Empty-state styling */ +.discover-sync-card-empty { + opacity: 0.55; +} + +.discover-sync-card-empty .discover-sync-toggle-slider { + opacity: 0.4; +} + +.discover-sync-empty-hint { + background: rgba(255, 193, 7, 0.08); + border: 1px solid rgba(255, 193, 7, 0.25); + border-radius: 10px; + padding: 16px 20px; + margin-bottom: 16px; + color: #ccc; + font-size: 0.9rem; + line-height: 1.5; +} + +.discover-sync-empty-hint p { + margin: 0 0 6px 0; +} + +.discover-sync-empty-hint p:last-child { + margin-bottom: 0; +} + +.discover-sync-empty-hint strong { + color: #ffc107; +} + +.discover-sync-source-info { + color: #888; + font-size: 0.82rem; + margin-bottom: 12px; + padding-left: 4px; +} + +.discover-sync-source-info strong { + color: #aaa; +} + +@media (max-width: 768px) { + .discover-sync-card { + flex-wrap: wrap; + gap: 12px; + } + + .discover-sync-card-actions { + width: 100%; + justify-content: flex-end; + } +} + +/* Discover playlist modal extras */ +.mirrored-modal-hero-icon.discover { + background: linear-gradient(135deg, rgba(167, 139, 250, 0.2) 0%, rgba(124, 58, 237, 0.12) 100%); + border-color: rgba(167, 139, 250, 0.3); + box-shadow: 0 8px 24px rgba(167, 139, 250, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.1); + color: #a78bfa; +} + +.discover-modal-track-img { + width: 32px; + height: 32px; + border-radius: 4px; + object-fit: cover; + vertical-align: middle; +} + +.discover-modal-track-img-placeholder { + width: 32px; + height: 32px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.06); + display: inline-block; + vertical-align: middle; +} + +.track-cover { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + flex-shrink: 0; +} + +#discover-playlist-modal .mirrored-track-header, +#discover-playlist-modal .mirrored-track-row { + grid-template-columns: 40px 40px 1.4fr 1.2fr 1fr 56px; } diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js index 4dc00ed8..525e4558 100644 --- a/webui/static/sync-services.js +++ b/webui/static/sync-services.js @@ -2784,6 +2784,11 @@ function initializeSyncPage() { loadMirroredPlaylists(); } + // Auto-load SoulSync Discover playlists on first tab activation + if (tabId === 'discover' && !discoverSyncPlaylistsLoaded) { + loadDiscoverSyncPlaylists(); + } + // Auto-load server playlists on first tab activation if (tabId === 'server' && !window._serverPlaylistsLoaded) { window._serverPlaylistsLoaded = true; From bbc05b87a7552cbb25277d5a1f37cf28b6d4cf7a Mon Sep 17 00:00:00 2001 From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com> Date: Thu, 23 Apr 2026 00:55:11 -0700 Subject: [PATCH 25/80] Track server push status, expand push to all playlist types, rename to _push_playlist_to_server --- database/music_database.py | 30 +++++++++++++++-- web_server.py | 67 ++++++++++++++++++++++++++------------ 2 files changed, 74 insertions(+), 23 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index 7b2be55f..88b96945 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -636,7 +636,8 @@ class MusicDatabase: playlist_folder_mode INTEGER DEFAULT 0, started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, completed_at TIMESTAMP, - track_results TEXT + track_results TEXT, + server_push_status TEXT ) """) cursor.execute("CREATE INDEX IF NOT EXISTS idx_sh_started_at ON sync_history (started_at DESC)") @@ -662,6 +663,16 @@ class MusicDatabase: except Exception: pass + # Migration: add server_push_status column to sync_history + try: + cursor.execute("SELECT server_push_status FROM sync_history LIMIT 1") + except Exception: + try: + cursor.execute("ALTER TABLE sync_history ADD COLUMN server_push_status TEXT") + logger.info("Added server_push_status column to sync_history table") + except Exception: + pass + # Migration: add track_artist column for per-track artist on compilations/DJ mixes try: cursor.execute("SELECT track_artist FROM tracks LIMIT 1") @@ -10497,6 +10508,20 @@ class MusicDatabase: logger.debug(f"Error updating sync history track results: {e}") return False + def update_sync_history_push_status(self, batch_id, status): + """Update the server push status for a sync_history entry.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE sync_history SET server_push_status = ? WHERE batch_id = ? + """, (status, batch_id)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.debug(f"Error updating sync history push status: {e}") + return False + def refresh_sync_history_entry(self, entry_id, tracks_found=0, tracks_downloaded=0, tracks_failed=0): """Update an existing sync_history entry with new stats and reset timestamps to move it to the top.""" try: @@ -10611,7 +10636,8 @@ class MusicDatabase: cursor.execute(""" SELECT id, batch_id, playlist_name, source, sync_type, source_page, total_tracks, tracks_found, tracks_downloaded, tracks_failed, - thumb_url, is_album_download, started_at, completed_at + thumb_url, is_album_download, started_at, completed_at, + server_push_status FROM sync_history WHERE completed_at IS NOT NULL AND started_at >= datetime('now', ? || ' days') diff --git a/web_server.py b/web_server.py index 3acf4143..ab8b926f 100644 --- a/web_server.py +++ b/web_server.py @@ -28923,11 +28923,17 @@ def _on_download_completed(batch_id, task_id, success=True): except Exception: pass - # Push discover playlists to media server after downloads complete + # Push playlists to media server after downloads complete playlist_id = batch.get('playlist_id') - if playlist_id and playlist_id.startswith('discover_'): + _push_prefixes = ( + 'discover_', 'auto_mirror_', 'youtube_mirrored_', + 'youtube_', 'tidal_', 'deezer_', 'spotify_public_', + 'listenbrainz_', 'beatport_', + ) + if playlist_id and playlist_id.startswith(_push_prefixes): + database.update_sync_history_push_status(batch_id, 'pending') threading.Thread( - target=_push_discover_playlist_to_server, + target=_push_playlist_to_server, args=(batch_id, batch), daemon=True ).start() @@ -32183,11 +32189,17 @@ def _check_batch_completion_v2(batch_id): except Exception: pass - # Push discover playlists to media server after downloads complete + # Push playlists to media server after downloads complete playlist_id = batch.get('playlist_id') - if playlist_id and playlist_id.startswith('discover_'): + _push_prefixes = ( + 'discover_', 'auto_mirror_', 'youtube_mirrored_', + 'youtube_', 'tidal_', 'deezer_', 'spotify_public_', + 'listenbrainz_', 'beatport_', + ) + if playlist_id and playlist_id.startswith(_push_prefixes): + database.update_sync_history_push_status(batch_id, 'pending') threading.Thread( - target=_push_discover_playlist_to_server, + target=_push_playlist_to_server, args=(batch_id, batch), daemon=True ).start() @@ -32735,18 +32747,23 @@ def _record_sync_history_completion(batch_id, batch): traceback.print_exc() -def _push_discover_playlist_to_server(batch_id, batch): - """After a discover batch completes, push the playlist to the media server. - Runs in a background thread. Triggers a scan, waits, then searches for tracks and creates/updates the playlist.""" +def _push_playlist_to_server(batch_id, batch): + """After a batch completes, push the playlist to the media server. + Runs in a background thread. Triggers a scan, waits, then searches for tracks and creates/updates the playlist. + Supports discover, mirrored, and auto-mirror playlists.""" + database = get_database() try: playlist_id = batch.get('playlist_id', '') playlist_name = batch.get('playlist_name', '') if not playlist_name: return + database.update_sync_history_push_status(batch_id, 'pushing') + analysis_results = batch.get('analysis_results', []) if not analysis_results: - logger.info(f"[DiscoverPush] No analysis results for {playlist_name} - skipping server push") + logger.info(f"[PlaylistPush] No analysis results for {playlist_name} - skipping server push") + database.update_sync_history_push_status(batch_id, 'skipped') return # Build list of tracks that should be in the playlist (found in library OR successfully downloaded) @@ -32779,16 +32796,17 @@ def _push_discover_playlist_to_server(batch_id, batch): }) if not tracks_to_find: - logger.info(f"[DiscoverPush] No tracks to push for {playlist_name}") + logger.info(f"[PlaylistPush] No tracks to push for {playlist_name}") + database.update_sync_history_push_status(batch_id, 'skipped') return - logger.info(f"[DiscoverPush] {playlist_name}: {len(tracks_to_find)} tracks to push to server, triggering scan first") + logger.info(f"[PlaylistPush] {playlist_name}: {len(tracks_to_find)} tracks to push to server, triggering scan first") # Trigger a library scan so newly downloaded tracks are indexed if navidrome_client and navidrome_client.is_connected(): navidrome_client.trigger_library_scan() elif hasattr(web_scan_manager, 'request_scan'): - web_scan_manager.request_scan(f"Discover playlist push: {playlist_name}") + web_scan_manager.request_scan(f"Playlist push: {playlist_name}") # Wait for scan to finish (poll every 5s, up to 90s) if navidrome_client and navidrome_client.is_connected(): @@ -32796,7 +32814,7 @@ def _push_discover_playlist_to_server(batch_id, batch): time.sleep(5) if not navidrome_client.is_library_scanning(): break - logger.info(f"[DiscoverPush] Scan complete, searching for tracks") + logger.info(f"[PlaylistPush] Scan complete, searching for tracks") else: time.sleep(30) @@ -32811,27 +32829,34 @@ def _push_discover_playlist_to_server(batch_id, batch): nav_track = getattr(best, '_original_navidrome_track', None) if nav_track: matched_server_tracks.append(nav_track) - logger.debug(f"[DiscoverPush] Matched: '{t['title']}' by '{t['artist']}' → {best.id}") + logger.debug(f"[PlaylistPush] Matched: '{t['title']}' by '{t['artist']}' → {best.id}") else: matched_server_tracks.append(best) else: - logger.info(f"[DiscoverPush] No match for: '{t['title']}' by '{t['artist']}'") + logger.info(f"[PlaylistPush] No match for: '{t['title']}' by '{t['artist']}'") if not matched_server_tracks: - logger.warning(f"[DiscoverPush] No tracks matched on server for {playlist_name}") + logger.warning(f"[PlaylistPush] No tracks matched on server for {playlist_name}") + database.update_sync_history_push_status(batch_id, 'failed') return - logger.info(f"[DiscoverPush] Pushing {len(matched_server_tracks)}/{len(tracks_to_find)} tracks to '{playlist_name}' on server") + logger.info(f"[PlaylistPush] Pushing {len(matched_server_tracks)}/{len(tracks_to_find)} tracks to '{playlist_name}' on server") success = navidrome_client.update_playlist(playlist_name, matched_server_tracks) if success: - logger.info(f"[DiscoverPush] Successfully pushed '{playlist_name}' to server with {len(matched_server_tracks)} tracks") + logger.info(f"[PlaylistPush] Successfully pushed '{playlist_name}' to server with {len(matched_server_tracks)} tracks") + database.update_sync_history_push_status(batch_id, 'success') else: - logger.warning(f"[DiscoverPush] Failed to push '{playlist_name}' to server") + logger.warning(f"[PlaylistPush] Failed to push '{playlist_name}' to server") + database.update_sync_history_push_status(batch_id, 'failed') except Exception as e: - logger.error(f"[DiscoverPush] Error pushing playlist to server: {e}") + logger.error(f"[PlaylistPush] Error pushing playlist to server: {e}") import traceback traceback.print_exc() + try: + database.update_sync_history_push_status(batch_id, 'failed') + except Exception: + pass # =============================== From 6bbc6b3c17e1664d084cfbcc06b6c73650235435 Mon Sep 17 00:00:00 2001 From: JohnBaumb <80135794+JohnBaumb@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:18:34 -0700 Subject: [PATCH 26/80] Add sync tab deep-linking, redirect Discover sync to sync tab, add Force Download toggle --- webui/static/discover.js | 148 ++++++++-------------------------- webui/static/init.js | 12 ++- webui/static/style.css | 10 +++ webui/static/sync-services.js | 48 +++++++++++ 4 files changed, 101 insertions(+), 117 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index 52be47c7..f23843c5 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -7859,113 +7859,14 @@ function checkForActiveDiscoverDownloads() { } async function startDiscoverPlaylistSync(playlistType, playlistName) { - console.log(`🔄 Starting sync for ${playlistName}`); + console.log(`🔄 Navigating to Sync → Discover tab for ${playlistName}`); - // Get tracks based on playlist type - let tracks = []; - if (playlistType === 'release_radar') { - tracks = discoverReleaseRadarTracks; - } else if (playlistType === 'discovery_weekly') { - tracks = discoverWeeklyTracks; - } else if (playlistType === 'seasonal_playlist') { - tracks = discoverSeasonalTracks; - } else if (playlistType === 'popular_picks') { - tracks = personalizedPopularPicks; - } else if (playlistType === 'hidden_gems') { - tracks = personalizedHiddenGems; - } else if (playlistType === 'discovery_shuffle') { - tracks = personalizedDiscoveryShuffle; - } else if (playlistType === 'familiar_favorites') { - tracks = personalizedFamiliarFavorites; - } else if (playlistType === 'build_playlist') { - tracks = buildPlaylistTracks; - } - - if (!tracks || tracks.length === 0) { - showToast(`No tracks available for ${playlistName}`, 'warning'); - return; - } - - // Convert to format expected by sync API - const spotifyTracks = tracks.map(track => { - let spotifyTrack; - - // Use track_data_json if available - if (track.track_data_json) { - spotifyTrack = track.track_data_json; - } else { - // Fallback: construct track object - spotifyTrack = { - id: track.spotify_track_id, - name: track.track_name, - artists: [{ name: track.artist_name }], - album: { - name: track.album_name, - images: track.album_cover_url ? [{ url: track.album_cover_url }] : [] - }, - duration_ms: track.duration_ms || 0 - }; - } - - // Normalize artists to array of strings for sync compatibility - if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) { - spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a); - } - - return spotifyTrack; + // Navigate to the Sync page → Discover tab, highlight the card, and auto-sync + navigateToSyncTab('discover', { + highlight: `discover-sync-card-${playlistType}`, + autoSync: playlistType, + autoSyncName: playlistName, }); - - // Create virtual playlist ID - const virtualPlaylistId = `discover_${playlistType}`; - - // Store in cache for sync function - playlistTrackCache[virtualPlaylistId] = spotifyTracks; - - // Create virtual playlist object - const virtualPlaylist = { - id: virtualPlaylistId, - name: playlistName, - track_count: spotifyTracks.length - }; - - // Add to spotify playlists array if not already there - if (!spotifyPlaylists.find(p => p.id === virtualPlaylistId)) { - spotifyPlaylists.push(virtualPlaylist); - } - - // Show sync status display (convert underscores to hyphens for ID) - const statusId = playlistType.replace(/_/g, '-') + '-sync-status'; - const statusDisplay = document.getElementById(statusId); - if (statusDisplay) { - statusDisplay.style.display = 'block'; - } - - // Disable sync button to prevent duplicate syncs (convert underscores to hyphens for ID) - const buttonId = playlistType.replace(/_/g, '-') + '-sync-btn'; - const syncButton = document.getElementById(buttonId); - if (syncButton) { - syncButton.disabled = true; - syncButton.style.opacity = '0.5'; - syncButton.style.cursor = 'not-allowed'; - } - - // Start sync using existing function - await startPlaylistSync(virtualPlaylistId); - - // Extract image URL from first track for download bar bubble - let imageUrl = null; - if (spotifyTracks && spotifyTracks.length > 0) { - const firstTrack = spotifyTracks[0]; - if (firstTrack.album && firstTrack.album.images && firstTrack.album.images.length > 0) { - imageUrl = firstTrack.album.images[0].url; - } - } - - // Add to discover download bar - addDiscoverDownload(virtualPlaylistId, playlistName, playlistType, imageUrl); - - // Start polling for progress updates - startDiscoverSyncPolling(playlistType, virtualPlaylistId); } // Track active discover sync pollers @@ -9131,6 +9032,13 @@ function renderDiscoverSyncCard(playlist, container, sourceLabel) {
+
+ + +
-
- -