From 5cbdcbbfe5d11e01ea9b26dc9bdaf0ce6dc27cee Mon Sep 17 00:00:00 2001 From: Skowll Date: Thu, 14 May 2026 18:46:54 +0200 Subject: [PATCH 01/81] add thread_id for telegram notification --- core/automation_engine.py | 3 +- webui/static/stats-automations.js | 10 +- webui/static/wishlist-tools.js | 151 ++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 3 deletions(-) diff --git a/core/automation_engine.py b/core/automation_engine.py index d19cbb58..82329279 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -997,6 +997,7 @@ class AutomationEngine: """Send message via Telegram Bot API.""" bot_token = config.get('bot_token', '').strip() chat_id = config.get('chat_id', '').strip() + thread_id = config.get('thread_id', '').strip() if not bot_token or not chat_id: raise ValueError("Bot token and chat ID are required for Telegram") @@ -1007,7 +1008,7 @@ class AutomationEngine: resp = requests.post( f'https://api.telegram.org/bot{bot_token}/sendMessage', - json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"}, + json={"chat_id": chat_id, "message_thread_id": thread_id, "text": message, "parse_mode": "HTML"}, timeout=10, ) data = resp.json() if resp.status_code == 200 else {} diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 7df4ab4b..72f5fe5f 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -4890,7 +4890,7 @@ function _promptNotifyConfig(groupName) { if (type === 'discord_webhook') { fieldsDiv.innerHTML = ''; } else if (type === 'telegram') { - fieldsDiv.innerHTML = ''; + fieldsDiv.innerHTML = ''; } else if (type === 'pushbullet') { fieldsDiv.innerHTML = ''; } else { @@ -4911,7 +4911,7 @@ function _promptNotifyConfig(groupName) { if (type === 'discord_webhook') { config = { webhook_url: (overlay.querySelector('#deploy-notify-url')?.value || '').trim() }; } else if (type === 'telegram') { - config = { bot_token: (overlay.querySelector('#deploy-notify-token')?.value || '').trim(), chat_id: (overlay.querySelector('#deploy-notify-chat')?.value || '').trim() }; + config = { bot_token: (overlay.querySelector('#deploy-notify-token')?.value || '').trim(), chat_id: (overlay.querySelector('#deploy-notify-chat')?.value || '').trim(), thread_id: (overlay.querySelector('#deploy-notify-thread')?.value || '').trim() }; } else if (type === 'pushbullet') { config = { access_token: (overlay.querySelector('#deploy-notify-token')?.value || '').trim() }; } else { @@ -6004,6 +6004,7 @@ function _renderBlockConfigFields(slotKey, blockType, config) { if (blockType === 'telegram') { const botToken = _escAttr(config.bot_token || ''); const chatId = _escAttr(config.chat_id || ''); + const threadID = _escAttr(config.thread_id || ''); return `
@@ -6012,6 +6013,10 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
+
+ + +
@@ -6291,6 +6296,7 @@ function _readPlacedConfig(slotKey) { return { bot_token: document.getElementById('cfg-' + slotKey + '-bot_token')?.value?.trim() || '', chat_id: document.getElementById('cfg-' + slotKey + '-chat_id')?.value?.trim() || '', + thread_id: document.getElementById('cfg-' + slotKey + '-thread_id')?.value?.trim() || '', message: document.getElementById('cfg-' + slotKey + '-message')?.value || '', }; } diff --git a/webui/static/wishlist-tools.js b/webui/static/wishlist-tools.js index f59ea4d0..c3a4d7fd 100644 --- a/webui/static/wishlist-tools.js +++ b/webui/static/wishlist-tools.js @@ -3094,6 +3094,150 @@ function openLibraryHistoryModal() { } } +// ────────────────────────────────────────────────────────────────────── +// Quarantine tab — rendered inside the Library History modal as a third +// tab next to Downloads + Server Imports. Reuses the existing list + +// pagination chrome; provides per-row Approve / Recover / Delete actions. +// ────────────────────────────────────────────────────────────────────── + +async function loadQuarantineList() { + const list = document.getElementById('library-history-list'); + const pagination = document.getElementById('library-history-pagination'); + const sourceBar = document.getElementById('history-source-bar'); + if (!list) return; + list.innerHTML = '
Loading...
'; + if (pagination) pagination.innerHTML = ''; + if (sourceBar) sourceBar.style.display = 'none'; + + try { + const resp = await fetch('/api/quarantine/list'); + const data = await resp.json(); + const entries = data.entries || []; + const countEl = document.getElementById('history-quarantine-count'); + if (countEl) countEl.textContent = entries.length; + + if (!data.success) { + list.innerHTML = `
Error: ${escapeHtml(data.error || 'Failed to load')}
`; + return; + } + if (entries.length === 0) { + list.innerHTML = '
🛡️

No quarantined files. Nice and clean.
'; + return; + } + list.innerHTML = entries.map(renderQuarantineEntry).join(''); + } catch (err) { + console.error('Error loading quarantine entries:', err); + list.innerHTML = '
Error loading quarantine
'; + } +} + +function renderQuarantineEntry(entry) { + const triggerLabels = { integrity: 'Duration / Integrity', acoustid: 'AcoustID Mismatch', bit_depth: 'Bit Depth Filter', unknown: 'Unknown' }; + const triggerColors = { integrity: '#facc15', acoustid: '#ef5350', bit_depth: '#fb923c', unknown: '#888' }; + const triggerLabel = triggerLabels[entry.trigger] || entry.trigger || 'Unknown'; + const triggerColor = triggerColors[entry.trigger] || '#888'; + + const id = escapeHtml(entry.id); + const approveLabel = entry.has_full_context ? 'Approve' : 'Recover'; + const approveTitle = entry.has_full_context + ? 'Re-run post-processing with only the failing check skipped' + : 'Legacy entry — move to Staging, finish via Import flow'; + const approveCall = entry.has_full_context + ? `approveQuarantineEntry('${id}')` + : `recoverQuarantineEntry('${id}')`; + + const meta = [entry.expected_artist, entry.original_filename].filter(Boolean).join(' — '); + const triggerBadge = `${escapeHtml(triggerLabel)}`; + const reasonDetail = `
Reason: ${escapeHtml(entry.reason || 'Unknown')}
`; + + return `
+
🛡️
+
+
+
+
${escapeHtml(entry.expected_track || entry.original_filename || 'Unknown')}
+ +
+
${triggerBadge}
+
${formatHistoryTime(entry.timestamp)}
+ + + +
+
+ ${reasonDetail} +
+
+
`; +} + +async function approveQuarantineEntry(entryId) { + const ok = await showConfirmDialog({ + title: 'Approve Quarantined File', + message: 'Re-run post-processing for this file with only the failing check skipped. The file will be tagged, lyrics generated, and moved into your library. Other quality gates (AcoustID + bit-depth) still run.', + confirmText: 'Approve & Import', + cancelText: 'Cancel', + }); + if (!ok) return; + try { + const r = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/approve`, { method: 'POST' }); + const data = await r.json(); + if (!data.success) { + showToast(`Approve failed: ${data.error}`, 'error'); + } else { + showToast(`Approved — skipped ${data.trigger_bypassed} check, re-running pipeline.`, 'success'); + } + } catch (err) { + showToast(`Approve failed: ${err.message}`, 'error'); + } + loadQuarantineList(); +} + +async function recoverQuarantineEntry(entryId) { + const ok = await showConfirmDialog({ + title: 'Recover To Staging', + message: 'Legacy entry — no embedded context. The file will be moved to your Staging folder so you can finish via the Import page (manual match).', + confirmText: 'Move To Staging', + cancelText: 'Cancel', + }); + if (!ok) return; + try { + const r = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/recover`, { method: 'POST' }); + const data = await r.json(); + if (!data.success) { + showToast(`Recover failed: ${data.error}`, 'error'); + } else { + showToast('Moved to Staging — finish via the Import page.', 'success'); + } + } catch (err) { + showToast(`Recover failed: ${err.message}`, 'error'); + } + loadQuarantineList(); +} + +async function deleteQuarantineEntry(entryId) { + const ok = await showConfirmDialog({ + title: 'Delete Quarantined File', + message: 'This permanently removes the file and its metadata sidecar. Cannot be undone.', + confirmText: 'Delete', + cancelText: 'Cancel', + destructive: true, + }); + if (!ok) return; + try { + const r = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}`, { method: 'DELETE' }); + const data = await r.json(); + if (!data.success) { + showToast(`Delete failed: ${data.error}`, 'error'); + } else { + showToast('Quarantined file deleted.', 'success'); + } + } catch (err) { + showToast(`Delete failed: ${err.message}`, 'error'); + } + loadQuarantineList(); +} + function closeLibraryHistoryModal() { const overlay = document.getElementById('library-history-overlay'); if (overlay) overlay.classList.add('hidden'); @@ -3110,6 +3254,12 @@ function switchHistoryTab(tab) { async function loadLibraryHistory() { const { tab, page, limit } = _libraryHistoryState; + if (tab === 'quarantine') { + // Refresh the count for the other two tabs in the background so + // the badge stays accurate when the user switches over. + loadQuarantineList(); + return; + } const list = document.getElementById('library-history-list'); const pagination = document.getElementById('library-history-pagination'); if (!list) return; @@ -6199,6 +6349,7 @@ const TOOL_HELP_CONTENT = { From d47e4ccc0d16c31833635d79580896d44e5332ac Mon Sep 17 00:00:00 2001 From: Skowll Date: Sun, 24 May 2026 14:13:57 +0200 Subject: [PATCH 02/81] edit telegram_id after initial pr --- core/automation_engine.py | 12 ++++++++++-- webui/static/stats-automations.js | 6 +++--- webui/static/wishlist-tools.js | 2 +- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/core/automation_engine.py b/core/automation_engine.py index 82329279..424c445e 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -997,7 +997,8 @@ class AutomationEngine: """Send message via Telegram Bot API.""" bot_token = config.get('bot_token', '').strip() chat_id = config.get('chat_id', '').strip() - thread_id = config.get('thread_id', '').strip() + thread_id = str(config.get('thread_id', '')).strip() + if not bot_token or not chat_id: raise ValueError("Bot token and chat ID are required for Telegram") @@ -1006,9 +1007,16 @@ class AutomationEngine: for key, value in variables.items(): message = message.replace('{' + key + '}', value) + payload = {"chat_id": chat_id, "text": message, "parse_mode": "HTML"} + if thread_id: + try: + payload["message_thread_id"] = int(thread_id) + except ValueError: + pass # invalid — fall back to main chat + resp = requests.post( f'https://api.telegram.org/bot{bot_token}/sendMessage', - json={"chat_id": chat_id, "message_thread_id": thread_id, "text": message, "parse_mode": "HTML"}, + json=payload, timeout=10, ) data = resp.json() if resp.status_code == 200 else {} diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 947a8c16..56a7e06a 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -4315,7 +4315,7 @@ function _promptNotifyConfig(groupName) { if (type === 'discord_webhook') { config = { webhook_url: (overlay.querySelector('#deploy-notify-url')?.value || '').trim() }; } else if (type === 'telegram') { - config = { bot_token: (overlay.querySelector('#deploy-notify-token')?.value || '').trim(), chat_id: (overlay.querySelector('#deploy-notify-chat')?.value || '').trim(), thread_id: (overlay.querySelector('#deploy-notify-thread')?.value || '').trim() }; + config = { bot_token: (overlay.querySelector('#deploy-notify-token')?.value || '').trim(), chat_id: (overlay.querySelector('#deploy-notify-chat')?.value || '').trim(), thread_id: (overlay.querySelector('#deploy-notify-thread')?.value || '').trim() }; } else if (type === 'pushbullet') { config = { access_token: (overlay.querySelector('#deploy-notify-token')?.value || '').trim() }; } else { @@ -5447,7 +5447,7 @@ function _renderBlockConfigFields(slotKey, blockType, config) { if (blockType === 'telegram') { const botToken = _escAttr(config.bot_token || ''); const chatId = _escAttr(config.chat_id || ''); - const threadID = _escAttr(config.thread_id || ''); + const threadId = _escAttr(config.thread_id || ''); return `
@@ -5458,7 +5458,7 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
- +
diff --git a/webui/static/wishlist-tools.js b/webui/static/wishlist-tools.js index 45e46fb4..df62111c 100644 --- a/webui/static/wishlist-tools.js +++ b/webui/static/wishlist-tools.js @@ -6530,7 +6530,7 @@ const TOOL_HELP_CONTENT = {
  • Bot Token: Your Telegram bot token (from @BotFather)
  • Chat ID: The chat/group ID to send messages to
  • -
  • Thread ID: Thread ID
  • +
  • Thread ID: Optional — only set if your group uses Telegram topics. Leave blank for the main chat.
  • Message Template: Custom message with variable placeholders
From 4ae65f4de95c857d8d8467785b8649cf23c49944 Mon Sep 17 00:00:00 2001 From: Skowll Date: Sun, 24 May 2026 14:52:03 +0200 Subject: [PATCH 03/81] remove unecessary str --- core/automation_engine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/automation_engine.py b/core/automation_engine.py index 424c445e..84b8ca51 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -997,7 +997,7 @@ class AutomationEngine: """Send message via Telegram Bot API.""" bot_token = config.get('bot_token', '').strip() chat_id = config.get('chat_id', '').strip() - thread_id = str(config.get('thread_id', '')).strip() + thread_id = config.get('thread_id', '').strip() if not bot_token or not chat_id: raise ValueError("Bot token and chat ID are required for Telegram") From a7ca7ddfad4f48a676a7f6d89f609befdd050ab8 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 16:15:36 -0700 Subject: [PATCH 04/81] Harden album bundle fallback flow Delay torrent and usenet album-bundle dispatch until missing-track analysis confirms there is work to do, matching the Soulseek album flow and avoiding release downloads for already-owned albums. Clear private album-bundle staging state when a release-level source intentionally falls back to per-track mode so workers can use the normal staging/search path instead of an empty private bundle directory. Verified by user: focused downloads master tests passed, 2 passed. --- core/downloads/album_bundle_dispatch.py | 2 + core/downloads/master.py | 39 ++++++++------- tests/downloads/test_downloads_master.py | 60 ++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 20 deletions(-) diff --git a/core/downloads/album_bundle_dispatch.py b/core/downloads/album_bundle_dispatch.py index 09ec1a46..734d35a9 100644 --- a/core/downloads/album_bundle_dispatch.py +++ b/core/downloads/album_bundle_dispatch.py @@ -179,6 +179,8 @@ def try_dispatch( 'phase': 'analysis', 'album_bundle_state': 'fallback', 'album_bundle_error': err, + 'album_bundle_private_staging': False, + 'album_bundle_staging_path': None, }) return False logger.error("[Album Bundle] %s flow failed for '%s': %s", diff --git a/core/downloads/master.py b/core/downloads/master.py index 83842f17..f80d46b6 100644 --- a/core/downloads/master.py +++ b/core/downloads/master.py @@ -356,26 +356,6 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma if force_download_all: logger.warning(f"[Force Download] Force download mode enabled for batch {batch_id} - treating all tracks as missing") - # Album-bundle gate for torrent / usenet single-source mode. - # See ``core/downloads/album_bundle_dispatch`` for the full - # narrow-gate rationale. Returns True iff the master worker - # should stop (gate fired and failed); False = engaged-and- - # succeeded OR didn't engage, both fall through to per-track. - _bundle_state = _BatchStateAccessImpl() - _album_bundle_source = _resolve_album_bundle_source(deps.config_manager) - if _album_bundle_source and _album_bundle_source != 'soulseek': - if _album_bundle_dispatch.try_dispatch( - batch_id=batch_id, - is_album=batch_is_album, - album_context=batch_album_context, - artist_context=batch_artist_context, - config_get=deps.config_manager.get, - plugin_resolver=deps.download_orchestrator.client, - state=_bundle_state, - source_override=_album_bundle_source, - ): - return - # Allow duplicate tracks across albums — when enabled, only skip tracks already # owned in THIS album, not tracks owned in other albums allow_duplicates = deps.config_manager.get('wishlist.allow_duplicate_tracks', True) @@ -671,6 +651,25 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma batch_playlist_folder_mode = batch.get('playlist_folder_mode', False) batch_playlist_name = batch.get('playlist_name', 'Unknown Playlist') + # Album-bundle sources download a whole release into private staging, + # then the normal per-track workers claim those staged files. Run this + # only after analysis has found missing tracks; otherwise an already + # owned album would still trigger a release download. + _bundle_state = _BatchStateAccessImpl() + _album_bundle_source = _resolve_album_bundle_source(deps.config_manager) + if _album_bundle_source and _album_bundle_source != 'soulseek': + if _album_bundle_dispatch.try_dispatch( + batch_id=batch_id, + is_album=batch_is_album, + album_context=batch_album_context, + artist_context=batch_artist_context, + config_get=deps.config_manager.get, + plugin_resolver=deps.download_orchestrator.client, + state=_bundle_state, + source_override=_album_bundle_source, + ): + return + # === ALBUM PRE-FLIGHT: Search for complete album folder before track-by-track === # Only run pre-flight when Soulseek is the download source (or hybrid with soulseek) preflight_source = None diff --git a/tests/downloads/test_downloads_master.py b/tests/downloads/test_downloads_master.py index a1586028..c2c5b758 100644 --- a/tests/downloads/test_downloads_master.py +++ b/tests/downloads/test_downloads_master.py @@ -526,6 +526,36 @@ def test_no_missing_with_auto_wishlist_submits_completion(monkeypatch): assert args == ('B7',) +def test_no_missing_album_does_not_dispatch_torrent_bundle(monkeypatch): + """Release-level sources must wait until analysis confirms missing tracks.""" + album = _DBAlbum(id_=42, title='Test Album') + db = _FakeDB(album=album, album_tracks=[_DBTrack('T1')]) + monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db) + + plugin = _FakeAlbumBundleSoulseek() + deps = _build_deps( + config=_FakeConfig({'download_source.mode': 'torrent'}), + soulseek=_FakePluginWrapper({'torrent': plugin}), + ) + _seed_batch( + 'B7a', + is_album_download=True, + album_context={'name': 'Test Album', 'total_tracks': 1}, + artist_context={'name': 'Artist'}, + ) + + mw.run_full_missing_tracks_process( + 'B7a', + 'album:1', + [{'name': 'T1', 'artists': ['Artist'], 'track_number': 1}], + deps, + ) + + assert plugin.calls == [] + assert download_batches['B7a']['phase'] == 'complete' + assert 'album_bundle_source' not in download_batches['B7a'] + + # --------------------------------------------------------------------------- # Album fast path # --------------------------------------------------------------------------- @@ -862,6 +892,36 @@ def test_hybrid_first_torrent_uses_album_bundle_before_per_track(monkeypatch): assert download_batches['B27']['album_bundle_source'] == 'torrent' +def test_album_bundle_fallback_clears_private_staging(monkeypatch): + db = _FakeDB() + monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db) + + plugin = _FakeAlbumBundleSoulseek({ + 'success': False, + 'fallback': True, + 'error': 'No release passed validation', + }) + deps = _build_deps( + config=_FakeConfig({'download_source.mode': 'torrent'}), + soulseek=_FakePluginWrapper({'torrent': plugin}), + ) + _seed_batch( + 'B29', + is_album_download=True, + album_context={'name': 'Test Album', 'total_tracks': 1}, + artist_context={'name': 'Artist'}, + ) + tracks = [{'name': 'T1', 'artists': ['Artist'], 'track_number': 1}] + + mw.run_full_missing_tracks_process('B29', 'album:1', tracks, deps) + + assert len(plugin.calls) == 1 + assert download_batches['B29']['album_bundle_state'] == 'fallback' + assert download_batches['B29']['album_bundle_private_staging'] is False + assert download_batches['B29']['album_bundle_staging_path'] is None + assert len(download_batches['B29']['queue']) == 1 + + # --------------------------------------------------------------------------- # Task creation # --------------------------------------------------------------------------- From 73bd2db5477490622fd4c50a35b016ea0664ecc5 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 19:31:00 -0700 Subject: [PATCH 05/81] Harden playlist pipeline source refresh Centralize mirrored playlist source reference normalization so edited links and IDs are stored consistently. Preserve URL-backed refresh refs, surface missing-source refresh failures, count background sync failures in pipeline summaries, and retry guarded automation skips after a short delay instead of losing a scheduled run. Add focused coverage for source refs, mirrored playlist source updates, refresh failures, and guarded retry behavior. --- core/automation/handlers/_pipeline_shared.py | 21 +++ core/automation/handlers/refresh_mirrored.py | 22 ++- core/automation_engine.py | 8 +- core/playlists/source_refs.py | 125 ++++++++++++++++++ database/music_database.py | 35 ++++- .../automation/test_handler_error_storage.py | 35 +++++ tests/automation/test_handlers_playlist.py | 42 ++++++ tests/database/test_mirrored_playlists.py | 62 +++++++++ tests/playlists/test_source_refs.py | 45 +++++++ web_server.py | 49 +++++++ webui/static/stats-automations.js | 46 +++++++ webui/static/style.css | 37 ++---- 12 files changed, 497 insertions(+), 30 deletions(-) create mode 100644 core/playlists/source_refs.py create mode 100644 tests/database/test_mirrored_playlists.py create mode 100644 tests/playlists/test_source_refs.py diff --git a/core/automation/handlers/_pipeline_shared.py b/core/automation/handlers/_pipeline_shared.py index c7427f8a..560ae3c3 100644 --- a/core/automation/handlers/_pipeline_shared.py +++ b/core/automation/handlers/_pipeline_shared.py @@ -80,9 +80,11 @@ def run_sync_and_wishlist( if sync_status == 'started': sync_id = sync_id_for_fn(pl) sync_poll_start = time.time() + timed_out = True while time.time() - sync_poll_start < _SYNC_PER_PLAYLIST_TIMEOUT_SECONDS: if (sync_id in sync_states and sync_states[sync_id].get('status') in _SYNC_TERMINAL_STATUSES): + timed_out = False break time.sleep(2) elapsed = int(time.time() - sync_poll_start) @@ -94,6 +96,25 @@ def run_sync_and_wishlist( ) ss = sync_states.get(sync_id, {}) + final_status = ss.get('status') + if timed_out: + sync_errors += 1 + deps.update_progress( + automation_id, + log_line=f'Sync timed out "{pl_name}" after {_SYNC_PER_PLAYLIST_TIMEOUT_SECONDS}s', + log_type='error', + ) + continue + if final_status in ('error', 'failed'): + sync_errors += 1 + reason = ss.get('error') or ss.get('reason') or 'background sync failed' + deps.update_progress( + automation_id, + log_line=f'Sync error "{pl_name}": {reason}', + log_type='error', + ) + continue + ss_result = ss.get('result', ss.get('progress', {})) matched = ss_result.get('matched_tracks', 0) if isinstance(ss_result, dict) else 0 total_synced += int(matched) if matched else 0 diff --git a/core/automation/handlers/refresh_mirrored.py b/core/automation/handlers/refresh_mirrored.py index 76ea32ce..02e546a8 100644 --- a/core/automation/handlers/refresh_mirrored.py +++ b/core/automation/handlers/refresh_mirrored.py @@ -18,6 +18,7 @@ import json from typing import Any, Dict from core.automation.deps import AutomationDeps +from core.playlists.source_refs import require_refresh_url def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]: @@ -129,7 +130,7 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[ # source_playlist_id is an MD5 hash; extract actual Spotify ID from stored description (URL). try: from core.spotify_public_scraper import parse_spotify_url, scrape_spotify_embed - spotify_url = pl.get('description', '') + spotify_url = require_refresh_url(source, pl.get('description', ''), pl.get('name', '')) parsed = parse_spotify_url(spotify_url) if spotify_url else None # If Spotify is authenticated, use the full API (auto-discovers with album art). @@ -185,6 +186,13 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[ # No extra_data — let preservation code keep existing discovery data. except Exception as e: deps.logger.warning(f"Spotify public playlist refresh failed for {source_id}: {e}") + errors.append(f"{pl.get('name', '?')}: {str(e)}") + deps.update_progress( + auto_id, + log_line=f'Refresh failed: "{pl.get("name", "")}" - {str(e)}', + log_type='error', + ) + continue elif source == 'deezer': try: @@ -228,7 +236,7 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[ elif source == 'youtube': # source_playlist_id is now a deterministic hash; use stored description (original URL) for refresh. - yt_url = pl.get('description', '') or f"https://www.youtube.com/playlist?list={source_id}" + yt_url = require_refresh_url(source, pl.get('description', ''), pl.get('name', '')) playlist_data = deps.parse_youtube_playlist(yt_url) if playlist_data and playlist_data.get('tracks'): tracks = [] @@ -242,6 +250,15 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[ 'source_track_id': t.get('id', ''), }) + if tracks is None: + errors.append(f"{pl.get('name', '?')}: no tracks returned from source") + deps.update_progress( + auto_id, + log_line=f'Refresh failed: "{pl.get("name", "")}" - no tracks returned from source', + log_type='error', + ) + continue + if tracks is not None: # Compare old vs new track IDs to detect changes. old_tracks = db.get_mirrored_playlist_tracks(pl['id']) if pl.get('id') else [] @@ -261,6 +278,7 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[ name=pl['name'], tracks=tracks, profile_id=pl.get('profile_id', 1), + description=pl.get('description'), owner=pl.get('owner'), image_url=pl.get('image_url'), ) diff --git a/core/automation_engine.py b/core/automation_engine.py index d19cbb58..61774579 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -617,7 +617,7 @@ class AutomationEngine: self._progress_finish_fn(automation_id, result) except Exception as e: logger.debug("scheduled progress finish (skipped): %s", e) - self._finish_run(auto, automation_id, result, error=None) + self._finish_run(auto, automation_id, result, error=None, retry_delay_seconds=300) return # Initialize progress tracking (skip if already done during delay) @@ -664,7 +664,7 @@ class AutomationEngine: self._finish_run(auto, automation_id, result, error) - def _finish_run(self, auto, automation_id, result, error): + def _finish_run(self, auto, automation_id, result, error, retry_delay_seconds=None): """Update DB with run stats and reschedule.""" next_run_str = None trigger_type = auto.get('trigger_type', '') @@ -672,7 +672,9 @@ class AutomationEngine: if trigger_type in self._trigger_handlers: try: trigger_config = json.loads(auto.get('trigger_config') or '{}') - if trigger_type == 'daily_time': + if retry_delay_seconds: + next_run_str = _utc_after(retry_delay_seconds) + elif trigger_type == 'daily_time': # Next run is tomorrow at the configured time (compute delay from local time, store as UTC) time_str = trigger_config.get('time', '00:00') hour, minute = map(int, time_str.split(':')) diff --git a/core/playlists/source_refs.py b/core/playlists/source_refs.py new file mode 100644 index 00000000..e4886384 --- /dev/null +++ b/core/playlists/source_refs.py @@ -0,0 +1,125 @@ +"""Helpers for mirrored-playlist upstream source references. + +Mirrored playlist rows have two legacy fields: +- ``source_playlist_id``: the stable lookup key used for uniqueness. +- ``description``: for URL-backed mirrors, the original/canonical URL. + +Keeping the normalization here prevents the refresh worker, API endpoint, +and UI repair flow from each inventing a slightly different meaning. +""" + +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass +from typing import Optional +from urllib.parse import parse_qs, urlparse + + +_SPOTIFY_ID_RE = re.compile(r"^[A-Za-z0-9]{16,32}$") + + +@dataclass(frozen=True) +class MirroredSourceRef: + source_playlist_id: str + description: Optional[str] + + +def normalize_mirrored_source_ref( + source: str, + source_ref: str, + existing_description: str = "", +) -> MirroredSourceRef: + """Normalize a user-provided source URL/ID for storage. + + URL-backed sources keep a deterministic hash in ``source_playlist_id`` and + store the canonical URL in ``description``. Direct-ID sources store the ID + directly and preserve the existing description unless a source-specific URL + parser says otherwise. + """ + source = (source or "").strip().lower() + source_ref = (source_ref or "").strip() + existing_description = (existing_description or "").strip() + + if not source_ref: + raise ValueError("Source link or ID is required") + + if source == "spotify_public": + canonical_url = _canonical_spotify_url(source_ref) + return MirroredSourceRef(_short_hash(canonical_url), canonical_url) + + if source == "youtube": + canonical_url = _canonical_youtube_url(source_ref) + return MirroredSourceRef(_short_hash(canonical_url), canonical_url) + + if source == "deezer" and source_ref.startswith(("http://", "https://")): + from core.deezer_client import DeezerClient + + parsed_id = DeezerClient.parse_playlist_url(source_ref) + if not parsed_id: + raise ValueError("Use a valid Deezer playlist URL or playlist ID") + return MirroredSourceRef(str(parsed_id), existing_description or None) + + return MirroredSourceRef(source_ref, existing_description or None) + + +def require_refresh_url(source: str, description: str, playlist_name: str = "") -> str: + """Return a URL required by hash-backed refresh sources, or raise clearly.""" + source = (source or "").strip().lower() + description = (description or "").strip() + if source in {"spotify_public", "youtube"}: + if not description.startswith(("http://", "https://")): + label = f" '{playlist_name}'" if playlist_name else "" + raise ValueError(f"{source} mirror{label} is missing its original source URL") + return description + + +def _canonical_spotify_url(source_ref: str) -> str: + parsed = _parse_spotify_ref(source_ref) + if parsed: + return f"https://open.spotify.com/{parsed['type']}/{parsed['id']}" + + # Repair flow convenience: if the user pastes only a Spotify ID, assume + # playlist. Album URLs still need their URL/URI so the type is explicit. + if _SPOTIFY_ID_RE.match(source_ref): + return f"https://open.spotify.com/playlist/{source_ref}" + + raise ValueError("Use a valid open.spotify.com playlist/album URL, Spotify URI, or playlist ID") + + +def _parse_spotify_ref(source_ref: str) -> Optional[dict]: + uri_match = re.match(r"spotify:(playlist|album):([A-Za-z0-9]+)", source_ref) + if uri_match: + return {"type": uri_match.group(1), "id": uri_match.group(2)} + + url_match = re.search( + r"https?://open\.spotify\.com/(playlist|album)/([A-Za-z0-9]+)", + source_ref, + ) + if url_match: + return {"type": url_match.group(1), "id": url_match.group(2)} + + return None + + +def _canonical_youtube_url(source_ref: str) -> str: + parsed_url = urlparse(source_ref) + playlist_id = "" + + if parsed_url.scheme and parsed_url.netloc: + host = parsed_url.netloc.lower() + if not ("youtube.com" in host or "music.youtube.com" in host): + raise ValueError("Use a valid YouTube playlist URL") + playlist_id = parse_qs(parsed_url.query).get("list", [""])[0] + else: + playlist_id = source_ref + + if not playlist_id: + raise ValueError("YouTube playlist URL must include a list= playlist id") + + return f"https://youtube.com/playlist?list={playlist_id}" + + +def _short_hash(value: str) -> str: + return hashlib.md5(value.encode()).hexdigest()[:12] diff --git a/database/music_database.py b/database/music_database.py index 0171d88e..876904bd 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -11828,7 +11828,7 @@ class MusicDatabase: VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(source, source_playlist_id, profile_id) DO UPDATE SET name = excluded.name, - description = excluded.description, + description = COALESCE(NULLIF(excluded.description, ''), mirrored_playlists.description), owner = excluded.owner, image_url = excluded.image_url, track_count = excluded.track_count, @@ -11939,6 +11939,39 @@ class MusicDatabase: logger.error(f"Error getting mirrored playlist tracks: {e}") return [] + def update_mirrored_playlist_source_ref( + self, + playlist_id: int, + source_playlist_id: str, + description: Optional[str] = None, + ) -> bool: + """Update a mirrored playlist's upstream source reference. + + This intentionally leaves mirrored tracks and discovery extra_data + untouched; refresh/discovery can use the new source reference on the + next run without losing existing local state. + """ + try: + with self._get_connection() as conn: + cursor = conn.cursor() + if description is None: + cursor.execute(""" + UPDATE mirrored_playlists + SET source_playlist_id = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (source_playlist_id, playlist_id)) + else: + cursor.execute(""" + UPDATE mirrored_playlists + SET source_playlist_id = ?, description = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (source_playlist_id, description, playlist_id)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Error updating mirrored playlist source reference: {e}") + return False + def update_mirrored_track_extra_data(self, track_id: int, extra_data_dict: dict) -> bool: """Merge new data into a mirrored track's extra_data JSON field.""" try: diff --git a/tests/automation/test_handler_error_storage.py b/tests/automation/test_handler_error_storage.py index e7eaeefd..04dc93dd 100644 --- a/tests/automation/test_handler_error_storage.py +++ b/tests/automation/test_handler_error_storage.py @@ -132,3 +132,38 @@ def test_skipped_status_records_no_error(engine_with_handler) -> None: engine.run_automation(1, skip_delay=True) kwargs = db_mock.update_automation_run.call_args.kwargs assert kwargs.get('error') is None + + +def test_guarded_scheduled_skip_retries_soon() -> None: + """A busy guarded action should retry soon instead of waiting a full interval.""" + db_mock = MagicMock() + db_mock.get_automation.return_value = { + 'id': 1, + 'name': 'Playlist Pipeline', + 'enabled': True, + 'action_type': 'playlist_pipeline', + 'action_config': '{}', + 'trigger_type': 'schedule', + 'trigger_config': '{"interval": 6, "unit": "hours"}', + } + db_mock.update_automation_run = MagicMock(return_value=True) + + engine = AutomationEngine(db_mock) + engine._running = True + engine.schedule_automation = MagicMock() + engine._action_handlers['playlist_pipeline'] = { + 'handler': lambda config: {'status': 'completed'}, + 'guard': lambda: True, + } + + engine.run_automation(1, skip_delay=True) + + kwargs = db_mock.update_automation_run.call_args.kwargs + assert kwargs.get('error') is None + assert kwargs.get('next_run') is not None + # It should be scheduled for roughly five minutes, not the configured six hours. + from datetime import datetime, timezone + + next_run = datetime.strptime(kwargs['next_run'], '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc) + delay = (next_run - datetime.now(timezone.utc)).total_seconds() + assert 0 < delay <= 360 diff --git a/tests/automation/test_handlers_playlist.py b/tests/automation/test_handlers_playlist.py index 1a1548ef..0bf7628c 100644 --- a/tests/automation/test_handlers_playlist.py +++ b/tests/automation/test_handlers_playlist.py @@ -26,6 +26,7 @@ import pytest from core.automation.deps import AutomationDeps, AutomationState from core.automation.handlers.discover_playlist import auto_discover_playlist +from core.automation.handlers._pipeline_shared import run_sync_and_wishlist from core.automation.handlers.playlist_pipeline import auto_playlist_pipeline from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored from core.automation.handlers.sync_playlist import auto_sync_playlist @@ -266,6 +267,24 @@ class TestRefreshMirrored: assert result['errors'] == '1' assert result['refreshed'] == '0' + def test_spotify_public_missing_source_url_is_reported_as_error(self): + db = _StubDB(playlists=[ + {'id': 1, 'name': 'No URL', 'source': 'spotify_public', 'source_playlist_id': 'hash'}, + ]) + progress = [] + deps = _build_deps( + get_database=lambda: db, + update_progress=lambda *a, **k: progress.append(k), + ) + + result = auto_refresh_mirrored({'playlist_id': '1'}, deps) + + assert result['status'] == 'completed' + assert result['refreshed'] == '0' + assert result['errors'] == '1' + assert db.mirror_calls == [] + assert any(p.get('log_type') == 'error' and 'missing its original source URL' in p.get('log_line', '') for p in progress) + # ─── sync_playlist ─────────────────────────────────────────────────── @@ -398,3 +417,26 @@ class TestPlaylistPipeline: assert result['status'] == 'error' assert result['_manages_own_progress'] is True assert deps.state.pipeline_running is False + + def test_shared_sync_tail_counts_background_sync_errors(self): + progress = [] + sync_states = { + 'auto_mirror_1': {'status': 'error', 'error': 'media server unavailable'}, + } + deps = _build_deps( + get_sync_states=lambda: sync_states, + update_progress=lambda *a, **k: progress.append(k), + ) + + result = run_sync_and_wishlist( + deps, + 'auto-1', + [{'id': 1, 'name': 'Broken'}], + sync_one_fn=lambda _pl: {'status': 'started'}, + sync_id_for_fn=lambda _pl: 'auto_mirror_1', + skip_wishlist=True, + ) + + assert result['errors'] == 1 + assert result['synced'] == 0 + assert any(p.get('log_type') == 'error' and 'media server unavailable' in p.get('log_line', '') for p in progress) diff --git a/tests/database/test_mirrored_playlists.py b/tests/database/test_mirrored_playlists.py new file mode 100644 index 00000000..8da26480 --- /dev/null +++ b/tests/database/test_mirrored_playlists.py @@ -0,0 +1,62 @@ +from database.music_database import MusicDatabase + + +def test_update_mirrored_playlist_source_ref_preserves_tracks(tmp_path): + db = MusicDatabase(str(tmp_path / "music.db")) + playlist_id = db.mirror_playlist( + source="youtube", + source_playlist_id="oldhash", + name="Mirror", + tracks=[ + { + "track_name": "Song", + "artist_name": "Artist", + "source_track_id": "yt1", + "extra_data": {"discovered": True}, + } + ], + profile_id=1, + description="https://youtube.com/playlist?list=old", + ) + + assert playlist_id is not None + + updated = db.update_mirrored_playlist_source_ref( + playlist_id, + "newhash", + "https://youtube.com/playlist?list=new", + ) + + assert updated is True + playlist = db.get_mirrored_playlist(playlist_id) + assert playlist["source_playlist_id"] == "newhash" + assert playlist["description"] == "https://youtube.com/playlist?list=new" + + tracks = db.get_mirrored_playlist_tracks(playlist_id) + assert len(tracks) == 1 + assert tracks[0]["track_name"] == "Song" + assert tracks[0]["extra_data"] is not None + + +def test_mirror_playlist_refresh_preserves_existing_description(tmp_path): + db = MusicDatabase(str(tmp_path / "music.db")) + playlist_id = db.mirror_playlist( + source="spotify_public", + source_playlist_id="hash", + name="Release Radar", + tracks=[{"track_name": "Song", "artist_name": "Artist"}], + profile_id=1, + description="https://open.spotify.com/playlist/abc", + ) + + refreshed_id = db.mirror_playlist( + source="spotify_public", + source_playlist_id="hash", + name="Release Radar", + tracks=[{"track_name": "New Song", "artist_name": "Artist"}], + profile_id=1, + ) + + assert refreshed_id == playlist_id + playlist = db.get_mirrored_playlist(playlist_id) + assert playlist["description"] == "https://open.spotify.com/playlist/abc" diff --git a/tests/playlists/test_source_refs.py b/tests/playlists/test_source_refs.py new file mode 100644 index 00000000..e7867f31 --- /dev/null +++ b/tests/playlists/test_source_refs.py @@ -0,0 +1,45 @@ +import pytest + +from core.playlists.source_refs import ( + normalize_mirrored_source_ref, + require_refresh_url, +) + + +def test_spotify_public_url_stores_hash_and_canonical_url(): + out = normalize_mirrored_source_ref( + "spotify_public", + "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M?si=abc", + ) + + assert out.source_playlist_id == "5e7de827abd1" + assert out.description == "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M" + + +def test_spotify_public_raw_id_defaults_to_playlist_url(): + out = normalize_mirrored_source_ref("spotify_public", "37i9dQZF1DXcBWIGoYBM5M") + + assert out.source_playlist_id == "5e7de827abd1" + assert out.description == "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M" + + +def test_youtube_url_stores_hash_and_canonical_url(): + out = normalize_mirrored_source_ref( + "youtube", + "https://music.youtube.com/playlist?list=PL123&si=abc", + ) + + assert out.source_playlist_id == "e5f5ab31b8f0" + assert out.description == "https://youtube.com/playlist?list=PL123" + + +def test_direct_id_sources_preserve_existing_description(): + out = normalize_mirrored_source_ref("tidal", "abc123", "Original service description") + + assert out.source_playlist_id == "abc123" + assert out.description == "Original service description" + + +def test_hash_backed_refresh_requires_url(): + with pytest.raises(ValueError, match="missing its original source URL"): + require_refresh_url("spotify_public", "", "Release Radar") diff --git a/web_server.py b/web_server.py index fc8c8a2e..da8f96ec 100644 --- a/web_server.py +++ b/web_server.py @@ -32211,6 +32211,55 @@ def get_mirrored_playlist_endpoint(playlist_id): logger.error(f"Error getting mirrored playlist: {e}") return jsonify({"error": str(e)}), 500 +@app.route('/api/mirrored-playlists//source-ref', methods=['PATCH']) +def update_mirrored_playlist_source_ref_endpoint(playlist_id): + """Update the upstream source link/id for a mirrored playlist.""" + try: + data = request.get_json() or {} + source_ref = data.get('source_ref') or data.get('source_playlist_id') or data.get('url') + + database = get_database() + playlist = database.get_mirrored_playlist(playlist_id) + if not playlist: + return jsonify({"error": "Playlist not found"}), 404 + + try: + from core.playlists.source_refs import normalize_mirrored_source_ref + normalized = normalize_mirrored_source_ref( + playlist.get('source'), + source_ref, + playlist.get('description') or '', + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + + existing = [ + pl for pl in database.get_mirrored_playlists(profile_id=playlist.get('profile_id', 1)) + if ( + pl.get('source') == playlist.get('source') + and str(pl.get('source_playlist_id')) == str(normalized.source_playlist_id) + and int(pl.get('id')) != int(playlist_id) + ) + ] + if existing: + return jsonify({ + "error": f"That source is already mirrored as '{existing[0].get('name', 'another playlist')}'" + }), 409 + + ok = database.update_mirrored_playlist_source_ref( + playlist_id, + normalized.source_playlist_id, + normalized.description, + ) + if not ok: + return jsonify({"error": "Failed to update source reference"}), 500 + + updated = database.get_mirrored_playlist(playlist_id) or {} + return jsonify({"success": True, "playlist": updated}) + except Exception as e: + logger.error(f"Error updating mirrored playlist source reference: {e}") + return jsonify({"error": str(e)}), 500 + @app.route('/api/mirrored-playlists/', methods=['DELETE']) def delete_mirrored_playlist_endpoint(playlist_id): """Delete a mirrored playlist.""" diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 9723c549..20f08de0 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -446,6 +446,7 @@ function renderMirroredCard(p, container) { const hash = `mirrored_${p.id}`; const state = youtubePlaylistStates[hash]; const phase = state ? state.phase : null; + const sourceRef = getMirroredSourceRef(p); // Build phase indicator let phaseHtml = ''; @@ -493,6 +494,7 @@ function renderMirroredCard(p, container) {
${disc > 0 ? `` : ''} + `; card.addEventListener('click', () => { @@ -532,6 +534,48 @@ function renderMirroredCard(p, container) { container.appendChild(card); } +function getMirroredSourceRef(p) { + const desc = (p && p.description) ? String(p.description).trim() : ''; + if ((p.source === 'spotify_public' || p.source === 'youtube') && /^https?:\/\//i.test(desc)) { + return desc; + } + return (p && p.source_playlist_id) ? String(p.source_playlist_id) : ''; +} + +async function editMirroredSourceRef(playlistId, name, source, currentRef) { + const label = (source === 'spotify_public' || source === 'youtube') + ? 'original playlist URL' + : 'original playlist ID or URL'; + const nextRef = window.prompt(`Update ${label} for "${name}"`, currentRef || ''); + if (nextRef === null) return; + const trimmed = nextRef.trim(); + if (!trimmed) { + showToast('Source link or ID is required', 'error'); + return; + } + + try { + const res = await fetch(`/api/mirrored-playlists/${playlistId}/source-ref`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source_ref: trimmed }) + }); + const data = await res.json(); + if (!res.ok || data.error) { + throw new Error(data.error || 'Failed to update source reference'); + } + showToast(`Updated source for ${name}`, 'success'); + loadMirroredPlaylists(); + const openModal = document.getElementById('mirrored-track-modal'); + if (openModal) { + closeMirroredModal(); + openMirroredPlaylistModal(playlistId); + } + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } +} + function updateMirroredCardPhase(urlHash, phase) { // Update the state phase (updateYouTubeCardPhase skips this for mirrored playlists due to no cardElement) const state = youtubePlaylistStates[urlHash]; @@ -785,6 +829,7 @@ async function openMirroredPlaylistModal(playlistId) { const tracks = data.tracks || []; const source = data.source || 'unknown'; + const sourceRef = getMirroredSourceRef(data); const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛' }; const sourceIcon = sourceIcons[source] || '📋'; @@ -827,6 +872,7 @@ async function openMirroredPlaylistModal(playlistId) { diff --git a/webui/static/style.css b/webui/static/style.css index 7706efc0..8ab1469c 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -12047,7 +12047,9 @@ body.helper-mode-active #dashboard-activity-feed:hover { letter-spacing: 0.3px; } -.mirrored-card-delete { +.mirrored-card-delete, +.mirrored-card-clear, +.mirrored-card-link { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.08); color: #555; @@ -12065,7 +12067,9 @@ body.helper-mode-active #dashboard-activity-feed:hover { opacity: 0; } -.mirrored-playlist-card:hover .mirrored-card-delete { +.mirrored-playlist-card:hover .mirrored-card-delete, +.mirrored-playlist-card:hover .mirrored-card-clear, +.mirrored-playlist-card:hover .mirrored-card-link { opacity: 1; } @@ -12076,28 +12080,6 @@ body.helper-mode-active #dashboard-activity-feed:hover { transform: scale(1.1); } -.mirrored-card-clear { - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.08); - color: #555; - cursor: pointer; - font-size: 14px; - padding: 0; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - transition: all 0.2s ease; - flex-shrink: 0; - opacity: 0; -} - -.mirrored-playlist-card:hover .mirrored-card-clear { - opacity: 1; -} - .mirrored-card-clear:hover { color: #a78bfa; background: rgba(167, 139, 250, 0.15); @@ -12105,6 +12087,13 @@ body.helper-mode-active #dashboard-activity-feed:hover { transform: scale(1.1); } +.mirrored-card-link:hover { + color: #64b5f6; + background: rgba(100, 181, 246, 0.15); + border-color: rgba(100, 181, 246, 0.3); + transform: scale(1.1); +} + .discovery-ratio { font-size: 0.8em; color: rgba(255, 255, 255, 0.4); From 547e49912170bf96f7eadeb46e6d094c254f59c1 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 19:32:39 -0700 Subject: [PATCH 06/81] Expose mirrored playlist source-ref health Return normalized source_ref metadata from mirrored playlist APIs so the UI no longer has to infer editable refresh links from description fields. Accept Spotify embed URLs during source-ref repair and add coverage for source-ref health reporting. --- core/playlists/source_refs.py | 35 +++++++++++++++++++++++++-- tests/playlists/test_source_refs.py | 37 +++++++++++++++++++++++++++++ web_server.py | 12 ++++++++++ webui/static/stats-automations.js | 1 + 4 files changed, 83 insertions(+), 2 deletions(-) diff --git a/core/playlists/source_refs.py b/core/playlists/source_refs.py index e4886384..22909895 100644 --- a/core/playlists/source_refs.py +++ b/core/playlists/source_refs.py @@ -13,7 +13,7 @@ from __future__ import annotations import hashlib import re from dataclasses import dataclass -from typing import Optional +from typing import Mapping, Optional from urllib.parse import parse_qs, urlparse @@ -26,6 +26,14 @@ class MirroredSourceRef: description: Optional[str] +@dataclass(frozen=True) +class MirroredSourceRefView: + source_ref: str + source_ref_kind: str + source_ref_status: str + source_ref_error: Optional[str] = None + + def normalize_mirrored_source_ref( source: str, source_ref: str, @@ -75,6 +83,29 @@ def require_refresh_url(source: str, description: str, playlist_name: str = "") return description +def describe_mirrored_source_ref(playlist: Mapping[str, object]) -> MirroredSourceRefView: + """Build a UI/API friendly view of a mirrored playlist's refresh ref.""" + source = str(playlist.get("source") or "").strip().lower() + source_playlist_id = str(playlist.get("source_playlist_id") or "").strip() + description = str(playlist.get("description") or "").strip() + name = str(playlist.get("name") or "") + + if source in {"spotify_public", "youtube"}: + if description.startswith(("http://", "https://")): + return MirroredSourceRefView(description, "url", "ok") + try: + require_refresh_url(source, description, name) + except ValueError as exc: + return MirroredSourceRefView( + source_playlist_id, + "url", + "missing", + str(exc), + ) + + return MirroredSourceRefView(source_playlist_id, "id", "ok" if source_playlist_id else "missing") + + def _canonical_spotify_url(source_ref: str) -> str: parsed = _parse_spotify_ref(source_ref) if parsed: @@ -94,7 +125,7 @@ def _parse_spotify_ref(source_ref: str) -> Optional[dict]: return {"type": uri_match.group(1), "id": uri_match.group(2)} url_match = re.search( - r"https?://open\.spotify\.com/(playlist|album)/([A-Za-z0-9]+)", + r"https?://open\.spotify\.com/(?:embed/)?(playlist|album)/([A-Za-z0-9]+)", source_ref, ) if url_match: diff --git a/tests/playlists/test_source_refs.py b/tests/playlists/test_source_refs.py index e7867f31..fb5e32e4 100644 --- a/tests/playlists/test_source_refs.py +++ b/tests/playlists/test_source_refs.py @@ -1,6 +1,7 @@ import pytest from core.playlists.source_refs import ( + describe_mirrored_source_ref, normalize_mirrored_source_ref, require_refresh_url, ) @@ -23,6 +24,16 @@ def test_spotify_public_raw_id_defaults_to_playlist_url(): assert out.description == "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M" +def test_spotify_public_embed_url_canonicalizes_to_open_url(): + out = normalize_mirrored_source_ref( + "spotify_public", + "https://open.spotify.com/embed/playlist/37i9dQZF1DX0kbJZpiYdZl?pi=abc", + ) + + assert out.source_playlist_id == "d29b105efc8e" + assert out.description == "https://open.spotify.com/playlist/37i9dQZF1DX0kbJZpiYdZl" + + def test_youtube_url_stores_hash_and_canonical_url(): out = normalize_mirrored_source_ref( "youtube", @@ -43,3 +54,29 @@ def test_direct_id_sources_preserve_existing_description(): def test_hash_backed_refresh_requires_url(): with pytest.raises(ValueError, match="missing its original source URL"): require_refresh_url("spotify_public", "", "Release Radar") + + +def test_describe_hash_backed_ref_reports_missing_url(): + view = describe_mirrored_source_ref({ + "source": "spotify_public", + "source_playlist_id": "hash", + "description": "", + "name": "Release Radar", + }) + + assert view.source_ref == "hash" + assert view.source_ref_kind == "url" + assert view.source_ref_status == "missing" + assert "missing its original source URL" in view.source_ref_error + + +def test_describe_hash_backed_ref_uses_url_when_present(): + view = describe_mirrored_source_ref({ + "source": "spotify_public", + "source_playlist_id": "hash", + "description": "https://open.spotify.com/playlist/abc", + }) + + assert view.source_ref == "https://open.spotify.com/playlist/abc" + assert view.source_ref_kind == "url" + assert view.source_ref_status == "ok" diff --git a/web_server.py b/web_server.py index da8f96ec..2a90c8e1 100644 --- a/web_server.py +++ b/web_server.py @@ -32183,6 +32183,7 @@ def mirror_playlist_endpoint(): def get_mirrored_playlists_endpoint(): """List all mirrored playlists for the active profile.""" try: + from core.playlists.source_refs import describe_mirrored_source_ref database = get_database() profile_id = get_current_profile_id() playlists = database.get_mirrored_playlists(profile_id=profile_id) @@ -32192,6 +32193,11 @@ def get_mirrored_playlists_endpoint(): pl['total_count'] = counts['total'] pl['wishlisted_count'] = counts['wishlisted'] pl['in_library_count'] = counts['in_library'] + source_ref = describe_mirrored_source_ref(pl) + pl['source_ref'] = source_ref.source_ref + pl['source_ref_kind'] = source_ref.source_ref_kind + pl['source_ref_status'] = source_ref.source_ref_status + pl['source_ref_error'] = source_ref.source_ref_error return jsonify(playlists) except Exception as e: logger.error(f"Error getting mirrored playlists: {e}") @@ -32201,10 +32207,16 @@ def get_mirrored_playlists_endpoint(): def get_mirrored_playlist_endpoint(playlist_id): """Get a mirrored playlist with its tracks.""" try: + from core.playlists.source_refs import describe_mirrored_source_ref database = get_database() playlist = database.get_mirrored_playlist(playlist_id) if not playlist: return jsonify({"error": "Playlist not found"}), 404 + source_ref = describe_mirrored_source_ref(playlist) + playlist['source_ref'] = source_ref.source_ref + playlist['source_ref_kind'] = source_ref.source_ref_kind + playlist['source_ref_status'] = source_ref.source_ref_status + playlist['source_ref_error'] = source_ref.source_ref_error playlist['tracks'] = database.get_mirrored_playlist_tracks(playlist_id) return jsonify(playlist) except Exception as e: diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 20f08de0..f7545036 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -535,6 +535,7 @@ function renderMirroredCard(p, container) { } function getMirroredSourceRef(p) { + if (p && p.source_ref) return String(p.source_ref); const desc = (p && p.description) ? String(p.description).trim() : ''; if ((p.source === 'spotify_public' || p.source === 'youtube') && /^https?:\/\//i.test(desc)) { return desc; From bc6bacb7dae9bcc3847a49c7152be27b42832982 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 19:44:13 -0700 Subject: [PATCH 07/81] Move mirrored playlist pipeline into playlist domain Extract the all-in-one mirrored playlist lifecycle into core/playlists/pipeline.py so automation becomes a thin adapter. Preserve the existing automation action and behavior while making the pipeline reusable by future direct playlist UI controls. --- core/automation/handlers/playlist_pipeline.py | 226 +--------------- core/playlists/pipeline.py | 249 ++++++++++++++++++ 2 files changed, 262 insertions(+), 213 deletions(-) create mode 100644 core/playlists/pipeline.py diff --git a/core/automation/handlers/playlist_pipeline.py b/core/automation/handlers/playlist_pipeline.py index 8fc98df2..eacea479 100644 --- a/core/automation/handlers/playlist_pipeline.py +++ b/core/automation/handlers/playlist_pipeline.py @@ -1,227 +1,27 @@ -"""Automation handler: ``playlist_pipeline`` action. +"""Automation adapter for the mirrored playlist pipeline. -Lifted from ``web_server._register_automation_handlers`` (the -``_auto_playlist_pipeline`` closure). Runs the full playlist -lifecycle in a single trigger: - - Phase 1: REFRESH -- pull fresh track lists from sources - Phase 2: DISCOVER -- look up official Spotify/iTunes metadata - Phase 3: SYNC -- push the result to the active media server - Phase 4: WISHLIST -- queue any missing tracks for download - -Each phase emits its own progress range so the trigger card shows -useful per-phase percentages instead of "loading...". Phase 4 is -optional via ``skip_wishlist`` config. - -Composition: this handler invokes ``auto_refresh_mirrored`` and -``auto_sync_playlist`` directly (passing ``_automation_id: None`` so -the sub-handlers don't hijack pipeline progress) instead of going -through the engine — keeps the four phases observable as one -trigger from the user's perspective. Pipeline-level guard -(``state.pipeline_running``) prevents overlapping runs. +The actual all-in-one playlist lifecycle lives in +``core.playlists.pipeline`` so it can be reused by non-automation UI actions. +This module only wires automation-specific dependencies and handlers. """ from __future__ import annotations -import threading -import time from typing import Any, Dict from core.automation.deps import AutomationDeps from core.automation.handlers._pipeline_shared import run_sync_and_wishlist from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored from core.automation.handlers.sync_playlist import auto_sync_playlist - - -# Per-playlist sync poll cap inside Phase 3. -# Discovery poll cap inside Phase 2. -_DISCOVERY_TIMEOUT_SECONDS = 3600 +from core.playlists.pipeline import run_mirrored_playlist_pipeline def auto_playlist_pipeline(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]: - """Run REFRESH → DISCOVER → SYNC → WISHLIST in sequence. - - Sets / clears ``deps.state.pipeline_running`` around the whole - run so the registration guard can short-circuit overlapping - triggers. - """ - deps.state.set_pipeline_running(True) - automation_id = config.get('_automation_id') - pipeline_start = time.time() - - try: - db = deps.get_database() - playlist_id = config.get('playlist_id') - process_all = config.get('all', False) - skip_wishlist = config.get('skip_wishlist', False) - - # Resolve playlists. - if process_all: - playlists = db.get_mirrored_playlists() - elif playlist_id: - p = db.get_mirrored_playlist(int(playlist_id)) - playlists = [p] if p else [] - else: - deps.state.set_pipeline_running(False) - return {'status': 'error', 'error': 'No playlist specified'} - - playlists = [pl for pl in playlists if pl.get('source', '') not in ('file', 'beatport')] - if not playlists: - deps.state.set_pipeline_running(False) - return {'status': 'error', 'error': 'No refreshable playlists found'} - - pl_names = ', '.join(p.get('name', '?') for p in playlists[:3]) - if len(playlists) > 3: - pl_names += f' (+{len(playlists) - 3} more)' - - deps.update_progress( - automation_id, - progress=2, - phase=f'Pipeline: {len(playlists)} playlist(s)', - log_line=f'Starting pipeline for: {pl_names}', - log_type='info', - ) - - # ── PHASE 1: REFRESH ────────────────────────────────────────── - deps.update_progress( - automation_id, - progress=3, - phase='Phase 1/4: Refreshing playlists...', - log_line='Phase 1: Refresh', - log_type='info', - ) - - refresh_config = dict(config) - refresh_config['_automation_id'] = None # Don't let sub-handler hijack pipeline progress. - refresh_result = auto_refresh_mirrored(refresh_config, deps) - refreshed = int(refresh_result.get('refreshed', 0)) - refresh_errors = int(refresh_result.get('errors', 0)) - - deps.update_progress( - automation_id, - progress=25, - phase='Phase 1/4: Refresh complete', - log_line=f'Phase 1 done: {refreshed} refreshed, {refresh_errors} errors', - log_type='success' if refresh_errors == 0 else 'warning', - ) - - # ── PHASE 2: DISCOVER ───────────────────────────────────────── - deps.update_progress( - automation_id, - progress=26, - phase='Phase 2/4: Discovering metadata...', - log_line='Phase 2: Discover', - log_type='info', - ) - - # Reload playlists (refresh may have updated them). - if process_all: - disc_playlists = db.get_mirrored_playlists() - else: - disc_playlists = [db.get_mirrored_playlist(int(playlist_id))] - disc_playlists = [p for p in disc_playlists if p] - - # Run discovery in a thread and wait for it. - disc_done = threading.Event() - - def _disc_wrapper(pls): - try: - # The worker updates automation_progress internally, - # but we pass None so it doesn't conflict with our - # pipeline progress. - deps.run_playlist_discovery_worker(pls, automation_id=None) - except Exception as e: - deps.logger.error(f"[Pipeline] Discovery error: {e}") - finally: - disc_done.set() - - threading.Thread( - target=_disc_wrapper, args=(disc_playlists,), - daemon=True, name='pipeline-discover', - ).start() - - # Poll for completion with progress updates. - poll_start = time.time() - while not disc_done.wait(timeout=3): - elapsed = int(time.time() - poll_start) - deps.update_progress( - automation_id, - progress=min(26 + elapsed // 4, 54), - phase=f'Phase 2/4: Discovering... ({elapsed}s)', - ) - if elapsed > _DISCOVERY_TIMEOUT_SECONDS: - deps.update_progress( - automation_id, - log_line='Discovery timed out after 1 hour', - log_type='warning', - ) - break - - deps.update_progress( - automation_id, - progress=55, - phase='Phase 2/4: Discovery complete', - log_line='Phase 2 done: discovery complete', - log_type='success', - ) - - # ── PHASE 3 + 4: SYNC + WISHLIST (delegated to shared helper) ── - # Each mirrored playlist payload only needs `id` + `name` for - # the helper; `auto_sync_playlist` reads the rest from the - # mirrored DB by id. - sync_summary = run_sync_and_wishlist( - deps, - automation_id, - [pl for pl in playlists if pl.get('id')], - sync_one_fn=lambda pl: auto_sync_playlist( - {'playlist_id': str(pl['id']), '_automation_id': None}, - deps, - ), - sync_id_for_fn=lambda pl: f"auto_mirror_{pl['id']}", - skip_wishlist=skip_wishlist, - progress_start=56, - progress_end=85, - sync_phase_label='Phase 3/4: Syncing to server...', - sync_phase_start_log='Phase 3: Sync', - wishlist_phase_label='Phase 4/4: Processing wishlist...', - wishlist_phase_start_log='Phase 4: Wishlist', - ) - total_synced = sync_summary['synced'] - total_skipped = sync_summary['skipped'] - sync_errors = sync_summary['errors'] - wishlist_queued = sync_summary['wishlist_queued'] - - # ── COMPLETE ────────────────────────────────────────────────── - duration = int(time.time() - pipeline_start) - deps.update_progress( - automation_id, - status='finished', - progress=100, - phase='Pipeline complete', - log_line=f'Pipeline finished in {duration // 60}m {duration % 60}s', - log_type='success', - ) - - deps.state.set_pipeline_running(False) - return { - 'status': 'completed', - '_manages_own_progress': True, - 'playlists_refreshed': str(refreshed), - 'tracks_discovered': 'completed', - 'tracks_synced': str(total_synced), - 'sync_skipped': str(total_skipped), - 'wishlist_queued': str(wishlist_queued), - 'duration_seconds': str(duration), - } - - except Exception as e: - deps.state.set_pipeline_running(False) - deps.update_progress( - automation_id, - status='error', - progress=100, - phase='Pipeline error', - log_line=f'Pipeline failed: {e}', - log_type='error', - ) - return {'status': 'error', 'error': str(e), '_manages_own_progress': True} + """Run REFRESH -> DISCOVER -> SYNC -> WISHLIST for mirrored playlists.""" + return run_mirrored_playlist_pipeline( + config, + deps, + refresh_fn=auto_refresh_mirrored, + sync_one_fn=auto_sync_playlist, + sync_and_wishlist_fn=run_sync_and_wishlist, + ) diff --git a/core/playlists/pipeline.py b/core/playlists/pipeline.py new file mode 100644 index 00000000..9f279118 --- /dev/null +++ b/core/playlists/pipeline.py @@ -0,0 +1,249 @@ +"""Mirrored playlist lifecycle pipeline. + +This module is the playlist-domain home for the all-in-one mirrored +playlist pipeline: + + refresh source -> discover metadata -> sync to server -> process wishlist + +Automation remains one caller, but the orchestration itself lives here so a +future playlist-card "Run Pipeline" button can call the same command. +""" + +from __future__ import annotations + +import threading +import time +from typing import Any, Callable, Dict, List + + +DISCOVERY_TIMEOUT_SECONDS = 3600 + + +RefreshFn = Callable[[Dict[str, Any], Any], Dict[str, Any]] +SyncOneFn = Callable[[Dict[str, Any], Any], Dict[str, Any]] +SyncAndWishlistFn = Callable[..., Dict[str, int]] + + +def run_mirrored_playlist_pipeline( + config: Dict[str, Any], + deps: Any, + *, + refresh_fn: RefreshFn, + sync_one_fn: SyncOneFn, + sync_and_wishlist_fn: SyncAndWishlistFn, +) -> Dict[str, Any]: + """Run REFRESH -> DISCOVER -> SYNC -> WISHLIST in sequence. + + ``deps`` intentionally uses duck typing. Today it is ``AutomationDeps``; + a future web/UI runner can provide the same small surface without becoming + an automation. + """ + deps.state.set_pipeline_running(True) + automation_id = config.get('_automation_id') + pipeline_start = time.time() + + try: + db = deps.get_database() + playlist_id = config.get('playlist_id') + process_all = config.get('all', False) + skip_wishlist = config.get('skip_wishlist', False) + + playlists = _resolve_pipeline_playlists(db, playlist_id, process_all) + if playlists is None: + deps.state.set_pipeline_running(False) + return {'status': 'error', 'error': 'No playlist specified'} + + playlists = _filter_refreshable_playlists(playlists) + if not playlists: + deps.state.set_pipeline_running(False) + return {'status': 'error', 'error': 'No refreshable playlists found'} + + deps.update_progress( + automation_id, + progress=2, + phase=f'Pipeline: {len(playlists)} playlist(s)', + log_line=f'Starting pipeline for: {_summarize_playlist_names(playlists)}', + log_type='info', + ) + + refreshed, refresh_errors = _run_refresh_phase( + config, + deps, + automation_id, + refresh_fn=refresh_fn, + ) + + _run_discovery_phase( + deps, + automation_id, + db=db, + playlist_id=playlist_id, + process_all=process_all, + ) + + sync_summary = sync_and_wishlist_fn( + deps, + automation_id, + [pl for pl in playlists if pl.get('id')], + sync_one_fn=lambda pl: sync_one_fn( + {'playlist_id': str(pl['id']), '_automation_id': None}, + deps, + ), + sync_id_for_fn=lambda pl: f"auto_mirror_{pl['id']}", + skip_wishlist=skip_wishlist, + progress_start=56, + progress_end=85, + sync_phase_label='Phase 3/4: Syncing to server...', + sync_phase_start_log='Phase 3: Sync', + wishlist_phase_label='Phase 4/4: Processing wishlist...', + wishlist_phase_start_log='Phase 4: Wishlist', + ) + + duration = int(time.time() - pipeline_start) + deps.update_progress( + automation_id, + status='finished', + progress=100, + phase='Pipeline complete', + log_line=f'Pipeline finished in {duration // 60}m {duration % 60}s', + log_type='success', + ) + + deps.state.set_pipeline_running(False) + return { + 'status': 'completed', + '_manages_own_progress': True, + 'playlists_refreshed': str(refreshed), + 'tracks_discovered': 'completed', + 'tracks_synced': str(sync_summary['synced']), + 'sync_skipped': str(sync_summary['skipped']), + 'wishlist_queued': str(sync_summary['wishlist_queued']), + 'duration_seconds': str(duration), + } + + except Exception as e: # noqa: BLE001 - pipeline callers should receive status dicts + deps.state.set_pipeline_running(False) + deps.update_progress( + automation_id, + status='error', + progress=100, + phase='Pipeline error', + log_line=f'Pipeline failed: {e}', + log_type='error', + ) + return {'status': 'error', 'error': str(e), '_manages_own_progress': True} + + +def _resolve_pipeline_playlists(db: Any, playlist_id: Any, process_all: bool) -> List[Dict[str, Any]] | None: + if process_all: + return db.get_mirrored_playlists() + if playlist_id: + playlist = db.get_mirrored_playlist(int(playlist_id)) + return [playlist] if playlist else [] + return None + + +def _filter_refreshable_playlists(playlists: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + return [pl for pl in playlists if pl.get('source', '') not in ('file', 'beatport')] + + +def _summarize_playlist_names(playlists: List[Dict[str, Any]]) -> str: + pl_names = ', '.join(p.get('name', '?') for p in playlists[:3]) + if len(playlists) > 3: + pl_names += f' (+{len(playlists) - 3} more)' + return pl_names + + +def _run_refresh_phase( + config: Dict[str, Any], + deps: Any, + automation_id: Any, + *, + refresh_fn: RefreshFn, +) -> tuple[int, int]: + deps.update_progress( + automation_id, + progress=3, + phase='Phase 1/4: Refreshing playlists...', + log_line='Phase 1: Refresh', + log_type='info', + ) + + refresh_config = dict(config) + refresh_config['_automation_id'] = None + refresh_result = refresh_fn(refresh_config, deps) + refreshed = int(refresh_result.get('refreshed', 0)) + refresh_errors = int(refresh_result.get('errors', 0)) + + deps.update_progress( + automation_id, + progress=25, + phase='Phase 1/4: Refresh complete', + log_line=f'Phase 1 done: {refreshed} refreshed, {refresh_errors} errors', + log_type='success' if refresh_errors == 0 else 'warning', + ) + return refreshed, refresh_errors + + +def _run_discovery_phase( + deps: Any, + automation_id: Any, + *, + db: Any, + playlist_id: Any, + process_all: bool, +) -> None: + deps.update_progress( + automation_id, + progress=26, + phase='Phase 2/4: Discovering metadata...', + log_line='Phase 2: Discover', + log_type='info', + ) + + if process_all: + disc_playlists = db.get_mirrored_playlists() + else: + disc_playlists = [db.get_mirrored_playlist(int(playlist_id))] + disc_playlists = [p for p in disc_playlists if p] + + disc_done = threading.Event() + + def _disc_wrapper(pls): + try: + deps.run_playlist_discovery_worker(pls, automation_id=None) + except Exception as e: # noqa: BLE001 - logged into pipeline progress + deps.logger.error(f"[Pipeline] Discovery error: {e}") + finally: + disc_done.set() + + threading.Thread( + target=_disc_wrapper, + args=(disc_playlists,), + daemon=True, + name='pipeline-discover', + ).start() + + poll_start = time.time() + while not disc_done.wait(timeout=3): + elapsed = int(time.time() - poll_start) + deps.update_progress( + automation_id, + progress=min(26 + elapsed // 4, 54), + phase=f'Phase 2/4: Discovering... ({elapsed}s)', + ) + if elapsed > DISCOVERY_TIMEOUT_SECONDS: + deps.update_progress( + automation_id, + log_line='Discovery timed out after 1 hour', + log_type='warning', + ) + break + + deps.update_progress( + automation_id, + progress=55, + phase='Phase 2/4: Discovery complete', + log_line='Phase 2 done: discovery complete', + log_type='success', + ) From f83c671570bd1db41049b3b524f922eee18a0b75 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 19:54:04 -0700 Subject: [PATCH 08/81] Add direct mirrored playlist pipeline runs Expose playlist-native run and status endpoints that reuse the shared mirrored playlist pipeline engine while routing progress into playlist UI state. Add a Run Pipeline action to mirrored playlist cards and modals with live status polling, and make the shared pipeline lock atomic for manual and scheduled callers. --- core/automation/deps.py | 8 + core/playlists/pipeline.py | 10 +- tests/automation/test_handlers_playlist.py | 13 ++ tests/automation/test_handlers_simple.py | 8 + web_server.py | 207 +++++++++++++++++++++ webui/static/stats-automations.js | 121 +++++++++++- webui/static/style.css | 32 ++++ 7 files changed, 396 insertions(+), 3 deletions(-) diff --git a/core/automation/deps.py b/core/automation/deps.py index a521a496..5811766e 100644 --- a/core/automation/deps.py +++ b/core/automation/deps.py @@ -56,6 +56,14 @@ class AutomationState: with self.lock: return self.pipeline_running + def try_start_pipeline(self) -> bool: + """Atomically mark the shared playlist pipeline as running.""" + with self.lock: + if self.pipeline_running: + return False + self.pipeline_running = True + return True + def set_scan_library_id(self, automation_id: Optional[str]) -> None: with self.lock: self.scan_library_automation_id = automation_id diff --git a/core/playlists/pipeline.py b/core/playlists/pipeline.py index 9f279118..2bf0fc9c 100644 --- a/core/playlists/pipeline.py +++ b/core/playlists/pipeline.py @@ -38,7 +38,15 @@ def run_mirrored_playlist_pipeline( a future web/UI runner can provide the same small surface without becoming an automation. """ - deps.state.set_pipeline_running(True) + if hasattr(deps.state, 'try_start_pipeline'): + if not deps.state.try_start_pipeline(): + return { + 'status': 'skipped', + 'reason': 'playlist_pipeline is already running', + '_manages_own_progress': True, + } + else: + deps.state.set_pipeline_running(True) automation_id = config.get('_automation_id') pipeline_start = time.time() diff --git a/tests/automation/test_handlers_playlist.py b/tests/automation/test_handlers_playlist.py index 0bf7628c..60442bea 100644 --- a/tests/automation/test_handlers_playlist.py +++ b/tests/automation/test_handlers_playlist.py @@ -387,6 +387,19 @@ class TestSyncPlaylist: class TestPlaylistPipeline: + def test_pipeline_skips_when_shared_lock_is_already_running(self): + deps = _build_deps() + deps.state.set_pipeline_running(True) + + result = auto_playlist_pipeline({'all': True}, deps) + + assert result == { + 'status': 'skipped', + 'reason': 'playlist_pipeline is already running', + '_manages_own_progress': True, + } + assert deps.state.pipeline_running is True + def test_no_playlist_specified_returns_error(self): deps = _build_deps() result = auto_playlist_pipeline({}, deps) diff --git a/tests/automation/test_handlers_simple.py b/tests/automation/test_handlers_simple.py index ac8b2ef4..5e535884 100644 --- a/tests/automation/test_handlers_simple.py +++ b/tests/automation/test_handlers_simple.py @@ -311,6 +311,14 @@ class TestAutomationState: s.set_pipeline_running(False) assert s.is_pipeline_running() is False + def test_try_start_pipeline_is_atomic(self): + s = AutomationState() + assert s.try_start_pipeline() is True + assert s.is_pipeline_running() is True + assert s.try_start_pipeline() is False + s.set_pipeline_running(False) + assert s.try_start_pipeline() is True + def test_concurrent_set_safe_via_lock(self): # Smoke test: two threads flipping the same field don't crash. # Lock ensures the final value is consistent. diff --git a/web_server.py b/web_server.py index 2a90c8e1..51303934 100644 --- a/web_server.py +++ b/web_server.py @@ -919,6 +919,12 @@ except Exception as e: # --- Automation Progress Tracking --- _scan_library_automation_id = None +_automation_deps = None + +# Playlist-native manual pipeline runs share the automation dependency +# bundle, but keep their own small progress state for the playlist UI. +playlist_pipeline_progress_states = {} +playlist_pipeline_progress_lock = threading.Lock() def _register_automation_handlers(): @@ -935,6 +941,8 @@ def _register_automation_handlers(): closures still live below until subsequent commits in the same branch finish the lift. """ + global _automation_deps + if not automation_engine: return @@ -32198,6 +32206,7 @@ def get_mirrored_playlists_endpoint(): pl['source_ref_kind'] = source_ref.source_ref_kind pl['source_ref_status'] = source_ref.source_ref_status pl['source_ref_error'] = source_ref.source_ref_error + pl['pipeline_state'] = _snapshot_playlist_pipeline_state(pl['id']) return jsonify(playlists) except Exception as e: logger.error(f"Error getting mirrored playlists: {e}") @@ -32217,6 +32226,7 @@ def get_mirrored_playlist_endpoint(playlist_id): playlist['source_ref_kind'] = source_ref.source_ref_kind playlist['source_ref_status'] = source_ref.source_ref_status playlist['source_ref_error'] = source_ref.source_ref_error + playlist['pipeline_state'] = _snapshot_playlist_pipeline_state(playlist_id) playlist['tracks'] = database.get_mirrored_playlist_tracks(playlist_id) return jsonify(playlist) except Exception as e: @@ -32272,6 +32282,203 @@ def update_mirrored_playlist_source_ref_endpoint(playlist_id): logger.error(f"Error updating mirrored playlist source reference: {e}") return jsonify({"error": str(e)}), 500 + +def _playlist_pipeline_state_key(playlist_id): + return f"mirrored_{int(playlist_id)}" + + +def _snapshot_playlist_pipeline_state(playlist_id): + key = _playlist_pipeline_state_key(playlist_id) + with playlist_pipeline_progress_lock: + state = playlist_pipeline_progress_states.get(key) + return dict(state) if state else None + + +def _replace_playlist_pipeline_state(playlist_id, state): + key = _playlist_pipeline_state_key(playlist_id) + with playlist_pipeline_progress_lock: + playlist_pipeline_progress_states[key] = dict(state) + return dict(playlist_pipeline_progress_states[key]) + + +def _update_playlist_pipeline_progress(playlist_id, **kwargs): + key = _playlist_pipeline_state_key(playlist_id) + with playlist_pipeline_progress_lock: + state = playlist_pipeline_progress_states.setdefault(key, { + 'run_id': key, + 'playlist_id': int(playlist_id), + 'status': 'running', + 'progress': 0, + 'phase': 'Starting pipeline...', + 'log': [], + 'started_at': time.time(), + 'finished_at': None, + 'result': None, + 'error': None, + }) + for field in ('status', 'progress', 'phase', 'result', 'error'): + if field in kwargs: + state[field] = kwargs[field] + if 'log_line' in kwargs and kwargs.get('log_line'): + state.setdefault('log', []).append({ + 'message': kwargs.get('log_line'), + 'type': kwargs.get('log_type') or 'info', + 'timestamp': time.time(), + }) + state['log'] = state['log'][-80:] + if state.get('status') in ('finished', 'error', 'skipped') and not state.get('finished_at'): + state['finished_at'] = time.time() + return dict(state) + + +class _PlaylistPipelineDepsProxy: + """Forward automation deps while routing progress into playlist UI state.""" + + def __init__(self, base_deps, playlist_id): + self._base_deps = base_deps + self._playlist_id = playlist_id + + def __getattr__(self, name): + return getattr(self._base_deps, name) + + def update_progress(self, _automation_id, **kwargs): + _update_playlist_pipeline_progress(self._playlist_id, **kwargs) + + +def _run_mirrored_playlist_pipeline_for_ui(playlist_id, skip_wishlist=False): + try: + if _automation_deps is None: + raise RuntimeError("Automation dependencies are not available") + + from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored + from core.automation.handlers.sync_playlist import auto_sync_playlist + from core.automation.handlers._pipeline_shared import run_sync_and_wishlist + from core.playlists.pipeline import run_mirrored_playlist_pipeline + + deps = _PlaylistPipelineDepsProxy(_automation_deps, playlist_id) + result = run_mirrored_playlist_pipeline( + { + 'playlist_id': str(playlist_id), + 'all': False, + 'skip_wishlist': bool(skip_wishlist), + '_automation_id': _playlist_pipeline_state_key(playlist_id), + }, + deps, + refresh_fn=auto_refresh_mirrored, + sync_one_fn=auto_sync_playlist, + sync_and_wishlist_fn=run_sync_and_wishlist, + ) + + status = result.get('status') + if status == 'completed': + _update_playlist_pipeline_progress( + playlist_id, + status='finished', + progress=100, + phase='Pipeline complete', + result=result, + ) + elif status == 'skipped': + _update_playlist_pipeline_progress( + playlist_id, + status='skipped', + progress=100, + phase='Pipeline already running', + error=result.get('reason') or 'Pipeline already running', + result=result, + log_line=result.get('reason') or 'Pipeline already running', + log_type='warning', + ) + else: + _update_playlist_pipeline_progress( + playlist_id, + status='error', + progress=100, + phase='Pipeline error', + error=result.get('error') or 'Pipeline failed', + result=result, + ) + except Exception as e: + logger.error(f"Manual mirrored playlist pipeline failed for {playlist_id}: {e}") + _update_playlist_pipeline_progress( + playlist_id, + status='error', + progress=100, + phase='Pipeline error', + error=str(e), + log_line=f'Pipeline failed: {e}', + log_type='error', + ) + + +@app.route('/api/mirrored-playlists//pipeline/run', methods=['POST']) +def run_mirrored_playlist_pipeline_endpoint(playlist_id): + """Run the all-in-one mirrored playlist pipeline from the playlist UI.""" + try: + database = get_database() + playlist = database.get_mirrored_playlist(playlist_id) + if not playlist: + return jsonify({"error": "Playlist not found"}), 404 + if playlist.get('source') in ('file', 'beatport'): + return jsonify({"error": "This playlist source cannot be refreshed by the pipeline"}), 400 + if _automation_deps is None: + return jsonify({"error": "Playlist pipeline is not available"}), 503 + if _automation_deps.state.is_pipeline_running(): + return jsonify({"error": "A playlist pipeline is already running"}), 409 + + data = request.get_json(silent=True) or {} + state = _replace_playlist_pipeline_state(playlist_id, { + 'run_id': _playlist_pipeline_state_key(playlist_id), + 'playlist_id': int(playlist_id), + 'playlist_name': playlist.get('name') or '', + 'status': 'running', + 'progress': 0, + 'phase': 'Starting pipeline...', + 'log': [{ + 'message': f"Starting pipeline for {playlist.get('name') or playlist_id}", + 'type': 'info', + 'timestamp': time.time(), + }], + 'started_at': time.time(), + 'finished_at': None, + 'result': None, + 'error': None, + }) + + threading.Thread( + target=_run_mirrored_playlist_pipeline_for_ui, + args=(playlist_id, bool(data.get('skip_wishlist', False))), + daemon=True, + name=f"playlist-pipeline-{playlist_id}", + ).start() + + return jsonify({"success": True, "state": state}) + except Exception as e: + logger.error(f"Error starting mirrored playlist pipeline: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/mirrored-playlists//pipeline/status', methods=['GET']) +def get_mirrored_playlist_pipeline_status_endpoint(playlist_id): + """Return the latest manual pipeline progress for a mirrored playlist.""" + try: + state = _snapshot_playlist_pipeline_state(playlist_id) + if not state: + return jsonify({ + "run_id": _playlist_pipeline_state_key(playlist_id), + "playlist_id": int(playlist_id), + "status": "idle", + "progress": 0, + "phase": "Idle", + "log": [], + "result": None, + "error": None, + }) + return jsonify(state) + except Exception as e: + logger.error(f"Error getting mirrored playlist pipeline status: {e}") + return jsonify({"error": str(e)}), 500 + @app.route('/api/mirrored-playlists/', methods=['DELETE']) def delete_mirrored_playlist_endpoint(playlist_id): """Delete a mirrored playlist.""" diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index f7545036..9d25e8bb 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -380,6 +380,7 @@ function importFileSubmit() { // ── Mirrored Playlists ──────────────────────────────────────────────── let mirroredPlaylistsLoaded = false; +const mirroredPipelinePollers = {}; /** * Fire-and-forget helper: send parsed playlist data to be mirrored on the backend. @@ -444,13 +445,34 @@ async function loadMirroredPlaylists() { function renderMirroredCard(p, container) { const ago = timeAgo(p.updated_at || p.mirrored_at); const hash = `mirrored_${p.id}`; - const state = youtubePlaylistStates[hash]; + const pipelineState = p.pipeline_state || null; + const pipelinePhase = pipelineState && pipelineState.status === 'running' ? 'pipeline_running' + : pipelineState && pipelineState.status === 'finished' ? 'pipeline_complete' + : pipelineState && (pipelineState.status === 'error' || pipelineState.status === 'skipped') ? 'pipeline_error' + : null; + const state = youtubePlaylistStates[hash] || (pipelinePhase ? { + phase: pipelinePhase, + pipeline_status: pipelineState.status, + pipeline_progress: pipelineState.progress || 0, + pipeline_phase: pipelineState.phase || '', + pipeline_error: pipelineState.error || '', + pipeline_log: pipelineState.log || [], + pipeline_result: pipelineState.result || null, + } : null); const phase = state ? state.phase : null; const sourceRef = getMirroredSourceRef(p); // Build phase indicator let phaseHtml = ''; - if (phase === 'discovering') { + if (phase === 'pipeline_running') { + const pct = state.pipeline_progress || state.progress || 0; + const label = state.pipeline_phase || state.phase_label || 'Pipeline running'; + phaseHtml = `${_esc(label)} ${pct}%`; + } else if (phase === 'pipeline_complete') { + phaseHtml = `Pipeline complete`; + } else if (phase === 'pipeline_error') { + phaseHtml = `Pipeline error`; + } else if (phase === 'discovering') { const pct = state.discoveryProgress || state.discovery_progress || 0; phaseHtml = `Discovering ${pct}%`; } else if (phase === 'discovered') { @@ -491,6 +513,7 @@ function renderMirroredCard(p, container) { Mirrored ${ago} ${ratioHtml} ${phaseHtml} + ${disc > 0 ? `` : ''} @@ -532,6 +555,10 @@ function renderMirroredCard(p, container) { } }); container.appendChild(card); + + if (pipelineState && pipelineState.status === 'running' && !mirroredPipelinePollers[hash]) { + pollMirroredPipelineStatus(p.id, p.name); + } } function getMirroredSourceRef(p) { @@ -577,6 +604,84 @@ async function editMirroredSourceRef(playlistId, name, source, currentRef) { } } +function applyMirroredPipelineState(playlistId, state) { + const hash = `mirrored_${playlistId}`; + const existing = youtubePlaylistStates[hash] || {}; + const status = state.status || 'idle'; + let phase = existing.phase; + if (status === 'running') phase = 'pipeline_running'; + else if (status === 'finished') phase = 'pipeline_complete'; + else if (status === 'error' || status === 'skipped') phase = 'pipeline_error'; + + youtubePlaylistStates[hash] = { + ...existing, + phase, + pipeline_status: status, + pipeline_progress: state.progress || 0, + pipeline_phase: state.phase || '', + pipeline_error: state.error || '', + pipeline_log: state.log || [], + pipeline_result: state.result || null, + }; + + updateMirroredCardPhase(hash, phase); +} + +async function runMirroredPlaylistPipeline(playlistId, name) { + try { + const res = await fetch(`/api/mirrored-playlists/${playlistId}/pipeline/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + const data = await res.json(); + if (!res.ok || data.error) { + throw new Error(data.error || 'Failed to start pipeline'); + } + applyMirroredPipelineState(playlistId, data.state || { status: 'running', progress: 0, phase: 'Starting pipeline...' }); + showToast(`Pipeline started for ${name}`, 'success'); + pollMirroredPipelineStatus(playlistId, name); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } +} + +function pollMirroredPipelineStatus(playlistId, name) { + const key = `mirrored_${playlistId}`; + if (mirroredPipelinePollers[key]) clearInterval(mirroredPipelinePollers[key]); + + const tick = async () => { + try { + const res = await fetch(`/api/mirrored-playlists/${playlistId}/pipeline/status`); + const state = await res.json(); + if (!res.ok || state.error) throw new Error(state.error || 'Failed to read pipeline status'); + applyMirroredPipelineState(playlistId, state); + + if (state.status === 'finished') { + clearInterval(mirroredPipelinePollers[key]); + delete mirroredPipelinePollers[key]; + showToast(`Pipeline complete for ${name}`, 'success'); + loadMirroredPlaylists(); + } else if (state.status === 'error' || state.status === 'skipped') { + clearInterval(mirroredPipelinePollers[key]); + delete mirroredPipelinePollers[key]; + showToast(state.error || `Pipeline stopped for ${name}`, 'error'); + loadMirroredPlaylists(); + } else if (state.status === 'idle') { + clearInterval(mirroredPipelinePollers[key]); + delete mirroredPipelinePollers[key]; + } + } catch (err) { + clearInterval(mirroredPipelinePollers[key]); + delete mirroredPipelinePollers[key]; + showToast(`Pipeline status error: ${err.message}`, 'error'); + } + }; + + tick(); + mirroredPipelinePollers[key] = setInterval(tick, 2500); +} + function updateMirroredCardPhase(urlHash, phase) { // Update the state phase (updateYouTubeCardPhase skips this for mirrored playlists due to no cardElement) const state = youtubePlaylistStates[urlHash]; @@ -597,6 +702,17 @@ function updateMirroredCardPhase(urlHash, phase) { // Add new phase indicator let phaseHtml = ''; switch (phase) { + case 'pipeline_running': + const pipelineProgress = state?.pipeline_progress || 0; + const pipelinePhase = state?.pipeline_phase || 'Pipeline running'; + phaseHtml = `${_esc(pipelinePhase)} ${pipelineProgress}%`; + break; + case 'pipeline_complete': + phaseHtml = `Pipeline complete`; + break; + case 'pipeline_error': + phaseHtml = `Pipeline error`; + break; case 'discovering': phaseHtml = `Discovering...`; break; @@ -874,6 +990,7 @@ async function openMirroredPlaylistModal(playlistId) { diff --git a/webui/static/style.css b/webui/static/style.css index 8ab1469c..51c619c6 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -12047,6 +12047,25 @@ body.helper-mode-active #dashboard-activity-feed:hover { letter-spacing: 0.3px; } +.mirrored-card-pipeline { + background: rgba(56, 189, 248, 0.12); + border: 1px solid rgba(56, 189, 248, 0.24); + border-radius: 6px; + color: #7dd3fc; + cursor: pointer; + font-size: 11px; + font-weight: 700; + height: 24px; + padding: 0 10px; + transition: all 0.2s ease; +} + +.mirrored-card-pipeline:hover { + background: rgba(56, 189, 248, 0.2); + border-color: rgba(56, 189, 248, 0.4); + color: #e0f2fe; +} + .mirrored-card-delete, .mirrored-card-clear, .mirrored-card-link { @@ -13226,6 +13245,19 @@ body.helper-mode-active #dashboard-activity-feed:hover { box-shadow: 0 8px 24px rgba(var(--accent-rgb), 0.4); } +.mirrored-btn-pipeline { + background: rgba(56, 189, 248, 0.12); + color: #7dd3fc; + border: 1px solid rgba(56, 189, 248, 0.28) !important; +} + +.mirrored-btn-pipeline:hover { + background: rgba(56, 189, 248, 0.2); + border-color: rgba(56, 189, 248, 0.45) !important; + color: #e0f2fe; + transform: translateY(-1px); +} + .mirrored-btn-delete { background: rgba(244, 67, 54, 0.12); color: #ef5350; From a1409576f49f9723bda1e211a2540b4ad08dd6c3 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 20:09:06 -0700 Subject: [PATCH 09/81] Move mirrored pipeline action into card controls Place the Run Pipeline action alongside the existing mirrored playlist card controls so users can find it next to clear, edit source, and delete. --- webui/static/stats-automations.js | 2 +- webui/static/style.css | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 9d25e8bb..6cf81220 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -513,10 +513,10 @@ function renderMirroredCard(p, container) { Mirrored ${ago} ${ratioHtml} ${phaseHtml} - ${disc > 0 ? `` : ''} + `; diff --git a/webui/static/style.css b/webui/static/style.css index 51c619c6..d633399a 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -12058,6 +12058,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { height: 24px; padding: 0 10px; transition: all 0.2s ease; + flex-shrink: 0; + opacity: 0; } .mirrored-card-pipeline:hover { @@ -12088,7 +12090,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .mirrored-playlist-card:hover .mirrored-card-delete, .mirrored-playlist-card:hover .mirrored-card-clear, -.mirrored-playlist-card:hover .mirrored-card-link { +.mirrored-playlist-card:hover .mirrored-card-link, +.mirrored-playlist-card:hover .mirrored-card-pipeline { opacity: 1; } From 46a0999ca2936bab113d1d1861ae64bec8375f03 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 20:23:37 -0700 Subject: [PATCH 10/81] Clarify mirrored playlist auto-sync action Rename the manual pipeline button to Auto-Sync and make non-JSON endpoint failures show an actionable restart message instead of a raw JSON parse error. --- webui/static/stats-automations.js | 35 ++++++++++++++++++++++--------- webui/static/style.css | 1 + 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 6cf81220..62be137b 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -516,7 +516,7 @@ function renderMirroredCard(p, container) { ${disc > 0 ? `` : ''} - + `; @@ -570,6 +570,25 @@ function getMirroredSourceRef(p) { return (p && p.source_playlist_id) ? String(p.source_playlist_id) : ''; } +async function parseMirroredPipelineResponse(res, fallbackMessage) { + const text = await res.text(); + let data = {}; + if (text) { + try { + data = JSON.parse(text); + } catch (_err) { + const detail = res.status === 404 + ? 'Auto-Sync endpoint not found. Restart the SoulSync server so the new backend routes load.' + : fallbackMessage; + throw new Error(detail); + } + } + if (!res.ok || data.error) { + throw new Error(data.error || fallbackMessage); + } + return data; +} + async function editMirroredSourceRef(playlistId, name, source, currentRef) { const label = (source === 'spotify_public' || source === 'youtube') ? 'original playlist URL' @@ -634,12 +653,9 @@ async function runMirroredPlaylistPipeline(playlistId, name) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); - const data = await res.json(); - if (!res.ok || data.error) { - throw new Error(data.error || 'Failed to start pipeline'); - } + const data = await parseMirroredPipelineResponse(res, 'Failed to start Auto-Sync'); applyMirroredPipelineState(playlistId, data.state || { status: 'running', progress: 0, phase: 'Starting pipeline...' }); - showToast(`Pipeline started for ${name}`, 'success'); + showToast(`Auto-Sync started for ${name}`, 'success'); pollMirroredPipelineStatus(playlistId, name); } catch (err) { showToast(`Error: ${err.message}`, 'error'); @@ -653,14 +669,13 @@ function pollMirroredPipelineStatus(playlistId, name) { const tick = async () => { try { const res = await fetch(`/api/mirrored-playlists/${playlistId}/pipeline/status`); - const state = await res.json(); - if (!res.ok || state.error) throw new Error(state.error || 'Failed to read pipeline status'); + const state = await parseMirroredPipelineResponse(res, 'Failed to read Auto-Sync status'); applyMirroredPipelineState(playlistId, state); if (state.status === 'finished') { clearInterval(mirroredPipelinePollers[key]); delete mirroredPipelinePollers[key]; - showToast(`Pipeline complete for ${name}`, 'success'); + showToast(`Auto-Sync complete for ${name}`, 'success'); loadMirroredPlaylists(); } else if (state.status === 'error' || state.status === 'skipped') { clearInterval(mirroredPipelinePollers[key]); @@ -990,7 +1005,7 @@ async function openMirroredPlaylistModal(playlistId) { diff --git a/webui/static/style.css b/webui/static/style.css index d633399a..0fa9218b 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -12056,6 +12056,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { font-size: 11px; font-weight: 700; height: 24px; + min-width: 76px; padding: 0 10px; transition: all 0.2s ease; flex-shrink: 0; From 854141f9033e499b13027a9bcc93706c33ef2632 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 20:31:03 -0700 Subject: [PATCH 11/81] Add playlist auto-sync schedule board Add a Sync-page Auto-Sync manager with source-grouped mirrored playlists, interval columns, and drag/drop scheduling backed by playlist_pipeline automations. Schedules created by the board are editable there, while existing custom pipeline automations are shown as locked automation-managed entries. --- webui/index.html | 1 + webui/static/stats-automations.js | 313 ++++++++++++++++++++++++++++++ webui/static/style.css | 272 ++++++++++++++++++++++++++ 3 files changed, 586 insertions(+) diff --git a/webui/index.html b/webui/index.html index 709c9b9e..6cee0db2 100644 --- a/webui/index.html +++ b/webui/index.html @@ -916,6 +916,7 @@ server

+
diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 62be137b..7e52a709 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -381,6 +381,13 @@ function importFileSubmit() { let mirroredPlaylistsLoaded = false; const mirroredPipelinePollers = {}; +const AUTO_SYNC_BUCKETS = [1, 2, 4, 8, 12, 16, 24, 48, 72, 168]; +let _autoSyncScheduleState = { + playlists: [], + automations: [], + playlistSchedules: {}, + automationManaged: [], +}; /** * Fire-and-forget helper: send parsed playlist data to be mirrored on the backend. @@ -570,6 +577,312 @@ function getMirroredSourceRef(p) { return (p && p.source_playlist_id) ? String(p.source_playlist_id) : ''; } +function autoSyncTriggerForHours(hours) { + const h = parseInt(hours, 10) || 24; + if (h >= 24 && h % 24 === 0) { + return { interval: h / 24, unit: 'days' }; + } + return { interval: h, unit: 'hours' }; +} + +function autoSyncHoursFromTrigger(config) { + const interval = parseInt(config?.interval, 10) || 0; + const unit = config?.unit || 'hours'; + if (!interval) return null; + if (unit === 'minutes') return Math.max(1, Math.round(interval / 60)); + if (unit === 'days') return interval * 24; + if (unit === 'weeks') return interval * 168; + return interval; +} + +function autoSyncBucketLabel(hours) { + if (hours === 168) return 'Weekly'; + if (hours >= 24) return `${hours / 24}d`; + return `${hours}h`; +} + +function autoSyncSourceLabel(source) { + const labels = { + spotify: 'Spotify', + spotify_public: 'Spotify Link', + tidal: 'Tidal', + youtube: 'YouTube', + deezer: 'Deezer', + qobuz: 'Qobuz', + beatport: 'Beatport', + file: 'File Imports', + }; + return labels[source] || source || 'Other'; +} + +function autoSyncIsPipelineAutomation(auto) { + return auto && auto.action_type === 'playlist_pipeline'; +} + +function autoSyncPlaylistIdFromAutomation(auto) { + if (!autoSyncIsPipelineAutomation(auto)) return null; + const cfg = auto.action_config || {}; + if (cfg.all === true || cfg.all === 'true') return null; + const raw = cfg.playlist_id; + if (raw === undefined || raw === null || raw === '') return null; + const id = parseInt(raw, 10); + return Number.isFinite(id) ? id : null; +} + +function autoSyncIsScheduleOwned(auto) { + const group = auto?.group_name || ''; + const name = auto?.name || ''; + return group === 'Playlist Auto-Sync' || name.startsWith('Auto-Sync:'); +} + +function buildAutoSyncScheduleState(playlists, automations) { + const playlistSchedules = {}; + const automationManaged = []; + automations.filter(autoSyncIsPipelineAutomation).forEach(auto => { + const playlistId = autoSyncPlaylistIdFromAutomation(auto); + const hours = auto.trigger_type === 'schedule' ? autoSyncHoursFromTrigger(auto.trigger_config || {}) : null; + if (playlistId && hours) { + playlistSchedules[playlistId] = { + automation_id: auto.id, + automation_name: auto.name, + hours, + enabled: auto.enabled !== false && auto.enabled !== 0, + owned: autoSyncIsScheduleOwned(auto), + next_run: auto.next_run, + trigger_config: auto.trigger_config || {}, + }; + } else { + automationManaged.push(auto); + } + }); + return { playlists, automations, playlistSchedules, automationManaged }; +} + +async function openAutoSyncScheduleModal() { + let overlay = document.getElementById('auto-sync-schedule-modal'); + if (!overlay) { + overlay = document.createElement('div'); + overlay.id = 'auto-sync-schedule-modal'; + overlay.className = 'auto-sync-overlay'; + document.body.appendChild(overlay); + } + overlay.innerHTML = ` +
+
+
+

Auto-Sync Schedule

+

Drop mirrored playlists onto an interval to schedule refresh, discovery, sync, and wishlist processing.

+
+ +
+
Loading schedule...
+
+ `; + overlay.style.display = 'flex'; + overlay.addEventListener('click', e => { if (e.target === overlay) closeAutoSyncScheduleModal(); }, { once: true }); + await refreshAutoSyncScheduleModal(); +} + +function closeAutoSyncScheduleModal() { + const overlay = document.getElementById('auto-sync-schedule-modal'); + if (overlay) overlay.remove(); +} + +async function refreshAutoSyncScheduleModal() { + const overlay = document.getElementById('auto-sync-schedule-modal'); + if (!overlay) return; + try { + const [playlistRes, automationRes] = await Promise.all([ + fetch('/api/mirrored-playlists'), + fetch('/api/automations'), + ]); + const playlists = await playlistRes.json(); + const automations = await automationRes.json(); + if (!playlistRes.ok || playlists.error) throw new Error(playlists.error || 'Failed to load mirrored playlists'); + if (!automationRes.ok || automations.error) throw new Error(automations.error || 'Failed to load automations'); + _autoSyncScheduleState = buildAutoSyncScheduleState(playlists, automations); + renderAutoSyncScheduleModal(); + } catch (err) { + overlay.innerHTML = ` +
+
+

Auto-Sync Schedule

Could not load schedule data.

+ +
+
${_esc(err.message)}
+
+ `; + } +} + +function renderAutoSyncScheduleModal() { + const overlay = document.getElementById('auto-sync-schedule-modal'); + if (!overlay) return; + + const { playlists, playlistSchedules, automationManaged } = _autoSyncScheduleState; + const grouped = playlists.reduce((acc, p) => { + const key = p.source || 'other'; + if (!acc[key]) acc[key] = []; + acc[key].push(p); + return acc; + }, {}); + const sourceKeys = Object.keys(grouped).sort((a, b) => autoSyncSourceLabel(a).localeCompare(autoSyncSourceLabel(b))); + + const sidebarHtml = sourceKeys.length ? sourceKeys.map(source => ` +
+
${_esc(autoSyncSourceLabel(source))}
+ ${grouped[source].map(p => { + const schedule = playlistSchedules[p.id]; + const assigned = schedule ? autoSyncBucketLabel(schedule.hours) : 'Unscheduled'; + return ` +
+
${_esc(p.name)}
+
${p.track_count || 0} tracks · ${_esc(assigned)}
+
+ `; + }).join('')} +
+ `).join('') : '
No mirrored playlists yet.
'; + + const bucketHtml = AUTO_SYNC_BUCKETS.map(hours => { + const assigned = playlists.filter(p => playlistSchedules[p.id]?.hours === hours); + return ` +
+
+ ${autoSyncBucketLabel(hours)} + ${assigned.length} +
+
+ ${assigned.length ? assigned.map(p => autoSyncScheduledCardHtml(p, playlistSchedules[p.id])).join('') : '
Drop playlists here
'} +
+
+ `; + }).join(''); + + const managedHtml = automationManaged.length ? ` +
+
Automation-managed pipelines
+ ${automationManaged.map(a => `${_esc(a.name || 'Playlist Pipeline')}`).join('')} +
+ ` : ''; + + overlay.innerHTML = ` +
+
+
+

Auto-Sync Schedule

+

Drag mirrored playlists into an interval. Each placement creates or updates a matching playlist-pipeline automation.

+
+ +
+ ${managedHtml} +
+ +
${bucketHtml}
+
+
+ `; +} + +function autoSyncScheduledCardHtml(playlist, schedule) { + const enabled = schedule?.enabled !== false; + const owned = schedule?.owned === true; + return ` +
+
+
${_esc(playlist.name)}
+
${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks${schedule?.next_run ? ` · ${autoSyncNextRunLabel(schedule.next_run)}` : ''}${owned ? '' : ' · Automations page'}
+
+ ${owned + ? `` + : 'Lock'} +
+ `; +} + +function autoSyncNextRunLabel(nextRun) { + if (!nextRun) return ''; + const ts = new Date(nextRun).getTime(); + if (!Number.isFinite(ts)) return ''; + const diff = ts - Date.now(); + if (diff <= 0) return 'due now'; + const mins = Math.ceil(diff / 60000); + if (mins < 60) return `next in ${mins}m`; + const hours = Math.ceil(mins / 60); + if (hours < 24) return `next in ${hours}h`; + return `next in ${Math.ceil(hours / 24)}d`; +} + +function autoSyncDragStart(event) { + const playlistId = event.currentTarget?.dataset?.playlistId; + if (!playlistId) return; + event.dataTransfer.setData('text/plain', playlistId); + event.dataTransfer.effectAllowed = 'move'; +} + +function autoSyncDragOver(event) { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; +} + +async function autoSyncDrop(event, hours) { + event.preventDefault(); + const playlistId = parseInt(event.dataTransfer.getData('text/plain'), 10); + if (!playlistId) return; + await saveAutoSyncPlaylistSchedule(playlistId, hours); +} + +async function saveAutoSyncPlaylistSchedule(playlistId, hours) { + const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); + if (!playlist) return; + const existing = _autoSyncScheduleState.playlistSchedules[playlistId]; + if (existing && !existing.owned) { + showToast('This playlist pipeline is managed from the Automations page for now.', 'info'); + return; + } + const payload = { + name: `Auto-Sync: ${playlist.name}`, + trigger_type: 'schedule', + trigger_config: autoSyncTriggerForHours(hours), + action_type: 'playlist_pipeline', + action_config: { playlist_id: String(playlistId), all: false }, + then_actions: [], + group_name: 'Playlist Auto-Sync', + }; + try { + const res = await fetch(existing ? `/api/automations/${existing.automation_id}` : '/api/automations', { + method: existing ? 'PUT' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || 'Failed to save Auto-Sync schedule'); + showToast(`${playlist.name} scheduled every ${autoSyncBucketLabel(hours)}`, 'success'); + await refreshAutoSyncScheduleModal(); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } +} + +async function unscheduleAutoSyncPlaylist(playlistId) { + const schedule = _autoSyncScheduleState.playlistSchedules[playlistId]; + const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); + if (!schedule) return; + if (!await showConfirmDialog({ title: 'Remove Auto-Sync', message: `Remove Auto-Sync schedule for "${playlist?.name || 'this playlist'}"?` })) return; + try { + const res = await fetch(`/api/automations/${schedule.automation_id}`, { method: 'DELETE' }); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || 'Failed to remove Auto-Sync schedule'); + showToast('Auto-Sync schedule removed', 'success'); + await refreshAutoSyncScheduleModal(); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } +} + async function parseMirroredPipelineResponse(res, fallbackMessage) { const text = await res.text(); let data = {}; diff --git a/webui/static/style.css b/webui/static/style.css index 0fa9218b..7e77f94f 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -11178,6 +11178,278 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .sync-history-btn:hover { color: rgb(var(--accent-rgb)); background: rgba(var(--accent-rgb), 0.1); border-color: rgba(var(--accent-rgb), 0.3); } +.auto-sync-manager-btn { + color: #7dd3fc; + border-color: rgba(56, 189, 248, 0.28); + background: rgba(56, 189, 248, 0.1); +} + +.auto-sync-overlay { + position: fixed; + inset: 0; + z-index: 10000; + display: none; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.72); + backdrop-filter: blur(12px); +} + +.auto-sync-modal { + width: min(1420px, calc(100vw - 48px)); + height: min(820px, calc(100vh - 48px)); + background: rgba(18, 20, 28, 0.98); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + box-shadow: 0 28px 80px rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.auto-sync-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + padding: 22px 24px 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.auto-sync-header h3 { + margin: 0 0 6px; + color: rgba(255, 255, 255, 0.92); + font-size: 21px; + font-weight: 700; +} + +.auto-sync-header p { + margin: 0; + color: rgba(255, 255, 255, 0.48); + font-size: 13px; +} + +.auto-sync-close { + width: 32px; + height: 32px; + border: 0; + border-radius: 6px; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.65); + cursor: pointer; + font-size: 22px; + line-height: 1; +} + +.auto-sync-close:hover { + background: rgba(255, 255, 255, 0.12); + color: #fff; +} + +.auto-sync-body { + min-height: 0; + flex: 1; + display: grid; + grid-template-columns: 300px 1fr; +} + +.auto-sync-sidebar { + min-height: 0; + border-right: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.025); + display: flex; + flex-direction: column; +} + +.auto-sync-sidebar-title { + padding: 16px 18px 12px; + color: rgba(255, 255, 255, 0.72); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +.auto-sync-source-list { + min-height: 0; + overflow-y: auto; + padding: 0 12px 16px; +} + +.auto-sync-source-group { + margin-bottom: 16px; +} + +.auto-sync-source-title { + padding: 8px 6px; + color: rgba(255, 255, 255, 0.42); + font-size: 12px; + font-weight: 700; +} + +.auto-sync-playlist, +.auto-sync-scheduled-card { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 7px; + background: rgba(255, 255, 255, 0.045); + cursor: grab; +} + +.auto-sync-playlist { + padding: 10px; + margin-bottom: 8px; +} + +.auto-sync-playlist:hover, +.auto-sync-scheduled-card:hover { + border-color: rgba(56, 189, 248, 0.32); + background: rgba(56, 189, 248, 0.08); +} + +.auto-sync-playlist.scheduled { + border-color: rgba(34, 197, 94, 0.22); +} + +.auto-sync-playlist-name, +.auto-sync-scheduled-name { + color: rgba(255, 255, 255, 0.86); + font-size: 13px; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auto-sync-playlist-meta, +.auto-sync-scheduled-meta { + margin-top: 4px; + color: rgba(255, 255, 255, 0.42); + font-size: 11px; +} + +.auto-sync-board { + min-width: 0; + overflow-x: auto; + padding: 18px; + display: grid; + grid-template-columns: repeat(10, minmax(170px, 1fr)); + gap: 12px; +} + +.auto-sync-column { + min-height: 0; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: rgba(255, 255, 255, 0.025); + display: flex; + flex-direction: column; +} + +.auto-sync-column-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + color: rgba(255, 255, 255, 0.86); + font-weight: 800; +} + +.auto-sync-column-head small { + color: #7dd3fc; + font-size: 11px; + font-weight: 700; +} + +.auto-sync-column-list { + flex: 1; + min-height: 220px; + padding: 10px; +} + +.auto-sync-drop-hint, +.auto-sync-empty, +.auto-sync-loading, +.auto-sync-error { + color: rgba(255, 255, 255, 0.38); + font-size: 13px; + text-align: center; +} + +.auto-sync-drop-hint { + border: 1px dashed rgba(255, 255, 255, 0.12); + border-radius: 7px; + padding: 18px 10px; +} + +.auto-sync-loading, +.auto-sync-error { + padding: 48px; +} + +.auto-sync-scheduled-card { + padding: 10px; + margin-bottom: 10px; + display: flex; + justify-content: space-between; + gap: 8px; +} + +.auto-sync-scheduled-card.disabled { + opacity: 0.52; +} + +.auto-sync-scheduled-card button { + width: 24px; + height: 24px; + border: 0; + border-radius: 5px; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.5); + cursor: pointer; + flex-shrink: 0; +} + +.auto-sync-scheduled-card button:hover { + color: #ef4444; + background: rgba(239, 68, 68, 0.12); +} + +.auto-sync-lock { + align-self: flex-start; + padding: 4px 6px; + border-radius: 5px; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.38); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + flex-shrink: 0; +} + +.auto-sync-managed { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 24px; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + color: rgba(255, 255, 255, 0.48); + font-size: 12px; + overflow-x: auto; +} + +.auto-sync-managed-title { + color: rgba(255, 255, 255, 0.72); + font-weight: 700; + flex-shrink: 0; +} + +.auto-sync-managed span { + padding: 4px 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + white-space: nowrap; +} + /* Enhanced Progress Bar Animation */ .progress-bar-fill { height: 100%; From 5421f3800ef089500d64a3014c0108c23d9057c1 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 20:39:48 -0700 Subject: [PATCH 12/81] Polish auto-sync manager modal Upgrade the Auto-Sync modal into a tabbed manager with a richer schedule board and a separate read-only Automation Pipelines tab for existing playlist_pipeline automations. --- webui/static/stats-automations.js | 142 ++++++++++++----- webui/static/style.css | 257 ++++++++++++++++++++++++++---- 2 files changed, 333 insertions(+), 66 deletions(-) diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 7e52a709..320113e5 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -386,8 +386,9 @@ let _autoSyncScheduleState = { playlists: [], automations: [], playlistSchedules: {}, - automationManaged: [], + automationPipelines: [], }; +let _autoSyncActiveTab = 'schedule'; /** * Fire-and-forget helper: send parsed playlist data to be mirrored on the backend. @@ -637,25 +638,26 @@ function autoSyncIsScheduleOwned(auto) { function buildAutoSyncScheduleState(playlists, automations) { const playlistSchedules = {}; - const automationManaged = []; - automations.filter(autoSyncIsPipelineAutomation).forEach(auto => { + const automationPipelines = []; + const pipelineAutomations = automations.filter(autoSyncIsPipelineAutomation); + pipelineAutomations.forEach(auto => { const playlistId = autoSyncPlaylistIdFromAutomation(auto); const hours = auto.trigger_type === 'schedule' ? autoSyncHoursFromTrigger(auto.trigger_config || {}) : null; - if (playlistId && hours) { + if (playlistId && hours && autoSyncIsScheduleOwned(auto)) { playlistSchedules[playlistId] = { automation_id: auto.id, automation_name: auto.name, hours, enabled: auto.enabled !== false && auto.enabled !== 0, - owned: autoSyncIsScheduleOwned(auto), + owned: true, next_run: auto.next_run, trigger_config: auto.trigger_config || {}, }; } else { - automationManaged.push(auto); + automationPipelines.push(auto); } }); - return { playlists, automations, playlistSchedules, automationManaged }; + return { playlists, automations, playlistSchedules, automationPipelines }; } async function openAutoSyncScheduleModal() { @@ -679,7 +681,7 @@ async function openAutoSyncScheduleModal() { `; overlay.style.display = 'flex'; - overlay.addEventListener('click', e => { if (e.target === overlay) closeAutoSyncScheduleModal(); }, { once: true }); + overlay.onclick = e => { if (e.target === overlay) closeAutoSyncScheduleModal(); }; await refreshAutoSyncScheduleModal(); } @@ -719,7 +721,49 @@ function renderAutoSyncScheduleModal() { const overlay = document.getElementById('auto-sync-schedule-modal'); if (!overlay) return; - const { playlists, playlistSchedules, automationManaged } = _autoSyncScheduleState; + const { playlists, playlistSchedules, automationPipelines } = _autoSyncScheduleState; + const scheduledCount = Object.keys(playlistSchedules).length; + const enabledCount = Object.values(playlistSchedules).filter(s => s.enabled).length; + const pipelineCount = automationPipelines.length; + const totalTracks = playlists.reduce((sum, p) => sum + (parseInt(p.track_count, 10) || 0), 0); + const scheduleActive = _autoSyncActiveTab === 'schedule'; + const automationsActive = _autoSyncActiveTab === 'automations'; + + const schedulePanel = renderAutoSyncSchedulePanel(playlists, playlistSchedules); + const automationPanel = renderAutoSyncAutomationPanel(automationPipelines, playlists); + + overlay.innerHTML = ` +
+
+
+
Playlist automation
+

Auto-Sync Manager

+

Schedule mirrored playlists through the same playlist-pipeline engine used by Automations.

+
+ +
+
+
${scheduledCount}scheduled playlists
+
${enabledCount}active schedules
+
${pipelineCount}automation pipelines
+
${totalTracks}mirrored tracks
+
+
+ + +
+
${schedulePanel}
+
${automationPanel}
+
+ `; +} + +function setAutoSyncTab(tab) { + _autoSyncActiveTab = tab === 'automations' ? 'automations' : 'schedule'; + renderAutoSyncScheduleModal(); +} + +function renderAutoSyncSchedulePanel(playlists, playlistSchedules) { const grouped = playlists.reduce((acc, p) => { const key = p.source || 'other'; if (!acc[key]) acc[key] = []; @@ -737,7 +781,7 @@ function renderAutoSyncScheduleModal() { return `
${_esc(p.name)}
-
${p.track_count || 0} tracks · ${_esc(assigned)}
+
${p.track_count || 0} tracks · ${_esc(assigned)}
`; }).join('')} @@ -753,29 +797,20 @@ function renderAutoSyncScheduleModal() { ${assigned.length}
- ${assigned.length ? assigned.map(p => autoSyncScheduledCardHtml(p, playlistSchedules[p.id])).join('') : '
Drop playlists here
'} + ${assigned.length ? assigned.map(p => autoSyncScheduledCardHtml(p, playlistSchedules[p.id])).join('') : '
Drop hereSchedule playlists at this interval
'}
`; }).join(''); - const managedHtml = automationManaged.length ? ` -
-
Automation-managed pipelines
- ${automationManaged.map(a => `${_esc(a.name || 'Playlist Pipeline')}`).join('')} -
- ` : ''; - - overlay.innerHTML = ` -
-
-
-

Auto-Sync Schedule

-

Drag mirrored playlists into an interval. Each placement creates or updates a matching playlist-pipeline automation.

-
- + return ` +
+
+ Drag playlists into an interval + Each placement creates or updates an Auto-Sync-owned playlist-pipeline automation.
- ${managedHtml} + +
${bucketHtml}
+ `; +} + +function renderAutoSyncAutomationPanel(automationPipelines, playlists) { + if (!automationPipelines.length) { + return '
No Automations-page playlist pipelines found.
'; + } + return ` +
+ Read-only Automations-page pipelines + These use the playlist pipeline but are managed from the Automations page, so this modal only displays them. +
+
+ ${automationPipelines.map(auto => autoSyncAutomationCardHtml(auto, playlists)).join('')} +
+ `; +} + +function autoSyncAutomationCardHtml(auto, playlists) { + const cfg = auto.action_config || {}; + const playlistId = autoSyncPlaylistIdFromAutomation(auto); + const playlist = playlistId ? playlists.find(p => parseInt(p.id, 10) === playlistId) : null; + const target = cfg.all === true || cfg.all === 'true' + ? 'All refreshable mirrored playlists' + : playlist ? playlist.name : playlistId ? `Playlist #${playlistId}` : 'Custom pipeline target'; + const trigger = _autoFormatTrigger(auto.trigger_type, auto.trigger_config || {}); + const enabled = auto.enabled !== false && auto.enabled !== 0; + const next = auto.next_run ? autoSyncNextRunLabel(auto.next_run) : 'not scheduled'; + return ` +
+
+
+ ${enabled ? 'Enabled' : 'Disabled'} + ${_esc(auto.name || 'Playlist Pipeline')} +
+
+ ${_esc(trigger)} + ${_esc(target)} + ${_esc(next)} +
+
+
Read only
`; } function autoSyncScheduledCardHtml(playlist, schedule) { const enabled = schedule?.enabled !== false; - const owned = schedule?.owned === true; return `
${_esc(playlist.name)}
-
${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks${schedule?.next_run ? ` · ${autoSyncNextRunLabel(schedule.next_run)}` : ''}${owned ? '' : ' · Automations page'}
+
${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks${schedule?.next_run ? ` · ${autoSyncNextRunLabel(schedule.next_run)}` : ''}
- ${owned - ? `` - : 'Lock'} +
`; } @@ -839,10 +913,6 @@ async function saveAutoSyncPlaylistSchedule(playlistId, hours) { const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); if (!playlist) return; const existing = _autoSyncScheduleState.playlistSchedules[playlistId]; - if (existing && !existing.owned) { - showToast('This playlist pipeline is managed from the Automations page for now.', 'info'); - return; - } const payload = { name: `Auto-Sync: ${playlist.name}`, trigger_type: 'schedule', diff --git a/webui/static/style.css b/webui/static/style.css index 7e77f94f..097f6ff1 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -11196,9 +11196,9 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-modal { - width: min(1420px, calc(100vw - 48px)); - height: min(820px, calc(100vh - 48px)); - background: rgba(18, 20, 28, 0.98); + width: min(1500px, calc(100vw - 40px)); + height: min(860px, calc(100vh - 40px)); + background: rgba(17, 19, 27, 0.98); border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 8px; box-shadow: 0 28px 80px rgba(0, 0, 0, 0.5); @@ -11216,6 +11216,14 @@ body.helper-mode-active #dashboard-activity-feed:hover { border-bottom: 1px solid rgba(255, 255, 255, 0.08); } +.auto-sync-eyebrow { + margin-bottom: 6px; + color: #7dd3fc; + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + .auto-sync-header h3 { margin: 0 0 6px; color: rgba(255, 255, 255, 0.92); @@ -11246,6 +11254,116 @@ body.helper-mode-active #dashboard-activity-feed:hover { color: #fff; } +.auto-sync-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1px; + background: rgba(255, 255, 255, 0.08); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.auto-sync-summary div { + padding: 14px 20px; + background: rgba(255, 255, 255, 0.025); +} + +.auto-sync-summary span { + display: block; + color: rgba(255, 255, 255, 0.92); + font-size: 20px; + font-weight: 800; +} + +.auto-sync-summary small { + display: block; + margin-top: 2px; + color: rgba(255, 255, 255, 0.42); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} + +.auto-sync-tabs { + display: flex; + gap: 8px; + padding: 12px 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.018); +} + +.auto-sync-tabs button { + height: 32px; + padding: 0 14px; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 6px; + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.58); + cursor: pointer; + font-size: 12px; + font-weight: 700; +} + +.auto-sync-tabs button:hover, +.auto-sync-tabs button.active { + border-color: rgba(56, 189, 248, 0.35); + background: rgba(56, 189, 248, 0.12); + color: #e0f2fe; +} + +.auto-sync-tab-panel { + display: none; + min-height: 0; + flex: 1; +} + +.auto-sync-tab-panel.active { + display: flex; + flex-direction: column; +} + +.auto-sync-board-intro, +.auto-sync-automation-intro { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + background: rgba(56, 189, 248, 0.035); +} + +.auto-sync-board-intro strong, +.auto-sync-automation-intro strong { + display: block; + color: rgba(255, 255, 255, 0.86); + font-size: 13px; +} + +.auto-sync-board-intro span, +.auto-sync-automation-intro span { + display: block; + margin-top: 2px; + color: rgba(255, 255, 255, 0.46); + font-size: 12px; +} + +.auto-sync-board-intro button { + height: 30px; + padding: 0 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + font-size: 12px; + font-weight: 700; +} + +.auto-sync-board-intro button:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + .auto-sync-body { min-height: 0; flex: 1; @@ -11262,7 +11380,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-sidebar-title { - padding: 16px 18px 12px; + padding: 16px 18px 10px; color: rgba(255, 255, 255, 0.72); font-size: 12px; font-weight: 700; @@ -11295,7 +11413,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-playlist { - padding: 10px; + padding: 11px 10px; margin-bottom: 8px; } @@ -11331,7 +11449,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { overflow-x: auto; padding: 18px; display: grid; - grid-template-columns: repeat(10, minmax(170px, 1fr)); + grid-template-columns: repeat(10, minmax(185px, 1fr)); gap: 12px; } @@ -11381,6 +11499,22 @@ body.helper-mode-active #dashboard-activity-feed:hover { padding: 18px 10px; } +.auto-sync-drop-hint strong, +.auto-sync-drop-hint span { + display: block; +} + +.auto-sync-drop-hint strong { + color: rgba(255, 255, 255, 0.52); + font-size: 12px; +} + +.auto-sync-drop-hint span { + margin-top: 3px; + color: rgba(255, 255, 255, 0.32); + font-size: 11px; +} + .auto-sync-loading, .auto-sync-error { padding: 48px; @@ -11414,40 +11548,103 @@ body.helper-mode-active #dashboard-activity-feed:hover { background: rgba(239, 68, 68, 0.12); } -.auto-sync-lock { - align-self: flex-start; - padding: 4px 6px; - border-radius: 5px; - background: rgba(255, 255, 255, 0.06); - color: rgba(255, 255, 255, 0.38); +.auto-sync-automation-list { + min-height: 0; + overflow-y: auto; + padding: 18px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.auto-sync-automation-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: rgba(255, 255, 255, 0.035); +} + +.auto-sync-automation-card:hover { + border-color: rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.055); +} + +.auto-sync-automation-main { + min-width: 0; + flex: 1; +} + +.auto-sync-automation-title-row { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.auto-sync-automation-title-row strong { + color: rgba(255, 255, 255, 0.88); + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auto-sync-status { + padding: 3px 7px; + border-radius: 999px; font-size: 10px; - font-weight: 700; + font-weight: 800; text-transform: uppercase; flex-shrink: 0; } -.auto-sync-managed { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 24px; - border-bottom: 1px solid rgba(255, 255, 255, 0.07); - color: rgba(255, 255, 255, 0.48); - font-size: 12px; - overflow-x: auto; +.auto-sync-status.enabled { + background: rgba(34, 197, 94, 0.14); + color: #4ade80; } -.auto-sync-managed-title { - color: rgba(255, 255, 255, 0.72); - font-weight: 700; +.auto-sync-status.disabled { + background: rgba(148, 163, 184, 0.14); + color: #94a3b8; +} + +.auto-sync-automation-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +.auto-sync-automation-meta span { + padding: 4px 8px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.48); + font-size: 11px; +} + +.auto-sync-automation-lock { + padding: 6px 9px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.42); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; flex-shrink: 0; } -.auto-sync-managed span { - padding: 4px 8px; - border-radius: 999px; - background: rgba(255, 255, 255, 0.06); - white-space: nowrap; +.auto-sync-automation-empty { + margin: 24px; + padding: 44px; + border: 1px dashed rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: rgba(255, 255, 255, 0.42); + text-align: center; } /* Enhanced Progress Bar Animation */ From 9a8e7d02a7b859abf7a6b25f14b1c264fe24a19b Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 21:01:46 -0700 Subject: [PATCH 13/81] Make auto-sync schedule modal responsive Constrain Auto-Sync columns inside the modal with per-column vertical scrolling, add responsive layouts for narrower and shorter viewports, and separate schedule interval labels from next-run timing. Also prevents unsupported mirrored sources from being scheduled into the playlist pipeline while still showing them as unavailable in the sidebar. --- webui/static/stats-automations.js | 50 ++++++++++-- webui/static/style.css | 131 +++++++++++++++++++++++++++++- 2 files changed, 173 insertions(+), 8 deletions(-) diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 320113e5..25bc01ce 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -602,6 +602,15 @@ function autoSyncBucketLabel(hours) { return `${hours}h`; } +function autoSyncIntervalLabel(hours) { + if (hours === 168) return 'Every week'; + if (hours >= 24) { + const days = hours / 24; + return `Every ${days} day${days === 1 ? '' : 's'}`; + } + return `Every ${hours} hour${hours === 1 ? '' : 's'}`; +} + function autoSyncSourceLabel(source) { const labels = { spotify: 'Spotify', @@ -616,6 +625,10 @@ function autoSyncSourceLabel(source) { return labels[source] || source || 'Other'; } +function autoSyncCanSchedulePlaylist(playlist) { + return playlist && !['file', 'beatport'].includes(playlist.source || ''); +} + function autoSyncIsPipelineAutomation(auto) { return auto && auto.action_type === 'playlist_pipeline'; } @@ -764,7 +777,9 @@ function setAutoSyncTab(tab) { } function renderAutoSyncSchedulePanel(playlists, playlistSchedules) { - const grouped = playlists.reduce((acc, p) => { + const schedulablePlaylists = playlists.filter(autoSyncCanSchedulePlaylist); + const unavailablePlaylists = playlists.filter(p => !autoSyncCanSchedulePlaylist(p)); + const grouped = schedulablePlaylists.reduce((acc, p) => { const key = p.source || 'other'; if (!acc[key]) acc[key] = []; acc[key].push(p); @@ -777,7 +792,7 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
${_esc(autoSyncSourceLabel(source))}
${grouped[source].map(p => { const schedule = playlistSchedules[p.id]; - const assigned = schedule ? autoSyncBucketLabel(schedule.hours) : 'Unscheduled'; + const assigned = schedule ? autoSyncIntervalLabel(schedule.hours) : 'Unscheduled'; return `
${_esc(p.name)}
@@ -786,15 +801,27 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) { `; }).join('')}
- `).join('') : '
No mirrored playlists yet.
'; + `).join('') : '
No refreshable mirrored playlists yet.
'; + + const unavailableHtml = unavailablePlaylists.length ? ` +
+
Not schedulable
+ ${unavailablePlaylists.map(p => ` +
+
${_esc(p.name)}
+
${_esc(autoSyncSourceLabel(p.source))} · refresh not supported
+
+ `).join('')} +
+ ` : ''; const bucketHtml = AUTO_SYNC_BUCKETS.map(hours => { - const assigned = playlists.filter(p => playlistSchedules[p.id]?.hours === hours); + const assigned = schedulablePlaylists.filter(p => playlistSchedules[p.id]?.hours === hours); return `
${autoSyncBucketLabel(hours)} - ${assigned.length} + ${assigned.length} playlist${assigned.length === 1 ? '' : 's'}
${assigned.length ? assigned.map(p => autoSyncScheduledCardHtml(p, playlistSchedules[p.id])).join('') : '
Drop hereSchedule playlists at this interval
'} @@ -814,7 +841,7 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
${bucketHtml}
@@ -866,11 +893,16 @@ function autoSyncAutomationCardHtml(auto, playlists) { function autoSyncScheduledCardHtml(playlist, schedule) { const enabled = schedule?.enabled !== false; + const nextLabel = schedule?.next_run ? autoSyncNextRunLabel(schedule.next_run) : ''; return `
${_esc(playlist.name)}
-
${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks${schedule?.next_run ? ` · ${autoSyncNextRunLabel(schedule.next_run)}` : ''}
+
${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks
+
+ ${_esc(autoSyncIntervalLabel(schedule?.hours || 24))} + ${nextLabel ? `${_esc(nextLabel)}` : ''} +
@@ -912,6 +944,10 @@ async function autoSyncDrop(event, hours) { async function saveAutoSyncPlaylistSchedule(playlistId, hours) { const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); if (!playlist) return; + if (!autoSyncCanSchedulePlaylist(playlist)) { + showToast('That playlist source cannot be refreshed by Auto-Sync.', 'info'); + return; + } const existing = _autoSyncScheduleState.playlistSchedules[playlistId]; const payload = { name: `Auto-Sync: ${playlist.name}`, diff --git a/webui/static/style.css b/webui/static/style.css index 097f6ff1..d32f772d 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -11214,6 +11214,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { gap: 20px; padding: 22px 24px 18px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); + flex-shrink: 0; } .auto-sync-eyebrow { @@ -11260,6 +11261,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { gap: 1px; background: rgba(255, 255, 255, 0.08); border-bottom: 1px solid rgba(255, 255, 255, 0.08); + flex-shrink: 0; } .auto-sync-summary div { @@ -11289,6 +11291,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { padding: 12px 18px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.018); + flex-shrink: 0; } .auto-sync-tabs button { @@ -11330,6 +11333,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { padding: 12px 18px; border-bottom: 1px solid rgba(255, 255, 255, 0.07); background: rgba(56, 189, 248, 0.035); + flex-shrink: 0; } .auto-sync-board-intro strong, @@ -11427,6 +11431,21 @@ body.helper-mode-active #dashboard-activity-feed:hover { border-color: rgba(34, 197, 94, 0.22); } +.auto-sync-playlist.unavailable { + cursor: default; + opacity: 0.58; +} + +.auto-sync-playlist.unavailable:hover { + border-color: rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.045); +} + +.auto-sync-source-group-disabled { + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + .auto-sync-playlist-name, .auto-sync-scheduled-name { color: rgba(255, 255, 255, 0.86); @@ -11444,17 +11463,48 @@ body.helper-mode-active #dashboard-activity-feed:hover { font-size: 11px; } +.auto-sync-scheduled-timing { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 8px; +} + +.auto-sync-scheduled-timing span, +.auto-sync-scheduled-timing small { + padding: 3px 6px; + border-radius: 999px; + font-size: 10px; + font-weight: 800; + line-height: 1; +} + +.auto-sync-scheduled-timing span { + background: rgba(56, 189, 248, 0.14); + color: #7dd3fc; +} + +.auto-sync-scheduled-timing small { + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.48); +} + .auto-sync-board { min-width: 0; + min-height: 0; overflow-x: auto; + overflow-y: hidden; padding: 18px; display: grid; grid-template-columns: repeat(10, minmax(185px, 1fr)); + grid-auto-rows: minmax(0, 1fr); gap: 12px; } .auto-sync-column { min-height: 0; + max-height: 100%; + overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 8px; background: rgba(255, 255, 255, 0.025); @@ -11470,6 +11520,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { border-bottom: 1px solid rgba(255, 255, 255, 0.07); color: rgba(255, 255, 255, 0.86); font-weight: 800; + flex-shrink: 0; } .auto-sync-column-head small { @@ -11480,10 +11531,27 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-column-list { flex: 1; - min-height: 220px; + min-height: 0; + overflow-y: auto; padding: 10px; } +.auto-sync-column-list::-webkit-scrollbar, +.auto-sync-board::-webkit-scrollbar, +.auto-sync-source-list::-webkit-scrollbar, +.auto-sync-automation-list::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.auto-sync-column-list::-webkit-scrollbar-thumb, +.auto-sync-board::-webkit-scrollbar-thumb, +.auto-sync-source-list::-webkit-scrollbar-thumb, +.auto-sync-automation-list::-webkit-scrollbar-thumb { + background: rgba(125, 211, 252, 0.22); + border-radius: 999px; +} + .auto-sync-drop-hint, .auto-sync-empty, .auto-sync-loading, @@ -11647,6 +11715,67 @@ body.helper-mode-active #dashboard-activity-feed:hover { text-align: center; } +@media (max-width: 1100px) { + .auto-sync-modal { + width: calc(100vw - 18px); + height: calc(100vh - 18px); + } + + .auto-sync-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .auto-sync-body { + grid-template-columns: 1fr; + grid-template-rows: minmax(150px, 30%) 1fr; + } + + .auto-sync-sidebar { + border-right: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + } + + .auto-sync-source-list { + display: flex; + gap: 12px; + overflow-x: auto; + overflow-y: hidden; + padding: 0 12px 12px; + } + + .auto-sync-source-group { + min-width: 220px; + margin-bottom: 0; + } + + .auto-sync-board { + grid-template-columns: repeat(10, minmax(165px, 180px)); + } +} + +@media (max-height: 760px) { + .auto-sync-header { + padding: 14px 18px 12px; + } + + .auto-sync-header p { + display: none; + } + + .auto-sync-summary div { + padding: 9px 16px; + } + + .auto-sync-tabs { + padding: 9px 14px; + } + + .auto-sync-board-intro, + .auto-sync-automation-intro { + padding: 9px 14px; + } +} + /* Enhanced Progress Bar Animation */ .progress-bar-fill { height: 100%; From dc4d157944dfff170e8927563522cebc61077b62 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 22:01:21 -0700 Subject: [PATCH 14/81] Fix Auto-Sync next-run countdown and theme its modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Playlist Auto-Sync schedule board was showing "next in 8h" on every card regardless of the configured interval. Root cause: backend stores next_run as a naive UTC string ("2026-05-25 05:00:00") and the new auto-sync renderer was parsing it with plain `new Date(...)`, which treats unmarked timestamps as local time. On Pacific time that offsets the displayed countdown by ~8 hours. Auto-Sync now routes through the existing `_autoParseUTC` helper that the rest of the Automations page already uses, so countdowns line up with the wall clock. A separate correctness fix in the automation update API: when a PUT changes `trigger_type` or `trigger_config`, the stored `next_run` is now blanked before the engine reschedules. Previously the scheduler's restart-survival path would preserve a stale future timestamp from the prior interval, so dragging a playlist from the 8h column to the 1h column kept firing at the old 8h mark. Boot-time restart behavior is unchanged — only user-driven schedule changes reset the clock. Modal restyle: the Auto-Sync manager's hardcoded sky-blue palette is replaced with `var(--accent-rgb)` everywhere so the modal honors the user's chosen accent color. Tinted glow on the modal border, tabbed header active state, scheduled-playlist chips, scrollbars, and a new drag-over highlight on columns all follow the accent theme. The column drag-over state is wired through new ondragleave handling so the highlight clears reliably when leaving a column. --- core/automation/api.py | 7 ++++ webui/static/helper.js | 5 +++ webui/static/stats-automations.js | 17 +++++++- webui/static/style.css | 69 ++++++++++++++++++++++--------- 4 files changed, 76 insertions(+), 22 deletions(-) diff --git a/core/automation/api.py b/core/automation/api.py index a6f78afe..a99e08ce 100644 --- a/core/automation/api.py +++ b/core/automation/api.py @@ -225,6 +225,13 @@ def update_automation( if cycle_path: return {'error': f'Signal cycle detected: {cycle_path}. This would cause an infinite loop.'}, 400 + trigger_changed = ( + 'trigger_type' in update_fields + or 'trigger_config' in update_fields + ) + if trigger_changed: + update_fields['next_run'] = None + success = database.update_automation(automation_id, **update_fields) if not success: return {'error': 'Automation not found'}, 404 diff --git a/webui/static/helper.js b/webui/static/helper.js index 3f9568f6..1e304a8b 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3413,6 +3413,11 @@ function closeHelperSearch() { // projects that span multiple commits before shipping. Strip the flag at // release time and add a real `date:` line at the top of the version block. const WHATS_NEW = { + '2.6.2': [ + { date: 'May 24, 2026 — 2.6.2 release' }, + { title: 'Fix: Auto-Sync "next in 8h" timezone bug', desc: 'scheduled Auto-Sync playlists were all showing "next in 8h" regardless of the interval — Every 1 hour, Every 1 day, anything. backend stores next-run as a naive UTC string and the frontend was parsing it as local time, which on Pacific time offset the displayed countdown by ~8 hours. Auto-Sync now uses the existing UTC-aware parser the rest of the Automations page already uses. as a separate correctness fix, the automation update endpoint also now blanks the stored next-run whenever the trigger type or trigger config changes, so the engine recomputes from scratch instead of preserving a leftover timestamp from the previous schedule.' }, + { title: 'Auto-Sync modal restyled to your accent color', desc: 'the Playlist Auto-Sync manager now picks up your chosen accent (the same one used everywhere else in the app) instead of the hardcoded sky-blue palette it shipped with. tabs, drop zones, scheduled-playlist chips, scrollbars, and the modal glow all follow the accent theme. drop targets also light up clearly while dragging.' }, + ], '2.6.1': [ { date: 'May 24, 2026 — 2.6.1 release' }, { title: 'React Import page polish', desc: 'Import now runs through the React route stack with album, singles, and auto-import tabs plus the state fixes needed for reliable Vite builds.' }, diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 25bc01ce..fa22d51b 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -818,7 +818,7 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) { const bucketHtml = AUTO_SYNC_BUCKETS.map(hours => { const assigned = schedulablePlaylists.filter(p => playlistSchedules[p.id]?.hours === hours); return ` -
+
${autoSyncBucketLabel(hours)} ${assigned.length} playlist${assigned.length === 1 ? '' : 's'} @@ -911,7 +911,7 @@ function autoSyncScheduledCardHtml(playlist, schedule) { function autoSyncNextRunLabel(nextRun) { if (!nextRun) return ''; - const ts = new Date(nextRun).getTime(); + const ts = _autoParseUTC(nextRun); if (!Number.isFinite(ts)) return ''; const diff = ts - Date.now(); if (diff <= 0) return 'due now'; @@ -932,10 +932,23 @@ function autoSyncDragStart(event) { function autoSyncDragOver(event) { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; + const col = event.currentTarget; + if (col && !col.classList.contains('drag-over')) { + col.classList.add('drag-over'); + } +} + +function autoSyncDragLeave(event) { + const col = event.currentTarget; + if (!col) return; + if (col.contains(event.relatedTarget)) return; + col.classList.remove('drag-over'); } async function autoSyncDrop(event, hours) { event.preventDefault(); + const col = event.currentTarget; + if (col) col.classList.remove('drag-over'); const playlistId = parseInt(event.dataTransfer.getData('text/plain'), 10); if (!playlistId) return; await saveAutoSyncPlaylistSchedule(playlistId, hours); diff --git a/webui/static/style.css b/webui/static/style.css index d32f772d..f2784d42 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -11179,9 +11179,15 @@ body.helper-mode-active #dashboard-activity-feed:hover { .sync-history-btn:hover { color: rgb(var(--accent-rgb)); background: rgba(var(--accent-rgb), 0.1); border-color: rgba(var(--accent-rgb), 0.3); } .auto-sync-manager-btn { - color: #7dd3fc; - border-color: rgba(56, 189, 248, 0.28); - background: rgba(56, 189, 248, 0.1); + color: rgb(var(--accent-light-rgb)); + border-color: rgba(var(--accent-rgb), 0.32); + background: rgba(var(--accent-rgb), 0.12); +} + +.auto-sync-manager-btn:hover { + border-color: rgba(var(--accent-rgb), 0.5); + background: rgba(var(--accent-rgb), 0.2); + color: rgb(var(--accent-neon-rgb)); } .auto-sync-overlay { @@ -11198,10 +11204,14 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-modal { width: min(1500px, calc(100vw - 40px)); height: min(860px, calc(100vh - 40px)); - background: rgba(17, 19, 27, 0.98); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 8px; - box-shadow: 0 28px 80px rgba(0, 0, 0, 0.5); + background: + radial-gradient(circle at top right, rgba(var(--accent-rgb), 0.08) 0%, transparent 60%), + rgba(17, 19, 27, 0.98); + border: 1px solid rgba(var(--accent-rgb), 0.2); + border-radius: 10px; + box-shadow: + 0 28px 80px rgba(0, 0, 0, 0.5), + 0 0 60px rgba(var(--accent-rgb), 0.08); display: flex; flex-direction: column; overflow: hidden; @@ -11219,10 +11229,11 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-eyebrow { margin-bottom: 6px; - color: #7dd3fc; + color: rgb(var(--accent-light-rgb)); font-size: 11px; font-weight: 800; text-transform: uppercase; + letter-spacing: 0.06em; } .auto-sync-header h3 { @@ -11308,9 +11319,10 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-tabs button:hover, .auto-sync-tabs button.active { - border-color: rgba(56, 189, 248, 0.35); - background: rgba(56, 189, 248, 0.12); - color: #e0f2fe; + border-color: rgba(var(--accent-rgb), 0.4); + background: rgba(var(--accent-rgb), 0.14); + color: rgb(var(--accent-neon-rgb)); + box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.08), 0 2px 10px rgba(var(--accent-rgb), 0.12); } .auto-sync-tab-panel { @@ -11331,8 +11343,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { justify-content: space-between; gap: 16px; padding: 12px 18px; - border-bottom: 1px solid rgba(255, 255, 255, 0.07); - background: rgba(56, 189, 248, 0.035); + border-bottom: 1px solid rgba(var(--accent-rgb), 0.16); + background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.08) 0%, rgba(var(--accent-rgb), 0.03) 60%, transparent 100%); flex-shrink: 0; } @@ -11423,12 +11435,14 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-playlist:hover, .auto-sync-scheduled-card:hover { - border-color: rgba(56, 189, 248, 0.32); - background: rgba(56, 189, 248, 0.08); + border-color: rgba(var(--accent-rgb), 0.4); + background: rgba(var(--accent-rgb), 0.1); + box-shadow: 0 4px 14px rgba(var(--accent-rgb), 0.12); } .auto-sync-playlist.scheduled { - border-color: rgba(34, 197, 94, 0.22); + border-color: rgba(var(--accent-rgb), 0.3); + background: rgba(var(--accent-rgb), 0.05); } .auto-sync-playlist.unavailable { @@ -11480,8 +11494,9 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-scheduled-timing span { - background: rgba(56, 189, 248, 0.14); - color: #7dd3fc; + background: rgba(var(--accent-rgb), 0.18); + color: rgb(var(--accent-light-rgb)); + border: 1px solid rgba(var(--accent-rgb), 0.25); } .auto-sync-scheduled-timing small { @@ -11510,6 +11525,13 @@ body.helper-mode-active #dashboard-activity-feed:hover { background: rgba(255, 255, 255, 0.025); display: flex; flex-direction: column; + transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease; +} + +.auto-sync-column.drag-over { + border-color: rgba(var(--accent-rgb), 0.6); + background: rgba(var(--accent-rgb), 0.08); + box-shadow: inset 0 0 0 1px rgba(var(--accent-rgb), 0.3), 0 6px 20px rgba(var(--accent-rgb), 0.15); } .auto-sync-column-head { @@ -11524,7 +11546,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-column-head small { - color: #7dd3fc; + color: rgb(var(--accent-light-rgb)); font-size: 11px; font-weight: 700; } @@ -11548,10 +11570,17 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-board::-webkit-scrollbar-thumb, .auto-sync-source-list::-webkit-scrollbar-thumb, .auto-sync-automation-list::-webkit-scrollbar-thumb { - background: rgba(125, 211, 252, 0.22); + background: rgba(var(--accent-rgb), 0.3); border-radius: 999px; } +.auto-sync-column-list::-webkit-scrollbar-thumb:hover, +.auto-sync-board::-webkit-scrollbar-thumb:hover, +.auto-sync-source-list::-webkit-scrollbar-thumb:hover, +.auto-sync-automation-list::-webkit-scrollbar-thumb:hover { + background: rgba(var(--accent-rgb), 0.5); +} + .auto-sync-drop-hint, .auto-sync-empty, .auto-sync-loading, From 871feb3997bf3f0a1bfa84371f07b0d0b3436a0b Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 22:31:34 -0700 Subject: [PATCH 15/81] Speed up playlist sync with a lazy per-artist track pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Syncing a playlist where most tracks weren't in the library was burning ~30 SQL queries per missed track. `services/sync_service.py` walked each Spotify track through `check_track_exists` with no `candidate_tracks`, hitting the legacy title-variation × artist-variation grid in `database/music_database.py:6041-6069` for every miss. The `sync_match_cache` only covered matches, so misses re-paid the full lookup cost every sync. A 30-track playlist with a 30% match rate (Discover Weekly was 9/30 in the test run) was taking ~4m14s, almost entirely in the matching phase. `check_track_exists` already accepts a `candidate_tracks` kwarg that skips the SQL widening and scores against an in-memory list (the batched path at `music_database.py:6031`, originally added for artist discography iteration). The sync service just wasn't using it. This commit wires that path in via a lazy per-artist pool: - `sync_playlist` creates an empty `candidate_pool` dict and passes it to each `_find_track_in_media_server` call. - `_get_or_fetch_artist_candidates` runs SQL for an artist only on the first track that needs them — playlists where every track is already in `sync_match_cache` pay zero pool cost (no upfront delay). - Subsequent misses for the same artist hit the memoized list and skip the per-variation SQL grid. - Artists with no library tracks still get a cached empty list, which triggers the batched path's instant short-circuit instead of falling into the SQL widening. - Any pool fetch failure returns None so the caller falls through to the original per-track SQL loop, so the worst case is the old behavior, never a regression. On a 30-track / 25-unique-artist playlist with a cold cache the SQL fan-out drops from ~900 queries to ~25; with a warm cache it drops to zero (no pool fetches at all). Applies to every entry point that goes through `sync_playlist`: manual sync, auto-sync schedules, the `playlist_pipeline` automation action, and the Sync All button. --- services/sync_service.py | 60 ++++++++++++++++++++++++++++++++++------ webui/static/helper.js | 1 + 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/services/sync_service.py b/services/sync_service.py index 5177c59d..7ded8400 100644 --- a/services/sync_service.py +++ b/services/sync_service.py @@ -213,13 +213,21 @@ class PlaylistSyncService: media_client, server_type = self._get_active_media_client() self._update_progress(playlist.name, f"Matching tracks against {server_type.title()} library", "", 20, 5, 2, total_tracks=total_tracks) - + + # Per-artist track pool, populated lazily inside _find_track_in_media_server. + # Only tracks that miss the sync_match_cache fast-path trigger a pool fetch + # for their artist — so warm-cache playlists pay zero pool cost. Misses + # for the same artist later in the playlist reuse the cached list and skip + # the per-variation SQL grid in check_track_exists. Empty dict (not None) + # to signal that pooling is enabled for this sync. + candidate_pool: Dict[str, list] = {} + # Use the same robust matching approach as "Download Missing Tracks" match_results = [] for i, track in enumerate(playlist.tracks): if self._cancelled: return self._create_error_result(playlist.name, ["Sync cancelled"]) - + # Update progress for each track progress_percent = 20 + (40 * (i + 1) / total_tracks) # 20-60% for matching # Extract artist name from both string and dict formats @@ -229,13 +237,13 @@ class PlaylistSyncService: current_track_name = f"{artist_name} - {track.name}" else: current_track_name = track.name - self._update_progress(playlist.name, "Matching tracks", current_track_name, progress_percent, 5, 2, + self._update_progress(playlist.name, "Matching tracks", current_track_name, progress_percent, 5, 2, total_tracks=total_tracks, matched_tracks=len([r for r in match_results if r.is_match]), failed_tracks=len([r for r in match_results if not r.is_match])) - + # Use the robust search approach - plex_match, confidence = await self._find_track_in_media_server(track) + plex_match, confidence = await self._find_track_in_media_server(track, candidate_pool=candidate_pool) match_result = MatchResult( spotify_track=track, @@ -469,7 +477,36 @@ class PlaylistSyncService: self.clear_progress_callback(playlist.name) self._cancelled = False - async def _find_track_in_media_server(self, spotify_track: SpotifyTrack) -> Tuple[Optional[TrackInfo], float]: + def _get_or_fetch_artist_candidates(self, candidate_pool: Optional[Dict[str, list]], db, artist_name: str, active_server) -> Optional[list]: + """Lazy per-artist pool fetch. Only fires SQL when a track for this artist + actually missed the sync_match_cache fast-path — playlists where every + track is cached pay zero pool cost. Returns the candidate list (possibly + empty) on success; returns None when pooling is disabled so the caller + falls back to the legacy per-track SQL loop. + """ + if candidate_pool is None: + return None + key = db._normalize_for_comparison(artist_name) + if key in candidate_pool: + return candidate_pool[key] + try: + candidates = db.search_tracks( + artist=artist_name, + limit=10000, + server_source=active_server, + ) + # Cache the empty result too — same key is asked once per artist this + # sync, then never again. Empty list still triggers the batched path + # in check_track_exists, which short-circuits without firing SQL. + candidate_pool[key] = candidates or [] + return candidate_pool[key] + except Exception as fetch_err: + logger.debug(f"Candidate pool fetch failed for '{artist_name}': {fetch_err}") + # Don't cache the failure — let a later artist for the same key retry, + # and let this call's check_track_exists fall through to legacy SQL. + return None + + async def _find_track_in_media_server(self, spotify_track: SpotifyTrack, candidate_pool: Optional[Dict[str, list]] = None) -> Tuple[Optional[TrackInfo], float]: """Find a track using the same improved database matching as Download Missing Tracks modal""" try: # Check active media server connection @@ -477,7 +514,7 @@ class PlaylistSyncService: if not media_client or not media_client.is_connected(): logger.warning(f"{server_type.upper()} client not connected") return None, 0.0 - + # Use the SAME improved database matching as PlaylistTrackAnalysisWorker from database.music_database import MusicDatabase from config.settings import config_manager @@ -542,7 +579,14 @@ class PlaylistSyncService: # Use the improved database check_track_exists method with server awareness try: db = MusicDatabase() - db_track, confidence = db.check_track_exists(original_title, artist_name, confidence_threshold=0.7, server_source=active_server) + artist_candidates = self._get_or_fetch_artist_candidates( + candidate_pool, db, artist_name, active_server, + ) + db_track, confidence = db.check_track_exists( + original_title, artist_name, + confidence_threshold=0.7, server_source=active_server, + candidate_tracks=artist_candidates, + ) if db_track and confidence >= 0.7: logger.debug(f"Database match found for '{original_title}' by '{artist_name}': '{db_track.title}' with confidence {confidence:.2f}") diff --git a/webui/static/helper.js b/webui/static/helper.js index 1e304a8b..c6859090 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3415,6 +3415,7 @@ function closeHelperSearch() { const WHATS_NEW = { '2.6.2': [ { date: 'May 24, 2026 — 2.6.2 release' }, + { title: 'Playlist sync is way faster on partial-match playlists', desc: 'syncing a playlist where most tracks weren\'t in the library used to take forever — a 30-track playlist with only 9 matches was burning 4+ minutes because every unmatched track ran the full title-variation × artist-variation SQL grid against the tracks table (~30 SQL queries per missed track). cache only covered matches, so misses re-paid the cost every sync. sync now uses a per-artist track pool that fills in lazily — only tracks that miss the sync match cache trigger a one-time fetch of their artist\'s library tracks, and later misses for the same artist reuse the in-memory list. playlists where every track is already cached pay zero pool cost (no upfront delay). benefits every sync entry point — manual, auto-sync, the playlist_pipeline automation action, and the Sync All button.' }, { title: 'Fix: Auto-Sync "next in 8h" timezone bug', desc: 'scheduled Auto-Sync playlists were all showing "next in 8h" regardless of the interval — Every 1 hour, Every 1 day, anything. backend stores next-run as a naive UTC string and the frontend was parsing it as local time, which on Pacific time offset the displayed countdown by ~8 hours. Auto-Sync now uses the existing UTC-aware parser the rest of the Automations page already uses. as a separate correctness fix, the automation update endpoint also now blanks the stored next-run whenever the trigger type or trigger config changes, so the engine recomputes from scratch instead of preserving a leftover timestamp from the previous schedule.' }, { title: 'Auto-Sync modal restyled to your accent color', desc: 'the Playlist Auto-Sync manager now picks up your chosen accent (the same one used everywhere else in the app) instead of the hardcoded sky-blue palette it shipped with. tabs, drop zones, scheduled-playlist chips, scrollbars, and the modal glow all follow the accent theme. drop targets also light up clearly while dragging.' }, ], From 687bb0ca2ca9d754fea2125d249f85814c722826 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 22:58:48 -0700 Subject: [PATCH 16/81] Add tests for next_run reset and lazy candidate pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tests/automation/test_automation_api.py` gains three update_automation tests covering the schedule-shape reset: - trigger_config change blanks next_run - trigger_type change blanks next_run - non-trigger field (name) leaves next_run alone `tests/sync/test_sync_candidate_pool.py` is new — nine tests for the lazy artist track pool in PlaylistSyncService: - candidate_pool=None disables pooling and skips the DB call - first lookup for an artist fetches and caches - second lookup for the same artist reuses the cache (zero DB calls) - empty result still cached so the next call short-circuits without SQL - defensive None return coerced to [] - search_tracks exception returns None and does NOT poison the cache - pool key is normalized so casing variants share a single fetch - different artists get separate pool entries - server_source plumbing survives the trip to search_tracks All assertions go through fakes / MagicMock — no real DB, no web_server.py import, no AST-parsing. --- tests/automation/test_automation_api.py | 50 +++++++ tests/sync/test_sync_candidate_pool.py | 167 ++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 tests/sync/test_sync_candidate_pool.py diff --git a/tests/automation/test_automation_api.py b/tests/automation/test_automation_api.py index fea7a032..12cbf05e 100644 --- a/tests/automation/test_automation_api.py +++ b/tests/automation/test_automation_api.py @@ -289,6 +289,56 @@ def test_update_then_actions_clears_notify_when_empty(): assert db.automations[1]['notify_config'] == '{}' +def test_update_trigger_config_change_resets_next_run(): + """Changing the interval must blank stored next_run so the engine + recomputes from scratch — otherwise dragging a playlist from the 8h + Auto-Sync column to the 1h column keeps firing at the old 8h mark.""" + db = _FakeDB() + db.automations[1] = { + 'id': 1, 'name': 'a', 'enabled': 1, 'is_system': 0, + 'trigger_type': 'schedule', + 'trigger_config': '{"interval": 8, "unit": "hours"}', + 'then_actions': '[]', + 'next_run': '2026-05-25 05:00:00', + } + eng = _FakeEngine() + body, status = api.update_automation(db, eng, automation_id=1, data={ + 'trigger_config': {'interval': 1, 'unit': 'hours'}, + }) + assert status == 200 + assert db.automations[1]['next_run'] is None + assert eng.scheduled == [1] + + +def test_update_trigger_type_change_resets_next_run(): + """Switching from interval to daily_time must also reset next_run.""" + db = _FakeDB() + db.automations[1] = { + 'id': 1, 'name': 'a', 'enabled': 1, 'is_system': 0, + 'trigger_type': 'schedule', 'trigger_config': '{}', + 'then_actions': '[]', + 'next_run': '2026-05-25 05:00:00', + } + api.update_automation(db, _FakeEngine(), automation_id=1, data={ + 'trigger_type': 'daily_time', + }) + assert db.automations[1]['next_run'] is None + + +def test_update_non_trigger_field_preserves_next_run(): + """Renaming an automation must NOT disturb its scheduled clock — + the reset is scoped to schedule-shape changes only.""" + db = _FakeDB() + db.automations[1] = { + 'id': 1, 'name': 'a', 'enabled': 1, 'is_system': 0, + 'trigger_type': 'schedule', 'trigger_config': '{}', + 'then_actions': '[]', + 'next_run': '2026-05-25 05:00:00', + } + api.update_automation(db, _FakeEngine(), automation_id=1, data={'name': 'renamed'}) + assert db.automations[1].get('next_run') == '2026-05-25 05:00:00' + + # --------------------------------------------------------------------------- # batch_update_group # --------------------------------------------------------------------------- diff --git a/tests/sync/test_sync_candidate_pool.py b/tests/sync/test_sync_candidate_pool.py new file mode 100644 index 00000000..ec43c450 --- /dev/null +++ b/tests/sync/test_sync_candidate_pool.py @@ -0,0 +1,167 @@ +"""Tests for the lazy per-artist candidate pool in PlaylistSyncService. + +The pool replaces a per-track SQL storm: instead of running ~30 +title-variation × artist-variation queries for every playlist track, +sync now fetches each unique artist's library tracks once and feeds the +matcher via the in-memory `candidate_tracks` path. The fetch is *lazy* +— it only fires when a track actually misses the sync_match_cache, +so warm-cache playlists pay zero pool cost. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from services.sync_service import PlaylistSyncService + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +def _make_service() -> PlaylistSyncService: + """Bare PlaylistSyncService — pool helper doesn't touch service state.""" + return PlaylistSyncService( + spotify_client=MagicMock(), + download_orchestrator=MagicMock(), + media_server_engine=MagicMock(), + ) + + +def _make_db_stub(search_returns=None, raise_on_search=None) -> MagicMock: + """MusicDatabase stub mirroring the contract the helper relies on: + - _normalize_for_comparison returns a lower-cased key + - search_tracks returns a list (or raises) + """ + db = MagicMock() + db._normalize_for_comparison.side_effect = lambda s: s.lower().strip() + if raise_on_search is not None: + db.search_tracks.side_effect = raise_on_search + else: + db.search_tracks.return_value = search_returns if search_returns is not None else [] + return db + + +# --------------------------------------------------------------------------- +# Pooling disabled — legacy fallback +# --------------------------------------------------------------------------- + +def test_returns_none_when_pool_disabled(): + """candidate_pool=None signals callers to fall through to the legacy + per-track SQL loop. Helper must not touch the DB.""" + svc = _make_service() + db = _make_db_stub() + result = svc._get_or_fetch_artist_candidates(None, db, 'Drake', 'plex') + assert result is None + db.search_tracks.assert_not_called() + + +# --------------------------------------------------------------------------- +# Lazy population +# --------------------------------------------------------------------------- + +def test_first_call_for_artist_runs_search_and_caches(): + svc = _make_service() + pool: dict = {} + db = _make_db_stub(search_returns=['t1', 't2']) + result = svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex') + assert result == ['t1', 't2'] + assert pool == {'drake': ['t1', 't2']} + db.search_tracks.assert_called_once_with( + artist='Drake', limit=10000, server_source='plex', + ) + + +def test_second_call_for_same_artist_reuses_cache(): + """Once an artist's pool is populated, subsequent lookups must not + re-fetch — that's the whole perf point of the pool.""" + svc = _make_service() + pool = {'drake': ['cached']} + db = _make_db_stub(search_returns=['fresh']) + result = svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex') + assert result == ['cached'] + db.search_tracks.assert_not_called() + + +def test_empty_result_is_still_cached(): + """Artist not in library → empty list cached. Next call short-circuits + via check_track_exists' batched path without firing SQL.""" + svc = _make_service() + pool: dict = {} + db = _make_db_stub(search_returns=[]) + result = svc._get_or_fetch_artist_candidates(pool, db, 'Obscure', 'plex') + assert result == [] + assert pool == {'obscure': []} + + +def test_none_return_normalized_to_empty_list(): + """Defensive — if search_tracks ever returns None, helper must coerce + to [] so the cached value is still a valid iterable for the matcher.""" + svc = _make_service() + pool: dict = {} + db = _make_db_stub() + db.search_tracks.return_value = None + result = svc._get_or_fetch_artist_candidates(pool, db, 'Anyone', 'plex') + assert result == [] + assert pool == {'anyone': []} + + +# --------------------------------------------------------------------------- +# Failure modes +# --------------------------------------------------------------------------- + +def test_fetch_failure_returns_none_and_does_not_cache(): + """A pool fetch exception must not poison the dict — the per-track + legacy path still has a chance to run for this track, and a later + track for the same artist can retry the fetch.""" + svc = _make_service() + pool: dict = {} + db = _make_db_stub(raise_on_search=RuntimeError('DB exploded')) + result = svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex') + assert result is None + assert pool == {} + + +# --------------------------------------------------------------------------- +# Normalization +# --------------------------------------------------------------------------- + +def test_pool_key_is_normalized_so_casing_variants_share_one_fetch(): + """'Drake' and 'DRAKE' must hash to the same pool entry — otherwise + a playlist that mixes casing would re-fetch the same artist twice.""" + svc = _make_service() + pool: dict = {} + db = _make_db_stub(search_returns=['t']) + svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex') + db.search_tracks.reset_mock() + result = svc._get_or_fetch_artist_candidates(pool, db, 'DRAKE', 'plex') + assert result == ['t'] + db.search_tracks.assert_not_called() + + +def test_different_artists_get_separate_pool_entries(): + svc = _make_service() + pool: dict = {} + db = _make_db_stub() + db.search_tracks.side_effect = [['drake-track'], ['sza-track']] + svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex') + svc._get_or_fetch_artist_candidates(pool, db, 'SZA', 'plex') + assert pool == {'drake': ['drake-track'], 'sza': ['sza-track']} + assert db.search_tracks.call_count == 2 + + +# --------------------------------------------------------------------------- +# Server source plumbing +# --------------------------------------------------------------------------- + +def test_active_server_is_passed_through_to_search_tracks(): + """Misrouting server_source would make the pool include tracks from + the wrong server (e.g. Plex tracks in a Jellyfin sync) — verify it + survives the trip.""" + svc = _make_service() + pool: dict = {} + db = _make_db_stub(search_returns=['t']) + svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'jellyfin') + db.search_tracks.assert_called_once_with( + artist='Drake', limit=10000, server_source='jellyfin', + ) From feb6778af4c1f1016250f15bc5bbbe6b944138e5 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 23:33:55 -0700 Subject: [PATCH 17/81] Address Cin review: extract helpers, indexed pool fetch, tidy nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes folded into one perf+cleanup pass: 1. Indexed fast path for the per-artist pool fetch. The previous `search_tracks(artist=name)` call hit `unidecode_lower(artists.name) LIKE ?`, a function-in-WHERE that can't use `idx_artists_name`. New `MusicDatabase.get_artist_tracks_indexed` does a two-step lookup: exact-name match (indexed) plus a case-insensitive fallback, then `tracks WHERE artist_id IN (...)` via `idx_tracks_artist_id`. Drops per-artist fetch from seconds to milliseconds for the common case. The sync helper falls back to the old LIKE-based `search_tracks` only when the indexed lookup finds nothing, preserving diacritic recall and `tracks.track_artist` feature-artist matches with zero regression. 2. Public text-normalization helper. Lifted the body of `MusicDatabase._normalize_for_comparison` into `core/text/normalize.py:normalize_for_comparison` so callers outside the database layer (matching engine, sync pool, future import-side comparisons) don't reach across the module boundary into a leading-underscore "private" method. The DB method now delegates, so existing internal call sites stay untouched. Sync's lazy pool now imports the public helper. 3. Artist-name walker extracted. `_artist_name` at module level in `services/sync_service.py` replaces two near-identical inline str-or-dict-or-fallback walkers (one in `sync_playlist`, one in `_find_track_in_media_server`). Returns `''` for None instead of the literal string `'None'`. Plus three small tidies from the same review: - `_POOL_FETCH_LIMIT = 10000` constant in place of the literal at the pool-fetch call site. - Trimmed the verbose docstring + comment block on the pool helper. - Set-intersection predicate for the trigger-shape reset in `core/automation/api.py` instead of a two-line `or` chain. Also removed the duplicate `_get_active_media_client()` call at sync_service.py:212/214 — pre-existing wart that was sitting in the same block I was editing. Tests: 21 new tests across `tests/database/`, `tests/sync/`, and `tests/text/`, plus updates to the existing pool tests to cover the new fast/fallback split. Full suite stays green (3953 passing). --- core/automation/api.py | 9 +- core/text/__init__.py | 0 core/text/normalize.py | 41 ++++++ database/music_database.py | 70 +++++++--- services/sync_service.py | 80 ++++++----- .../test_get_artist_tracks_indexed.py | 127 ++++++++++++++++++ tests/sync/test_artist_name_extraction.py | 43 ++++++ tests/sync/test_sync_candidate_pool.py | 79 ++++++++--- tests/text/__init__.py | 0 tests/text/test_normalize.py | 38 ++++++ 10 files changed, 412 insertions(+), 75 deletions(-) create mode 100644 core/text/__init__.py create mode 100644 core/text/normalize.py create mode 100644 tests/database/test_get_artist_tracks_indexed.py create mode 100644 tests/sync/test_artist_name_extraction.py create mode 100644 tests/text/__init__.py create mode 100644 tests/text/test_normalize.py diff --git a/core/automation/api.py b/core/automation/api.py index a99e08ce..2b06cc21 100644 --- a/core/automation/api.py +++ b/core/automation/api.py @@ -225,11 +225,10 @@ def update_automation( if cycle_path: return {'error': f'Signal cycle detected: {cycle_path}. This would cause an infinite loop.'}, 400 - trigger_changed = ( - 'trigger_type' in update_fields - or 'trigger_config' in update_fields - ) - if trigger_changed: + # Schedule-shape changes must invalidate the stored next_run so the + # scheduler recomputes it; otherwise restart-survival logic keeps the + # leftover timestamp from the previous interval. + if {'trigger_type', 'trigger_config'} & update_fields.keys(): update_fields['next_run'] = None success = database.update_automation(automation_id, **update_fields) diff --git a/core/text/__init__.py b/core/text/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/core/text/normalize.py b/core/text/normalize.py new file mode 100644 index 00000000..6362ed64 --- /dev/null +++ b/core/text/normalize.py @@ -0,0 +1,41 @@ +"""Shared text-normalization helpers. + +Extracted from `MusicDatabase._normalize_for_comparison` so callers +outside the database layer (matching engine, sync candidate pool, +import comparisons) don't have to reach across the module boundary +into a leading-underscore "private" method. + +Pure functions, no I/O. +""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + +try: + from unidecode import unidecode as _unidecode + _HAS_UNIDECODE = True +except ImportError: + _unidecode = None # type: ignore[assignment] + _HAS_UNIDECODE = False + logger.warning("unidecode not available, accent matching may be limited") + + +def normalize_for_comparison(text: str) -> str: + """Lowercase + strip whitespace + fold accents to ASCII. + + ``é → e``, ``ñ → n``, ``Björk → bjork``. Used as the dictionary key + for the sync candidate pool and for fuzzy library lookups where + diacritic differences must NOT split a single artist into two pool + entries. + + Empty / falsy input returns ``""`` so callers can blindly key dicts + with the result. + """ + if not text: + return "" + if _HAS_UNIDECODE: + text = _unidecode(text) + return text.lower().strip() diff --git a/database/music_database.py b/database/music_database.py index 876904bd..f7b813b0 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -6370,6 +6370,54 @@ class MusicDatabase: logger.error(f"Error fetching candidate albums for artist '{artist}': {e}") return candidates + def get_artist_tracks_indexed(self, name: str, server_source: Optional[str] = None, limit: int = 10000) -> List[DatabaseTrack]: + """Indexed two-step lookup: artist_id by exact name (then case-insensitive + fallback), then tracks via `artist_id IN (...)`. Avoids the function-in-WHERE + pattern in search_tracks that defeats the artists.name index. Returns [] + when the artist isn't in the library — caller can decide to fall back to + the slower LIKE-based path for track_artist / diacritic recall.""" + if not name: + return [] + try: + conn = self._get_connection() + cursor = conn.cursor() + + # Step 1: exact case-sensitive match — hits idx_artists_name in O(log n). + # Spotify's canonical artist names match the library 90%+ of the time. + cursor.execute("SELECT id FROM artists WHERE name = ?", (name,)) + artist_ids = [r['id'] for r in cursor.fetchall()] + + # Step 2: case-insensitive fallback if exact missed. Full scan, but only + # runs on the (uncommon) miss path so amortized cost stays low. + if not artist_ids: + cursor.execute("SELECT id FROM artists WHERE LOWER(name) = LOWER(?)", (name,)) + artist_ids = [r['id'] for r in cursor.fetchall()] + + if not artist_ids: + return [] + + placeholders = ','.join('?' for _ in artist_ids) + where = f"t.artist_id IN ({placeholders})" + params: list = list(artist_ids) + if server_source: + where += " AND t.server_source = ?" + params.append(server_source) + params.append(limit) + + cursor.execute(f""" + SELECT t.*, a.name as artist_name, al.title as album_title, + al.thumb_url as album_thumb_url + FROM tracks t + JOIN artists a ON a.id = t.artist_id + JOIN albums al ON al.id = t.album_id + WHERE {where} + LIMIT ? + """, params) + return self._rows_to_tracks(cursor.fetchall()) + except Exception as e: + logger.error(f"Error fetching indexed artist tracks for '{name}': {e}") + return [] + def get_candidate_tracks_for_albums(self, album_ids: List) -> List[DatabaseTrack]: """ Fetch every track belonging to the given set of album IDs in a single query. @@ -6897,22 +6945,12 @@ class MusicDatabase: return unique_variations def _normalize_for_comparison(self, text: str) -> str: - """Normalize text for comparison with Unicode accent handling""" - if not text: - return "" - - # Try to use unidecode for accent normalization, fallback to basic if not available - try: - from unidecode import unidecode - # Convert accents: é→e, ñ→n, ü→u, etc. - normalized = unidecode(text) - except ImportError: - # Fallback: basic normalization without accent handling - normalized = text - logger.warning("unidecode not available, accent matching may be limited") - - # Convert to lowercase and strip - return normalized.lower().strip() + """Delegates to `core.text.normalize.normalize_for_comparison`. + Kept as an instance method so existing internal callers don't need + to be touched — new code should import the public helper directly. + """ + from core.text.normalize import normalize_for_comparison + return normalize_for_comparison(text) def _calculate_track_confidence(self, search_title: str, search_artist: str, db_track: DatabaseTrack) -> float: """Calculate confidence score for track match with enhanced cleaning and Unicode normalization""" diff --git a/services/sync_service.py b/services/sync_service.py index 7ded8400..e51980ef 100644 --- a/services/sync_service.py +++ b/services/sync_service.py @@ -10,6 +10,24 @@ from core.matching_engine import MusicMatchingEngine, MatchResult logger = get_logger("sync_service") +# Per-artist track pool cap. High enough that no plausible artist hits it +# (the largest catalogs in our test libraries sit in the low thousands), low +# enough to avoid pathological pulls if the DB ever returns garbage. +_POOL_FETCH_LIMIT = 10000 + + +def _artist_name(artist) -> str: + """Pull the display name out of a Spotify artist entry — they come back + as bare strings on some endpoints and ``{name: ...}`` dicts on others. + Falls back to ``str(artist)`` so callers never get None.""" + if isinstance(artist, str): + return artist + if isinstance(artist, dict): + name = artist.get('name') + if isinstance(name, str): + return name + return str(artist) if artist is not None else '' + @dataclass class SyncResult: playlist_name: str @@ -209,17 +227,12 @@ class PlaylistSyncService: return self._create_error_result(playlist.name, ["Sync cancelled"]) total_tracks = len(playlist.tracks) - media_client, server_type = self._get_active_media_client() - media_client, server_type = self._get_active_media_client() self._update_progress(playlist.name, f"Matching tracks against {server_type.title()} library", "", 20, 5, 2, total_tracks=total_tracks) - # Per-artist track pool, populated lazily inside _find_track_in_media_server. - # Only tracks that miss the sync_match_cache fast-path trigger a pool fetch - # for their artist — so warm-cache playlists pay zero pool cost. Misses - # for the same artist later in the playlist reuse the cached list and skip - # the per-variation SQL grid in check_track_exists. Empty dict (not None) - # to signal that pooling is enabled for this sync. + # Empty dict (not None) signals pooling is enabled for this sync; + # entries are filled lazily by `_find_track_in_media_server` so warm + # caches pay zero pool cost. candidate_pool: Dict[str, list] = {} # Use the same robust matching approach as "Download Missing Tracks" @@ -230,11 +243,8 @@ class PlaylistSyncService: # Update progress for each track progress_percent = 20 + (40 * (i + 1) / total_tracks) # 20-60% for matching - # Extract artist name from both string and dict formats if track.artists: - first_artist = track.artists[0] - artist_name = first_artist if isinstance(first_artist, str) else (first_artist.get('name', 'Unknown') if isinstance(first_artist, dict) else str(first_artist)) - current_track_name = f"{artist_name} - {track.name}" + current_track_name = f"{_artist_name(track.artists[0]) or 'Unknown'} - {track.name}" else: current_track_name = track.name self._update_progress(playlist.name, "Matching tracks", current_track_name, progress_percent, 5, 2, @@ -478,32 +488,38 @@ class PlaylistSyncService: self._cancelled = False def _get_or_fetch_artist_candidates(self, candidate_pool: Optional[Dict[str, list]], db, artist_name: str, active_server) -> Optional[list]: - """Lazy per-artist pool fetch. Only fires SQL when a track for this artist - actually missed the sync_match_cache fast-path — playlists where every - track is cached pay zero pool cost. Returns the candidate list (possibly - empty) on success; returns None when pooling is disabled so the caller - falls back to the legacy per-track SQL loop. - """ + """Lazy per-artist pool fetch. Returns None when pooling is off so the + caller falls back to the per-track SQL loop; otherwise returns the + cached list (possibly empty), fetching on first miss.""" if candidate_pool is None: return None - key = db._normalize_for_comparison(artist_name) + from core.text.normalize import normalize_for_comparison + key = normalize_for_comparison(artist_name) if key in candidate_pool: return candidate_pool[key] try: - candidates = db.search_tracks( - artist=artist_name, - limit=10000, + # Fast path — indexed artist_id lookup. Hits idx_artists_name + + # idx_tracks_artist_id, returns in milliseconds for both hits and + # misses. Handles the 90%+ exact-name case. + candidates = db.get_artist_tracks_indexed( + artist_name, server_source=active_server, + limit=_POOL_FETCH_LIMIT, ) - # Cache the empty result too — same key is asked once per artist this - # sync, then never again. Empty list still triggers the batched path - # in check_track_exists, which short-circuits without firing SQL. + # Slow-path fallback only when fast path found nothing. Preserves + # recall for diacritic variants and `tracks.track_artist` features + # (compilations / soundtracks where the per-track artist differs + # from the album artist). + if not candidates: + candidates = db.search_tracks( + artist=artist_name, + limit=_POOL_FETCH_LIMIT, + server_source=active_server, + ) candidate_pool[key] = candidates or [] return candidate_pool[key] except Exception as fetch_err: logger.debug(f"Candidate pool fetch failed for '{artist_name}': {fetch_err}") - # Don't cache the failure — let a later artist for the same key retry, - # and let this call's check_track_exists fall through to legacy SQL. return None async def _find_track_in_media_server(self, spotify_track: SpotifyTrack, candidate_pool: Optional[Dict[str, list]] = None) -> Tuple[Optional[TrackInfo], float]: @@ -568,14 +584,8 @@ class PlaylistSyncService: if self._cancelled: return None, 0.0 - # Extract artist name from both string and dict formats - if isinstance(artist, str): - artist_name = artist - elif isinstance(artist, dict) and 'name' in artist: - artist_name = artist['name'] - else: - artist_name = str(artist) - + artist_name = _artist_name(artist) + # Use the improved database check_track_exists method with server awareness try: db = MusicDatabase() diff --git a/tests/database/test_get_artist_tracks_indexed.py b/tests/database/test_get_artist_tracks_indexed.py new file mode 100644 index 00000000..729d94de --- /dev/null +++ b/tests/database/test_get_artist_tracks_indexed.py @@ -0,0 +1,127 @@ +"""Tests for `MusicDatabase.get_artist_tracks_indexed` — the indexed +two-step lookup that backs the sync candidate pool fast path.""" + +from __future__ import annotations + +from database.music_database import MusicDatabase + + +def _seed(db: MusicDatabase, rows): + """Insert (artist_name, album_title, track_title, server_source) tuples. + IDs are TEXT PRIMARY KEY in this schema so we hand-mint string IDs to + keep foreign-key wiring happy.""" + conn = db._get_connection() + cursor = conn.cursor() + artist_ids: dict = {} + album_ids: dict = {} + track_counter = 0 + for artist_name, album_title, track_title, server_source in rows: + if artist_name not in artist_ids: + aid = f"a-{len(artist_ids) + 1}" + cursor.execute( + "INSERT INTO artists (id, name, server_source) VALUES (?, ?, ?)", + (aid, artist_name, server_source), + ) + artist_ids[artist_name] = aid + album_key = (artist_name, album_title, server_source) + if album_key not in album_ids: + alid = f"al-{len(album_ids) + 1}" + cursor.execute( + "INSERT INTO albums (id, artist_id, title, server_source) VALUES (?, ?, ?, ?)", + (alid, artist_ids[artist_name], album_title, server_source), + ) + album_ids[album_key] = alid + track_counter += 1 + cursor.execute( + "INSERT INTO tracks (id, album_id, artist_id, title, server_source) VALUES (?, ?, ?, ?, ?)", + (f"t-{track_counter}", album_ids[album_key], artist_ids[artist_name], track_title, server_source), + ) + conn.commit() + + +def test_exact_name_match_returns_tracks(tmp_path): + db = MusicDatabase(str(tmp_path / "music.db")) + _seed(db, [ + ('Drake', 'For All The Dogs', 'First Person Shooter', 'plex'), + ('Drake', 'For All The Dogs', 'Slime You Out', 'plex'), + ('SZA', 'SOS', 'Kill Bill', 'plex'), + ]) + tracks = db.get_artist_tracks_indexed('Drake') + titles = sorted(t.title for t in tracks) + assert titles == ['First Person Shooter', 'Slime You Out'] + + +def test_case_insensitive_fallback_finds_artist(tmp_path): + """Exact match misses 'DRAKE' (case-sensitive index lookup), but the + fallback LOWER() comparison still finds the canonical 'Drake' row.""" + db = MusicDatabase(str(tmp_path / "music.db")) + _seed(db, [ + ('Drake', 'FATD', 'IDGAF', 'plex'), + ]) + tracks = db.get_artist_tracks_indexed('DRAKE') + assert len(tracks) == 1 + assert tracks[0].title == 'IDGAF' + + +def test_artist_absent_returns_empty_list(tmp_path): + """Genuinely missing artists must fall straight through both steps + and return [] — that's what lets the caller skip the slow LIKE + fallback when the artist isn't in the library at all.""" + db = MusicDatabase(str(tmp_path / "music.db")) + _seed(db, [ + ('Drake', 'FATD', 'IDGAF', 'plex'), + ]) + assert db.get_artist_tracks_indexed('Nonexistent Artist') == [] + + +def test_empty_name_returns_empty(tmp_path): + db = MusicDatabase(str(tmp_path / "music.db")) + assert db.get_artist_tracks_indexed('') == [] + + +def test_server_source_filter_excludes_other_servers(tmp_path): + """The pool is per-server — Plex sync must not see Jellyfin tracks + even when the artist exists on both.""" + db = MusicDatabase(str(tmp_path / "music.db")) + _seed(db, [ + ('Drake', 'Plex Album', 'Plex Track', 'plex'), + ('Drake', 'Jellyfin Album', 'Jellyfin Track', 'jellyfin'), + ]) + plex_tracks = db.get_artist_tracks_indexed('Drake', server_source='plex') + jellyfin_tracks = db.get_artist_tracks_indexed('Drake', server_source='jellyfin') + assert [t.title for t in plex_tracks] == ['Plex Track'] + assert [t.title for t in jellyfin_tracks] == ['Jellyfin Track'] + + +def test_no_server_filter_returns_all_servers(tmp_path): + db = MusicDatabase(str(tmp_path / "music.db")) + _seed(db, [ + ('Drake', 'Plex Album', 'Plex Track', 'plex'), + ('Drake', 'Jellyfin Album', 'Jellyfin Track', 'jellyfin'), + ]) + tracks = db.get_artist_tracks_indexed('Drake') + titles = sorted(t.title for t in tracks) + assert titles == ['Jellyfin Track', 'Plex Track'] + + +def test_limit_caps_result_set(tmp_path): + db = MusicDatabase(str(tmp_path / "music.db")) + _seed(db, [('Drake', 'Album', f'Track {i}', 'plex') for i in range(10)]) + tracks = db.get_artist_tracks_indexed('Drake', limit=3) + assert len(tracks) == 3 + + +def test_returned_tracks_carry_artist_and_album_fields(tmp_path): + """check_track_exists' batched path reads `artist_name` and + `album_title` off each track for confidence scoring — verify the + indexed query attaches them like search_tracks does.""" + db = MusicDatabase(str(tmp_path / "music.db")) + _seed(db, [ + ('Drake', 'For All The Dogs', 'First Person Shooter', 'plex'), + ]) + tracks = db.get_artist_tracks_indexed('Drake') + assert len(tracks) == 1 + t = tracks[0] + assert t.artist_name == 'Drake' + assert t.album_title == 'For All The Dogs' + assert t.title == 'First Person Shooter' diff --git a/tests/sync/test_artist_name_extraction.py b/tests/sync/test_artist_name_extraction.py new file mode 100644 index 00000000..6a2fcb4a --- /dev/null +++ b/tests/sync/test_artist_name_extraction.py @@ -0,0 +1,43 @@ +"""Tests for `_artist_name` in services/sync_service.py — the helper +that pulls a string name out of Spotify's bare-string / dict / fallback +artist representations.""" + +from services.sync_service import _artist_name + + +def test_bare_string_returned_as_is(): + assert _artist_name('Drake') == 'Drake' + + +def test_dict_with_name_field(): + assert _artist_name({'name': 'Drake', 'id': '3TVXt'}) == 'Drake' + + +def test_dict_without_name_field_falls_back_to_str_repr(): + """Missing name field shouldn't crash — caller should still get a + string back, even if it's the awkward dict repr.""" + out = _artist_name({'id': '3TVXt'}) + assert isinstance(out, str) + assert out != '' + + +def test_dict_with_non_string_name_falls_back(): + """Defensive — if some endpoint ever returns {name: None} or a list, + the helper must not propagate the bad type.""" + out = _artist_name({'name': None}) + assert isinstance(out, str) + + +def test_none_returns_empty_string(): + assert _artist_name(None) == '' + + +def test_unexpected_type_returns_string_repr(): + """A weird type (int, custom object) must coerce to a string instead + of raising — sync iterates a lot of inputs and one bad row shouldn't + crash the whole loop.""" + assert _artist_name(12345) == '12345' + + +def test_empty_string_stays_empty(): + assert _artist_name('') == '' diff --git a/tests/sync/test_sync_candidate_pool.py b/tests/sync/test_sync_candidate_pool.py index ec43c450..d233b38d 100644 --- a/tests/sync/test_sync_candidate_pool.py +++ b/tests/sync/test_sync_candidate_pool.py @@ -28,15 +28,19 @@ def _make_service() -> PlaylistSyncService: ) -def _make_db_stub(search_returns=None, raise_on_search=None) -> MagicMock: +def _make_db_stub(indexed_returns=None, search_returns=None, raise_on_search=None) -> MagicMock: """MusicDatabase stub mirroring the contract the helper relies on: - - _normalize_for_comparison returns a lower-cased key - - search_tracks returns a list (or raises) + - get_artist_tracks_indexed is the fast path (indexed artist_id lookup) + - search_tracks is the slow LIKE-based fallback for recall edge cases + + Pool-key normalization runs through `core.text.normalize` directly, + not through the db, so no `_normalize_for_comparison` stub is needed. """ db = MagicMock() - db._normalize_for_comparison.side_effect = lambda s: s.lower().strip() + db.get_artist_tracks_indexed.return_value = indexed_returns if indexed_returns is not None else [] if raise_on_search is not None: db.search_tracks.side_effect = raise_on_search + db.get_artist_tracks_indexed.side_effect = raise_on_search else: db.search_tracks.return_value = search_returns if search_returns is not None else [] return db @@ -54,21 +58,43 @@ def test_returns_none_when_pool_disabled(): result = svc._get_or_fetch_artist_candidates(None, db, 'Drake', 'plex') assert result is None db.search_tracks.assert_not_called() + db.get_artist_tracks_indexed.assert_not_called() # --------------------------------------------------------------------------- # Lazy population # --------------------------------------------------------------------------- -def test_first_call_for_artist_runs_search_and_caches(): +def test_indexed_fast_path_hits_skip_the_like_fallback(): + """When the indexed lookup finds tracks, the LIKE-based fallback must + NOT run — that's the whole perf point of the fast path.""" svc = _make_service() pool: dict = {} - db = _make_db_stub(search_returns=['t1', 't2']) + db = _make_db_stub(indexed_returns=['t1', 't2']) result = svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex') assert result == ['t1', 't2'] assert pool == {'drake': ['t1', 't2']} + db.get_artist_tracks_indexed.assert_called_once_with( + 'Drake', server_source='plex', limit=10000, + ) + db.search_tracks.assert_not_called() + + +def test_like_fallback_runs_when_indexed_returns_empty(): + """Diacritics / featured-artist recall lives in the LIKE path. The + helper must fall through to search_tracks when the indexed lookup + finds nothing, otherwise sync regresses on those cases. Note that + the pool key is accent-folded (`Beyoncé` → `beyonce`) so library + spellings with/without diacritics share one entry.""" + svc = _make_service() + pool: dict = {} + db = _make_db_stub(indexed_returns=[], search_returns=['feature-track']) + result = svc._get_or_fetch_artist_candidates(pool, db, 'Beyoncé', 'plex') + assert result == ['feature-track'] + assert pool == {'beyonce': ['feature-track']} + db.get_artist_tracks_indexed.assert_called_once() db.search_tracks.assert_called_once_with( - artist='Drake', limit=10000, server_source='plex', + artist='Beyoncé', limit=10000, server_source='plex', ) @@ -77,29 +103,31 @@ def test_second_call_for_same_artist_reuses_cache(): re-fetch — that's the whole perf point of the pool.""" svc = _make_service() pool = {'drake': ['cached']} - db = _make_db_stub(search_returns=['fresh']) + db = _make_db_stub(indexed_returns=['fresh'], search_returns=['stale']) result = svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex') assert result == ['cached'] + db.get_artist_tracks_indexed.assert_not_called() db.search_tracks.assert_not_called() -def test_empty_result_is_still_cached(): - """Artist not in library → empty list cached. Next call short-circuits - via check_track_exists' batched path without firing SQL.""" +def test_artist_absent_from_library_cached_as_empty_list(): + """Both paths return [] → cache [] so the next call short-circuits + via check_track_exists' batched path without firing SQL again.""" svc = _make_service() pool: dict = {} - db = _make_db_stub(search_returns=[]) + db = _make_db_stub(indexed_returns=[], search_returns=[]) result = svc._get_or_fetch_artist_candidates(pool, db, 'Obscure', 'plex') assert result == [] assert pool == {'obscure': []} def test_none_return_normalized_to_empty_list(): - """Defensive — if search_tracks ever returns None, helper must coerce + """Defensive — if both paths ever return None, helper must coerce to [] so the cached value is still a valid iterable for the matcher.""" svc = _make_service() pool: dict = {} db = _make_db_stub() + db.get_artist_tracks_indexed.return_value = None db.search_tracks.return_value = None result = svc._get_or_fetch_artist_candidates(pool, db, 'Anyone', 'plex') assert result == [] @@ -131,11 +159,13 @@ def test_pool_key_is_normalized_so_casing_variants_share_one_fetch(): a playlist that mixes casing would re-fetch the same artist twice.""" svc = _make_service() pool: dict = {} - db = _make_db_stub(search_returns=['t']) + db = _make_db_stub(indexed_returns=['t']) svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex') + db.get_artist_tracks_indexed.reset_mock() db.search_tracks.reset_mock() result = svc._get_or_fetch_artist_candidates(pool, db, 'DRAKE', 'plex') assert result == ['t'] + db.get_artist_tracks_indexed.assert_not_called() db.search_tracks.assert_not_called() @@ -143,24 +173,35 @@ def test_different_artists_get_separate_pool_entries(): svc = _make_service() pool: dict = {} db = _make_db_stub() - db.search_tracks.side_effect = [['drake-track'], ['sza-track']] + db.get_artist_tracks_indexed.side_effect = [['drake-track'], ['sza-track']] svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex') svc._get_or_fetch_artist_candidates(pool, db, 'SZA', 'plex') assert pool == {'drake': ['drake-track'], 'sza': ['sza-track']} - assert db.search_tracks.call_count == 2 + assert db.get_artist_tracks_indexed.call_count == 2 # --------------------------------------------------------------------------- # Server source plumbing # --------------------------------------------------------------------------- -def test_active_server_is_passed_through_to_search_tracks(): +def test_active_server_is_passed_through_to_indexed_path(): """Misrouting server_source would make the pool include tracks from the wrong server (e.g. Plex tracks in a Jellyfin sync) — verify it - survives the trip.""" + survives the trip on the fast path.""" svc = _make_service() pool: dict = {} - db = _make_db_stub(search_returns=['t']) + db = _make_db_stub(indexed_returns=['t']) + svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'jellyfin') + db.get_artist_tracks_indexed.assert_called_once_with( + 'Drake', server_source='jellyfin', limit=10000, + ) + + +def test_active_server_is_passed_through_to_like_fallback(): + """Same server_source check for the slow LIKE-based fallback path.""" + svc = _make_service() + pool: dict = {} + db = _make_db_stub(indexed_returns=[], search_returns=['t']) svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'jellyfin') db.search_tracks.assert_called_once_with( artist='Drake', limit=10000, server_source='jellyfin', diff --git a/tests/text/__init__.py b/tests/text/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/text/test_normalize.py b/tests/text/test_normalize.py new file mode 100644 index 00000000..e2024f1c --- /dev/null +++ b/tests/text/test_normalize.py @@ -0,0 +1,38 @@ +"""Tests for `core.text.normalize.normalize_for_comparison`.""" + +from core.text.normalize import normalize_for_comparison + + +def test_empty_input_returns_empty_string(): + assert normalize_for_comparison("") == "" + assert normalize_for_comparison(None) == "" # type: ignore[arg-type] + + +def test_lowercases_ascii(): + assert normalize_for_comparison("Drake") == "drake" + assert normalize_for_comparison("DRAKE") == "drake" + + +def test_strips_surrounding_whitespace(): + assert normalize_for_comparison(" Drake ") == "drake" + assert normalize_for_comparison("\tDrake\n") == "drake" + + +def test_folds_accents_to_ascii(): + """Diacritic-different spellings of the same artist must collapse to + one normalized key — otherwise the pool would re-fetch the same + artist when the playlist and library disagree on casing/accents.""" + assert normalize_for_comparison("Beyoncé") == "beyonce" + assert normalize_for_comparison("Björk") == "bjork" + assert normalize_for_comparison("Subcarpaţi") == "subcarpati" + + +def test_combines_lowercase_and_accent_folding(): + assert normalize_for_comparison("BEYONCÉ") == "beyonce" + + +def test_preserves_internal_whitespace(): + """Multi-word artist names must keep their internal spacing — only + leading/trailing whitespace is stripped.""" + assert normalize_for_comparison("Bon Iver") == "bon iver" + assert normalize_for_comparison("Tame Impala") == "tame impala" From 9b086c5a656d7e10b42a7551fc49b609d43c11ea Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 23:40:22 -0700 Subject: [PATCH 18/81] Add owned_by column for Auto-Sync schedule ownership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Auto-Sync schedule board was detecting its own automations by checking `group_name === 'Playlist Auto-Sync' || name.startsWith('Auto-Sync:')`. That's fragile — renaming the row from the Automations page silently hands ownership back to the read-only Automation Pipelines tab and the board stops managing it. This commit replaces the string convention with an explicit `automations.owned_by` TEXT column: - Migration `_add_automation_owned_by_column` adds the column and backfills `'auto_sync'` for existing rows that match the legacy `group_name`/`name`-prefix pattern, so users running the migration don't lose their schedules. - `database.create_automation` and `database.update_automation` accept `owned_by` (the latter via its `allowed` kwarg set). - `core/automation/api.py` forwards `owned_by` on both POST and PUT. Missing field is left as None, preserving today's behavior for every caller that doesn't opt in. - The Auto-Sync schedule board posts `owned_by: 'auto_sync'` and the detection helper now prefers that signal, falling back to the legacy name/group convention so any hand-rolled rows still show up. Tests: three new cases in `tests/automation/test_automation_api.py` covering create-with-owned-by, create-without (defaults to None), and update set/clear. The fake DB grew the matching kwarg. --- core/automation/api.py | 4 +++ database/music_database.py | 42 +++++++++++++++++++++---- tests/automation/test_automation_api.py | 39 ++++++++++++++++++++++- webui/static/stats-automations.js | 6 ++++ 4 files changed, 84 insertions(+), 7 deletions(-) diff --git a/core/automation/api.py b/core/automation/api.py index 2b06cc21..0e05dd13 100644 --- a/core/automation/api.py +++ b/core/automation/api.py @@ -172,9 +172,11 @@ def create_automation( return {'error': f'Signal cycle detected: {cycle_path}. This would cause an infinite loop.'}, 400 group_name = data.get('group_name') or None + owned_by = data.get('owned_by') or None auto_id = database.create_automation( name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions_json, group_name, + owned_by=owned_by, ) if auto_id is None: return {'error': 'Failed to create automation'}, 500 @@ -217,6 +219,8 @@ def update_automation( update_fields['notify_config'] = json.dumps(data['notify_config']) if 'group_name' in data: update_fields['group_name'] = data['group_name'] or None + if 'owned_by' in data: + update_fields['owned_by'] = data['owned_by'] or None if not update_fields: return {'error': 'No fields to update'}, 400 diff --git a/database/music_database.py b/database/music_database.py index f7b813b0..6ffd1311 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -539,6 +539,7 @@ class MusicDatabase: self._add_automation_system_column(cursor) self._add_automation_then_actions_column(cursor) self._add_automation_group_name_column(cursor) + self._add_automation_owned_by_column(cursor) # Library issues — user-reported problems with tracks/albums/artists cursor.execute(""" @@ -845,6 +846,28 @@ class MusicDatabase: except Exception as e: logger.error(f"Error adding automation group_name column: {e}") + def _add_automation_owned_by_column(self, cursor): + """Add owned_by column so feature surfaces (Auto-Sync schedule + board, future pipeline groups) can recognize automations they + manage without relying on fragile name-prefix string matches.""" + try: + cursor.execute("PRAGMA table_info(automations)") + cols = [c[1] for c in cursor.fetchall()] + if 'owned_by' not in cols: + cursor.execute("ALTER TABLE automations ADD COLUMN owned_by TEXT DEFAULT NULL") + logger.info("Added owned_by column to automations table") + # Backfill existing Auto-Sync automations created via the + # name/group-prefix convention so the board keeps managing them. + cursor.execute(""" + UPDATE automations + SET owned_by = 'auto_sync' + WHERE (group_name = 'Playlist Auto-Sync' OR name LIKE 'Auto-Sync:%') + AND owned_by IS NULL + """) + logger.info(f"Backfilled {cursor.rowcount} existing Auto-Sync automations with owned_by='auto_sync'") + except Exception as e: + logger.error(f"Error adding automation owned_by column: {e}") + def _add_automation_then_actions_column(self, cursor): """Add then_actions column to automations table and migrate existing notify data.""" try: @@ -12154,15 +12177,22 @@ class MusicDatabase: def create_automation(self, name: str, trigger_type: str, trigger_config: str, action_type: str, action_config: str, profile_id: int = 1, notify_type: str = None, notify_config: str = '{}', - then_actions: str = '[]', group_name: str = None): - """Create a new automation. Returns the new automation ID or None.""" + then_actions: str = '[]', group_name: str = None, + owned_by: str = None): + """Create a new automation. Returns the new automation ID or None. + + ``owned_by`` tags an automation as managed by a feature surface + (e.g. ``'auto_sync'`` for entries the Playlist Auto-Sync board + creates) so that surface can recognize its own rows without + scraping the display name. + """ try: with self._get_connection() as conn: cursor = conn.cursor() cursor.execute(""" - INSERT INTO automations (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions, group_name) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions, group_name)) + INSERT INTO automations (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions, group_name, owned_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions, group_name, owned_by)) conn.commit() return cursor.lastrowid except Exception as e: @@ -12209,7 +12239,7 @@ class MusicDatabase: def update_automation(self, automation_id: int, **kwargs) -> bool: """Update automation fields.""" - allowed = {'name', 'enabled', 'trigger_type', 'trigger_config', 'action_type', 'action_config', 'next_run', 'notify_type', 'notify_config', 'last_result', 'is_system', 'then_actions', 'group_name'} + allowed = {'name', 'enabled', 'trigger_type', 'trigger_config', 'action_type', 'action_config', 'next_run', 'notify_type', 'notify_config', 'last_result', 'is_system', 'then_actions', 'group_name', 'owned_by'} updates = {k: v for k, v in kwargs.items() if k in allowed} if not updates: return False diff --git a/tests/automation/test_automation_api.py b/tests/automation/test_automation_api.py index 12cbf05e..73373eb3 100644 --- a/tests/automation/test_automation_api.py +++ b/tests/automation/test_automation_api.py @@ -28,7 +28,8 @@ class _FakeDB: return dict(self.automations[automation_id]) if automation_id in self.automations else None def create_automation(self, name, trigger_type, trigger_config, action_type, action_config, - profile_id, notify_type, notify_config, then_actions, group_name): + profile_id, notify_type, notify_config, then_actions, group_name, + owned_by=None): aid = self._next_id self._next_id += 1 self.automations[aid] = { @@ -37,6 +38,7 @@ class _FakeDB: 'action_config': action_config, 'profile_id': profile_id, 'notify_type': notify_type, 'notify_config': notify_config, 'then_actions': then_actions, 'group_name': group_name, + 'owned_by': owned_by, 'enabled': 1, 'is_system': 0, } return aid @@ -339,6 +341,41 @@ def test_update_non_trigger_field_preserves_next_run(): assert db.automations[1].get('next_run') == '2026-05-25 05:00:00' +def test_create_with_owned_by_persists_marker(): + """Auto-Sync schedule board posts `owned_by: 'auto_sync'` so it can + recognize its own rows on subsequent reads without name-prefix + string scraping.""" + db = _FakeDB() + api.create_automation(db, _FakeEngine(), profile_id=1, data={ + 'name': 'Auto-Sync: Discover Weekly', + 'trigger_type': 'schedule', + 'trigger_config': {'interval': 1, 'unit': 'hours'}, + 'action_type': 'playlist_pipeline', + 'owned_by': 'auto_sync', + }) + assert db.automations[1]['owned_by'] == 'auto_sync' + + +def test_create_without_owned_by_defaults_to_none(): + db = _FakeDB() + api.create_automation(db, _FakeEngine(), profile_id=1, data={ + 'name': 'Plain', 'trigger_type': 'schedule', 'action_type': 'process_wishlist', + }) + assert db.automations[1]['owned_by'] is None + + +def test_update_can_set_or_clear_owned_by(): + db = _FakeDB() + db.automations[1] = { + 'id': 1, 'name': 'a', 'enabled': 1, 'is_system': 0, + 'trigger_type': 'schedule', 'owned_by': None, + } + api.update_automation(db, _FakeEngine(), automation_id=1, data={'owned_by': 'auto_sync'}) + assert db.automations[1]['owned_by'] == 'auto_sync' + api.update_automation(db, _FakeEngine(), automation_id=1, data={'owned_by': None}) + assert db.automations[1]['owned_by'] is None + + # --------------------------------------------------------------------------- # batch_update_group # --------------------------------------------------------------------------- diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index fa22d51b..2c90db0c 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -644,6 +644,11 @@ function autoSyncPlaylistIdFromAutomation(auto) { } function autoSyncIsScheduleOwned(auto) { + // Primary signal: the explicit owned_by flag the board writes on every + // schedule it creates. Falls back to the legacy name/group convention + // so rows created before the column existed (or hand-edited from the + // Automations page) still get recognized after backfill. + if (auto?.owned_by === 'auto_sync') return true; const group = auto?.group_name || ''; const name = auto?.name || ''; return group === 'Playlist Auto-Sync' || name.startsWith('Auto-Sync:'); @@ -970,6 +975,7 @@ async function saveAutoSyncPlaylistSchedule(playlistId, hours) { action_config: { playlist_id: String(playlistId), all: false }, then_actions: [], group_name: 'Playlist Auto-Sync', + owned_by: 'auto_sync', }; try { const res = await fetch(existing ? `/api/automations/${existing.automation_id}` : '/api/automations', { From 449a26e56b0406fb036809dc3d469aaa2fd8bc3d Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 23:46:08 -0700 Subject: [PATCH 19/81] Extract Auto-Sync into webui/static/auto-sync.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cin review: stats-automations.js had ~600 lines of new Auto-Sync code piled into an already-large shared file. Moved into its own module: - New webui/static/auto-sync.js holds: - Schedule board state (`AUTO_SYNC_BUCKETS`, `_autoSyncScheduleState`, `_autoSyncActiveTab`, `mirroredPipelinePollers`) - All `autoSync*` functions (trigger conversion, render panels, drag/drop, save/unschedule, schedule modal lifecycle) - Mirrored-playlist pipeline helpers (`runMirroredPlaylistPipeline`, `pollMirroredPipelineStatus`, `applyMirroredPipelineState`, `parseMirroredPipelineResponse`, `editMirroredSourceRef`, `getMirroredSourceRef`) - index.html loads auto-sync.js immediately after stats-automations.js so the older `renderMirroredCard` path can keep reaching these globals through the window namespace. - stats-automations.js drops 567 lines and gains a one-line breadcrumb pointing at the new file. No behavior changes — every function moved verbatim. Globals stay in the same window namespace, so the still-resident `renderMirroredCard` keeps calling `runMirroredPlaylistPipeline` / `editMirroredSourceRef` / `mirroredPipelinePollers` exactly as before. Both files pass `node --check`. Full Python suite still green. --- webui/index.html | 1 + webui/static/auto-sync.js | 585 ++++++++++++++++++++++++++++++ webui/static/stats-automations.js | 579 +---------------------------- 3 files changed, 590 insertions(+), 575 deletions(-) create mode 100644 webui/static/auto-sync.js diff --git a/webui/index.html b/webui/index.html index 6cee0db2..0ddcbaad 100644 --- a/webui/index.html +++ b/webui/index.html @@ -7885,6 +7885,7 @@ + diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js new file mode 100644 index 00000000..e30cda02 --- /dev/null +++ b/webui/static/auto-sync.js @@ -0,0 +1,585 @@ +// Auto-Sync: schedule board + mirrored-playlist pipeline runs +// ───────────────────────────────────────────────────────────────────── +// Extracted from stats-automations.js (Cin review feedback). All +// references rely on globals available at runtime — `_esc`, `_escAttr`, +// `_autoParseUTC`, `_autoFormatTrigger`, `showToast`, `showConfirmDialog`, +// `loadMirroredPlaylists`, `updateMirroredCardPhase`, +// `openMirroredPlaylistModal`, `closeMirroredModal`, `youtubePlaylistStates` +// all live in stats-automations.js (or earlier helpers). This file +// declares the auto-sync-specific state + render/event functions on top. + +const mirroredPipelinePollers = {}; +const AUTO_SYNC_BUCKETS = [1, 2, 4, 8, 12, 16, 24, 48, 72, 168]; +let _autoSyncScheduleState = { + playlists: [], + automations: [], + playlistSchedules: {}, + automationPipelines: [], +}; +let _autoSyncActiveTab = 'schedule'; + +function getMirroredSourceRef(p) { + if (p && p.source_ref) return String(p.source_ref); + const desc = (p && p.description) ? String(p.description).trim() : ''; + if ((p.source === 'spotify_public' || p.source === 'youtube') && /^https?:\/\//i.test(desc)) { + return desc; + } + return (p && p.source_playlist_id) ? String(p.source_playlist_id) : ''; +} + +function autoSyncTriggerForHours(hours) { + const h = parseInt(hours, 10) || 24; + if (h >= 24 && h % 24 === 0) { + return { interval: h / 24, unit: 'days' }; + } + return { interval: h, unit: 'hours' }; +} + +function autoSyncHoursFromTrigger(config) { + const interval = parseInt(config?.interval, 10) || 0; + const unit = config?.unit || 'hours'; + if (!interval) return null; + if (unit === 'minutes') return Math.max(1, Math.round(interval / 60)); + if (unit === 'days') return interval * 24; + if (unit === 'weeks') return interval * 168; + return interval; +} + +function autoSyncBucketLabel(hours) { + if (hours === 168) return 'Weekly'; + if (hours >= 24) return `${hours / 24}d`; + return `${hours}h`; +} + +function autoSyncIntervalLabel(hours) { + if (hours === 168) return 'Every week'; + if (hours >= 24) { + const days = hours / 24; + return `Every ${days} day${days === 1 ? '' : 's'}`; + } + return `Every ${hours} hour${hours === 1 ? '' : 's'}`; +} + +function autoSyncSourceLabel(source) { + const labels = { + spotify: 'Spotify', + spotify_public: 'Spotify Link', + tidal: 'Tidal', + youtube: 'YouTube', + deezer: 'Deezer', + qobuz: 'Qobuz', + beatport: 'Beatport', + file: 'File Imports', + }; + return labels[source] || source || 'Other'; +} + +function autoSyncCanSchedulePlaylist(playlist) { + return playlist && !['file', 'beatport'].includes(playlist.source || ''); +} + +function autoSyncIsPipelineAutomation(auto) { + return auto && auto.action_type === 'playlist_pipeline'; +} + +function autoSyncPlaylistIdFromAutomation(auto) { + if (!autoSyncIsPipelineAutomation(auto)) return null; + const cfg = auto.action_config || {}; + if (cfg.all === true || cfg.all === 'true') return null; + const raw = cfg.playlist_id; + if (raw === undefined || raw === null || raw === '') return null; + const id = parseInt(raw, 10); + return Number.isFinite(id) ? id : null; +} + +function autoSyncIsScheduleOwned(auto) { + // Primary signal: the explicit owned_by flag the board writes on every + // schedule it creates. Falls back to the legacy name/group convention + // so rows created before the column existed (or hand-edited from the + // Automations page) still get recognized after backfill. + if (auto?.owned_by === 'auto_sync') return true; + const group = auto?.group_name || ''; + const name = auto?.name || ''; + return group === 'Playlist Auto-Sync' || name.startsWith('Auto-Sync:'); +} + +function buildAutoSyncScheduleState(playlists, automations) { + const playlistSchedules = {}; + const automationPipelines = []; + const pipelineAutomations = automations.filter(autoSyncIsPipelineAutomation); + pipelineAutomations.forEach(auto => { + const playlistId = autoSyncPlaylistIdFromAutomation(auto); + const hours = auto.trigger_type === 'schedule' ? autoSyncHoursFromTrigger(auto.trigger_config || {}) : null; + if (playlistId && hours && autoSyncIsScheduleOwned(auto)) { + playlistSchedules[playlistId] = { + automation_id: auto.id, + automation_name: auto.name, + hours, + enabled: auto.enabled !== false && auto.enabled !== 0, + owned: true, + next_run: auto.next_run, + trigger_config: auto.trigger_config || {}, + }; + } else { + automationPipelines.push(auto); + } + }); + return { playlists, automations, playlistSchedules, automationPipelines }; +} + +async function openAutoSyncScheduleModal() { + let overlay = document.getElementById('auto-sync-schedule-modal'); + if (!overlay) { + overlay = document.createElement('div'); + overlay.id = 'auto-sync-schedule-modal'; + overlay.className = 'auto-sync-overlay'; + document.body.appendChild(overlay); + } + overlay.innerHTML = ` +
+
+
+

Auto-Sync Schedule

+

Drop mirrored playlists onto an interval to schedule refresh, discovery, sync, and wishlist processing.

+
+ +
+
Loading schedule...
+
+ `; + overlay.style.display = 'flex'; + overlay.onclick = e => { if (e.target === overlay) closeAutoSyncScheduleModal(); }; + await refreshAutoSyncScheduleModal(); +} + +function closeAutoSyncScheduleModal() { + const overlay = document.getElementById('auto-sync-schedule-modal'); + if (overlay) overlay.remove(); +} + +async function refreshAutoSyncScheduleModal() { + const overlay = document.getElementById('auto-sync-schedule-modal'); + if (!overlay) return; + try { + const [playlistRes, automationRes] = await Promise.all([ + fetch('/api/mirrored-playlists'), + fetch('/api/automations'), + ]); + const playlists = await playlistRes.json(); + const automations = await automationRes.json(); + if (!playlistRes.ok || playlists.error) throw new Error(playlists.error || 'Failed to load mirrored playlists'); + if (!automationRes.ok || automations.error) throw new Error(automations.error || 'Failed to load automations'); + _autoSyncScheduleState = buildAutoSyncScheduleState(playlists, automations); + renderAutoSyncScheduleModal(); + } catch (err) { + overlay.innerHTML = ` +
+
+

Auto-Sync Schedule

Could not load schedule data.

+ +
+
${_esc(err.message)}
+
+ `; + } +} + +function renderAutoSyncScheduleModal() { + const overlay = document.getElementById('auto-sync-schedule-modal'); + if (!overlay) return; + + const { playlists, playlistSchedules, automationPipelines } = _autoSyncScheduleState; + const scheduledCount = Object.keys(playlistSchedules).length; + const enabledCount = Object.values(playlistSchedules).filter(s => s.enabled).length; + const pipelineCount = automationPipelines.length; + const totalTracks = playlists.reduce((sum, p) => sum + (parseInt(p.track_count, 10) || 0), 0); + const scheduleActive = _autoSyncActiveTab === 'schedule'; + const automationsActive = _autoSyncActiveTab === 'automations'; + + const schedulePanel = renderAutoSyncSchedulePanel(playlists, playlistSchedules); + const automationPanel = renderAutoSyncAutomationPanel(automationPipelines, playlists); + + overlay.innerHTML = ` +
+
+
+
Playlist automation
+

Auto-Sync Manager

+

Schedule mirrored playlists through the same playlist-pipeline engine used by Automations.

+
+ +
+
+
${scheduledCount}scheduled playlists
+
${enabledCount}active schedules
+
${pipelineCount}automation pipelines
+
${totalTracks}mirrored tracks
+
+
+ + +
+
${schedulePanel}
+
${automationPanel}
+
+ `; +} + +function setAutoSyncTab(tab) { + _autoSyncActiveTab = tab === 'automations' ? 'automations' : 'schedule'; + renderAutoSyncScheduleModal(); +} + +function renderAutoSyncSchedulePanel(playlists, playlistSchedules) { + const schedulablePlaylists = playlists.filter(autoSyncCanSchedulePlaylist); + const unavailablePlaylists = playlists.filter(p => !autoSyncCanSchedulePlaylist(p)); + const grouped = schedulablePlaylists.reduce((acc, p) => { + const key = p.source || 'other'; + if (!acc[key]) acc[key] = []; + acc[key].push(p); + return acc; + }, {}); + const sourceKeys = Object.keys(grouped).sort((a, b) => autoSyncSourceLabel(a).localeCompare(autoSyncSourceLabel(b))); + + const sidebarHtml = sourceKeys.length ? sourceKeys.map(source => ` +
+
${_esc(autoSyncSourceLabel(source))}
+ ${grouped[source].map(p => { + const schedule = playlistSchedules[p.id]; + const assigned = schedule ? autoSyncIntervalLabel(schedule.hours) : 'Unscheduled'; + return ` +
+
${_esc(p.name)}
+
${p.track_count || 0} tracks · ${_esc(assigned)}
+
+ `; + }).join('')} +
+ `).join('') : '
No refreshable mirrored playlists yet.
'; + + const unavailableHtml = unavailablePlaylists.length ? ` +
+
Not schedulable
+ ${unavailablePlaylists.map(p => ` +
+
${_esc(p.name)}
+
${_esc(autoSyncSourceLabel(p.source))} · refresh not supported
+
+ `).join('')} +
+ ` : ''; + + const bucketHtml = AUTO_SYNC_BUCKETS.map(hours => { + const assigned = schedulablePlaylists.filter(p => playlistSchedules[p.id]?.hours === hours); + return ` +
+
+ ${autoSyncBucketLabel(hours)} + ${assigned.length} playlist${assigned.length === 1 ? '' : 's'} +
+
+ ${assigned.length ? assigned.map(p => autoSyncScheduledCardHtml(p, playlistSchedules[p.id])).join('') : '
Drop hereSchedule playlists at this interval
'} +
+
+ `; + }).join(''); + + return ` +
+
+ Drag playlists into an interval + Each placement creates or updates an Auto-Sync-owned playlist-pipeline automation. +
+ +
+
+ +
${bucketHtml}
+
+ `; +} + +function renderAutoSyncAutomationPanel(automationPipelines, playlists) { + if (!automationPipelines.length) { + return '
No Automations-page playlist pipelines found.
'; + } + return ` +
+ Read-only Automations-page pipelines + These use the playlist pipeline but are managed from the Automations page, so this modal only displays them. +
+
+ ${automationPipelines.map(auto => autoSyncAutomationCardHtml(auto, playlists)).join('')} +
+ `; +} + +function autoSyncAutomationCardHtml(auto, playlists) { + const cfg = auto.action_config || {}; + const playlistId = autoSyncPlaylistIdFromAutomation(auto); + const playlist = playlistId ? playlists.find(p => parseInt(p.id, 10) === playlistId) : null; + const target = cfg.all === true || cfg.all === 'true' + ? 'All refreshable mirrored playlists' + : playlist ? playlist.name : playlistId ? `Playlist #${playlistId}` : 'Custom pipeline target'; + const trigger = _autoFormatTrigger(auto.trigger_type, auto.trigger_config || {}); + const enabled = auto.enabled !== false && auto.enabled !== 0; + const next = auto.next_run ? autoSyncNextRunLabel(auto.next_run) : 'not scheduled'; + return ` +
+
+
+ ${enabled ? 'Enabled' : 'Disabled'} + ${_esc(auto.name || 'Playlist Pipeline')} +
+
+ ${_esc(trigger)} + ${_esc(target)} + ${_esc(next)} +
+
+
Read only
+
+ `; +} + +function autoSyncScheduledCardHtml(playlist, schedule) { + const enabled = schedule?.enabled !== false; + const nextLabel = schedule?.next_run ? autoSyncNextRunLabel(schedule.next_run) : ''; + return ` +
+
+
${_esc(playlist.name)}
+
${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks
+
+ ${_esc(autoSyncIntervalLabel(schedule?.hours || 24))} + ${nextLabel ? `${_esc(nextLabel)}` : ''} +
+
+ +
+ `; +} + +function autoSyncNextRunLabel(nextRun) { + if (!nextRun) return ''; + const ts = _autoParseUTC(nextRun); + if (!Number.isFinite(ts)) return ''; + const diff = ts - Date.now(); + if (diff <= 0) return 'due now'; + const mins = Math.ceil(diff / 60000); + if (mins < 60) return `next in ${mins}m`; + const hours = Math.ceil(mins / 60); + if (hours < 24) return `next in ${hours}h`; + return `next in ${Math.ceil(hours / 24)}d`; +} + +function autoSyncDragStart(event) { + const playlistId = event.currentTarget?.dataset?.playlistId; + if (!playlistId) return; + event.dataTransfer.setData('text/plain', playlistId); + event.dataTransfer.effectAllowed = 'move'; +} + +function autoSyncDragOver(event) { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + const col = event.currentTarget; + if (col && !col.classList.contains('drag-over')) { + col.classList.add('drag-over'); + } +} + +function autoSyncDragLeave(event) { + const col = event.currentTarget; + if (!col) return; + if (col.contains(event.relatedTarget)) return; + col.classList.remove('drag-over'); +} + +async function autoSyncDrop(event, hours) { + event.preventDefault(); + const col = event.currentTarget; + if (col) col.classList.remove('drag-over'); + const playlistId = parseInt(event.dataTransfer.getData('text/plain'), 10); + if (!playlistId) return; + await saveAutoSyncPlaylistSchedule(playlistId, hours); +} + +async function saveAutoSyncPlaylistSchedule(playlistId, hours) { + const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); + if (!playlist) return; + if (!autoSyncCanSchedulePlaylist(playlist)) { + showToast('That playlist source cannot be refreshed by Auto-Sync.', 'info'); + return; + } + const existing = _autoSyncScheduleState.playlistSchedules[playlistId]; + const payload = { + name: `Auto-Sync: ${playlist.name}`, + trigger_type: 'schedule', + trigger_config: autoSyncTriggerForHours(hours), + action_type: 'playlist_pipeline', + action_config: { playlist_id: String(playlistId), all: false }, + then_actions: [], + group_name: 'Playlist Auto-Sync', + owned_by: 'auto_sync', + }; + try { + const res = await fetch(existing ? `/api/automations/${existing.automation_id}` : '/api/automations', { + method: existing ? 'PUT' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || 'Failed to save Auto-Sync schedule'); + showToast(`${playlist.name} scheduled every ${autoSyncBucketLabel(hours)}`, 'success'); + await refreshAutoSyncScheduleModal(); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } +} + +async function unscheduleAutoSyncPlaylist(playlistId) { + const schedule = _autoSyncScheduleState.playlistSchedules[playlistId]; + const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); + if (!schedule) return; + if (!await showConfirmDialog({ title: 'Remove Auto-Sync', message: `Remove Auto-Sync schedule for "${playlist?.name || 'this playlist'}"?` })) return; + try { + const res = await fetch(`/api/automations/${schedule.automation_id}`, { method: 'DELETE' }); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || 'Failed to remove Auto-Sync schedule'); + showToast('Auto-Sync schedule removed', 'success'); + await refreshAutoSyncScheduleModal(); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } +} + +async function parseMirroredPipelineResponse(res, fallbackMessage) { + const text = await res.text(); + let data = {}; + if (text) { + try { + data = JSON.parse(text); + } catch (_err) { + const detail = res.status === 404 + ? 'Auto-Sync endpoint not found. Restart the SoulSync server so the new backend routes load.' + : fallbackMessage; + throw new Error(detail); + } + } + if (!res.ok || data.error) { + throw new Error(data.error || fallbackMessage); + } + return data; +} + +async function editMirroredSourceRef(playlistId, name, source, currentRef) { + const label = (source === 'spotify_public' || source === 'youtube') + ? 'original playlist URL' + : 'original playlist ID or URL'; + const nextRef = window.prompt(`Update ${label} for "${name}"`, currentRef || ''); + if (nextRef === null) return; + const trimmed = nextRef.trim(); + if (!trimmed) { + showToast('Source link or ID is required', 'error'); + return; + } + + try { + const res = await fetch(`/api/mirrored-playlists/${playlistId}/source-ref`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source_ref: trimmed }) + }); + const data = await res.json(); + if (!res.ok || data.error) { + throw new Error(data.error || 'Failed to update source reference'); + } + showToast(`Updated source for ${name}`, 'success'); + loadMirroredPlaylists(); + const openModal = document.getElementById('mirrored-track-modal'); + if (openModal) { + closeMirroredModal(); + openMirroredPlaylistModal(playlistId); + } + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } +} + +function applyMirroredPipelineState(playlistId, state) { + const hash = `mirrored_${playlistId}`; + const existing = youtubePlaylistStates[hash] || {}; + const status = state.status || 'idle'; + let phase = existing.phase; + if (status === 'running') phase = 'pipeline_running'; + else if (status === 'finished') phase = 'pipeline_complete'; + else if (status === 'error' || status === 'skipped') phase = 'pipeline_error'; + + youtubePlaylistStates[hash] = { + ...existing, + phase, + pipeline_status: status, + pipeline_progress: state.progress || 0, + pipeline_phase: state.phase || '', + pipeline_error: state.error || '', + pipeline_log: state.log || [], + pipeline_result: state.result || null, + }; + + updateMirroredCardPhase(hash, phase); +} + +async function runMirroredPlaylistPipeline(playlistId, name) { + try { + const res = await fetch(`/api/mirrored-playlists/${playlistId}/pipeline/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + const data = await parseMirroredPipelineResponse(res, 'Failed to start Auto-Sync'); + applyMirroredPipelineState(playlistId, data.state || { status: 'running', progress: 0, phase: 'Starting pipeline...' }); + showToast(`Auto-Sync started for ${name}`, 'success'); + pollMirroredPipelineStatus(playlistId, name); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } +} + +function pollMirroredPipelineStatus(playlistId, name) { + const key = `mirrored_${playlistId}`; + if (mirroredPipelinePollers[key]) clearInterval(mirroredPipelinePollers[key]); + + const tick = async () => { + try { + const res = await fetch(`/api/mirrored-playlists/${playlistId}/pipeline/status`); + const state = await parseMirroredPipelineResponse(res, 'Failed to read Auto-Sync status'); + applyMirroredPipelineState(playlistId, state); + + if (state.status === 'finished') { + clearInterval(mirroredPipelinePollers[key]); + delete mirroredPipelinePollers[key]; + showToast(`Auto-Sync complete for ${name}`, 'success'); + loadMirroredPlaylists(); + } else if (state.status === 'error' || state.status === 'skipped') { + clearInterval(mirroredPipelinePollers[key]); + delete mirroredPipelinePollers[key]; + showToast(state.error || `Pipeline stopped for ${name}`, 'error'); + loadMirroredPlaylists(); + } else if (state.status === 'idle') { + clearInterval(mirroredPipelinePollers[key]); + delete mirroredPipelinePollers[key]; + } + } catch (err) { + clearInterval(mirroredPipelinePollers[key]); + delete mirroredPipelinePollers[key]; + showToast(`Pipeline status error: ${err.message}`, 'error'); + } + }; + + tick(); + mirroredPipelinePollers[key] = setInterval(tick, 2500); +} diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 2c90db0c..3586b247 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -380,15 +380,10 @@ function importFileSubmit() { // ── Mirrored Playlists ──────────────────────────────────────────────── let mirroredPlaylistsLoaded = false; -const mirroredPipelinePollers = {}; -const AUTO_SYNC_BUCKETS = [1, 2, 4, 8, 12, 16, 24, 48, 72, 168]; -let _autoSyncScheduleState = { - playlists: [], - automations: [], - playlistSchedules: {}, - automationPipelines: [], -}; -let _autoSyncActiveTab = 'schedule'; +// Auto-Sync state + functions moved to webui/static/auto-sync.js; declared +// as globals there so the older mirrored-playlist render path below can +// still reach `mirroredPipelinePollers`, `runMirroredPlaylistPipeline`, +// `editMirroredSourceRef`, `getMirroredSourceRef`, and `pollMirroredPipelineStatus`. /** * Fire-and-forget helper: send parsed playlist data to be mirrored on the backend. @@ -569,572 +564,6 @@ function renderMirroredCard(p, container) { } } -function getMirroredSourceRef(p) { - if (p && p.source_ref) return String(p.source_ref); - const desc = (p && p.description) ? String(p.description).trim() : ''; - if ((p.source === 'spotify_public' || p.source === 'youtube') && /^https?:\/\//i.test(desc)) { - return desc; - } - return (p && p.source_playlist_id) ? String(p.source_playlist_id) : ''; -} - -function autoSyncTriggerForHours(hours) { - const h = parseInt(hours, 10) || 24; - if (h >= 24 && h % 24 === 0) { - return { interval: h / 24, unit: 'days' }; - } - return { interval: h, unit: 'hours' }; -} - -function autoSyncHoursFromTrigger(config) { - const interval = parseInt(config?.interval, 10) || 0; - const unit = config?.unit || 'hours'; - if (!interval) return null; - if (unit === 'minutes') return Math.max(1, Math.round(interval / 60)); - if (unit === 'days') return interval * 24; - if (unit === 'weeks') return interval * 168; - return interval; -} - -function autoSyncBucketLabel(hours) { - if (hours === 168) return 'Weekly'; - if (hours >= 24) return `${hours / 24}d`; - return `${hours}h`; -} - -function autoSyncIntervalLabel(hours) { - if (hours === 168) return 'Every week'; - if (hours >= 24) { - const days = hours / 24; - return `Every ${days} day${days === 1 ? '' : 's'}`; - } - return `Every ${hours} hour${hours === 1 ? '' : 's'}`; -} - -function autoSyncSourceLabel(source) { - const labels = { - spotify: 'Spotify', - spotify_public: 'Spotify Link', - tidal: 'Tidal', - youtube: 'YouTube', - deezer: 'Deezer', - qobuz: 'Qobuz', - beatport: 'Beatport', - file: 'File Imports', - }; - return labels[source] || source || 'Other'; -} - -function autoSyncCanSchedulePlaylist(playlist) { - return playlist && !['file', 'beatport'].includes(playlist.source || ''); -} - -function autoSyncIsPipelineAutomation(auto) { - return auto && auto.action_type === 'playlist_pipeline'; -} - -function autoSyncPlaylistIdFromAutomation(auto) { - if (!autoSyncIsPipelineAutomation(auto)) return null; - const cfg = auto.action_config || {}; - if (cfg.all === true || cfg.all === 'true') return null; - const raw = cfg.playlist_id; - if (raw === undefined || raw === null || raw === '') return null; - const id = parseInt(raw, 10); - return Number.isFinite(id) ? id : null; -} - -function autoSyncIsScheduleOwned(auto) { - // Primary signal: the explicit owned_by flag the board writes on every - // schedule it creates. Falls back to the legacy name/group convention - // so rows created before the column existed (or hand-edited from the - // Automations page) still get recognized after backfill. - if (auto?.owned_by === 'auto_sync') return true; - const group = auto?.group_name || ''; - const name = auto?.name || ''; - return group === 'Playlist Auto-Sync' || name.startsWith('Auto-Sync:'); -} - -function buildAutoSyncScheduleState(playlists, automations) { - const playlistSchedules = {}; - const automationPipelines = []; - const pipelineAutomations = automations.filter(autoSyncIsPipelineAutomation); - pipelineAutomations.forEach(auto => { - const playlistId = autoSyncPlaylistIdFromAutomation(auto); - const hours = auto.trigger_type === 'schedule' ? autoSyncHoursFromTrigger(auto.trigger_config || {}) : null; - if (playlistId && hours && autoSyncIsScheduleOwned(auto)) { - playlistSchedules[playlistId] = { - automation_id: auto.id, - automation_name: auto.name, - hours, - enabled: auto.enabled !== false && auto.enabled !== 0, - owned: true, - next_run: auto.next_run, - trigger_config: auto.trigger_config || {}, - }; - } else { - automationPipelines.push(auto); - } - }); - return { playlists, automations, playlistSchedules, automationPipelines }; -} - -async function openAutoSyncScheduleModal() { - let overlay = document.getElementById('auto-sync-schedule-modal'); - if (!overlay) { - overlay = document.createElement('div'); - overlay.id = 'auto-sync-schedule-modal'; - overlay.className = 'auto-sync-overlay'; - document.body.appendChild(overlay); - } - overlay.innerHTML = ` -
-
-
-

Auto-Sync Schedule

-

Drop mirrored playlists onto an interval to schedule refresh, discovery, sync, and wishlist processing.

-
- -
-
Loading schedule...
-
- `; - overlay.style.display = 'flex'; - overlay.onclick = e => { if (e.target === overlay) closeAutoSyncScheduleModal(); }; - await refreshAutoSyncScheduleModal(); -} - -function closeAutoSyncScheduleModal() { - const overlay = document.getElementById('auto-sync-schedule-modal'); - if (overlay) overlay.remove(); -} - -async function refreshAutoSyncScheduleModal() { - const overlay = document.getElementById('auto-sync-schedule-modal'); - if (!overlay) return; - try { - const [playlistRes, automationRes] = await Promise.all([ - fetch('/api/mirrored-playlists'), - fetch('/api/automations'), - ]); - const playlists = await playlistRes.json(); - const automations = await automationRes.json(); - if (!playlistRes.ok || playlists.error) throw new Error(playlists.error || 'Failed to load mirrored playlists'); - if (!automationRes.ok || automations.error) throw new Error(automations.error || 'Failed to load automations'); - _autoSyncScheduleState = buildAutoSyncScheduleState(playlists, automations); - renderAutoSyncScheduleModal(); - } catch (err) { - overlay.innerHTML = ` -
-
-

Auto-Sync Schedule

Could not load schedule data.

- -
-
${_esc(err.message)}
-
- `; - } -} - -function renderAutoSyncScheduleModal() { - const overlay = document.getElementById('auto-sync-schedule-modal'); - if (!overlay) return; - - const { playlists, playlistSchedules, automationPipelines } = _autoSyncScheduleState; - const scheduledCount = Object.keys(playlistSchedules).length; - const enabledCount = Object.values(playlistSchedules).filter(s => s.enabled).length; - const pipelineCount = automationPipelines.length; - const totalTracks = playlists.reduce((sum, p) => sum + (parseInt(p.track_count, 10) || 0), 0); - const scheduleActive = _autoSyncActiveTab === 'schedule'; - const automationsActive = _autoSyncActiveTab === 'automations'; - - const schedulePanel = renderAutoSyncSchedulePanel(playlists, playlistSchedules); - const automationPanel = renderAutoSyncAutomationPanel(automationPipelines, playlists); - - overlay.innerHTML = ` -
-
-
-
Playlist automation
-

Auto-Sync Manager

-

Schedule mirrored playlists through the same playlist-pipeline engine used by Automations.

-
- -
-
-
${scheduledCount}scheduled playlists
-
${enabledCount}active schedules
-
${pipelineCount}automation pipelines
-
${totalTracks}mirrored tracks
-
-
- - -
-
${schedulePanel}
-
${automationPanel}
-
- `; -} - -function setAutoSyncTab(tab) { - _autoSyncActiveTab = tab === 'automations' ? 'automations' : 'schedule'; - renderAutoSyncScheduleModal(); -} - -function renderAutoSyncSchedulePanel(playlists, playlistSchedules) { - const schedulablePlaylists = playlists.filter(autoSyncCanSchedulePlaylist); - const unavailablePlaylists = playlists.filter(p => !autoSyncCanSchedulePlaylist(p)); - const grouped = schedulablePlaylists.reduce((acc, p) => { - const key = p.source || 'other'; - if (!acc[key]) acc[key] = []; - acc[key].push(p); - return acc; - }, {}); - const sourceKeys = Object.keys(grouped).sort((a, b) => autoSyncSourceLabel(a).localeCompare(autoSyncSourceLabel(b))); - - const sidebarHtml = sourceKeys.length ? sourceKeys.map(source => ` -
-
${_esc(autoSyncSourceLabel(source))}
- ${grouped[source].map(p => { - const schedule = playlistSchedules[p.id]; - const assigned = schedule ? autoSyncIntervalLabel(schedule.hours) : 'Unscheduled'; - return ` -
-
${_esc(p.name)}
-
${p.track_count || 0} tracks · ${_esc(assigned)}
-
- `; - }).join('')} -
- `).join('') : '
No refreshable mirrored playlists yet.
'; - - const unavailableHtml = unavailablePlaylists.length ? ` -
-
Not schedulable
- ${unavailablePlaylists.map(p => ` -
-
${_esc(p.name)}
-
${_esc(autoSyncSourceLabel(p.source))} · refresh not supported
-
- `).join('')} -
- ` : ''; - - const bucketHtml = AUTO_SYNC_BUCKETS.map(hours => { - const assigned = schedulablePlaylists.filter(p => playlistSchedules[p.id]?.hours === hours); - return ` -
-
- ${autoSyncBucketLabel(hours)} - ${assigned.length} playlist${assigned.length === 1 ? '' : 's'} -
-
- ${assigned.length ? assigned.map(p => autoSyncScheduledCardHtml(p, playlistSchedules[p.id])).join('') : '
Drop hereSchedule playlists at this interval
'} -
-
- `; - }).join(''); - - return ` -
-
- Drag playlists into an interval - Each placement creates or updates an Auto-Sync-owned playlist-pipeline automation. -
- -
-
- -
${bucketHtml}
-
- `; -} - -function renderAutoSyncAutomationPanel(automationPipelines, playlists) { - if (!automationPipelines.length) { - return '
No Automations-page playlist pipelines found.
'; - } - return ` -
- Read-only Automations-page pipelines - These use the playlist pipeline but are managed from the Automations page, so this modal only displays them. -
-
- ${automationPipelines.map(auto => autoSyncAutomationCardHtml(auto, playlists)).join('')} -
- `; -} - -function autoSyncAutomationCardHtml(auto, playlists) { - const cfg = auto.action_config || {}; - const playlistId = autoSyncPlaylistIdFromAutomation(auto); - const playlist = playlistId ? playlists.find(p => parseInt(p.id, 10) === playlistId) : null; - const target = cfg.all === true || cfg.all === 'true' - ? 'All refreshable mirrored playlists' - : playlist ? playlist.name : playlistId ? `Playlist #${playlistId}` : 'Custom pipeline target'; - const trigger = _autoFormatTrigger(auto.trigger_type, auto.trigger_config || {}); - const enabled = auto.enabled !== false && auto.enabled !== 0; - const next = auto.next_run ? autoSyncNextRunLabel(auto.next_run) : 'not scheduled'; - return ` -
-
-
- ${enabled ? 'Enabled' : 'Disabled'} - ${_esc(auto.name || 'Playlist Pipeline')} -
-
- ${_esc(trigger)} - ${_esc(target)} - ${_esc(next)} -
-
-
Read only
-
- `; -} - -function autoSyncScheduledCardHtml(playlist, schedule) { - const enabled = schedule?.enabled !== false; - const nextLabel = schedule?.next_run ? autoSyncNextRunLabel(schedule.next_run) : ''; - return ` -
-
-
${_esc(playlist.name)}
-
${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks
-
- ${_esc(autoSyncIntervalLabel(schedule?.hours || 24))} - ${nextLabel ? `${_esc(nextLabel)}` : ''} -
-
- -
- `; -} - -function autoSyncNextRunLabel(nextRun) { - if (!nextRun) return ''; - const ts = _autoParseUTC(nextRun); - if (!Number.isFinite(ts)) return ''; - const diff = ts - Date.now(); - if (diff <= 0) return 'due now'; - const mins = Math.ceil(diff / 60000); - if (mins < 60) return `next in ${mins}m`; - const hours = Math.ceil(mins / 60); - if (hours < 24) return `next in ${hours}h`; - return `next in ${Math.ceil(hours / 24)}d`; -} - -function autoSyncDragStart(event) { - const playlistId = event.currentTarget?.dataset?.playlistId; - if (!playlistId) return; - event.dataTransfer.setData('text/plain', playlistId); - event.dataTransfer.effectAllowed = 'move'; -} - -function autoSyncDragOver(event) { - event.preventDefault(); - event.dataTransfer.dropEffect = 'move'; - const col = event.currentTarget; - if (col && !col.classList.contains('drag-over')) { - col.classList.add('drag-over'); - } -} - -function autoSyncDragLeave(event) { - const col = event.currentTarget; - if (!col) return; - if (col.contains(event.relatedTarget)) return; - col.classList.remove('drag-over'); -} - -async function autoSyncDrop(event, hours) { - event.preventDefault(); - const col = event.currentTarget; - if (col) col.classList.remove('drag-over'); - const playlistId = parseInt(event.dataTransfer.getData('text/plain'), 10); - if (!playlistId) return; - await saveAutoSyncPlaylistSchedule(playlistId, hours); -} - -async function saveAutoSyncPlaylistSchedule(playlistId, hours) { - const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); - if (!playlist) return; - if (!autoSyncCanSchedulePlaylist(playlist)) { - showToast('That playlist source cannot be refreshed by Auto-Sync.', 'info'); - return; - } - const existing = _autoSyncScheduleState.playlistSchedules[playlistId]; - const payload = { - name: `Auto-Sync: ${playlist.name}`, - trigger_type: 'schedule', - trigger_config: autoSyncTriggerForHours(hours), - action_type: 'playlist_pipeline', - action_config: { playlist_id: String(playlistId), all: false }, - then_actions: [], - group_name: 'Playlist Auto-Sync', - owned_by: 'auto_sync', - }; - try { - const res = await fetch(existing ? `/api/automations/${existing.automation_id}` : '/api/automations', { - method: existing ? 'PUT' : 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - const data = await res.json(); - if (!res.ok || data.error) throw new Error(data.error || 'Failed to save Auto-Sync schedule'); - showToast(`${playlist.name} scheduled every ${autoSyncBucketLabel(hours)}`, 'success'); - await refreshAutoSyncScheduleModal(); - } catch (err) { - showToast(`Error: ${err.message}`, 'error'); - } -} - -async function unscheduleAutoSyncPlaylist(playlistId) { - const schedule = _autoSyncScheduleState.playlistSchedules[playlistId]; - const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); - if (!schedule) return; - if (!await showConfirmDialog({ title: 'Remove Auto-Sync', message: `Remove Auto-Sync schedule for "${playlist?.name || 'this playlist'}"?` })) return; - try { - const res = await fetch(`/api/automations/${schedule.automation_id}`, { method: 'DELETE' }); - const data = await res.json(); - if (!res.ok || data.error) throw new Error(data.error || 'Failed to remove Auto-Sync schedule'); - showToast('Auto-Sync schedule removed', 'success'); - await refreshAutoSyncScheduleModal(); - } catch (err) { - showToast(`Error: ${err.message}`, 'error'); - } -} - -async function parseMirroredPipelineResponse(res, fallbackMessage) { - const text = await res.text(); - let data = {}; - if (text) { - try { - data = JSON.parse(text); - } catch (_err) { - const detail = res.status === 404 - ? 'Auto-Sync endpoint not found. Restart the SoulSync server so the new backend routes load.' - : fallbackMessage; - throw new Error(detail); - } - } - if (!res.ok || data.error) { - throw new Error(data.error || fallbackMessage); - } - return data; -} - -async function editMirroredSourceRef(playlistId, name, source, currentRef) { - const label = (source === 'spotify_public' || source === 'youtube') - ? 'original playlist URL' - : 'original playlist ID or URL'; - const nextRef = window.prompt(`Update ${label} for "${name}"`, currentRef || ''); - if (nextRef === null) return; - const trimmed = nextRef.trim(); - if (!trimmed) { - showToast('Source link or ID is required', 'error'); - return; - } - - try { - const res = await fetch(`/api/mirrored-playlists/${playlistId}/source-ref`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ source_ref: trimmed }) - }); - const data = await res.json(); - if (!res.ok || data.error) { - throw new Error(data.error || 'Failed to update source reference'); - } - showToast(`Updated source for ${name}`, 'success'); - loadMirroredPlaylists(); - const openModal = document.getElementById('mirrored-track-modal'); - if (openModal) { - closeMirroredModal(); - openMirroredPlaylistModal(playlistId); - } - } catch (err) { - showToast(`Error: ${err.message}`, 'error'); - } -} - -function applyMirroredPipelineState(playlistId, state) { - const hash = `mirrored_${playlistId}`; - const existing = youtubePlaylistStates[hash] || {}; - const status = state.status || 'idle'; - let phase = existing.phase; - if (status === 'running') phase = 'pipeline_running'; - else if (status === 'finished') phase = 'pipeline_complete'; - else if (status === 'error' || status === 'skipped') phase = 'pipeline_error'; - - youtubePlaylistStates[hash] = { - ...existing, - phase, - pipeline_status: status, - pipeline_progress: state.progress || 0, - pipeline_phase: state.phase || '', - pipeline_error: state.error || '', - pipeline_log: state.log || [], - pipeline_result: state.result || null, - }; - - updateMirroredCardPhase(hash, phase); -} - -async function runMirroredPlaylistPipeline(playlistId, name) { - try { - const res = await fetch(`/api/mirrored-playlists/${playlistId}/pipeline/run`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}) - }); - const data = await parseMirroredPipelineResponse(res, 'Failed to start Auto-Sync'); - applyMirroredPipelineState(playlistId, data.state || { status: 'running', progress: 0, phase: 'Starting pipeline...' }); - showToast(`Auto-Sync started for ${name}`, 'success'); - pollMirroredPipelineStatus(playlistId, name); - } catch (err) { - showToast(`Error: ${err.message}`, 'error'); - } -} - -function pollMirroredPipelineStatus(playlistId, name) { - const key = `mirrored_${playlistId}`; - if (mirroredPipelinePollers[key]) clearInterval(mirroredPipelinePollers[key]); - - const tick = async () => { - try { - const res = await fetch(`/api/mirrored-playlists/${playlistId}/pipeline/status`); - const state = await parseMirroredPipelineResponse(res, 'Failed to read Auto-Sync status'); - applyMirroredPipelineState(playlistId, state); - - if (state.status === 'finished') { - clearInterval(mirroredPipelinePollers[key]); - delete mirroredPipelinePollers[key]; - showToast(`Auto-Sync complete for ${name}`, 'success'); - loadMirroredPlaylists(); - } else if (state.status === 'error' || state.status === 'skipped') { - clearInterval(mirroredPipelinePollers[key]); - delete mirroredPipelinePollers[key]; - showToast(state.error || `Pipeline stopped for ${name}`, 'error'); - loadMirroredPlaylists(); - } else if (state.status === 'idle') { - clearInterval(mirroredPipelinePollers[key]); - delete mirroredPipelinePollers[key]; - } - } catch (err) { - clearInterval(mirroredPipelinePollers[key]); - delete mirroredPipelinePollers[key]; - showToast(`Pipeline status error: ${err.message}`, 'error'); - } - }; - - tick(); - mirroredPipelinePollers[key] = setInterval(tick, 2500); -} - function updateMirroredCardPhase(urlHash, phase) { // Update the state phase (updateYouTubeCardPhase skips this for mirrored playlists due to no cardElement) const state = youtubePlaylistStates[urlHash]; From a65ba7e6a3c724e8f38539d96f9e84873415be43 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 25 May 2026 00:01:34 -0700 Subject: [PATCH 20/81] Add node:test contract for auto-sync.js helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cin review: no JS tests covered the autoSync* helpers, so the timezone fix shipped without a regression test in the layer where the bug actually lived. New `tests/static/test_auto_sync.mjs` runs under `node --test` (built-in runner, no extra deps) and pins: - `autoSyncTriggerForHours` / `autoSyncHoursFromTrigger` round-trip for 1h, 4h, 12h, 24h, 48h, 168h. Catches off-by-one in the day vs hour conversion that backs the schedule board's drag-drop. - `autoSyncBucketLabel` / `autoSyncIntervalLabel` formatting + pluralization. - `autoSyncSourceLabel` known + unknown + falsy. - Predicates (`autoSyncCanSchedulePlaylist`, `autoSyncIsPipelineAutomation`, `autoSyncPlaylistIdFromAutomation`, `autoSyncIsScheduleOwned`) including the `owned_by`-flag / legacy-name-prefix split from the previous commit. - `buildAutoSyncScheduleState` partitions board-owned schedules from custom pipelines correctly. - `autoSyncNextRunLabel` parses the naive UTC timestamp as UTC, not local — exactly the regression that took an hour to diagnose this session. Includes a past-time check ("due now") and a multi-day case. - `getMirroredSourceRef` source_ref/description-URL/source_playlist_id resolution order. Cross-realm note: vm-sandbox return values fail `deepStrictEqual` against host-realm objects even when shape matches, so a small `deepShapeEqual` helper round-trips through JSON for structural comparison. The `_autoParseUTC` stub mirrors the real implementation in stats-automations.js so the timezone test exercises both files end to end. `tests/test_auto_sync_js.py` is the pytest shim — shells out to `node --test` and surfaces failures inline. Skips cleanly when node isn't on PATH or is older than 22, matching the existing discover-section-controller test pattern. Also updated SPLIT_MODULES in tests/test_script_split_integrity.py to include the new auto-sync.js — the onclick-coverage check was failing because `openAutoSyncScheduleModal` (referenced from index.html via the Sync page button) now lives in a module the integrity scanner wasn't searching. 39 new JS test cases, all green via `node --test` and via the pytest wrapper. --- tests/static/test_auto_sync.mjs | 394 +++++++++++++++++++++++++++ tests/test_auto_sync_js.py | 72 +++++ tests/test_script_split_integrity.py | 1 + 3 files changed, 467 insertions(+) create mode 100644 tests/static/test_auto_sync.mjs create mode 100644 tests/test_auto_sync_js.py diff --git a/tests/static/test_auto_sync.mjs b/tests/static/test_auto_sync.mjs new file mode 100644 index 00000000..311be6c2 --- /dev/null +++ b/tests/static/test_auto_sync.mjs @@ -0,0 +1,394 @@ +// Tests for the pure-function helpers in `webui/static/auto-sync.js`. +// Run via: +// +// node --test tests/static/test_auto_sync.mjs +// +// The pytest wrapper at `tests/test_auto_sync_js.py` shells out to +// `node --test` and surfaces the result inside the regular pytest run. +// +// The module is loaded into a sandboxed `vm` context with stubs for +// the few globals it relies on (`_autoParseUTC` for the timezone-aware +// next_run label). No DOM — just the calculation contract. + +import { test, describe, before } from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +// Values returned from the sandboxed VM context are cross-realm — their +// prototype chain differs from the test realm's, so deepStrictEqual on +// raw VM objects fails even when shape and values match. JSON-round-trip +// to compare structural equality only. +function deepShapeEqual(actual, expected, msg) { + assert.deepEqual( + JSON.parse(JSON.stringify(actual)), + JSON.parse(JSON.stringify(expected)), + msg, + ); +} + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const AUTOSYNC_PATH = resolve(__dirname, '..', '..', 'webui', 'static', 'auto-sync.js'); + +let AUTOSYNC_SOURCE; +before(() => { + AUTOSYNC_SOURCE = readFileSync(AUTOSYNC_PATH, 'utf8'); +}); + +// Match the actual implementation in stats-automations.js so the +// timezone-bug fix is exercised end-to-end through auto-sync.js. +function realAutoParseUTC(ts) { + if (!ts) return NaN; + if (/[Zz]$/.test(ts) || /[+-]\d{2}:\d{2}$/.test(ts)) return new Date(ts).getTime(); + return new Date(ts + 'Z').getTime(); +} + +function makeSandbox() { + const sandbox = { + window: {}, + document: { getElementById: () => null, body: {} }, + console: { debug: () => {}, error: () => {}, log: () => {} }, + fetch: async () => { throw new Error('fetch not stubbed for this test'); }, + // Globals that auto-sync.js expects to find in the window namespace + _autoParseUTC: realAutoParseUTC, + _autoFormatTrigger: () => 'trigger', + _esc: (s) => String(s), + _escAttr: (s) => String(s), + showToast: () => {}, + showConfirmDialog: async () => true, + loadMirroredPlaylists: () => {}, + updateMirroredCardPhase: () => {}, + openMirroredPlaylistModal: () => {}, + closeMirroredModal: () => {}, + youtubePlaylistStates: {}, + setInterval: () => 0, + clearInterval: () => {}, + }; + vm.createContext(sandbox); + vm.runInContext(AUTOSYNC_SOURCE, sandbox); + return sandbox; +} + +// ========================================================================= +// autoSyncTriggerForHours / autoSyncHoursFromTrigger — round-trip +// ========================================================================= + +describe('autoSyncTriggerForHours', () => { + test('sub-day intervals become hours', () => { + const sb = makeSandbox(); + deepShapeEqual(sb.autoSyncTriggerForHours(1), { interval: 1, unit: 'hours' }); + deepShapeEqual(sb.autoSyncTriggerForHours(12), { interval: 12, unit: 'hours' }); + }); + + test('whole-day multiples become days', () => { + const sb = makeSandbox(); + deepShapeEqual(sb.autoSyncTriggerForHours(24), { interval: 1, unit: 'days' }); + deepShapeEqual(sb.autoSyncTriggerForHours(48), { interval: 2, unit: 'days' }); + deepShapeEqual(sb.autoSyncTriggerForHours(168), { interval: 7, unit: 'days' }); + }); + + test('non-day-multiple > 24 stays as hours', () => { + const sb = makeSandbox(); + // 36h doesn't divide evenly into days, stay as hours + deepShapeEqual(sb.autoSyncTriggerForHours(36), { interval: 36, unit: 'hours' }); + }); + + test('invalid input defaults to 24 hours', () => { + const sb = makeSandbox(); + // Per autoSyncTriggerForHours: `parseInt(undefined, 10) || 24` → 24, becomes 1 day + deepShapeEqual(sb.autoSyncTriggerForHours(undefined), { interval: 1, unit: 'days' }); + }); +}); + +describe('autoSyncHoursFromTrigger', () => { + test('hours unit returned directly', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncHoursFromTrigger({ interval: 6, unit: 'hours' }), 6); + }); + + test('days unit multiplied by 24', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncHoursFromTrigger({ interval: 3, unit: 'days' }), 72); + }); + + test('weeks unit multiplied by 168', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncHoursFromTrigger({ interval: 1, unit: 'weeks' }), 168); + }); + + test('minutes unit rounds up to at least 1 hour', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncHoursFromTrigger({ interval: 30, unit: 'minutes' }), 1); + assert.equal(sb.autoSyncHoursFromTrigger({ interval: 90, unit: 'minutes' }), 2); + }); + + test('zero or missing interval returns null', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncHoursFromTrigger({}), null); + assert.equal(sb.autoSyncHoursFromTrigger({ interval: 0 }), null); + }); + + test('round-trip with autoSyncTriggerForHours preserves hour count', () => { + const sb = makeSandbox(); + for (const hours of [1, 4, 12, 24, 48, 168]) { + const config = sb.autoSyncTriggerForHours(hours); + assert.equal(sb.autoSyncHoursFromTrigger(config), hours, `round-trip ${hours}`); + } + }); +}); + +// ========================================================================= +// Label helpers +// ========================================================================= + +describe('autoSyncBucketLabel', () => { + test('weekly bucket', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncBucketLabel(168), 'Weekly'); + }); + + test('day-multiple buckets', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncBucketLabel(24), '1d'); + assert.equal(sb.autoSyncBucketLabel(48), '2d'); + }); + + test('sub-day buckets', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncBucketLabel(1), '1h'); + assert.equal(sb.autoSyncBucketLabel(12), '12h'); + }); +}); + +describe('autoSyncIntervalLabel', () => { + test('pluralization', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncIntervalLabel(1), 'Every 1 hour'); + assert.equal(sb.autoSyncIntervalLabel(2), 'Every 2 hours'); + assert.equal(sb.autoSyncIntervalLabel(24), 'Every 1 day'); + assert.equal(sb.autoSyncIntervalLabel(48), 'Every 2 days'); + assert.equal(sb.autoSyncIntervalLabel(168), 'Every week'); + }); +}); + +describe('autoSyncSourceLabel', () => { + test('known sources mapped', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncSourceLabel('spotify'), 'Spotify'); + assert.equal(sb.autoSyncSourceLabel('youtube'), 'YouTube'); + }); + + test('unknown source returns the raw key', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncSourceLabel('newthing'), 'newthing'); + }); + + test('falsy source returns "Other"', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncSourceLabel(''), 'Other'); + assert.equal(sb.autoSyncSourceLabel(null), 'Other'); + }); +}); + +// ========================================================================= +// Schedulability and ownership predicates +// ========================================================================= + +describe('autoSyncCanSchedulePlaylist', () => { + test('blocks file and beatport sources', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncCanSchedulePlaylist({ source: 'file' }), false); + assert.equal(sb.autoSyncCanSchedulePlaylist({ source: 'beatport' }), false); + }); + + test('allows refreshable sources', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncCanSchedulePlaylist({ source: 'spotify' }), true); + assert.equal(sb.autoSyncCanSchedulePlaylist({ source: 'youtube' }), true); + }); + + test('null/undefined playlist returns falsy', () => { + const sb = makeSandbox(); + assert.ok(!sb.autoSyncCanSchedulePlaylist(null)); + assert.ok(!sb.autoSyncCanSchedulePlaylist(undefined)); + }); +}); + +describe('autoSyncIsPipelineAutomation', () => { + test('matches playlist_pipeline action type', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncIsPipelineAutomation({ action_type: 'playlist_pipeline' }), true); + assert.equal(sb.autoSyncIsPipelineAutomation({ action_type: 'process_wishlist' }), false); + }); +}); + +describe('autoSyncPlaylistIdFromAutomation', () => { + test('extracts numeric playlist_id', () => { + const sb = makeSandbox(); + const auto = { action_type: 'playlist_pipeline', action_config: { playlist_id: '42' } }; + assert.equal(sb.autoSyncPlaylistIdFromAutomation(auto), 42); + }); + + test('returns null when all=true (catch-all pipeline)', () => { + const sb = makeSandbox(); + const auto = { action_type: 'playlist_pipeline', action_config: { all: true } }; + assert.equal(sb.autoSyncPlaylistIdFromAutomation(auto), null); + }); + + test('returns null for non-pipeline automations', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncPlaylistIdFromAutomation({ action_type: 'other' }), null); + }); + + test('returns null when playlist_id missing', () => { + const sb = makeSandbox(); + const auto = { action_type: 'playlist_pipeline', action_config: {} }; + assert.equal(sb.autoSyncPlaylistIdFromAutomation(auto), null); + }); +}); + +describe('autoSyncIsScheduleOwned', () => { + test('owned_by="auto_sync" wins over name/group', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncIsScheduleOwned({ + owned_by: 'auto_sync', name: 'Whatever', group_name: 'unrelated', + }), true); + }); + + test('legacy name-prefix still recognized', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncIsScheduleOwned({ name: 'Auto-Sync: Discover Weekly' }), true); + }); + + test('legacy group_name still recognized', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncIsScheduleOwned({ group_name: 'Playlist Auto-Sync' }), true); + }); + + test('automation with no owned_by and no legacy markers returns false', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncIsScheduleOwned({ name: 'My Custom Pipeline' }), false); + }); +}); + +// ========================================================================= +// State partitioning +// ========================================================================= + +describe('buildAutoSyncScheduleState', () => { + test('partitions board-owned schedules from custom pipelines', () => { + const sb = makeSandbox(); + const playlists = [{ id: 1, name: 'Discover Weekly' }, { id: 2, name: 'Top Hits' }]; + const automations = [ + { + id: 10, action_type: 'playlist_pipeline', trigger_type: 'schedule', + trigger_config: { interval: 1, unit: 'hours' }, + action_config: { playlist_id: '1' }, + owned_by: 'auto_sync', + enabled: 1, + }, + { + id: 11, action_type: 'playlist_pipeline', trigger_type: 'schedule', + trigger_config: { interval: 1, unit: 'days' }, + action_config: { playlist_id: '99' }, // not in playlists, but custom-owned + enabled: 1, + // no owned_by → custom pipeline + }, + ]; + const state = sb.buildAutoSyncScheduleState(playlists, automations); + assert.equal(Object.keys(state.playlistSchedules).length, 1); + assert.equal(state.playlistSchedules[1].automation_id, 10); + assert.equal(state.playlistSchedules[1].hours, 1); + assert.equal(state.automationPipelines.length, 1); + assert.equal(state.automationPipelines[0].id, 11); + }); + + test('non-pipeline automations are ignored entirely', () => { + const sb = makeSandbox(); + const automations = [ + { id: 20, action_type: 'process_wishlist', trigger_type: 'schedule' }, + ]; + const state = sb.buildAutoSyncScheduleState([], automations); + deepShapeEqual(state.playlistSchedules, {}); + deepShapeEqual(state.automationPipelines, []); + }); +}); + +// ========================================================================= +// Timezone-aware countdown — the headline bug this branch fixed +// ========================================================================= + +describe('autoSyncNextRunLabel', () => { + test('empty string for missing input', () => { + const sb = makeSandbox(); + assert.equal(sb.autoSyncNextRunLabel(''), ''); + assert.equal(sb.autoSyncNextRunLabel(null), ''); + }); + + test('naive UTC string is parsed as UTC, not local', () => { + const sb = makeSandbox(); + // Pick a time exactly one hour from now in UTC. If the parser + // mistakenly treats the bare timestamp as LOCAL it would land + // wildly far from 1h on machines in non-UTC timezones — + // that's exactly the bug Cin's review flagged. + const future = new Date(Date.now() + 60 * 60 * 1000); + const iso = future.toISOString().slice(0, 19).replace('T', ' '); + const label = sb.autoSyncNextRunLabel(iso); + // Allow either "next in 60m" (right at the boundary) or "next in 1h" + assert.ok(/^next in (60m|1h)$/.test(label), `expected ~1h, got "${label}"`); + }); + + test('"due now" when timestamp is in the past', () => { + const sb = makeSandbox(); + const past = new Date(Date.now() - 60 * 1000).toISOString().slice(0, 19).replace('T', ' '); + assert.equal(sb.autoSyncNextRunLabel(past), 'due now'); + }); + + test('day-scale label when timestamp is days out', () => { + const sb = makeSandbox(); + const future = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000); + const iso = future.toISOString().slice(0, 19).replace('T', ' '); + const label = sb.autoSyncNextRunLabel(iso); + assert.match(label, /^next in \dd$/); + }); +}); + +// ========================================================================= +// getMirroredSourceRef — playlist source URL resolution +// ========================================================================= + +describe('getMirroredSourceRef', () => { + test('explicit source_ref wins', () => { + const sb = makeSandbox(); + assert.equal( + sb.getMirroredSourceRef({ source_ref: 'https://example.com/x' }), + 'https://example.com/x', + ); + }); + + test('falls back to description URL for spotify_public', () => { + const sb = makeSandbox(); + const p = { + source: 'spotify_public', + description: 'https://open.spotify.com/playlist/abc', + }; + assert.equal(sb.getMirroredSourceRef(p), 'https://open.spotify.com/playlist/abc'); + }); + + test('non-URL description ignored, falls through to source_playlist_id', () => { + const sb = makeSandbox(); + const p = { + source: 'spotify_public', + description: 'just a note about this playlist', + source_playlist_id: 'abc123', + }; + assert.equal(sb.getMirroredSourceRef(p), 'abc123'); + }); + + test('empty playlist returns empty string', () => { + const sb = makeSandbox(); + assert.equal(sb.getMirroredSourceRef({}), ''); + }); +}); diff --git a/tests/test_auto_sync_js.py b/tests/test_auto_sync_js.py new file mode 100644 index 00000000..cd610b73 --- /dev/null +++ b/tests/test_auto_sync_js.py @@ -0,0 +1,72 @@ +"""Run the JS tests for `webui/static/auto-sync.js` under the regular +pytest sweep. + +The actual contract tests live in `tests/static/test_auto_sync.mjs` +and run via Node.js's stable built-in test runner (`node --test`). +This shim shells out to that runner and asserts a clean exit so the +JS tests fail the suite if the auto-sync helpers regress. + +Skipped when: + - `node` isn't on PATH (e.g. Python-only dev container). + - Node version < 22 (the built-in `--test` runner went stable in 18 + but the assert-flavor we use is 22+). + +Run directly: + node --test tests/static/test_auto_sync.mjs +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + + +_REPO_ROOT = Path(__file__).resolve().parents[1] +_TEST_FILE = _REPO_ROOT / "tests" / "static" / "test_auto_sync.mjs" + + +def _node_available() -> bool: + if not shutil.which("node"): + return False + try: + result = subprocess.run( + ["node", "--version"], + capture_output=True, text=True, timeout=10, + ) + except (subprocess.SubprocessError, FileNotFoundError): + return False + if result.returncode != 0: + return False + raw = (result.stdout or "").strip().lstrip("v") + try: + major = int(raw.split(".")[0]) + except (ValueError, IndexError): + return False + return major >= 22 + + +def test_auto_sync_js(): + """Pin the auto-sync helper contract via `node --test`.""" + if not _node_available(): + pytest.skip("Node.js >= 22 required to run the JS auto-sync tests") + + if not _TEST_FILE.exists(): + pytest.skip(f"JS test file missing: {_TEST_FILE}") + + result = subprocess.run( + ["node", "--test", str(_TEST_FILE)], + capture_output=True, text=True, + cwd=str(_REPO_ROOT), + timeout=60, + ) + + if result.returncode != 0: + pytest.fail( + "JS auto-sync tests failed:\n\n" + f"--- stdout ---\n{result.stdout}\n" + f"--- stderr ---\n{result.stderr}", + pytrace=False, + ) diff --git a/tests/test_script_split_integrity.py b/tests/test_script_split_integrity.py index a801f7d1..1f11f05a 100644 --- a/tests/test_script_split_integrity.py +++ b/tests/test_script_split_integrity.py @@ -45,6 +45,7 @@ SPLIT_MODULES = [ "discover.js", "enrichment.js", "stats-automations.js", + "auto-sync.js", "pages-extra.js", "init.js", ] From f67fff22b41b19a9af35f84088a563ed61149491 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 25 May 2026 00:36:11 -0700 Subject: [PATCH 21/81] Redesign dashboard quick actions tile Replace the old Tools CTA with a unified three-lane dashboard launcher for Tools, Auto-Sync, and Automations, using restrained glass/accent styling and responsive stacked behavior. --- webui/index.html | 37 +++++++-- webui/static/style.css | 183 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 9 deletions(-) diff --git a/webui/index.html b/webui/index.html index 0ddcbaad..dd89a5bc 100644 --- a/webui/index.html +++ b/webui/index.html @@ -838,17 +838,36 @@ -
+
-

Tools

-

Database, scanning, backups, cache, maintenance & more.

+

Quick Actions

+

Jump into the places that shape how SoulSync runs.

-
-
- -
-
Open Tools
- +
+ + +
diff --git a/webui/static/style.css b/webui/static/style.css index f2784d42..75c53d83 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -59805,6 +59805,189 @@ body.reduce-effects .dash-card::after { /* Body overrides for embedded sub-grids — make them compact for bento */ +/* Quick Actions: polished three-lane dashboard launcher */ +.dash-card--quick-actions { + overflow: hidden; +} + +.dash-card--quick-actions .dash-card__body { + min-height: 128px; +} + +.dashboard-action-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + overflow: hidden; + background: + linear-gradient(135deg, rgba(255,255,255,0.12), rgba(255,255,255,0.025)), + rgba(255, 255, 255, 0.035); + box-shadow: + inset 0 1px 0 rgba(255,255,255,0.08), + 0 12px 28px rgba(0,0,0,0.16); +} + +.dashboard-action-lane { + position: relative; + min-width: 0; + min-height: 126px; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + padding: 16px; + border: 0; + background: + radial-gradient(circle at 22% 10%, var(--lane-glow), transparent 42%), + rgba(255, 255, 255, 0.032); + color: rgba(255, 255, 255, 0.88); + cursor: pointer; + text-align: left; + transition: background 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; +} + +.dashboard-action-lane + .dashboard-action-lane { + box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.07); +} + +.dashboard-action-lane::after { + content: ''; + position: absolute; + inset: 10px; + border-radius: 11px; + border: 1px solid transparent; + pointer-events: none; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.dashboard-action-lane:hover, +.dashboard-action-lane:focus-visible { + background: + radial-gradient(circle at 22% 10%, var(--lane-glow-strong), transparent 46%), + rgba(255, 255, 255, 0.06); + transform: translateY(-1px); +} + +.dashboard-action-lane:hover::after, +.dashboard-action-lane:focus-visible::after { + border-color: var(--lane-border); + background: rgba(255, 255, 255, 0.025); +} + +.dashboard-action-lane.tools { + --lane-glow: rgba(56, 189, 248, 0.18); + --lane-glow-strong: rgba(56, 189, 248, 0.3); + --lane-border: rgba(56, 189, 248, 0.28); + --lane-accent: #7dd3fc; +} + +.dashboard-action-lane.auto-sync { + --lane-glow: rgba(34, 197, 94, 0.16); + --lane-glow-strong: rgba(34, 197, 94, 0.28); + --lane-border: rgba(34, 197, 94, 0.26); + --lane-accent: #86efac; +} + +.dashboard-action-lane.automations { + --lane-glow: rgba(168, 85, 247, 0.18); + --lane-glow-strong: rgba(168, 85, 247, 0.3); + --lane-border: rgba(168, 85, 247, 0.28); + --lane-accent: #d8b4fe; +} + +.dashboard-action-icon { + width: 38px; + height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 11px; + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.11); + color: var(--lane-accent); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.dashboard-action-label { + display: block; + color: rgba(255, 255, 255, 0.92); + font-size: 14px; + font-weight: 800; + line-height: 1.1; +} + +.dashboard-action-copy { + display: block; + min-height: 28px; + margin-top: 4px; + color: rgba(255, 255, 255, 0.48); + font-size: 11px; + font-weight: 600; + line-height: 1.25; +} + +.dashboard-action-arrow { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--lane-accent); + font-size: 11px; + font-weight: 800; +} + +.dashboard-action-arrow::after { + content: '›'; + font-size: 16px; + line-height: 1; + transform: translateY(-1px); + transition: transform 0.18s ease; +} + +.dashboard-action-lane:hover .dashboard-action-arrow::after, +.dashboard-action-lane:focus-visible .dashboard-action-arrow::after { + transform: translate(3px, -1px); +} + +@media (max-width: 1180px) { + .dashboard-action-lane { + padding: 14px 12px; + } + + .dashboard-action-copy { + min-height: 42px; + } +} + +@media (max-width: 760px) { + .dashboard-action-grid { + grid-template-columns: 1fr; + gap: 1px; + } + + .dashboard-action-lane { + min-height: 86px; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 12px; + } + + .dashboard-action-lane + .dashboard-action-lane { + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.07); + } + + .dashboard-action-copy { + min-height: 0; + } + + .dashboard-action-arrow { + align-self: center; + } +} + /* Service status: 3 service cards side-by-side in a tighter grid */ .dash-card[data-card="services"] .service-status-grid { display: grid; From f402badac9edcaa8d331a6944c8ffd6dc184bd6a Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 25 May 2026 00:38:00 -0700 Subject: [PATCH 22/81] Align dashboard actions with accent theme Update the dashboard Quick Actions tile to use the shared accent color variables for lane glow, icon chips, borders, hover states, and keyboard focus while keeping the three-destination launcher responsive. --- webui/index.html | 2 +- webui/static/style.css | 68 +++++++++++++++++++++++++++++++----------- 2 files changed, 51 insertions(+), 19 deletions(-) diff --git a/webui/index.html b/webui/index.html index dd89a5bc..1fd1df4b 100644 --- a/webui/index.html +++ b/webui/index.html @@ -837,7 +837,7 @@
- +

Quick Actions

diff --git a/webui/static/style.css b/webui/static/style.css index 75c53d83..df0752c8 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -59815,22 +59815,43 @@ body.reduce-effects .dash-card::after { } .dashboard-action-grid { + position: relative; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 14px; overflow: hidden; + isolation: isolate; background: + radial-gradient(circle at 50% -20%, rgba(var(--accent-rgb), 0.16), transparent 44%), linear-gradient(135deg, rgba(255,255,255,0.12), rgba(255,255,255,0.025)), rgba(255, 255, 255, 0.035); box-shadow: inset 0 1px 0 rgba(255,255,255,0.08), - 0 12px 28px rgba(0,0,0,0.16); + 0 12px 28px rgba(0,0,0,0.16), + 0 0 0 1px rgba(var(--accent-rgb), 0.04); +} + +.dashboard-action-grid::before { + content: ''; + position: absolute; + inset: 0 0 auto; + height: 2px; + background: linear-gradient(90deg, + transparent, + rgba(var(--accent-rgb), 0.4), + rgb(var(--accent-light-rgb)), + rgba(var(--accent-rgb), 0.4), + transparent); + opacity: 0.85; + pointer-events: none; + z-index: 2; } .dashboard-action-lane { position: relative; + z-index: 1; min-width: 0; min-height: 126px; display: flex; @@ -59841,7 +59862,7 @@ body.reduce-effects .dash-card::after { padding: 16px; border: 0; background: - radial-gradient(circle at 22% 10%, var(--lane-glow), transparent 42%), + radial-gradient(circle at var(--lane-glow-x, 22%) 10%, var(--lane-glow), transparent 42%), rgba(255, 255, 255, 0.032); color: rgba(255, 255, 255, 0.88); cursor: pointer; @@ -59866,11 +59887,16 @@ body.reduce-effects .dash-card::after { .dashboard-action-lane:hover, .dashboard-action-lane:focus-visible { background: - radial-gradient(circle at 22% 10%, var(--lane-glow-strong), transparent 46%), + radial-gradient(circle at var(--lane-glow-x, 22%) 10%, var(--lane-glow-strong), transparent 46%), rgba(255, 255, 255, 0.06); transform: translateY(-1px); } +.dashboard-action-lane:focus-visible { + outline: 2px solid rgba(var(--accent-rgb), 0.55); + outline-offset: -4px; +} + .dashboard-action-lane:hover::after, .dashboard-action-lane:focus-visible::after { border-color: var(--lane-border); @@ -59878,24 +59904,27 @@ body.reduce-effects .dash-card::after { } .dashboard-action-lane.tools { - --lane-glow: rgba(56, 189, 248, 0.18); - --lane-glow-strong: rgba(56, 189, 248, 0.3); - --lane-border: rgba(56, 189, 248, 0.28); - --lane-accent: #7dd3fc; + --lane-glow-x: 18%; + --lane-glow: rgba(var(--accent-rgb), 0.12); + --lane-glow-strong: rgba(var(--accent-rgb), 0.22); + --lane-border: rgba(var(--accent-rgb), 0.26); + --lane-accent: rgb(var(--accent-light-rgb)); } .dashboard-action-lane.auto-sync { - --lane-glow: rgba(34, 197, 94, 0.16); - --lane-glow-strong: rgba(34, 197, 94, 0.28); - --lane-border: rgba(34, 197, 94, 0.26); - --lane-accent: #86efac; + --lane-glow-x: 50%; + --lane-glow: rgba(var(--accent-rgb), 0.18); + --lane-glow-strong: rgba(var(--accent-rgb), 0.3); + --lane-border: rgba(var(--accent-rgb), 0.34); + --lane-accent: rgb(var(--accent-light-rgb)); } .dashboard-action-lane.automations { - --lane-glow: rgba(168, 85, 247, 0.18); - --lane-glow-strong: rgba(168, 85, 247, 0.3); - --lane-border: rgba(168, 85, 247, 0.28); - --lane-accent: #d8b4fe; + --lane-glow-x: 82%; + --lane-glow: rgba(var(--accent-rgb), 0.12); + --lane-glow-strong: rgba(var(--accent-rgb), 0.22); + --lane-border: rgba(var(--accent-rgb), 0.26); + --lane-accent: rgb(var(--accent-light-rgb)); } .dashboard-action-icon { @@ -59905,10 +59934,12 @@ body.reduce-effects .dash-card::after { align-items: center; justify-content: center; border-radius: 11px; - background: rgba(255, 255, 255, 0.07); - border: 1px solid rgba(255, 255, 255, 0.11); + background: rgba(var(--accent-rgb), 0.1); + border: 1px solid rgba(var(--accent-rgb), 0.18); color: var(--lane-accent); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 0 16px rgba(var(--accent-rgb), 0.08); } .dashboard-action-label { @@ -59936,6 +59967,7 @@ body.reduce-effects .dash-card::after { color: var(--lane-accent); font-size: 11px; font-weight: 800; + letter-spacing: 0.01em; } .dashboard-action-arrow::after { From efdcde1892cc6f1dc97e0406c6c5b69c17fd3801 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 25 May 2026 00:55:55 -0700 Subject: [PATCH 23/81] Add playlist auto-sync run history Persist per-playlist pipeline run snapshots from the shared playlist pipeline, expose a history API, and upgrade the Auto-Sync modal with live pipeline monitoring, Run now controls, and a runs-style history tab. --- core/playlists/pipeline.py | 118 ++++++++- database/music_database.py | 91 +++++++ web_server.py | 31 +++ webui/static/auto-sync.js | 286 ++++++++++++++++++++- webui/static/style.css | 510 +++++++++++++++++++++++++++++++++++-- 5 files changed, 999 insertions(+), 37 deletions(-) diff --git a/core/playlists/pipeline.py b/core/playlists/pipeline.py index 2bf0fc9c..00d76432 100644 --- a/core/playlists/pipeline.py +++ b/core/playlists/pipeline.py @@ -11,8 +11,10 @@ future playlist-card "Run Pipeline" button can call the same command. from __future__ import annotations +import json import threading import time +from datetime import datetime, timezone from typing import Any, Callable, Dict, List @@ -48,7 +50,12 @@ def run_mirrored_playlist_pipeline( else: deps.state.set_pipeline_running(True) automation_id = config.get('_automation_id') + trigger_source = config.get('_trigger_source') or ( + 'manual' if str(automation_id or '').startswith('mirrored_') else 'automation' + ) pipeline_start = time.time() + history_playlists: List[Dict[str, Any]] = [] + before_snapshots: Dict[int, Dict[str, Any]] = {} try: db = deps.get_database() @@ -65,6 +72,12 @@ def run_mirrored_playlist_pipeline( if not playlists: deps.state.set_pipeline_running(False) return {'status': 'error', 'error': 'No refreshable playlists found'} + history_playlists = list(playlists) + before_snapshots = { + int(pl['id']): _playlist_history_snapshot(db, pl) + for pl in history_playlists + if pl.get('id') + } deps.update_progress( automation_id, @@ -117,8 +130,7 @@ def run_mirrored_playlist_pipeline( log_type='success', ) - deps.state.set_pipeline_running(False) - return { + result = { 'status': 'completed', '_manages_own_progress': True, 'playlists_refreshed': str(refreshed), @@ -128,9 +140,38 @@ def run_mirrored_playlist_pipeline( 'wishlist_queued': str(sync_summary['wishlist_queued']), 'duration_seconds': str(duration), } + try: + _record_playlist_pipeline_history( + db, + history_playlists, + before_snapshots, + result, + status='completed', + started_at=pipeline_start, + finished_at=time.time(), + trigger_source=trigger_source, + ) + except Exception as history_error: # noqa: BLE001 - history should never fail a successful pipeline + deps.logger.debug(f"[Pipeline] History recording failed: {history_error}") + deps.state.set_pipeline_running(False) + return result except Exception as e: # noqa: BLE001 - pipeline callers should receive status dicts deps.state.set_pipeline_running(False) + try: + if history_playlists: + _record_playlist_pipeline_history( + db, + history_playlists, + before_snapshots, + {'status': 'error', 'error': str(e), '_manages_own_progress': True}, + status='error', + started_at=pipeline_start, + finished_at=time.time(), + trigger_source=trigger_source, + ) + except Exception as history_error: # noqa: BLE001 - history should never mask pipeline errors + deps.logger.debug(f"[Pipeline] History recording failed after error: {history_error}") deps.update_progress( automation_id, status='error', @@ -142,6 +183,79 @@ def run_mirrored_playlist_pipeline( return {'status': 'error', 'error': str(e), '_manages_own_progress': True} +def _pipeline_history_timestamp(ts: float) -> str: + return datetime.fromtimestamp(ts, timezone.utc).isoformat() + + +def _playlist_history_snapshot(db: Any, playlist: Dict[str, Any]) -> Dict[str, Any]: + playlist_id = int(playlist['id']) + current = db.get_mirrored_playlist(playlist_id) or playlist + counts = db.get_mirrored_playlist_status_counts(playlist_id) + return { + 'playlist_id': playlist_id, + 'name': current.get('name') or playlist.get('name') or '', + 'source': current.get('source') or playlist.get('source') or '', + 'track_count': int(counts.get('total') or current.get('track_count') or 0), + 'discovered_count': int(counts.get('discovered') or 0), + 'wishlisted_count': int(counts.get('wishlisted') or 0), + 'in_library_count': int(counts.get('in_library') or 0), + } + + +def _playlist_history_summary(before: Dict[str, Any], after: Dict[str, Any], status: str) -> str: + before_tracks = int(before.get('track_count') or 0) + after_tracks = int(after.get('track_count') or 0) + track_delta = after_tracks - before_tracks + before_discovered = int(before.get('discovered_count') or 0) + after_discovered = int(after.get('discovered_count') or 0) + discovered_delta = after_discovered - before_discovered + parts = [status.capitalize()] + parts.append(f"{before_tracks} -> {after_tracks} tracks") + if track_delta: + parts.append(f"{track_delta:+d} tracks") + if discovered_delta: + parts.append(f"{discovered_delta:+d} discovered") + return ' | '.join(parts) + + +def _record_playlist_pipeline_history( + db: Any, + playlists: List[Dict[str, Any]], + before_snapshots: Dict[int, Dict[str, Any]], + result: Dict[str, Any], + *, + status: str, + started_at: float, + finished_at: float, + trigger_source: str, +) -> None: + if not hasattr(db, 'insert_playlist_pipeline_run_history'): + return + duration = max(0, finished_at - started_at) + for playlist in playlists: + if not playlist.get('id'): + continue + playlist_id = int(playlist['id']) + before = before_snapshots.get(playlist_id, {}) + after = _playlist_history_snapshot(db, playlist) + db.insert_playlist_pipeline_run_history( + playlist_id=playlist_id, + playlist_name=after.get('name') or playlist.get('name') or '', + source=after.get('source') or playlist.get('source') or '', + profile_id=int(playlist.get('profile_id') or 1), + trigger_source=trigger_source, + started_at=_pipeline_history_timestamp(started_at), + finished_at=_pipeline_history_timestamp(finished_at), + duration_seconds=duration, + status=status, + summary=_playlist_history_summary(before, after, status), + before_json=json.dumps(before), + after_json=json.dumps(after), + result_json=json.dumps(result), + log_lines=None, + ) + + def _resolve_pipeline_playlists(db: Any, playlist_id: Any, process_all: bool) -> List[Dict[str, Any]] | None: if process_all: return db.get_mirrored_playlists() diff --git a/database/music_database.py b/database/music_database.py index 6ffd1311..e27500e9 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -531,6 +531,30 @@ class MusicDatabase: """) cursor.execute("CREATE INDEX IF NOT EXISTS idx_arh_automation_id ON automation_run_history(automation_id)") + # Playlist pipeline run history table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS playlist_pipeline_run_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + playlist_id INTEGER, + playlist_name TEXT, + source TEXT, + profile_id INTEGER DEFAULT 1, + trigger_source TEXT DEFAULT 'pipeline', + started_at TIMESTAMP, + finished_at TIMESTAMP, + duration_seconds REAL, + status TEXT NOT NULL, + summary TEXT, + before_json TEXT, + after_json TEXT, + result_json TEXT, + log_lines TEXT, + FOREIGN KEY (playlist_id) REFERENCES mirrored_playlists(id) ON DELETE SET NULL + ) + """) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_pprh_playlist_id ON playlist_pipeline_run_history(playlist_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_pprh_profile_id ON playlist_pipeline_run_history(profile_id)") + # Add explored_at to mirrored_playlists (migration) self._add_mirrored_playlist_explored_column(cursor) @@ -12417,6 +12441,73 @@ class MusicDatabase: logger.error(f"Error clearing automation run history: {e}") return 0 + def insert_playlist_pipeline_run_history(self, playlist_id, playlist_name, source, + profile_id, trigger_source, started_at, + finished_at, duration_seconds, status, + summary=None, before_json=None, + after_json=None, result_json=None, + log_lines=None): + """Insert a playlist pipeline run history entry and retain recent rows per profile.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO playlist_pipeline_run_history + (playlist_id, playlist_name, source, profile_id, trigger_source, + started_at, finished_at, duration_seconds, status, summary, + before_json, after_json, result_json, log_lines) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + playlist_id, playlist_name, source, profile_id, trigger_source, + started_at, finished_at, duration_seconds, status, summary, + before_json, after_json, result_json, log_lines, + )) + cursor.execute(""" + DELETE FROM playlist_pipeline_run_history + WHERE profile_id = ? AND id NOT IN ( + SELECT id FROM playlist_pipeline_run_history + WHERE profile_id = ? + ORDER BY id DESC LIMIT 300 + ) + """, (profile_id, profile_id)) + conn.commit() + return True + except Exception as e: + logger.error(f"Error inserting playlist pipeline run history for {playlist_id}: {e}") + return False + + def get_playlist_pipeline_run_history(self, profile_id=1, playlist_id=None, limit=50, offset=0): + """Get playlist pipeline run history, newest first.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + where = ["profile_id = ?"] + params = [profile_id] + if playlist_id: + where.append("playlist_id = ?") + params.append(playlist_id) + where_sql = " AND ".join(where) + cursor.execute( + f"SELECT COUNT(*) FROM playlist_pipeline_run_history WHERE {where_sql}", + params, + ) + total = cursor.fetchone()[0] + cursor.execute(f""" + SELECT id, playlist_id, playlist_name, source, profile_id, trigger_source, + started_at, finished_at, duration_seconds, status, summary, + before_json, after_json, result_json, log_lines + FROM playlist_pipeline_run_history + WHERE {where_sql} + ORDER BY id DESC + LIMIT ? OFFSET ? + """, [*params, limit, offset]) + cols = [d[0] for d in cursor.description] + rows = [dict(zip(cols, row, strict=False)) for row in cursor.fetchall()] + return {'history': rows, 'total': total} + except Exception as e: + logger.error(f"Error getting playlist pipeline run history: {e}") + return {'history': [], 'total': 0} + def get_radio_tracks(self, track_id, limit=20, exclude_ids=None) -> Dict[str, Any]: """Find similar tracks for radio mode auto-play queue. diff --git a/web_server.py b/web_server.py index 51303934..f50a9e18 100644 --- a/web_server.py +++ b/web_server.py @@ -32479,6 +32479,37 @@ def get_mirrored_playlist_pipeline_status_endpoint(playlist_id): logger.error(f"Error getting mirrored playlist pipeline status: {e}") return jsonify({"error": str(e)}), 500 + +@app.route('/api/playlist-pipeline/history', methods=['GET']) +def get_playlist_pipeline_history_endpoint(): + """Return persisted run history for mirrored playlist pipeline executions.""" + try: + database = get_database() + profile_id = get_current_profile_id() + limit = min(max(int(request.args.get('limit', 50)), 1), 100) + offset = max(int(request.args.get('offset', 0)), 0) + playlist_id_raw = request.args.get('playlist_id') + playlist_id = int(playlist_id_raw) if playlist_id_raw else None + data = database.get_playlist_pipeline_run_history( + profile_id=profile_id, + playlist_id=playlist_id, + limit=limit, + offset=offset, + ) + for entry in data.get('history', []): + for key in ('before_json', 'after_json', 'result_json', 'log_lines'): + if entry.get(key): + try: + entry[key] = json.loads(entry[key]) + except (json.JSONDecodeError, TypeError): + entry[key] = [] if key == 'log_lines' else {} + else: + entry[key] = [] if key == 'log_lines' else {} + return jsonify(data) + except Exception as e: + logger.error(f"Error getting playlist pipeline history: {e}") + return jsonify({"error": str(e)}), 500 + @app.route('/api/mirrored-playlists/', methods=['DELETE']) def delete_mirrored_playlist_endpoint(playlist_id): """Delete a mirrored playlist.""" diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js index e30cda02..b54b725e 100644 --- a/webui/static/auto-sync.js +++ b/webui/static/auto-sync.js @@ -10,11 +10,15 @@ const mirroredPipelinePollers = {}; const AUTO_SYNC_BUCKETS = [1, 2, 4, 8, 12, 16, 24, 48, 72, 168]; +let _autoSyncStatusPoller = null; +let _autoSyncIsDragging = false; let _autoSyncScheduleState = { playlists: [], automations: [], playlistSchedules: {}, automationPipelines: [], + runHistory: [], + runHistoryTotal: 0, }; let _autoSyncActiveTab = 'schedule'; @@ -103,7 +107,7 @@ function autoSyncIsScheduleOwned(auto) { return group === 'Playlist Auto-Sync' || name.startsWith('Auto-Sync:'); } -function buildAutoSyncScheduleState(playlists, automations) { +function buildAutoSyncScheduleState(playlists, automations, historyData = {}) { const playlistSchedules = {}; const automationPipelines = []; const pipelineAutomations = automations.filter(autoSyncIsPipelineAutomation); @@ -124,7 +128,14 @@ function buildAutoSyncScheduleState(playlists, automations) { automationPipelines.push(auto); } }); - return { playlists, automations, playlistSchedules, automationPipelines }; + return { + playlists, + automations, + playlistSchedules, + automationPipelines, + runHistory: historyData.history || [], + runHistoryTotal: historyData.total || 0, + }; } async function openAutoSyncScheduleModal() { @@ -154,6 +165,7 @@ async function openAutoSyncScheduleModal() { function closeAutoSyncScheduleModal() { const overlay = document.getElementById('auto-sync-schedule-modal'); + stopAutoSyncStatusPolling(); if (overlay) overlay.remove(); } @@ -161,16 +173,20 @@ async function refreshAutoSyncScheduleModal() { const overlay = document.getElementById('auto-sync-schedule-modal'); if (!overlay) return; try { - const [playlistRes, automationRes] = await Promise.all([ + const [playlistRes, automationRes, historyRes] = await Promise.all([ fetch('/api/mirrored-playlists'), fetch('/api/automations'), + fetch('/api/playlist-pipeline/history?limit=50'), ]); const playlists = await playlistRes.json(); const automations = await automationRes.json(); + const historyData = await historyRes.json(); if (!playlistRes.ok || playlists.error) throw new Error(playlists.error || 'Failed to load mirrored playlists'); if (!automationRes.ok || automations.error) throw new Error(automations.error || 'Failed to load automations'); - _autoSyncScheduleState = buildAutoSyncScheduleState(playlists, automations); + if (!historyRes.ok || historyData.error) throw new Error(historyData.error || 'Failed to load pipeline run history'); + _autoSyncScheduleState = buildAutoSyncScheduleState(playlists, automations, historyData); renderAutoSyncScheduleModal(); + manageAutoSyncStatusPolling(); } catch (err) { overlay.innerHTML = `
@@ -188,16 +204,19 @@ function renderAutoSyncScheduleModal() { const overlay = document.getElementById('auto-sync-schedule-modal'); if (!overlay) return; - const { playlists, playlistSchedules, automationPipelines } = _autoSyncScheduleState; + const { playlists, playlistSchedules, automationPipelines, runHistory, runHistoryTotal } = _autoSyncScheduleState; const scheduledCount = Object.keys(playlistSchedules).length; const enabledCount = Object.values(playlistSchedules).filter(s => s.enabled).length; const pipelineCount = automationPipelines.length; const totalTracks = playlists.reduce((sum, p) => sum + (parseInt(p.track_count, 10) || 0), 0); const scheduleActive = _autoSyncActiveTab === 'schedule'; const automationsActive = _autoSyncActiveTab === 'automations'; + const historyActive = _autoSyncActiveTab === 'history'; const schedulePanel = renderAutoSyncSchedulePanel(playlists, playlistSchedules); const automationPanel = renderAutoSyncAutomationPanel(automationPipelines, playlists); + const historyPanel = renderAutoSyncHistoryPanel(runHistory, runHistoryTotal); + const monitor = renderAutoSyncPipelineMonitor(playlists); overlay.innerHTML = `
@@ -215,18 +234,21 @@ function renderAutoSyncScheduleModal() {
${pipelineCount}automation pipelines
${totalTracks}mirrored tracks
+ ${monitor}
+
${schedulePanel}
${automationPanel}
+
${historyPanel}
`; } function setAutoSyncTab(tab) { - _autoSyncActiveTab = tab === 'automations' ? 'automations' : 'schedule'; + _autoSyncActiveTab = ['automations', 'history'].includes(tab) ? tab : 'schedule'; renderAutoSyncScheduleModal(); } @@ -248,7 +270,7 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) { const schedule = playlistSchedules[p.id]; const assigned = schedule ? autoSyncIntervalLabel(schedule.hours) : 'Unscheduled'; return ` -
+
${_esc(p.name)}
${p.track_count || 0} tracks · ${_esc(assigned)}
@@ -302,6 +324,90 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) { `; } +function getAutoSyncPipelinePlaylists(playlists) { + return playlists + .map(p => ({ playlist: p, state: p.pipeline_state || null })) + .filter(item => item.state && item.state.status && item.state.status !== 'idle') + .sort((a, b) => { + const aRunning = a.state.status === 'running' ? 1 : 0; + const bRunning = b.state.status === 'running' ? 1 : 0; + if (aRunning !== bRunning) return bRunning - aRunning; + return (b.state.finished_at || b.state.started_at || 0) - (a.state.finished_at || a.state.started_at || 0); + }); +} + +function autoSyncPipelineStatusLabel(status) { + if (status === 'running') return 'Running'; + if (status === 'finished') return 'Completed'; + if (status === 'skipped') return 'Skipped'; + if (status === 'error') return 'Needs attention'; + return 'Idle'; +} + +function autoSyncPipelineStatusClass(status) { + if (status === 'running') return 'running'; + if (status === 'finished') return 'finished'; + if (status === 'error' || status === 'skipped') return 'error'; + return 'idle'; +} + +function renderAutoSyncPipelineMonitor(playlists) { + const pipelineItems = getAutoSyncPipelinePlaylists(playlists); + const running = pipelineItems.filter(item => item.state.status === 'running'); + const recent = pipelineItems.filter(item => item.state.status !== 'running').slice(0, 2); + const visible = [...running, ...recent].slice(0, 4); + const title = running.length + ? `${running.length} pipeline${running.length === 1 ? '' : 's'} running` + : 'No pipelines running'; + const detail = running.length + ? 'Live status refreshes while this modal is open.' + : 'Use Run now on a scheduled playlist when you want the pipeline immediately.'; + + return ` +
+
+
+ Live pipeline monitor + ${_esc(title)} + ${_esc(detail)} +
+ +
+
+ ${visible.length ? visible.map(({ playlist, state }) => autoSyncPipelineMonitorCardHtml(playlist, state)).join('') : ` +
+ Ready + Scheduled playlists will appear here while the all-in-one pipeline runs. +
+ `} +
+
+ `; +} + +function autoSyncPipelineMonitorCardHtml(playlist, state) { + const status = state.status || 'idle'; + const progress = Math.max(0, Math.min(100, parseInt(state.progress, 10) || 0)); + const latest = Array.isArray(state.log) && state.log.length ? state.log[state.log.length - 1].message : ''; + const phase = state.phase || autoSyncPipelineStatusLabel(status); + return ` +
+
+
+ ${_esc(playlist.name || `Playlist #${playlist.id}`)} + ${_esc(autoSyncPipelineStatusLabel(status))} +
+
${_esc(phase)}
+
+
+
+ ${latest ? `${_esc(latest)}` : ''} +
+ +
+ `; +} + function renderAutoSyncAutomationPanel(automationPipelines, playlists) { if (!automationPipelines.length) { return '
No Automations-page playlist pipelines found.
'; @@ -317,6 +423,118 @@ function renderAutoSyncAutomationPanel(automationPipelines, playlists) { `; } +function renderAutoSyncHistoryPanel(history, total) { + if (!history.length) { + return ` +
+ No playlist pipeline runs yet + Future Auto-Sync and playlist pipeline runs will record before/after playlist snapshots here. +
+ `; + } + return ` +
+
+ Playlist pipeline run history + Each run records what changed on the mirrored playlist before and after refresh, discovery, sync, and wishlist processing. +
+ +
+
+ ${history.map(autoSyncHistoryEntryHtml).join('')} + ${total > history.length ? `
Showing ${history.length} of ${total} runs
` : ''} +
+ `; +} + +function autoSyncHistoryEntryHtml(entry) { + const status = entry.status || 'completed'; + const before = entry.before_json || {}; + const after = entry.after_json || {}; + const result = entry.result_json || {}; + const started = entry.started_at ? _autoTimeAgo(entry.started_at) : ''; + const duration = entry.duration_seconds ? autoSyncDurationLabel(entry.duration_seconds) : ''; + const trackDelta = autoSyncDelta(after.track_count, before.track_count); + const discoveredDelta = autoSyncDelta(after.discovered_count, before.discovered_count); + const wishlistDelta = autoSyncDelta(after.wishlisted_count, before.wishlisted_count); + const libraryDelta = autoSyncDelta(after.in_library_count, before.in_library_count); + const entryId = `auto-sync-history-${entry.id}`; + return ` +
+
+ ${_esc(autoSyncHistoryStatusLabel(status))} +
+ ${_esc(entry.playlist_name || 'Mirrored playlist')} + ${_esc(entry.summary || '')} +
+
+ ${started ? `${_esc(started)}` : ''} + ${duration ? `${_esc(duration)}` : ''} + ${_esc(entry.trigger_source || 'pipeline')} +
+
+
+
+ ${autoSyncHistoryStatHtml('Tracks', before.track_count, after.track_count, trackDelta)} + ${autoSyncHistoryStatHtml('Discovered', before.discovered_count, after.discovered_count, discoveredDelta)} + ${autoSyncHistoryStatHtml('Wishlisted', before.wishlisted_count, after.wishlisted_count, wishlistDelta)} + ${autoSyncHistoryStatHtml('In library', before.in_library_count, after.in_library_count, libraryDelta)} +
+
+ ${autoSyncHistoryResultPill('Refreshed', result.playlists_refreshed)} + ${autoSyncHistoryResultPill('Synced', result.tracks_synced)} + ${autoSyncHistoryResultPill('Skipped', result.sync_skipped)} + ${autoSyncHistoryResultPill('Queued', result.wishlist_queued)} + ${result.error ? `${_esc(result.error)}` : ''} +
+
+
+ `; +} + +function autoSyncToggleHistoryEntry(entryId) { + const el = document.getElementById(entryId); + if (el) el.classList.toggle('expanded'); +} + +function autoSyncHistoryStatusLabel(status) { + if (status === 'completed' || status === 'finished') return 'Completed'; + if (status === 'error') return 'Error'; + if (status === 'skipped') return 'Skipped'; + return status || 'Run'; +} + +function autoSyncDurationLabel(seconds) { + const total = Math.max(0, Math.round(parseFloat(seconds) || 0)); + if (total < 60) return `${total}s`; + const mins = Math.floor(total / 60); + const secs = total % 60; + return `${mins}m ${secs}s`; +} + +function autoSyncDelta(after, before) { + const a = parseInt(after, 10) || 0; + const b = parseInt(before, 10) || 0; + return a - b; +} + +function autoSyncHistoryStatHtml(label, before, after, delta) { + const beforeValue = parseInt(before, 10) || 0; + const afterValue = parseInt(after, 10) || 0; + const deltaText = delta ? ` (${delta > 0 ? '+' : ''}${delta})` : ''; + return ` +
+ ${_esc(label)} + ${beforeValue} -> ${afterValue}${_esc(deltaText)} +
+ `; +} + +function autoSyncHistoryResultPill(label, value) { + if (value === undefined || value === null || value === '') return ''; + return `${_esc(label)}: ${_esc(String(value))}`; +} + function autoSyncAutomationCardHtml(auto, playlists) { const cfg = auto.action_config || {}; const playlistId = autoSyncPlaylistIdFromAutomation(auto); @@ -348,8 +566,9 @@ function autoSyncAutomationCardHtml(auto, playlists) { function autoSyncScheduledCardHtml(playlist, schedule) { const enabled = schedule?.enabled !== false; const nextLabel = schedule?.next_run ? autoSyncNextRunLabel(schedule.next_run) : ''; + const isRunning = playlist.pipeline_state?.status === 'running'; return ` -
+
${_esc(playlist.name)}
${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks
@@ -358,7 +577,10 @@ function autoSyncScheduledCardHtml(playlist, schedule) { ${nextLabel ? `${_esc(nextLabel)}` : ''}
- +
+ + +
`; } @@ -379,6 +601,7 @@ function autoSyncNextRunLabel(nextRun) { function autoSyncDragStart(event) { const playlistId = event.currentTarget?.dataset?.playlistId; if (!playlistId) return; + _autoSyncIsDragging = true; event.dataTransfer.setData('text/plain', playlistId); event.dataTransfer.effectAllowed = 'move'; } @@ -401,6 +624,7 @@ function autoSyncDragLeave(event) { async function autoSyncDrop(event, hours) { event.preventDefault(); + _autoSyncIsDragging = false; const col = event.currentTarget; if (col) col.classList.remove('drag-over'); const playlistId = parseInt(event.dataTransfer.getData('text/plain'), 10); @@ -408,6 +632,10 @@ async function autoSyncDrop(event, hours) { await saveAutoSyncPlaylistSchedule(playlistId, hours); } +function autoSyncDragEnd() { + _autoSyncIsDragging = false; +} + async function saveAutoSyncPlaylistSchedule(playlistId, hours) { const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); if (!playlist) return; @@ -457,6 +685,37 @@ async function unscheduleAutoSyncPlaylist(playlistId) { } } +async function runAutoSyncScheduledPlaylist(playlistId) { + const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); + if (!playlist) return; + await runMirroredPlaylistPipeline(playlistId, playlist.name || `Playlist #${playlistId}`); + await refreshAutoSyncScheduleModal(); +} + +function manageAutoSyncStatusPolling() { + const overlay = document.getElementById('auto-sync-schedule-modal'); + if (!overlay) { + stopAutoSyncStatusPolling(); + return; + } + const hasRunning = _autoSyncScheduleState.playlists.some(p => p.pipeline_state?.status === 'running'); + if (!hasRunning) { + stopAutoSyncStatusPolling(); + return; + } + if (_autoSyncStatusPoller) return; + _autoSyncStatusPoller = setInterval(() => { + if (_autoSyncIsDragging) return; + refreshAutoSyncScheduleModal(); + }, 3000); +} + +function stopAutoSyncStatusPolling() { + if (!_autoSyncStatusPoller) return; + clearInterval(_autoSyncStatusPoller); + _autoSyncStatusPoller = null; +} + async function parseMirroredPipelineResponse(res, fallbackMessage) { const text = await res.text(); let data = {}; @@ -543,6 +802,13 @@ async function runMirroredPlaylistPipeline(playlistId, name) { const data = await parseMirroredPipelineResponse(res, 'Failed to start Auto-Sync'); applyMirroredPipelineState(playlistId, data.state || { status: 'running', progress: 0, phase: 'Starting pipeline...' }); showToast(`Auto-Sync started for ${name}`, 'success'); + _autoSyncScheduleState.playlists = _autoSyncScheduleState.playlists.map(p => ( + parseInt(p.id, 10) === parseInt(playlistId, 10) + ? { ...p, pipeline_state: data.state || { status: 'running', progress: 0, phase: 'Starting pipeline...' } } + : p + )); + renderAutoSyncScheduleModal(); + manageAutoSyncStatusPolling(); pollMirroredPipelineStatus(playlistId, name); } catch (err) { showToast(`Error: ${err.message}`, 'error'); @@ -564,11 +830,13 @@ function pollMirroredPipelineStatus(playlistId, name) { delete mirroredPipelinePollers[key]; showToast(`Auto-Sync complete for ${name}`, 'success'); loadMirroredPlaylists(); + refreshAutoSyncScheduleModal(); } else if (state.status === 'error' || state.status === 'skipped') { clearInterval(mirroredPipelinePollers[key]); delete mirroredPipelinePollers[key]; showToast(state.error || `Pipeline stopped for ${name}`, 'error'); loadMirroredPlaylists(); + refreshAutoSyncScheduleModal(); } else if (state.status === 'idle') { clearInterval(mirroredPipelinePollers[key]); delete mirroredPipelinePollers[key]; diff --git a/webui/static/style.css b/webui/static/style.css index df0752c8..1dad85bc 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -11203,7 +11203,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-modal { width: min(1500px, calc(100vw - 40px)); - height: min(860px, calc(100vh - 40px)); + height: min(920px, calc(100vh - 32px)); background: radial-gradient(circle at top right, rgba(var(--accent-rgb), 0.08) 0%, transparent 60%), rgba(17, 19, 27, 0.98); @@ -11296,6 +11296,214 @@ body.helper-mode-active #dashboard-activity-feed:hover { text-transform: uppercase; } +.auto-sync-monitor { + flex-shrink: 0; + padding: 14px 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: + radial-gradient(circle at 14% 0%, rgba(var(--accent-rgb), 0.14), transparent 34%), + rgba(255, 255, 255, 0.018); +} + +.auto-sync-monitor-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 10px; +} + +.auto-sync-monitor-head strong, +.auto-sync-monitor-head small, +.auto-sync-monitor-kicker { + display: block; +} + +.auto-sync-monitor-kicker { + margin-bottom: 3px; + color: rgb(var(--accent-light-rgb)); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.auto-sync-monitor-head strong { + color: rgba(255, 255, 255, 0.9); + font-size: 14px; +} + +.auto-sync-monitor-head small { + margin-top: 2px; + color: rgba(255, 255, 255, 0.44); + font-size: 12px; +} + +.auto-sync-monitor-head button, +.auto-sync-board-intro button, +.auto-sync-history-intro button { + height: 30px; + padding: 0 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + font-size: 12px; + font-weight: 700; +} + +.auto-sync-monitor-head button:hover, +.auto-sync-board-intro button:hover, +.auto-sync-history-intro button:hover { + background: rgba(var(--accent-rgb), 0.14); + border-color: rgba(var(--accent-rgb), 0.32); + color: rgb(var(--accent-light-rgb)); +} + +.auto-sync-monitor-list { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.auto-sync-monitor-card, +.auto-sync-monitor-empty { + min-width: 0; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: rgba(255, 255, 255, 0.035); +} + +.auto-sync-monitor-card { + display: flex; + align-items: stretch; + justify-content: space-between; + gap: 10px; + padding: 11px; +} + +.auto-sync-monitor-card.running { + border-color: rgba(var(--accent-rgb), 0.34); + background: rgba(var(--accent-rgb), 0.08); + box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.08), 0 8px 24px rgba(var(--accent-rgb), 0.08); +} + +.auto-sync-monitor-card.error { + border-color: rgba(239, 68, 68, 0.28); + background: rgba(239, 68, 68, 0.07); +} + +.auto-sync-monitor-card.finished { + border-color: rgba(34, 197, 94, 0.22); + background: rgba(34, 197, 94, 0.055); +} + +.auto-sync-monitor-card-main { + min-width: 0; + flex: 1; +} + +.auto-sync-monitor-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.auto-sync-monitor-title-row strong { + min-width: 0; + color: rgba(255, 255, 255, 0.88); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auto-sync-monitor-title-row span { + flex-shrink: 0; + color: rgb(var(--accent-light-rgb)); + font-size: 10px; + font-weight: 800; + text-transform: uppercase; +} + +.auto-sync-monitor-phase { + margin-top: 5px; + color: rgba(255, 255, 255, 0.52); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auto-sync-monitor-progress { + height: 5px; + margin-top: 8px; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); +} + +.auto-sync-monitor-progress div { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, rgb(var(--accent-rgb)), rgb(var(--accent-light-rgb))); + transition: width 0.25s ease; +} + +.auto-sync-monitor-card small { + display: block; + margin-top: 6px; + color: rgba(255, 255, 255, 0.38); + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auto-sync-monitor-card button { + align-self: center; + height: 28px; + padding: 0 9px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + background: rgba(255, 255, 255, 0.055); + color: rgba(255, 255, 255, 0.64); + cursor: pointer; + font-size: 11px; + font-weight: 800; +} + +.auto-sync-monitor-card button:hover { + border-color: rgba(var(--accent-rgb), 0.36); + background: rgba(var(--accent-rgb), 0.14); + color: rgb(var(--accent-light-rgb)); +} + +.auto-sync-monitor-empty { + grid-column: 1 / -1; + padding: 16px; +} + +.auto-sync-monitor-empty span, +.auto-sync-monitor-empty small { + display: block; +} + +.auto-sync-monitor-empty span { + color: rgba(255, 255, 255, 0.72); + font-size: 13px; + font-weight: 800; +} + +.auto-sync-monitor-empty small { + margin-top: 3px; + color: rgba(255, 255, 255, 0.38); + font-size: 12px; +} + .auto-sync-tabs { display: flex; gap: 8px; @@ -11337,7 +11545,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-board-intro, -.auto-sync-automation-intro { +.auto-sync-automation-intro, +.auto-sync-history-intro { display: flex; align-items: center; justify-content: space-between; @@ -11349,37 +11558,22 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-board-intro strong, -.auto-sync-automation-intro strong { +.auto-sync-automation-intro strong, +.auto-sync-history-intro strong { display: block; color: rgba(255, 255, 255, 0.86); font-size: 13px; } .auto-sync-board-intro span, -.auto-sync-automation-intro span { +.auto-sync-automation-intro span, +.auto-sync-history-intro span { display: block; margin-top: 2px; color: rgba(255, 255, 255, 0.46); font-size: 12px; } -.auto-sync-board-intro button { - height: 30px; - padding: 0 12px; - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 6px; - background: rgba(255, 255, 255, 0.05); - color: rgba(255, 255, 255, 0.7); - cursor: pointer; - font-size: 12px; - font-weight: 700; -} - -.auto-sync-board-intro button:hover { - background: rgba(255, 255, 255, 0.1); - color: #fff; -} - .auto-sync-body { min-height: 0; flex: 1; @@ -11561,7 +11755,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-column-list::-webkit-scrollbar, .auto-sync-board::-webkit-scrollbar, .auto-sync-source-list::-webkit-scrollbar, -.auto-sync-automation-list::-webkit-scrollbar { +.auto-sync-automation-list::-webkit-scrollbar, +.auto-sync-history-list::-webkit-scrollbar { width: 6px; height: 6px; } @@ -11569,7 +11764,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-column-list::-webkit-scrollbar-thumb, .auto-sync-board::-webkit-scrollbar-thumb, .auto-sync-source-list::-webkit-scrollbar-thumb, -.auto-sync-automation-list::-webkit-scrollbar-thumb { +.auto-sync-automation-list::-webkit-scrollbar-thumb, +.auto-sync-history-list::-webkit-scrollbar-thumb { background: rgba(var(--accent-rgb), 0.3); border-radius: 999px; } @@ -11577,7 +11773,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-column-list::-webkit-scrollbar-thumb:hover, .auto-sync-board::-webkit-scrollbar-thumb:hover, .auto-sync-source-list::-webkit-scrollbar-thumb:hover, -.auto-sync-automation-list::-webkit-scrollbar-thumb:hover { +.auto-sync-automation-list::-webkit-scrollbar-thumb:hover, +.auto-sync-history-list::-webkit-scrollbar-thumb:hover { background: rgba(var(--accent-rgb), 0.5); } @@ -11629,6 +11826,14 @@ body.helper-mode-active #dashboard-activity-feed:hover { opacity: 0.52; } +.auto-sync-scheduled-actions { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; + flex-shrink: 0; +} + .auto-sync-scheduled-card button { width: 24px; height: 24px; @@ -11640,7 +11845,29 @@ body.helper-mode-active #dashboard-activity-feed:hover { flex-shrink: 0; } -.auto-sync-scheduled-card button:hover { +.auto-sync-scheduled-card button.run { + width: auto; + min-width: 64px; + padding: 0 8px; + color: rgb(var(--accent-light-rgb)); + background: rgba(var(--accent-rgb), 0.12); + border: 1px solid rgba(var(--accent-rgb), 0.22); + font-size: 10px; + font-weight: 800; +} + +.auto-sync-scheduled-card button.run:disabled { + cursor: default; + opacity: 0.62; +} + +.auto-sync-scheduled-card button.run:not(:disabled):hover { + color: rgb(var(--accent-neon-rgb)); + background: rgba(var(--accent-rgb), 0.2); + border-color: rgba(var(--accent-rgb), 0.4); +} + +.auto-sync-scheduled-card button:not(.run):hover { color: #ef4444; background: rgba(239, 68, 68, 0.12); } @@ -11744,6 +11971,191 @@ body.helper-mode-active #dashboard-activity-feed:hover { text-align: center; } +.auto-sync-history-list { + min-height: 0; + overflow-y: auto; + padding: 18px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.auto-sync-history-entry { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: rgba(255, 255, 255, 0.035); + overflow: hidden; +} + +.auto-sync-history-entry:hover { + border-color: rgba(var(--accent-rgb), 0.22); +} + +.auto-sync-history-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 13px 14px; + cursor: pointer; +} + +.auto-sync-history-status { + padding: 4px 8px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.14); + color: #94a3b8; + font-size: 10px; + font-weight: 800; + text-transform: uppercase; +} + +.auto-sync-history-status.completed, +.auto-sync-history-status.finished { + background: rgba(74, 222, 128, 0.14); + color: #4ade80; +} + +.auto-sync-history-status.error { + background: rgba(239, 68, 68, 0.14); + color: #ef4444; +} + +.auto-sync-history-status.skipped { + background: rgba(250, 204, 21, 0.14); + color: #facc15; +} + +.auto-sync-history-main { + min-width: 0; +} + +.auto-sync-history-main strong, +.auto-sync-history-main small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auto-sync-history-main strong { + color: rgba(255, 255, 255, 0.88); + font-size: 13px; +} + +.auto-sync-history-main small { + margin-top: 3px; + color: rgba(255, 255, 255, 0.42); + font-size: 11px; +} + +.auto-sync-history-meta { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.auto-sync-history-meta span, +.auto-sync-history-result span { + padding: 4px 7px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.055); + color: rgba(255, 255, 255, 0.48); + font-size: 10px; + font-weight: 700; +} + +.auto-sync-history-detail { + display: none; + padding: 0 14px 14px; +} + +.auto-sync-history-detail.expanded { + display: block; +} + +.auto-sync-history-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + padding-top: 2px; +} + +.auto-sync-history-stats div { + padding: 10px; + border-radius: 7px; + background: rgba(255, 255, 255, 0.04); +} + +.auto-sync-history-stats span, +.auto-sync-history-stats strong { + display: block; +} + +.auto-sync-history-stats span { + color: rgba(255, 255, 255, 0.38); + font-size: 10px; + font-weight: 800; + text-transform: uppercase; +} + +.auto-sync-history-stats strong { + margin-top: 4px; + color: rgba(255, 255, 255, 0.82); + font-size: 12px; +} + +.auto-sync-history-result { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 9px; +} + +.auto-sync-history-result span { + color: rgb(var(--accent-light-rgb)); + background: rgba(var(--accent-rgb), 0.1); + border: 1px solid rgba(var(--accent-rgb), 0.18); +} + +.auto-sync-history-result span.error { + color: #ef4444; + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.18); +} + +.auto-sync-history-empty { + margin: 24px; + padding: 44px; + border: 1px dashed rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: rgba(255, 255, 255, 0.42); + text-align: center; +} + +.auto-sync-history-empty strong, +.auto-sync-history-empty span { + display: block; +} + +.auto-sync-history-empty strong { + color: rgba(255, 255, 255, 0.72); + font-size: 14px; +} + +.auto-sync-history-empty span { + margin-top: 6px; + font-size: 12px; +} + +.auto-sync-history-total { + padding: 12px; + color: rgba(255, 255, 255, 0.38); + font-size: 12px; + text-align: center; +} + @media (max-width: 1100px) { .auto-sync-modal { width: calc(100vw - 18px); @@ -11754,6 +12166,10 @@ body.helper-mode-active #dashboard-activity-feed:hover { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .auto-sync-monitor-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .auto-sync-body { grid-template-columns: 1fr; grid-template-rows: minmax(150px, 30%) 1fr; @@ -11795,16 +12211,58 @@ body.helper-mode-active #dashboard-activity-feed:hover { padding: 9px 16px; } + .auto-sync-monitor { + padding: 10px 14px; + } + + .auto-sync-monitor-empty, + .auto-sync-monitor-card { + padding: 9px; + } + .auto-sync-tabs { padding: 9px 14px; } .auto-sync-board-intro, - .auto-sync-automation-intro { + .auto-sync-automation-intro, + .auto-sync-history-intro { padding: 9px 14px; } } +@media (max-width: 720px) { + .auto-sync-monitor-list { + grid-template-columns: 1fr; + } + + .auto-sync-monitor-head { + align-items: flex-start; + flex-direction: column; + } + + .auto-sync-monitor-card { + flex-direction: column; + } + + .auto-sync-monitor-card button { + align-self: flex-start; + } + + .auto-sync-history-row { + grid-template-columns: 1fr; + align-items: flex-start; + } + + .auto-sync-history-meta { + justify-content: flex-start; + } + + .auto-sync-history-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + /* Enhanced Progress Bar Animation */ .progress-bar-fill { height: 100%; From d8d8e0bcb53f5526d0ed6f4c5dd08cd876ed7639 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 25 May 2026 07:44:35 -0700 Subject: [PATCH 24/81] Refine auto-sync modal spacing Compact the inactive pipeline monitor, widen schedule columns, increase board gutters, and rework scheduled playlist cards so Run now and remove actions no longer crowd playlist text. --- webui/static/auto-sync.js | 13 +++--- webui/static/style.css | 93 +++++++++++++++++++++++++++------------ 2 files changed, 69 insertions(+), 37 deletions(-) diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js index b54b725e..cbbe1d7e 100644 --- a/webui/static/auto-sync.js +++ b/webui/static/auto-sync.js @@ -373,14 +373,11 @@ function renderAutoSyncPipelineMonitor(playlists) {
-
- ${visible.length ? visible.map(({ playlist, state }) => autoSyncPipelineMonitorCardHtml(playlist, state)).join('') : ` -
- Ready - Scheduled playlists will appear here while the all-in-one pipeline runs. -
- `} -
+ ${visible.length ? ` +
+ ${visible.map(({ playlist, state }) => autoSyncPipelineMonitorCardHtml(playlist, state)).join('')} +
+ ` : '
ReadyScheduled playlists appear here while the all-in-one pipeline runs.
'} `; } diff --git a/webui/static/style.css b/webui/static/style.css index 1dad85bc..d6aaa4da 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -11203,7 +11203,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-modal { width: min(1500px, calc(100vw - 40px)); - height: min(920px, calc(100vh - 32px)); + height: min(940px, calc(100vh - 28px)); background: radial-gradient(circle at top right, rgba(var(--accent-rgb), 0.08) 0%, transparent 60%), rgba(17, 19, 27, 0.98); @@ -11222,7 +11222,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { align-items: flex-start; justify-content: space-between; gap: 20px; - padding: 22px 24px 18px; + padding: 22px 26px 18px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); flex-shrink: 0; } @@ -11276,7 +11276,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-summary div { - padding: 14px 20px; + padding: 12px 20px; background: rgba(255, 255, 255, 0.025); } @@ -11298,7 +11298,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-monitor { flex-shrink: 0; - padding: 14px 18px; + padding: 12px 18px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); background: radial-gradient(circle at 14% 0%, rgba(var(--accent-rgb), 0.14), transparent 34%), @@ -11310,7 +11310,11 @@ body.helper-mode-active #dashboard-activity-feed:hover { align-items: center; justify-content: space-between; gap: 16px; - margin-bottom: 10px; + margin-bottom: 0; +} + +.auto-sync-monitor-head > div { + min-width: 0; } .auto-sync-monitor-head strong, @@ -11365,6 +11369,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; + margin-top: 10px; } .auto-sync-monitor-card, @@ -11380,7 +11385,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { align-items: stretch; justify-content: space-between; gap: 10px; - padding: 11px; + padding: 10px; } .auto-sync-monitor-card.running { @@ -11483,8 +11488,13 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-monitor-empty { - grid-column: 1 / -1; - padding: 16px; + display: flex; + align-items: baseline; + gap: 10px; + width: fit-content; + max-width: 100%; + margin-top: 8px; + padding: 8px 10px; } .auto-sync-monitor-empty span, @@ -11499,15 +11509,18 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-monitor-empty small { - margin-top: 3px; + margin-top: 0; color: rgba(255, 255, 255, 0.38); font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .auto-sync-tabs { display: flex; gap: 8px; - padding: 12px 18px; + padding: 12px 18px 10px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.018); flex-shrink: 0; @@ -11551,7 +11564,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { align-items: center; justify-content: space-between; gap: 16px; - padding: 12px 18px; + padding: 14px 18px; border-bottom: 1px solid rgba(var(--accent-rgb), 0.16); background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.08) 0%, rgba(var(--accent-rgb), 0.03) 60%, transparent 100%); flex-shrink: 0; @@ -11578,7 +11591,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { min-height: 0; flex: 1; display: grid; - grid-template-columns: 300px 1fr; + grid-template-columns: 300px minmax(0, 1fr); } .auto-sync-sidebar { @@ -11703,11 +11716,11 @@ body.helper-mode-active #dashboard-activity-feed:hover { min-height: 0; overflow-x: auto; overflow-y: hidden; - padding: 18px; + padding: 26px; display: grid; - grid-template-columns: repeat(10, minmax(185px, 1fr)); + grid-template-columns: repeat(10, minmax(225px, 1fr)); grid-auto-rows: minmax(0, 1fr); - gap: 12px; + gap: 16px; } .auto-sync-column { @@ -11732,7 +11745,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { display: flex; align-items: center; justify-content: space-between; - padding: 12px; + padding: 14px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.07); color: rgba(255, 255, 255, 0.86); font-weight: 800; @@ -11749,7 +11762,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { flex: 1; min-height: 0; overflow-y: auto; - padding: 10px; + padding: 14px; } .auto-sync-column-list::-webkit-scrollbar, @@ -11815,11 +11828,12 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-scheduled-card { - padding: 10px; - margin-bottom: 10px; - display: flex; - justify-content: space-between; - gap: 8px; + padding: 14px; + margin-bottom: 12px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 12px; } .auto-sync-scheduled-card.disabled { @@ -11828,9 +11842,9 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-scheduled-actions { display: flex; - flex-direction: column; + flex-direction: row; align-items: flex-end; - gap: 6px; + gap: 7px; flex-shrink: 0; } @@ -11847,8 +11861,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-scheduled-card button.run { width: auto; - min-width: 64px; - padding: 0 8px; + min-width: 68px; + padding: 0 9px; color: rgb(var(--accent-light-rgb)); background: rgba(var(--accent-rgb), 0.12); border: 1px solid rgba(var(--accent-rgb), 0.22); @@ -12194,7 +12208,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-board { - grid-template-columns: repeat(10, minmax(165px, 180px)); + grid-template-columns: repeat(10, minmax(210px, 230px)); + padding: 18px; } } @@ -12212,7 +12227,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-monitor { - padding: 10px 14px; + padding: 9px 14px; } .auto-sync-monitor-empty, @@ -12227,7 +12242,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-board-intro, .auto-sync-automation-intro, .auto-sync-history-intro { - padding: 9px 14px; + padding: 10px 14px; } } @@ -12241,6 +12256,16 @@ body.helper-mode-active #dashboard-activity-feed:hover { flex-direction: column; } + .auto-sync-monitor-empty { + align-items: flex-start; + flex-direction: column; + width: 100%; + } + + .auto-sync-monitor-empty small { + white-space: normal; + } + .auto-sync-monitor-card { flex-direction: column; } @@ -12261,6 +12286,16 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-history-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .auto-sync-scheduled-card { + grid-template-columns: 1fr; + } + + .auto-sync-scheduled-actions { + align-items: center; + justify-content: space-between; + width: 100%; + } } /* Enhanced Progress Bar Animation */ From 9eb7a49c24c51f8d76dcedd80636a4ffcf864796 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 25 May 2026 07:46:45 -0700 Subject: [PATCH 25/81] Fix auto-sync scheduled card layout Give placed playlist cards a dedicated content wrapper, full-width action row, wider board columns, and defensive wrapping so titles, timing badges, and Run now controls stay inside card bounds. --- webui/static/auto-sync.js | 2 +- webui/static/style.css | 48 +++++++++++++++++++++++++++++++-------- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js index cbbe1d7e..790e8e71 100644 --- a/webui/static/auto-sync.js +++ b/webui/static/auto-sync.js @@ -566,7 +566,7 @@ function autoSyncScheduledCardHtml(playlist, schedule) { const isRunning = playlist.pipeline_state?.status === 'running'; return `
-
+
${_esc(playlist.name)}
${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks
diff --git a/webui/static/style.css b/webui/static/style.css index d6aaa4da..de5e2906 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -11677,6 +11677,12 @@ body.helper-mode-active #dashboard-activity-feed:hover { white-space: nowrap; } +.auto-sync-scheduled-name { + white-space: normal; + overflow-wrap: anywhere; + line-height: 1.25; +} + .auto-sync-playlist-meta, .auto-sync-scheduled-meta { margin-top: 4px; @@ -11684,6 +11690,11 @@ body.helper-mode-active #dashboard-activity-feed:hover { font-size: 11px; } +.auto-sync-scheduled-meta { + overflow-wrap: anywhere; + line-height: 1.3; +} + .auto-sync-scheduled-timing { display: flex; flex-wrap: wrap; @@ -11693,11 +11704,15 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-scheduled-timing span, .auto-sync-scheduled-timing small { + max-width: 100%; padding: 3px 6px; border-radius: 999px; font-size: 10px; font-weight: 800; line-height: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .auto-sync-scheduled-timing span { @@ -11718,7 +11733,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { overflow-y: hidden; padding: 26px; display: grid; - grid-template-columns: repeat(10, minmax(225px, 1fr)); + grid-template-columns: repeat(10, minmax(240px, 1fr)); grid-auto-rows: minmax(0, 1fr); gap: 16px; } @@ -11828,24 +11843,33 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-scheduled-card { + box-sizing: border-box; + width: 100%; + min-width: 0; padding: 14px; margin-bottom: 12px; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: start; - gap: 12px; + display: flex; + flex-direction: column; + gap: 11px; + overflow: hidden; } .auto-sync-scheduled-card.disabled { opacity: 0.52; } +.auto-sync-scheduled-main { + min-width: 0; + width: 100%; +} + .auto-sync-scheduled-actions { display: flex; flex-direction: row; - align-items: flex-end; - gap: 7px; - flex-shrink: 0; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; } .auto-sync-scheduled-card button { @@ -11861,13 +11885,17 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-scheduled-card button.run { width: auto; - min-width: 68px; + min-width: 0; + flex: 1; padding: 0 9px; color: rgb(var(--accent-light-rgb)); background: rgba(var(--accent-rgb), 0.12); border: 1px solid rgba(var(--accent-rgb), 0.22); font-size: 10px; font-weight: 800; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .auto-sync-scheduled-card button.run:disabled { @@ -12208,7 +12236,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-board { - grid-template-columns: repeat(10, minmax(210px, 230px)); + grid-template-columns: repeat(10, minmax(230px, 250px)); padding: 18px; } } From 06c76bbcaf0785c8a74e42df16f17325e7172572 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 25 May 2026 07:55:52 -0700 Subject: [PATCH 26/81] Make auto-sync run history readable Render playlist pipeline history as visible run cards with fallback summaries, preview chips, metadata, Details controls, and an explicit empty result message for sparse payloads. --- webui/static/auto-sync.js | 37 +++++++++++++++---- webui/static/style.css | 76 +++++++++++++++++++++++++++++++-------- 2 files changed, 91 insertions(+), 22 deletions(-) diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js index 790e8e71..586d8ad6 100644 --- a/webui/static/auto-sync.js +++ b/webui/static/auto-sync.js @@ -456,18 +456,32 @@ function autoSyncHistoryEntryHtml(entry) { const wishlistDelta = autoSyncDelta(after.wishlisted_count, before.wishlisted_count); const libraryDelta = autoSyncDelta(after.in_library_count, before.in_library_count); const entryId = `auto-sync-history-${entry.id}`; + const playlistName = entry.playlist_name || after.name || before.name || `Playlist #${entry.playlist_id || 'unknown'}`; + const summary = entry.summary || autoSyncHistoryFallbackSummary(before, after, status); + const resultHtml = [ + autoSyncHistoryResultPill('Refreshed', result.playlists_refreshed), + autoSyncHistoryResultPill('Synced', result.tracks_synced), + autoSyncHistoryResultPill('Skipped', result.sync_skipped), + autoSyncHistoryResultPill('Queued', result.wishlist_queued), + result.error ? `${_esc(result.error)}` : '', + ].filter(Boolean).join(''); return `
${_esc(autoSyncHistoryStatusLabel(status))}
- ${_esc(entry.playlist_name || 'Mirrored playlist')} - ${_esc(entry.summary || '')} + ${_esc(playlistName)} + ${_esc(summary)} +
+ ${autoSyncHistoryPreviewPill('Tracks', before.track_count, after.track_count, trackDelta)} + ${autoSyncHistoryPreviewPill('Discovered', before.discovered_count, after.discovered_count, discoveredDelta)} +
${started ? `${_esc(started)}` : ''} ${duration ? `${_esc(duration)}` : ''} ${_esc(entry.trigger_source || 'pipeline')} +
@@ -478,17 +492,19 @@ function autoSyncHistoryEntryHtml(entry) { ${autoSyncHistoryStatHtml('In library', before.in_library_count, after.in_library_count, libraryDelta)}
- ${autoSyncHistoryResultPill('Refreshed', result.playlists_refreshed)} - ${autoSyncHistoryResultPill('Synced', result.tracks_synced)} - ${autoSyncHistoryResultPill('Skipped', result.sync_skipped)} - ${autoSyncHistoryResultPill('Queued', result.wishlist_queued)} - ${result.error ? `${_esc(result.error)}` : ''} + ${resultHtml || 'No detailed result payload recorded for this run.'}
`; } +function autoSyncHistoryFallbackSummary(before, after, status) { + const beforeTracks = parseInt(before.track_count, 10) || 0; + const afterTracks = parseInt(after.track_count, 10) || 0; + return `${autoSyncHistoryStatusLabel(status)} | ${beforeTracks} -> ${afterTracks} tracks`; +} + function autoSyncToggleHistoryEntry(entryId) { const el = document.getElementById(entryId); if (el) el.classList.toggle('expanded'); @@ -527,6 +543,13 @@ function autoSyncHistoryStatHtml(label, before, after, delta) { `; } +function autoSyncHistoryPreviewPill(label, before, after, delta) { + const beforeValue = parseInt(before, 10) || 0; + const afterValue = parseInt(after, 10) || 0; + const deltaText = delta ? ` ${delta > 0 ? '+' : ''}${delta}` : ''; + return `${_esc(label)} ${beforeValue}->${afterValue}${_esc(deltaText)}`; +} + function autoSyncHistoryResultPill(label, value) { if (value === undefined || value === null || value === '') return ''; return `${_esc(label)}: ${_esc(String(value))}`; diff --git a/webui/static/style.css b/webui/static/style.css index de5e2906..6b211b5e 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -12016,29 +12016,36 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-history-list { min-height: 0; overflow-y: auto; - padding: 18px; + padding: 20px; display: flex; flex-direction: column; - gap: 10px; + gap: 12px; } .auto-sync-history-entry { - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 8px; - background: rgba(255, 255, 255, 0.035); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.025)), + rgba(255, 255, 255, 0.035); overflow: hidden; + box-shadow: 0 10px 26px rgba(0, 0, 0, 0.14); } .auto-sync-history-entry:hover { - border-color: rgba(var(--accent-rgb), 0.22); + border-color: rgba(var(--accent-rgb), 0.3); + background: + linear-gradient(135deg, rgba(var(--accent-rgb), 0.075), rgba(255, 255, 255, 0.028)), + rgba(255, 255, 255, 0.04); } .auto-sync-history-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; - gap: 12px; - padding: 13px 14px; + gap: 14px; + min-height: 84px; + padding: 15px 16px; cursor: pointer; } @@ -12077,18 +12084,38 @@ body.helper-mode-active #dashboard-activity-feed:hover { display: block; overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; } .auto-sync-history-main strong { color: rgba(255, 255, 255, 0.88); - font-size: 13px; + font-size: 14px; + line-height: 1.25; + white-space: nowrap; } .auto-sync-history-main small { margin-top: 3px; - color: rgba(255, 255, 255, 0.42); - font-size: 11px; + color: rgba(255, 255, 255, 0.5); + font-size: 12px; + line-height: 1.3; + white-space: normal; +} + +.auto-sync-history-preview { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 9px; +} + +.auto-sync-history-preview span { + padding: 4px 7px; + border-radius: 6px; + background: rgba(var(--accent-rgb), 0.1); + border: 1px solid rgba(var(--accent-rgb), 0.16); + color: rgb(var(--accent-light-rgb)); + font-size: 10px; + font-weight: 800; } .auto-sync-history-meta { @@ -12099,7 +12126,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-history-meta span, -.auto-sync-history-result span { +.auto-sync-history-result span, +.auto-sync-history-meta button { padding: 4px 7px; border-radius: 6px; background: rgba(255, 255, 255, 0.055); @@ -12108,9 +12136,21 @@ body.helper-mode-active #dashboard-activity-feed:hover { font-weight: 700; } +.auto-sync-history-meta button { + border: 1px solid rgba(var(--accent-rgb), 0.18); + color: rgb(var(--accent-light-rgb)); + cursor: pointer; +} + +.auto-sync-history-meta button:hover { + background: rgba(var(--accent-rgb), 0.14); + border-color: rgba(var(--accent-rgb), 0.35); +} + .auto-sync-history-detail { display: none; - padding: 0 14px 14px; + padding: 0 16px 16px; + border-top: 1px solid rgba(255, 255, 255, 0.07); } .auto-sync-history-detail.expanded { @@ -12121,7 +12161,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; - padding-top: 2px; + padding-top: 14px; } .auto-sync-history-stats div { @@ -12167,6 +12207,12 @@ body.helper-mode-active #dashboard-activity-feed:hover { border-color: rgba(239, 68, 68, 0.18); } +.auto-sync-history-result span.muted { + color: rgba(255, 255, 255, 0.42); + background: rgba(255, 255, 255, 0.045); + border-color: rgba(255, 255, 255, 0.08); +} + .auto-sync-history-empty { margin: 24px; padding: 44px; From 119e8f1599a8ed7e35763811da2743c3d186b491 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 25 May 2026 07:59:27 -0700 Subject: [PATCH 27/81] Harden auto-sync history row rendering Normalize sparse playlist pipeline history rows before rendering and add a visible fallback for empty entries so the Run History tab cannot collapse into unreadable divider lines. --- webui/static/auto-sync.js | 39 +++++++++++++++++++++++++++++++++++++-- webui/static/style.css | 16 ++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js index 586d8ad6..b9899fce 100644 --- a/webui/static/auto-sync.js +++ b/webui/static/auto-sync.js @@ -438,13 +438,14 @@ function renderAutoSyncHistoryPanel(history, total) {
- ${history.map(autoSyncHistoryEntryHtml).join('')} + ${history.map((entry, index) => autoSyncHistoryEntryHtml(entry, index)).join('')} ${total > history.length ? `
Showing ${history.length} of ${total} runs
` : ''}
`; } -function autoSyncHistoryEntryHtml(entry) { +function autoSyncHistoryEntryHtml(entry, index = 0) { + entry = autoSyncNormalizeHistoryEntry(entry, index); const status = entry.status || 'completed'; const before = entry.before_json || {}; const after = entry.after_json || {}; @@ -499,6 +500,40 @@ function autoSyncHistoryEntryHtml(entry) { `; } +function autoSyncNormalizeHistoryEntry(entry, index) { + if (!entry || typeof entry !== 'object') { + return { + id: `unknown-${index}`, + status: 'completed', + playlist_name: 'Playlist pipeline run', + trigger_source: 'pipeline', + summary: 'Run history entry did not include detailed metadata.', + before_json: {}, + after_json: {}, + result_json: {}, + }; + } + return { + ...entry, + id: entry.id ?? `history-${index}`, + before_json: autoSyncParseHistoryObject(entry.before_json), + after_json: autoSyncParseHistoryObject(entry.after_json), + result_json: autoSyncParseHistoryObject(entry.result_json), + }; +} + +function autoSyncParseHistoryObject(value) { + if (!value) return {}; + if (typeof value === 'object') return value; + if (typeof value !== 'string') return {}; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch (_err) { + return {}; + } +} + function autoSyncHistoryFallbackSummary(before, after, status) { const beforeTracks = parseInt(before.track_count, 10) || 0; const afterTracks = parseInt(after.track_count, 10) || 0; diff --git a/webui/static/style.css b/webui/static/style.css index 6b211b5e..b9a75954 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -12023,6 +12023,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-history-entry { + display: block; + min-height: 84px; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 10px; background: @@ -12032,6 +12034,19 @@ body.helper-mode-active #dashboard-activity-feed:hover { box-shadow: 0 10px 26px rgba(0, 0, 0, 0.14); } +.auto-sync-history-entry:empty { + display: flex; + align-items: center; + padding: 16px; +} + +.auto-sync-history-entry:empty::before { + content: 'Run history entry unavailable. Refresh after the next playlist pipeline run.'; + color: rgba(255, 255, 255, 0.58); + font-size: 12px; + font-weight: 700; +} + .auto-sync-history-entry:hover { border-color: rgba(var(--accent-rgb), 0.3); background: @@ -12047,6 +12062,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { min-height: 84px; padding: 15px 16px; cursor: pointer; + background: rgba(255, 255, 255, 0.018); } .auto-sync-history-status { From 60b346aa330dcfcfcd9b333300580a97abd275a2 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 25 May 2026 08:19:05 -0700 Subject: [PATCH 28/81] Standardize auto-sync modal cards Bring Auto-Sync automation and run-history cards closer to the Automations page pattern with status dots, flow chips, compact metadata, and denser run preview details. --- webui/static/auto-sync.js | 37 ++++++++-- webui/static/style.css | 143 ++++++++++++++++++++++++++------------ 2 files changed, 133 insertions(+), 47 deletions(-) diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js index b9899fce..83fc38e6 100644 --- a/webui/static/auto-sync.js +++ b/webui/static/auto-sync.js @@ -469,13 +469,27 @@ function autoSyncHistoryEntryHtml(entry, index = 0) { return `
- ${_esc(autoSyncHistoryStatusLabel(status))} +
- ${_esc(playlistName)} +
+ ${_esc(playlistName)} + ${_esc(autoSyncHistoryStatusLabel(status))} +
+
+ ${_esc(entry.trigger_source || 'pipeline')} + + Refresh + + Discover + + Sync + wishlist +
${_esc(summary)}
${autoSyncHistoryPreviewPill('Tracks', before.track_count, after.track_count, trackDelta)} ${autoSyncHistoryPreviewPill('Discovered', before.discovered_count, after.discovered_count, discoveredDelta)} + ${autoSyncHistoryPreviewPill('Wishlisted', before.wishlisted_count, after.wishlisted_count, wishlistDelta)} + ${autoSyncHistoryPreviewPill('Library', before.in_library_count, after.in_library_count, libraryDelta)}
@@ -552,6 +566,12 @@ function autoSyncHistoryStatusLabel(status) { return status || 'Run'; } +function autoSyncHistoryStatusClass(status) { + if (status === 'completed' || status === 'finished') return 'enabled'; + if (status === 'error' || status === 'skipped') return 'disabled'; + return 'enabled'; +} + function autoSyncDurationLabel(seconds) { const total = Math.max(0, Math.round(parseFloat(seconds) || 0)); if (total < 60) return `${total}s`; @@ -600,15 +620,24 @@ function autoSyncAutomationCardHtml(auto, playlists) { const trigger = _autoFormatTrigger(auto.trigger_type, auto.trigger_config || {}); const enabled = auto.enabled !== false && auto.enabled !== 0; const next = auto.next_run ? autoSyncNextRunLabel(auto.next_run) : 'not scheduled'; + const sourceLabel = playlist ? autoSyncSourceLabel(playlist.source) : (cfg.all === true || cfg.all === 'true' ? 'All sources' : 'Pipeline'); return `
+
- ${enabled ? 'Enabled' : 'Disabled'} ${_esc(auto.name || 'Playlist Pipeline')}
+
+ ${_esc(trigger)} + + Playlist pipeline + + Refresh + sync +
- ${_esc(trigger)} + ${enabled ? 'Enabled' : 'Disabled'} + ${_esc(sourceLabel)} ${_esc(target)} ${_esc(next)}
diff --git a/webui/static/style.css b/webui/static/style.css index b9a75954..ef29229d 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -11927,16 +11927,17 @@ body.helper-mode-active #dashboard-activity-feed:hover { display: flex; align-items: center; justify-content: space-between; - gap: 16px; - padding: 14px 16px; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 8px; - background: rgba(255, 255, 255, 0.035); + gap: 12px; + padding: 12px 14px; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 10px; + background: rgba(22, 22, 22, 0.95); + overflow: hidden; } .auto-sync-automation-card:hover { - border-color: rgba(255, 255, 255, 0.14); - background: rgba(255, 255, 255, 0.055); + border-color: rgba(var(--accent-rgb), 0.2); + background: rgba(28, 28, 28, 0.98); } .auto-sync-automation-main { @@ -11953,12 +11954,66 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-automation-title-row strong { color: rgba(255, 255, 255, 0.88); - font-size: 14px; + font-size: 13px; + font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.auto-sync-card-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.auto-sync-card-status-dot.enabled { + background: #4ade80; + box-shadow: 0 0 6px rgba(74, 222, 128, 0.4); +} + +.auto-sync-card-status-dot.disabled { + background: #555; +} + +.auto-sync-card-flow { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + margin-top: 5px; +} + +.auto-sync-card-flow .flow-trigger, +.auto-sync-card-flow .flow-action, +.auto-sync-card-flow .flow-notify { + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + white-space: nowrap; +} + +.auto-sync-card-flow .flow-trigger { + background: rgba(var(--accent-rgb), 0.12); + color: rgb(var(--accent-light-rgb)); +} + +.auto-sync-card-flow .flow-action { + background: rgba(88, 101, 242, 0.12); + color: #7289da; +} + +.auto-sync-card-flow .flow-notify { + background: rgba(250, 204, 21, 0.1); + color: #fbbf24; +} + +.auto-sync-card-flow .flow-arrow { + color: rgba(255, 255, 255, 0.25); + font-size: 12px; +} + .auto-sync-status { padding: 3px 7px; border-radius: 999px; @@ -11982,15 +12037,16 @@ body.helper-mode-active #dashboard-activity-feed:hover { display: flex; flex-wrap: wrap; gap: 8px; - margin-top: 8px; + margin-top: 7px; } .auto-sync-automation-meta span { - padding: 4px 8px; - border-radius: 6px; - background: rgba(255, 255, 255, 0.05); - color: rgba(255, 255, 255, 0.48); - font-size: 11px; + padding: 3px 7px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.045); + color: rgba(255, 255, 255, 0.42); + font-size: 10px; + font-weight: 700; } .auto-sync-automation-lock { @@ -12025,13 +12081,11 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-history-entry { display: block; min-height: 84px; - border: 1px solid rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.07); border-radius: 10px; - background: - linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.025)), - rgba(255, 255, 255, 0.035); + background: rgba(22, 22, 22, 0.95); overflow: hidden; - box-shadow: 0 10px 26px rgba(0, 0, 0, 0.14); + box-shadow: none; } .auto-sync-history-entry:empty { @@ -12048,21 +12102,18 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-history-entry:hover { - border-color: rgba(var(--accent-rgb), 0.3); - background: - linear-gradient(135deg, rgba(var(--accent-rgb), 0.075), rgba(255, 255, 255, 0.028)), - rgba(255, 255, 255, 0.04); + border-color: rgba(var(--accent-rgb), 0.2); + background: rgba(28, 28, 28, 0.98); } .auto-sync-history-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; - gap: 14px; - min-height: 84px; - padding: 15px 16px; + gap: 12px; + min-height: 96px; + padding: 13px 14px; cursor: pointer; - background: rgba(255, 255, 255, 0.018); } .auto-sync-history-status { @@ -12095,25 +12146,32 @@ body.helper-mode-active #dashboard-activity-feed:hover { min-width: 0; } -.auto-sync-history-main strong, -.auto-sync-history-main small { - display: block; - overflow: hidden; - text-overflow: ellipsis; +.auto-sync-history-title-row { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; } -.auto-sync-history-main strong { +.auto-sync-history-title-row strong { + min-width: 0; color: rgba(255, 255, 255, 0.88); - font-size: 14px; + font-size: 13px; + font-weight: 600; line-height: 1.25; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } .auto-sync-history-main small { + display: block; margin-top: 3px; - color: rgba(255, 255, 255, 0.5); - font-size: 12px; + color: rgba(255, 255, 255, 0.4); + font-size: 11px; line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; white-space: normal; } @@ -12121,17 +12179,16 @@ body.helper-mode-active #dashboard-activity-feed:hover { display: flex; flex-wrap: wrap; gap: 6px; - margin-top: 9px; + margin-top: 7px; } .auto-sync-history-preview span { - padding: 4px 7px; - border-radius: 6px; - background: rgba(var(--accent-rgb), 0.1); - border: 1px solid rgba(var(--accent-rgb), 0.16); - color: rgb(var(--accent-light-rgb)); + padding: 3px 7px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.045); + color: rgba(255, 255, 255, 0.42); font-size: 10px; - font-weight: 800; + font-weight: 700; } .auto-sync-history-meta { From e86e74d640eb9ee0b912075870ecdf3dc337a0a4 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 25 May 2026 08:24:47 -0700 Subject: [PATCH 29/81] Expand auto-sync run history cards Make playlist pipeline run history cards clickable and keyboard-accessible, with expanded detail sections for summary stats, timeline, before/after snapshots, result payload fields, and future run logs. Refresh the card styling so the expanded state remains responsive inside the Auto-Sync modal. --- webui/static/auto-sync.js | 171 +++++++++++++++++++++++++++++++++----- webui/static/style.css | 161 +++++++++++++++++++++++++++++++++-- 2 files changed, 303 insertions(+), 29 deletions(-) diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js index 83fc38e6..1feb491a 100644 --- a/webui/static/auto-sync.js +++ b/webui/static/auto-sync.js @@ -459,16 +459,9 @@ function autoSyncHistoryEntryHtml(entry, index = 0) { const entryId = `auto-sync-history-${entry.id}`; const playlistName = entry.playlist_name || after.name || before.name || `Playlist #${entry.playlist_id || 'unknown'}`; const summary = entry.summary || autoSyncHistoryFallbackSummary(before, after, status); - const resultHtml = [ - autoSyncHistoryResultPill('Refreshed', result.playlists_refreshed), - autoSyncHistoryResultPill('Synced', result.tracks_synced), - autoSyncHistoryResultPill('Skipped', result.sync_skipped), - autoSyncHistoryResultPill('Queued', result.wishlist_queued), - result.error ? `${_esc(result.error)}` : '', - ].filter(Boolean).join(''); return ` -
-
+
+
`; @@ -556,7 +541,18 @@ function autoSyncHistoryFallbackSummary(before, after, status) { function autoSyncToggleHistoryEntry(entryId) { const el = document.getElementById(entryId); - if (el) el.classList.toggle('expanded'); + const card = document.getElementById(`${entryId}-card`); + const row = card?.querySelector('.auto-sync-history-row'); + if (!el) return; + const expanded = el.classList.toggle('expanded'); + if (card) card.classList.toggle('expanded', expanded); + if (row) row.setAttribute('aria-expanded', expanded ? 'true' : 'false'); +} + +function autoSyncHistoryEntryKeydown(event, entryId) { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + autoSyncToggleHistoryEntry(entryId); } function autoSyncHistoryStatusLabel(status) { @@ -610,6 +606,141 @@ function autoSyncHistoryResultPill(label, value) { return `${_esc(label)}: ${_esc(String(value))}`; } +function autoSyncHistoryDetailHtml(entry, before, after, result, deltas) { + const resultPills = [ + autoSyncHistoryResultPill('Refreshed', result.playlists_refreshed), + autoSyncHistoryResultPill('Discovered', result.tracks_discovered), + autoSyncHistoryResultPill('Synced', result.tracks_synced), + autoSyncHistoryResultPill('Skipped', result.sync_skipped), + autoSyncHistoryResultPill('Wishlisted', result.wishlist_queued), + autoSyncHistoryResultPill('Duration', result.duration_seconds ? autoSyncDurationLabel(result.duration_seconds) : ''), + result.error ? `${_esc(result.error)}` : '', + ].filter(Boolean).join(''); + const timeline = [ + ['Started', autoSyncFormatDateTime(entry.started_at)], + ['Finished', autoSyncFormatDateTime(entry.finished_at)], + ['Duration', entry.duration_seconds ? autoSyncDurationLabel(entry.duration_seconds) : 'Not recorded'], + ['Trigger', entry.trigger_source || 'pipeline'], + ['Source', entry.source || after.source || before.source || 'Unknown'], + ['Playlist ID', entry.playlist_id || after.playlist_id || before.playlist_id || 'Unknown'], + ]; + return ` +
+
+
Run Summary
+
+ ${autoSyncHistoryStatHtml('Tracks', before.track_count, after.track_count, deltas.trackDelta)} + ${autoSyncHistoryStatHtml('Discovered', before.discovered_count, after.discovered_count, deltas.discoveredDelta)} + ${autoSyncHistoryStatHtml('Wishlisted', before.wishlisted_count, after.wishlisted_count, deltas.wishlistDelta)} + ${autoSyncHistoryStatHtml('In library', before.in_library_count, after.in_library_count, deltas.libraryDelta)} +
+
+ ${resultPills || 'No detailed result payload recorded for this run.'} +
+
+
+
Timeline
+
+ ${timeline.map(([label, value]) => autoSyncHistoryFactHtml(label, value)).join('')} +
+
+
+
+ ${autoSyncHistorySnapshotHtml('Before refresh', before)} + ${autoSyncHistorySnapshotHtml('After pipeline', after)} +
+ ${autoSyncHistoryObjectHtml('Result payload', result, { skipPrivate: true })} + ${autoSyncHistoryLogsHtml(entry.log_lines)} + `; +} + +function autoSyncHistoryFactHtml(label, value) { + return ` +
+ ${_esc(label)} + ${_esc(autoSyncValueLabel(value))} +
+ `; +} + +function autoSyncHistorySnapshotHtml(title, snapshot) { + const fields = [ + ['Name', snapshot.name], + ['Source', snapshot.source], + ['Tracks', snapshot.track_count], + ['Discovered', snapshot.discovered_count], + ['Wishlisted', snapshot.wishlisted_count], + ['In library', snapshot.in_library_count], + ]; + return ` +
+
${_esc(title)}
+
+ ${fields.map(([label, value]) => autoSyncHistoryFactHtml(label, value)).join('')} +
+
+ `; +} + +function autoSyncHistoryObjectHtml(title, obj, options = {}) { + if (!obj || typeof obj !== 'object') return ''; + const entries = Object.entries(obj) + .filter(([key, value]) => !(options.skipPrivate && key.startsWith('_')) && value !== undefined && value !== null && value !== '') + .slice(0, 24); + if (!entries.length) return ''; + return ` +
+
${_esc(title)}
+
+ ${entries.map(([key, value]) => ` +
+ ${_esc(autoSyncHumanizeKey(key))} + ${_esc(autoSyncValueLabel(value))} +
+ `).join('')} +
+
+ `; +} + +function autoSyncHistoryLogsHtml(logLines) { + if (!Array.isArray(logLines) || !logLines.length) return ''; + return ` +
+
Run Log
+
+ ${logLines.slice(-12).map(line => { + const text = typeof line === 'string' ? line : (line.message || line.log_line || JSON.stringify(line)); + const type = typeof line === 'object' ? (line.type || line.log_type || 'info') : 'info'; + return `
${_esc(text)}
`; + }).join('')} +
+
+ `; +} + +function autoSyncFormatDateTime(value) { + if (!value) return ''; + const ts = _autoParseUTC(value); + if (!Number.isFinite(ts)) return value; + return new Date(ts).toLocaleString(); +} + +function autoSyncHumanizeKey(key) { + return String(key || '') + .replace(/^_+/, '') + .replace(/_/g, ' ') + .replace(/\b\w/g, ch => ch.toUpperCase()); +} + +function autoSyncValueLabel(value) { + if (value === undefined || value === null || value === '') return 'Not recorded'; + if (typeof value === 'boolean') return value ? 'Yes' : 'No'; + if (Array.isArray(value)) return value.length ? value.map(autoSyncValueLabel).join(', ') : 'None'; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + function autoSyncAutomationCardHtml(auto, playlists) { const cfg = auto.action_config || {}; const playlistId = autoSyncPlaylistIdFromAutomation(auto); diff --git a/webui/static/style.css b/webui/static/style.css index ef29229d..9c85642a 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -12088,6 +12088,11 @@ body.helper-mode-active #dashboard-activity-feed:hover { box-shadow: none; } +.auto-sync-history-entry.expanded { + border-color: rgba(var(--accent-rgb), 0.26); + background: rgba(26, 26, 26, 0.98); +} + .auto-sync-history-entry:empty { display: flex; align-items: center; @@ -12116,6 +12121,11 @@ body.helper-mode-active #dashboard-activity-feed:hover { cursor: pointer; } +.auto-sync-history-row:focus-visible { + outline: 2px solid rgba(var(--accent-rgb), 0.45); + outline-offset: -3px; +} + .auto-sync-history-status { padding: 4px 8px; border-radius: 999px; @@ -12196,11 +12206,11 @@ body.helper-mode-active #dashboard-activity-feed:hover { flex-wrap: wrap; justify-content: flex-end; gap: 6px; + max-width: 190px; } .auto-sync-history-meta span, -.auto-sync-history-result span, -.auto-sync-history-meta button { +.auto-sync-history-result span { padding: 4px 7px; border-radius: 6px; background: rgba(255, 255, 255, 0.055); @@ -12209,32 +12219,74 @@ body.helper-mode-active #dashboard-activity-feed:hover { font-weight: 700; } -.auto-sync-history-meta button { +.auto-sync-history-meta .auto-sync-history-expand-label { border: 1px solid rgba(var(--accent-rgb), 0.18); color: rgb(var(--accent-light-rgb)); - cursor: pointer; + padding-right: 20px; + position: relative; } -.auto-sync-history-meta button:hover { +.auto-sync-history-meta .auto-sync-history-expand-label::after { + content: ''; + position: absolute; + right: 8px; + top: 50%; + width: 6px; + height: 6px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: translateY(-65%) rotate(45deg); + transition: transform 160ms ease; +} + +.auto-sync-history-entry.expanded .auto-sync-history-expand-label::after { + transform: translateY(-30%) rotate(225deg); +} + +.auto-sync-history-entry:hover .auto-sync-history-expand-label { background: rgba(var(--accent-rgb), 0.14); border-color: rgba(var(--accent-rgb), 0.35); } .auto-sync-history-detail { display: none; - padding: 0 16px 16px; + padding: 16px; border-top: 1px solid rgba(255, 255, 255, 0.07); } .auto-sync-history-detail.expanded { - display: block; + display: flex; + flex-direction: column; + gap: 12px; +} + +.auto-sync-history-detail-grid { + display: grid; + grid-template-columns: minmax(0, 1.45fr) minmax(220px, 0.8fr); + gap: 12px; +} + +.auto-sync-history-section { + min-width: 0; + padding: 12px; + border: 1px solid rgba(255, 255, 255, 0.075); + border-radius: 9px; + background: rgba(255, 255, 255, 0.028); +} + +.auto-sync-history-section-title { + color: rgba(255, 255, 255, 0.7); + font-size: 10px; + font-weight: 850; + letter-spacing: 0; + text-transform: uppercase; } .auto-sync-history-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; - padding-top: 14px; + padding-top: 10px; } .auto-sync-history-stats div { @@ -12265,7 +12317,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { display: flex; flex-wrap: wrap; gap: 7px; - margin-top: 9px; + margin-top: 10px; } .auto-sync-history-result span { @@ -12286,6 +12338,81 @@ body.helper-mode-active #dashboard-activity-feed:hover { border-color: rgba(255, 255, 255, 0.08); } +.auto-sync-history-snapshots { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.auto-sync-history-facts, +.auto-sync-history-payload { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 10px; +} + +.auto-sync-history-facts.compact { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.auto-sync-history-facts div, +.auto-sync-history-payload div { + min-width: 0; + padding: 9px; + border-radius: 7px; + background: rgba(255, 255, 255, 0.04); +} + +.auto-sync-history-facts span, +.auto-sync-history-payload span, +.auto-sync-history-facts strong, +.auto-sync-history-payload strong { + display: block; + min-width: 0; +} + +.auto-sync-history-facts span, +.auto-sync-history-payload span { + color: rgba(255, 255, 255, 0.36); + font-size: 10px; + font-weight: 800; + text-transform: uppercase; +} + +.auto-sync-history-facts strong, +.auto-sync-history-payload strong { + margin-top: 4px; + color: rgba(255, 255, 255, 0.78); + font-size: 11px; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.auto-sync-history-logs { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 190px; + overflow: auto; + margin-top: 10px; + padding: 10px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.18); +} + +.auto-sync-history-logs div { + color: rgba(255, 255, 255, 0.54); + font-family: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', monospace; + font-size: 10px; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.auto-sync-history-logs .success { color: #4ade80; } +.auto-sync-history-logs .error { color: #ef4444; } +.auto-sync-history-logs .warning { color: #facc15; } + .auto-sync-history-empty { margin: 24px; padding: 44px; @@ -12358,6 +12485,15 @@ body.helper-mode-active #dashboard-activity-feed:hover { grid-template-columns: repeat(10, minmax(230px, 250px)); padding: 18px; } + + .auto-sync-history-detail-grid, + .auto-sync-history-snapshots { + grid-template-columns: 1fr; + } + + .auto-sync-history-facts.compact { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } @media (max-height: 760px) { @@ -12428,12 +12564,19 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-history-meta { justify-content: flex-start; + max-width: none; } .auto-sync-history-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .auto-sync-history-facts, + .auto-sync-history-facts.compact, + .auto-sync-history-payload { + grid-template-columns: 1fr; + } + .auto-sync-scheduled-card { grid-template-columns: 1fr; } From 81ad3079b422769d14cf50eaf63c609647ee41b2 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 25 May 2026 08:28:36 -0700 Subject: [PATCH 30/81] Fix auto-sync history card expansion Bind run history card expand interactions after the modal renders instead of relying on inline handlers, and reshape the run cards into a clearer two-row layout with controlled metadata, preview chips, and roomier expanded detail padding. --- webui/static/auto-sync.js | 34 ++++++++++++++------ webui/static/style.css | 67 +++++++++++++++++++++++++++------------ 2 files changed, 72 insertions(+), 29 deletions(-) diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js index 1feb491a..06b805af 100644 --- a/webui/static/auto-sync.js +++ b/webui/static/auto-sync.js @@ -245,6 +245,7 @@ function renderAutoSyncScheduleModal() {
${historyPanel}
`; + bindAutoSyncHistoryCardInteractions(overlay); } function setAutoSyncTab(tab) { @@ -460,8 +461,8 @@ function autoSyncHistoryEntryHtml(entry, index = 0) { const playlistName = entry.playlist_name || after.name || before.name || `Playlist #${entry.playlist_id || 'unknown'}`; const summary = entry.summary || autoSyncHistoryFallbackSummary(before, after, status); return ` -
- +
`; @@ -143,6 +144,14 @@ function renderListenBrainzSyncPlaylists() { handleListenBrainzSyncCardClick(mbid, title); }); }); + + // If the tab is currently visible, kick the refresh loop so cards + // start showing live state immediately. ``_startLbSyncCardRefreshLoop`` + // is idempotent + self-stops when the tab loses focus. + const tab = document.getElementById('listenbrainz-tab-content'); + if (tab && tab.classList.contains('active')) { + _startLbSyncCardRefreshLoop(); + } } async function handleListenBrainzSyncCardClick(playlistMbid, playlistTitle) { @@ -206,6 +215,98 @@ async function handleListenBrainzSyncCardClick(playlistMbid, playlistTitle) { } } +// Live card refresh — keeps the Sync-tab cards in sync with the +// canonical ``listenbrainzPlaylistStates`` dict that the discovery / +// sync polling loops own. Tidal does this via explicit +// ``updateTidalCardPhase`` / ``updateTidalCardProgress`` calls +// sprinkled through its polling code; we get the same UX with a +// single 500ms tick that reads the shared state. The loop only runs +// while the LB tab is the active Sync tab so it's cheap. + +let _lbSyncCardRefreshInterval = null; + +function _refreshOneLbSyncCard(card) { + const mbid = card.dataset.lbMbid; + if (!mbid) return; + const state = (typeof listenbrainzPlaylistStates !== 'undefined') + ? listenbrainzPlaylistStates[mbid] : null; + if (!state) return; + + const phase = state.phase || 'fresh'; + const phaseEl = card.querySelector('.playlist-card-phase-text'); + if (phaseEl) { + const text = (typeof getPhaseText === 'function') ? getPhaseText(phase) : phase; + const color = (typeof getPhaseColor === 'function') ? getPhaseColor(phase) : ''; + if (phaseEl.textContent !== text) phaseEl.textContent = text; + if (color) phaseEl.style.color = color; + } + + const btnEl = card.querySelector('.playlist-card-action-btn'); + if (btnEl) { + const btnText = (typeof getActionButtonText === 'function') + ? getActionButtonText(phase) : btnEl.textContent; + if (btnEl.textContent !== btnText) btnEl.textContent = btnText; + } + + // Discovery progress mirrors Tidal's per-card text: + // "♪ / ✓ / ✗ / %". + // During sync, swap to the sync progress payload the LB sync poller + // writes into state.lastSyncProgress (same shape Tidal uses). + const progEl = card.querySelector('.playlist-card-progress'); + if (!progEl) return; + if (phase === 'fresh') { + progEl.classList.add('hidden'); + progEl.textContent = ''; + return; + } + + if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) { + const sp = state.lastSyncProgress; + const matched = sp.matched_tracks || sp.spotify_matches || 0; + const total = sp.total_tracks || sp.spotify_total || 0; + const failed = (sp.failed_tracks !== undefined) + ? sp.failed_tracks : Math.max(0, total - matched); + const pct = total > 0 ? Math.round((matched / total) * 100) : 0; + progEl.textContent = `♪ ${total} / ✓ ${matched} / ✗ ${failed} / ${pct}%`; + progEl.classList.remove('hidden'); + return; + } + + const total = state.spotify_total || state.spotifyTotal || 0; + const matched = state.spotify_matches || state.spotifyMatches || 0; + const failed = Math.max(0, total - matched); + const pct = total > 0 ? Math.round((matched / total) * 100) + : (state.discovery_progress || state.discoveryProgress || 0); + progEl.textContent = `♪ ${total} / ✓ ${matched} / ✗ ${failed} / ${pct}%`; + progEl.classList.remove('hidden'); +} + +function _refreshAllLbSyncCards() { + document.querySelectorAll('#listenbrainz-tab-content .listenbrainz-playlist-card') + .forEach(_refreshOneLbSyncCard); +} + +function _startLbSyncCardRefreshLoop() { + if (_lbSyncCardRefreshInterval) return; + _lbSyncCardRefreshInterval = setInterval(() => { + const tab = document.getElementById('listenbrainz-tab-content'); + if (!tab || !tab.classList.contains('active')) { + _stopLbSyncCardRefreshLoop(); + return; + } + _refreshAllLbSyncCards(); + }, 500); + // Initial tick so the user doesn't wait 500ms for the first update. + _refreshAllLbSyncCards(); +} + +function _stopLbSyncCardRefreshLoop() { + if (_lbSyncCardRefreshInterval) { + clearInterval(_lbSyncCardRefreshInterval); + _lbSyncCardRefreshInterval = null; + } +} + // Sub-tab switching (For You / My Playlists / Collaborative). function _initListenBrainzSyncSubTabs() { const subTabContainer = document.querySelector('#listenbrainz-tab-content .listenbrainz-sub-tabs'); diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js index e1ab023f..58113d05 100644 --- a/webui/static/sync-services.js +++ b/webui/static/sync-services.js @@ -3735,10 +3735,18 @@ function initializeSyncPage() { // Auto-load ListenBrainz Sync-tab playlists on first activation. // Reuses the LB discovery + sync flow already wired up for the // Discover page — the tab is purely a Sync-page entry point. - if (tabId === 'listenbrainz' && typeof loadListenBrainzSyncPlaylists === 'function' - && !window._listenbrainzSyncTabLoaded) { - window._listenbrainzSyncTabLoaded = true; - loadListenBrainzSyncPlaylists(); + if (tabId === 'listenbrainz') { + if (typeof loadListenBrainzSyncPlaylists === 'function' + && !window._listenbrainzSyncTabLoaded) { + window._listenbrainzSyncTabLoaded = true; + loadListenBrainzSyncPlaylists(); + } + // Cards mirror canonical listenbrainzPlaylistStates via a + // 500ms refresh loop that auto-stops when the tab loses + // active state — gives parity with Tidal/Qobuz live updates. + if (typeof _startLbSyncCardRefreshLoop === 'function') { + _startLbSyncCardRefreshLoop(); + } } }); }); From 969d5ffc1b5d618daa40ab60c1a887457d7593fa Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 14:41:57 -0700 Subject: [PATCH 54/81] =?UTF-8?q?Fix=20LB=20Sync=20tab=20card=20styling=20?= =?UTF-8?q?=E2=80=94=20dead=20CSS=20+=20ID=20collision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two interacting bugs that left LB Sync-tab cards rendering with a solid orange gradient background instead of the dark glass style every other Sync-page card uses: 1. **Duplicate element id** ``listenbrainz-tab-content``: the new Sync-tab content div reused the same id the Discover page's pre-existing LB section already owned. Two elements with the same id is invalid HTML, and ``getElementById`` in the refresh loop was hitting the Sync version first while ``initialize SyncPage``'s ``${tabId}-tab-content`` lookup could race against it. Renamed the Sync-page tab id + ``data-tab`` attribute to ``listenbrainz-sync`` (matches the existing ``${tabId}-tab- content`` convention so the lookup becomes ``listenbrainz-sync-tab-content``). Discover-page LB tab keeps its original id untouched. 2. **Dead ``.listenbrainz-playlist-card`` rule** at style.css L36155 painting a solid ``linear-gradient(#eb743b → #d26230)`` over the card. That class was orphaned — no JS or HTML instantiated it before Phase 1c.1 — but it sat at higher source order than my unified ``.youtube-playlist-card, .tidal-playlist-card, ...`` rule, so the bare-class selector won the cascade and overwrote the dark glass background. Also removed the matching dead ``.listenbrainz-icon { font- size: 48px }`` rule and its local ``@keyframes pulse`` copy (the keyframes are defined in four other live blocks). 3. **Missing LB selectors in unified inner-element rules**: ``.listenbrainz-playlist-card`` was only added to the OUTER card selector group in the first pass — the inner ``.playlist-card-icon`` / ``.playlist-card-content`` / ``.playlist-card-name`` / ``.playlist-card-info`` / ``.playlist-card-action-btn`` (+ ::before, :hover, :disabled) selector groups were left out, so the inner elements lost all their styling. Bulk-added LB to every group so the card inherits the full glass shell the other sources get, with a brand-orange ``rgba(235, 116, 59, ...)`` accent matching the Tidal / Deezer / Spotify-public pattern. --- webui/index.html | 6 +-- webui/static/style.css | 66 ++++++++++--------------------- webui/static/sync-listenbrainz.js | 8 ++-- webui/static/sync-services.js | 5 ++- 4 files changed, 32 insertions(+), 53 deletions(-) diff --git a/webui/index.html b/webui/index.html index 6da1ac1b..9c150945 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1027,7 +1027,7 @@ -
- -
+ +

Your ListenBrainz Playlists

diff --git a/webui/static/style.css b/webui/static/style.css index b252989c..1b9bf4e5 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -17611,21 +17611,25 @@ body.helper-mode-active #dashboard-activity-feed:hover { .tidal-playlist-card .playlist-card-icon { background: rgba(255, 102, 0, 0.12); border: 1px solid rgba(255, 102, 0, 0.2); color: #ff6600; font-size: 16px; } .deezer-playlist-card .playlist-card-icon { background: rgba(162, 56, 255, 0.12); border: 1px solid rgba(162, 56, 255, 0.2); color: #a238ff; } .spotify-public-card .playlist-card-icon { background: rgba(29, 185, 84, 0.12); border: 1px solid rgba(29, 185, 84, 0.2); color: #1DB954; } +.listenbrainz-playlist-card .playlist-card-icon { background: rgba(235, 116, 59, 0.12); border: 1px solid rgba(235, 116, 59, 0.2); color: #eb743b; } .youtube-playlist-card .playlist-card-content, .tidal-playlist-card .playlist-card-content, .deezer-playlist-card .playlist-card-content, -.spotify-public-card .playlist-card-content { flex: 1; min-width: 0; } +.spotify-public-card .playlist-card-content, +.listenbrainz-playlist-card .playlist-card-content { flex: 1; min-width: 0; } .youtube-playlist-card .playlist-card-name, .tidal-playlist-card .playlist-card-name, .deezer-playlist-card .playlist-card-name, -.spotify-public-card .playlist-card-name { font-size: 15px; font-weight: 600; color: rgba(255, 255, 255, 0.9); margin-bottom: 4px; } +.spotify-public-card .playlist-card-name, +.listenbrainz-playlist-card .playlist-card-name { font-size: 15px; font-weight: 600; color: rgba(255, 255, 255, 0.9); margin-bottom: 4px; } .youtube-playlist-card .playlist-card-info, .tidal-playlist-card .playlist-card-info, .deezer-playlist-card .playlist-card-info, -.spotify-public-card .playlist-card-info { font-size: 13px; color: rgba(255, 255, 255, 0.4); display: flex; align-items: center; gap: 10px; } +.spotify-public-card .playlist-card-info, +.listenbrainz-playlist-card .playlist-card-info { font-size: 13px; color: rgba(255, 255, 255, 0.4); display: flex; align-items: center; gap: 10px; } .youtube-playlist-card .playlist-card-track-count { color: rgba(255, 255, 255, 0.7); } .youtube-playlist-card .playlist-card-phase-text { font-weight: 500; } @@ -17635,7 +17639,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .youtube-playlist-card .playlist-card-action-btn, .tidal-playlist-card .playlist-card-action-btn, .deezer-playlist-card .playlist-card-action-btn, -.spotify-public-card .playlist-card-action-btn { +.spotify-public-card .playlist-card-action-btn, +.listenbrainz-playlist-card .playlist-card-action-btn { background: linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%); border: 1px solid rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.8); @@ -17654,7 +17659,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .youtube-playlist-card .playlist-card-action-btn::before, .tidal-playlist-card .playlist-card-action-btn::before, .deezer-playlist-card .playlist-card-action-btn::before, -.spotify-public-card .playlist-card-action-btn::before { +.spotify-public-card .playlist-card-action-btn::before, +.listenbrainz-playlist-card .playlist-card-action-btn::before { content: ''; position: absolute; inset: 0; @@ -17667,21 +17673,25 @@ body.helper-mode-active #dashboard-activity-feed:hover { .tidal-playlist-card .playlist-card-action-btn::before { background: linear-gradient(135deg, rgba(255, 102, 0, 0.2), rgba(255, 102, 0, 0.05)); } .deezer-playlist-card .playlist-card-action-btn::before { background: linear-gradient(135deg, rgba(162, 56, 255, 0.2), rgba(162, 56, 255, 0.05)); } .spotify-public-card .playlist-card-action-btn::before { background: linear-gradient(135deg, rgba(29, 185, 84, 0.2), rgba(29, 185, 84, 0.05)); } +.listenbrainz-playlist-card .playlist-card-action-btn::before { background: linear-gradient(135deg, rgba(235, 116, 59, 0.2), rgba(235, 116, 59, 0.05)); } .youtube-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before, .tidal-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before, .deezer-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before, -.spotify-public-card .playlist-card-action-btn:hover:not(:disabled)::before { opacity: 1; } +.spotify-public-card .playlist-card-action-btn:hover:not(:disabled)::before, +.listenbrainz-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before { opacity: 1; } .youtube-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(255, 0, 0, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(255, 0, 0, 0.15); } .tidal-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(255, 102, 0, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(255, 102, 0, 0.15); } .deezer-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(162, 56, 255, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(162, 56, 255, 0.15); } .spotify-public-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(29, 185, 84, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(29, 185, 84, 0.15); } +.listenbrainz-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(235, 116, 59, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(235, 116, 59, 0.15); } .youtube-playlist-card .playlist-card-action-btn:disabled, .tidal-playlist-card .playlist-card-action-btn:disabled, .deezer-playlist-card .playlist-card-action-btn:disabled, -.spotify-public-card .playlist-card-action-btn:disabled { +.spotify-public-card .playlist-card-action-btn:disabled, +.listenbrainz-playlist-card .playlist-card-action-btn:disabled { background: rgba(255, 255, 255, 0.03); border-color: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.2); @@ -36142,44 +36152,10 @@ div.artist-hero-badge { margin-top: 60px; } -.listenbrainz-playlist-card { - background: linear-gradient(135deg, #eb743b 0%, #d26230 100%); - display: flex; - align-items: center; - justify-content: center; - position: relative; - overflow: hidden; -} - -.listenbrainz-playlist-card::before { - content: ''; - position: absolute; - top: -50%; - left: -50%; - width: 200%; - height: 200%; - background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%); - animation: pulse 3s ease-in-out infinite; -} - -.listenbrainz-icon { - font-size: 48px; - position: relative; - z-index: 1; - filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2)); -} - -@keyframes pulse { - - 0%, - 100% { - opacity: 0.5; - } - - 50% { - opacity: 0.8; - } -} +/* (Removed dead Discover-page LB card styling — solid orange gradient + * + .listenbrainz-icon { font-size: 48px } were orphaned rules that + * collided with the Sync-page LB tab cards. The legacy class was + * never instantiated in JS or HTML outside the new Sync tab.) */ /* ========================================= */ /* ========================================= */ diff --git a/webui/static/sync-listenbrainz.js b/webui/static/sync-listenbrainz.js index fd3b1045..39401423 100644 --- a/webui/static/sync-listenbrainz.js +++ b/webui/static/sync-listenbrainz.js @@ -148,7 +148,7 @@ function renderListenBrainzSyncPlaylists() { // If the tab is currently visible, kick the refresh loop so cards // start showing live state immediately. ``_startLbSyncCardRefreshLoop`` // is idempotent + self-stops when the tab loses focus. - const tab = document.getElementById('listenbrainz-tab-content'); + const tab = document.getElementById('listenbrainz-sync-tab-content'); if (tab && tab.classList.contains('active')) { _startLbSyncCardRefreshLoop(); } @@ -282,14 +282,14 @@ function _refreshOneLbSyncCard(card) { } function _refreshAllLbSyncCards() { - document.querySelectorAll('#listenbrainz-tab-content .listenbrainz-playlist-card') + document.querySelectorAll('#listenbrainz-sync-tab-content .listenbrainz-playlist-card') .forEach(_refreshOneLbSyncCard); } function _startLbSyncCardRefreshLoop() { if (_lbSyncCardRefreshInterval) return; _lbSyncCardRefreshInterval = setInterval(() => { - const tab = document.getElementById('listenbrainz-tab-content'); + const tab = document.getElementById('listenbrainz-sync-tab-content'); if (!tab || !tab.classList.contains('active')) { _stopLbSyncCardRefreshLoop(); return; @@ -309,7 +309,7 @@ function _stopLbSyncCardRefreshLoop() { // Sub-tab switching (For You / My Playlists / Collaborative). function _initListenBrainzSyncSubTabs() { - const subTabContainer = document.querySelector('#listenbrainz-tab-content .listenbrainz-sub-tabs'); + const subTabContainer = document.querySelector('#listenbrainz-sync-tab-content .listenbrainz-sub-tabs'); if (!subTabContainer) return; subTabContainer.addEventListener('click', (e) => { const btn = e.target.closest('.listenbrainz-sub-tab-btn'); diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js index 58113d05..c6a9edd3 100644 --- a/webui/static/sync-services.js +++ b/webui/static/sync-services.js @@ -3735,7 +3735,10 @@ function initializeSyncPage() { // Auto-load ListenBrainz Sync-tab playlists on first activation. // Reuses the LB discovery + sync flow already wired up for the // Discover page — the tab is purely a Sync-page entry point. - if (tabId === 'listenbrainz') { + // Tab id is ``listenbrainz-sync`` (not ``listenbrainz``) so the + // ``${tabId}-tab-content`` lookup doesn't collide with the + // Discover page's own ``id="listenbrainz-tab-content"``. + if (tabId === 'listenbrainz-sync') { if (typeof loadListenBrainzSyncPlaylists === 'function' && !window._listenbrainzSyncTabLoaded) { window._listenbrainzSyncTabLoaded = true; From f521be77209cd73a150366ba741ba171271943f5 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 14:48:45 -0700 Subject: [PATCH 55/81] LB Sync tab: fix track counts + auto-mirror on discovery complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to the LB Sync tab work: 1. **Track counts all showed 0.** The ``/api/discover/listenbrainz/*`` endpoints assemble a JSPF-shaped payload but drop the cached ``track_count`` field from the underlying ``listenbrainz_playlists`` row — the JSON the frontend sees only carries ``title`` / ``creator`` / ``annotation`` / an empty ``track`` array. The Discover-page renderer worked around it by hard-coding a fallback of 50; the Sync-page renderer had no such fallback, so every card displayed "0 tracks". Backend now includes ``track_count`` directly in each playlist payload (it's already in the cached row) so any frontend can render an accurate count without resorting to a default. JS still falls back to ``annotation.track_count`` and then ``track.length`` for older callers. 2. **LB playlists never landed in Mirrored Playlists.** The existing ``/api/listenbrainz/sync/start/`` endpoint runs the converted Spotify tracks through ``_run_sync_task`` — i.e. it pushes them to the user's media server (Plex / Jellyfin / Navidrome / SoulSync) as a server-side playlist. It does NOT call ``database.mirror_playlist``. So no ``mirrored_playlists`` row gets created and the playlist can't be picked up by the Auto-Sync scheduler, can't show up under the Mirrored tab, doesn't participate in pipeline automations — the whole point of the Sync-tab unification. Tidal works because Tidal mirrors on tab load with raw tracks then enriches via discovery. LB tracks only have provider IDs *after* discovery, so the equivalent moment for LB is "discovery complete". Added ``_mirrorListenBrainzAfterDiscovery(mbid)`` that pulls the matched ``spotify_data`` out of ``discovery_results`` and posts to ``/api/mirror-playlist`` via the existing ``mirrorPlaylist`` helper. Hooked into both the WebSocket and HTTP-poll completion handlers of ``startListenBrainzDiscoveryPolling``. UPSERT-keyed on (source, source_playlist_id, profile_id), so re-running discovery is a safe no-op refresh. Result: any LB playlist the user discovers (from either the Discover page or the new Sync tab) now lands in ``mirrored_playlists`` with ``source='listenbrainz'`` + matched tracks carrying canonical ``extra_data`` JSON, ready for the Auto-Sync refresh + sync pipeline wired up in Phase 1a + 1b. --- web_server.py | 1 + webui/static/sync-listenbrainz.js | 4 +- webui/static/sync-services.js | 73 +++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/web_server.py b/web_server.py index d49e238c..31d1b08b 100644 --- a/web_server.py +++ b/web_server.py @@ -29986,6 +29986,7 @@ def _get_lb_discover_playlists(playlist_type): "identifier": f"https://listenbrainz.org/playlist/{playlist['playlist_mbid']}", "title": playlist['title'], "creator": playlist['creator'], + "track_count": playlist.get('track_count', 0), "annotation": playlist.get('annotation', {}), "track": [] } diff --git a/webui/static/sync-listenbrainz.js b/webui/static/sync-listenbrainz.js index 39401423..d025b485 100644 --- a/webui/static/sync-listenbrainz.js +++ b/webui/static/sync-listenbrainz.js @@ -96,7 +96,9 @@ function renderListenBrainzSyncPlaylists() { const title = inner.title || inner.name || 'ListenBrainz Playlist'; const creator = inner.creator || 'ListenBrainz'; let count = 0; - if (inner.annotation && inner.annotation.track_count) { + if (inner.track_count) { + count = inner.track_count; + } else if (inner.annotation && inner.annotation.track_count) { count = inner.annotation.track_count; } else if (Array.isArray(inner.track) && inner.track.length > 0) { count = inner.track.length; diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js index c6a9edd3..085ed157 100644 --- a/webui/static/sync-services.js +++ b/webui/static/sync-services.js @@ -10793,6 +10793,72 @@ async function resetBeatportChart(urlHash) { // LISTENBRAINZ PLAYLIST DISCOVERY & SYNC // ============================================================================ +/** + * Auto-mirror a ListenBrainz playlist into the mirrored_playlists + * table after discovery completes. Pattern parity with Tidal — + * Tidal mirrors on tab load with raw tracks, then discovery enriches; + * LB tracks only have provider IDs after discovery, so we mirror at + * the end. Idempotent (UPSERT on source + source_playlist_id + + * profile_id), so calling it twice is a no-op. + */ +function _mirrorListenBrainzAfterDiscovery(playlistMbid) { + try { + const state = listenbrainzPlaylistStates[playlistMbid]; + if (!state || !state.playlist) return; + if (typeof mirrorPlaylist !== 'function') return; + + const results = state.discovery_results || []; + if (!results.length) return; + + const tracks = results + .filter(r => r && r.spotify_data && r.spotify_data.id) + .map(r => { + const sp = r.spotify_data; + const artistName = Array.isArray(sp.artists) + ? (typeof sp.artists[0] === 'object' ? sp.artists[0].name : sp.artists[0]) + : (sp.artists || ''); + const albumName = (sp.album && typeof sp.album === 'object') + ? sp.album.name : (sp.album || ''); + const albumImage = (sp.album && sp.album.images && sp.album.images[0]) + ? sp.album.images[0].url : (sp.image_url || null); + return { + track_name: sp.name || '', + artist_name: artistName || '', + album_name: albumName || '', + duration_ms: sp.duration_ms || 0, + image_url: albumImage, + source_track_id: sp.id || '', + extra_data: JSON.stringify({ + discovered: true, + provider: sp.source || 'spotify', + confidence: r.confidence || 1.0, + matched_data: sp, + }), + }; + }); + + if (!tracks.length) { + console.warn(`🪞 [LB Mirror] No matched tracks in '${state.playlist.name}', skipping mirror`); + return; + } + + mirrorPlaylist( + 'listenbrainz', + playlistMbid, + state.playlist.name || 'ListenBrainz Playlist', + tracks, + { + owner: state.playlist.creator || 'ListenBrainz', + description: state.playlist.description || '', + image_url: state.playlist.image_url || '', + } + ); + console.log(`🪞 [LB Mirror] Mirrored '${state.playlist.name}' with ${tracks.length} matched tracks`); + } catch (err) { + console.warn('LB mirror-after-discovery failed:', err); + } +} + function startListenBrainzDiscoveryPolling(playlistMbid) { console.log(`🔄 Starting ListenBrainz discovery polling for: ${playlistMbid}`); @@ -10843,6 +10909,10 @@ function startListenBrainzDiscoveryPolling(playlistMbid) { const playlistIdEl = `discover-lb-playlist-${playlistMbid}`; const syncBtn = document.getElementById(`${playlistIdEl}-sync-btn`); if (syncBtn) syncBtn.style.display = 'inline-block'; + // Mirror matched tracks → mirrored_playlists table so the + // playlist participates in Auto-Sync schedules just like + // Tidal / Qobuz / Spotify mirrors do. + _mirrorListenBrainzAfterDiscovery(playlistMbid); showToast('ListenBrainz discovery complete!', 'success'); } }; @@ -10935,6 +11005,9 @@ function startListenBrainzDiscoveryPolling(playlistMbid) { } console.log('✅ ListenBrainz discovery complete:', playlistMbid); + // Mirror matched tracks → mirrored_playlists table so + // the playlist participates in Auto-Sync schedules. + _mirrorListenBrainzAfterDiscovery(playlistMbid); showToast('ListenBrainz discovery complete!', 'success'); } From 6198fc37d87c054cfb631becffa24553f7d56695 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 15:12:56 -0700 Subject: [PATCH 56/81] LB manager: cascade-delete mirrored rows when LB cache prunes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ListenBrainz auto-rotates the user's "For You" playlists weekly: "Weekly Jams for X, week of 2026-05-25 Mon" gets a fresh MBID every Monday, and the prior week's playlist gets dropped from ListenBrainz's API after ~25 weeks. The LB manager already mirrors that retention policy in ``_cleanup_old_playlists`` (keeps the 25 most-recent per category). The Sync-tab auto-mirror flow, though, created a ``mirrored_playlists`` row for each unique MBID — so the user's Mirrored tab would accumulate 100+ dead Weekly Jams / Weekly Exploration rows per year, each pointing at an LB playlist the cache had already pruned. Fix: when LB manager removes a cached LB playlist (either via the periodic ``_cleanup_old_playlists`` rotation or an explicit ``delete_cached_playlist`` call), also delete the matching ``mirrored_playlists`` row + its tracks. Downloaded tracks stay in the library — only the mirror row + track refs go. - New ``_cascade_delete_mirrored_for_mbids(cursor, mbids, source)`` helper runs in the same transaction as the LB cache delete so the two stay consistent. - ``_cleanup_old_playlists`` now selects ``playlist_mbid`` alongside ``id`` from the stale rows + passes the mbids through the cascade helper before committing. - ``delete_cached_playlist`` looks up the playlist's type first (so it knows whether to target ``source='listenbrainz'`` or ``source='lastfm'`` mirrored rows), then cascades. Cleanup is best-effort: any cascade error logs a warning but doesn't roll back the LB cache delete itself. Losing the cache→mirror link in a rare edge case is preferable to crashing the LB update loop. --- core/listenbrainz_manager.py | 81 +++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/core/listenbrainz_manager.py b/core/listenbrainz_manager.py index 914a85ce..c29e4cc6 100644 --- a/core/listenbrainz_manager.py +++ b/core/listenbrainz_manager.py @@ -344,13 +344,15 @@ class ListenBrainzManager: try: # Get IDs of playlists to delete (all except keep_count most recent) cursor.execute(""" - SELECT id FROM listenbrainz_playlists + SELECT id, playlist_mbid FROM listenbrainz_playlists WHERE playlist_type = ? AND profile_id = ? ORDER BY last_updated DESC LIMIT -1 OFFSET ? """, (playlist_type, self.profile_id, keep_count)) - old_playlist_ids = [row[0] for row in cursor.fetchall()] + stale_rows = cursor.fetchall() + old_playlist_ids = [row[0] for row in stale_rows] + old_mbids = [row[1] for row in stale_rows if row[1]] if old_playlist_ids: # Delete tracks for old playlists @@ -362,12 +364,66 @@ class ListenBrainzManager: logger.info(f"Removed {len(old_playlist_ids)} old {playlist_type} playlists") + # Cascade delete: matching mirrored_playlists rows go too. + # LB Weekly Jams / Weekly Exploration get new MBIDs every + # week — without this, the user accumulates dead mirror + # rows that point at LB playlists the cache already pruned. + # Downloaded tracks stay in the library; only the mirror + # row + its track refs are removed. + if old_mbids: + mirror_source = ( + 'lastfm' if playlist_type == 'lastfm_radio' else 'listenbrainz' + ) + self._cascade_delete_mirrored_for_mbids(cursor, old_mbids, mirror_source) + except Exception as e: logger.error(f"Error cleaning up {playlist_type} playlists: {e}") conn.commit() conn.close() + def _cascade_delete_mirrored_for_mbids(self, cursor, mbids, source): + """Delete mirrored_playlists rows whose source_playlist_id matches + any of ``mbids`` for this profile + source. + + Runs on the same cursor as the caller so the cleanup lands in + the same transaction. Silent on failure (cleanup is best-effort + — losing the cache-prune-mirror link in rare edge cases is + preferable to crashing the LB update loop).""" + if not mbids: + return + try: + placeholders = ','.join('?' * len(mbids)) + # Find matching mirror IDs first so we can delete tracks + + # row in two well-defined steps. ``mirrored_playlist_tracks`` + # has no ON DELETE CASCADE constraint enforced unless PRAGMA + # foreign_keys is on, so do it explicitly. + cursor.execute( + f""" + SELECT id FROM mirrored_playlists + WHERE source = ? AND profile_id = ? + AND source_playlist_id IN ({placeholders}) + """, + (source, self.profile_id, *mbids), + ) + mirror_ids = [row[0] for row in cursor.fetchall()] + if not mirror_ids: + return + mid_ph = ','.join('?' * len(mirror_ids)) + cursor.execute( + f"DELETE FROM mirrored_playlist_tracks WHERE playlist_id IN ({mid_ph})", + mirror_ids, + ) + cursor.execute( + f"DELETE FROM mirrored_playlists WHERE id IN ({mid_ph})", + mirror_ids, + ) + logger.info( + f"Cascade-removed {len(mirror_ids)} stale {source} mirrored playlists" + ) + except Exception as exc: + logger.warning(f"Cascade delete of mirrored {source} rows failed: {exc}") + def save_lastfm_radio_playlist(self, seed_track: str, seed_artist: str, similar_tracks: List[Dict]) -> str: """ Persist a Last.fm similar-tracks playlist to the DB under playlist_type='lastfm_radio'. @@ -500,6 +556,21 @@ class ListenBrainzManager: """Delete a cached playlist and its tracks (CASCADE handles tracks via FK)""" conn = self._get_db_connection() cursor = conn.cursor() + + # Figure out the source flavor before deleting the row — the + # cascade below needs to know whether the matching mirror is + # ``source='listenbrainz'`` or ``source='lastfm'``. + playlist_type = '' + try: + cursor.execute( + "SELECT playlist_type FROM listenbrainz_playlists WHERE playlist_mbid = ? AND profile_id = ?", + (playlist_mbid, self.profile_id), + ) + row = cursor.fetchone() + playlist_type = row[0] if row else '' + except Exception: + pass + # Delete tracks first (SQLite FK CASCADE requires PRAGMA foreign_keys=ON) cursor.execute(""" DELETE FROM listenbrainz_tracks WHERE playlist_id IN ( @@ -510,6 +581,12 @@ class ListenBrainzManager: "DELETE FROM listenbrainz_playlists WHERE playlist_mbid = ? AND profile_id = ?", (playlist_mbid, self.profile_id) ) + + # Cascade the delete into mirrored_playlists so the user's + # Mirrored tab doesn't accumulate dead LB rows. + mirror_source = 'lastfm' if playlist_type == 'lastfm_radio' else 'listenbrainz' + self._cascade_delete_mirrored_for_mbids(cursor, [playlist_mbid], mirror_source) + conn.commit() conn.close() From 38e35930a98563c831fd08dbf8ab225752685094 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 15:24:23 -0700 Subject: [PATCH 57/81] Add Last.fm Radio tab to Sync page (Phase 1c.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling to the ListenBrainz Sync tab from Phase 1c.1. Last.fm Radio playlists already live in the same ``listenbrainz_playlists`` table as LB ones (``playlist_type='lastfm_radio'``) and run through the same MB-track discovery worker, so this tab is intentionally thin — list + render + delegate. Card click hands straight off to the LB Sync-tab click handler since the downstream modal + state machine are identical. - ``webui/index.html``: new `` +
+ `; + }).join(''); + + container.querySelectorAll('.lastfm-playlist-card').forEach(card => { + card.addEventListener('click', () => { + const mbid = card.dataset.lbMbid; + const title = card.dataset.lbTitle; + // Reuses the LB Sync-tab click handler — Last.fm radios are + // stored in the same table + matched by the same discovery + // worker, so the click flow is byte-identical. + if (typeof handleListenBrainzSyncCardClick === 'function') { + handleListenBrainzSyncCardClick(mbid, title); + } + }); + }); + + // Reuse the shared refresh loop from sync-listenbrainz.js — it + // already iterates Last.fm cards alongside LB cards. + if (typeof _startLbSyncCardRefreshLoop === 'function') { + const tab = document.getElementById('lastfm-sync-tab-content'); + if (tab && tab.classList.contains('active')) { + _startLbSyncCardRefreshLoop(); + } + } +} + +document.addEventListener('DOMContentLoaded', () => { + const btn = document.getElementById('lastfm-sync-refresh-btn'); + if (btn) btn.addEventListener('click', loadLastfmSyncPlaylists); +}); diff --git a/webui/static/sync-listenbrainz.js b/webui/static/sync-listenbrainz.js index d025b485..b8415ce6 100644 --- a/webui/static/sync-listenbrainz.js +++ b/webui/static/sync-listenbrainz.js @@ -284,15 +284,28 @@ function _refreshOneLbSyncCard(card) { } function _refreshAllLbSyncCards() { - document.querySelectorAll('#listenbrainz-sync-tab-content .listenbrainz-playlist-card') - .forEach(_refreshOneLbSyncCard); + // Both LB and Last.fm-radio tabs render MB-track cards that share + // the ``listenbrainzPlaylistStates`` state machine (Last.fm radios + // are stored in the same listenbrainz_playlists table with + // ``playlist_type='lastfm_radio'``). The refresh loop iterates + // visible cards from either tab so we don't need a second loop. + document.querySelectorAll( + '#listenbrainz-sync-tab-content .listenbrainz-playlist-card, ' + + '#lastfm-sync-tab-content .lastfm-playlist-card' + ).forEach(_refreshOneLbSyncCard); +} + +function _isMbStyleSyncTabActive() { + const lb = document.getElementById('listenbrainz-sync-tab-content'); + const lfm = document.getElementById('lastfm-sync-tab-content'); + return (lb && lb.classList.contains('active')) + || (lfm && lfm.classList.contains('active')); } function _startLbSyncCardRefreshLoop() { if (_lbSyncCardRefreshInterval) return; _lbSyncCardRefreshInterval = setInterval(() => { - const tab = document.getElementById('listenbrainz-sync-tab-content'); - if (!tab || !tab.classList.contains('active')) { + if (!_isMbStyleSyncTabActive()) { _stopLbSyncCardRefreshLoop(); return; } diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js index 085ed157..67f6ded7 100644 --- a/webui/static/sync-services.js +++ b/webui/static/sync-services.js @@ -3751,6 +3751,19 @@ function initializeSyncPage() { _startLbSyncCardRefreshLoop(); } } + + // Last.fm Sync tab — same MB-track shape as LB, shares the + // listenbrainzPlaylistStates machinery + refresh loop. + if (tabId === 'lastfm-sync') { + if (typeof loadLastfmSyncPlaylists === 'function' + && !window._lastfmSyncTabLoaded) { + window._lastfmSyncTabLoaded = true; + loadLastfmSyncPlaylists(); + } + if (typeof _startLbSyncCardRefreshLoop === 'function') { + _startLbSyncCardRefreshLoop(); + } + } }); }); From bbc950d3256220ae1ee3affa66b1ac2588076022 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 15:33:51 -0700 Subject: [PATCH 58/81] Auto-Sync manager: add LB / Last.fm / SoulSync Discovery / iTunes labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``autoSyncSourceLabel`` was missing entries for the post-Phase-0 sources, so any mirrored playlists with ``source='listenbrainz'`` or ``'lastfm'`` rendered their raw lowercase identifier in the sidebar's group heading instead of a friendly brand label. Added the four newer sources. Also added ``itunes_link`` which the iTunes link tab has been able to create for a few releases now. Cosmetic only — the existing ``autoSyncCanSchedulePlaylist`` gate already accepts everything except ``file`` and ``beatport``, so these sources were always schedulable; the group heading just had no human label. --- webui/static/auto-sync.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js index fcd6e2ef..380c1736 100644 --- a/webui/static/auto-sync.js +++ b/webui/static/auto-sync.js @@ -77,6 +77,10 @@ function autoSyncSourceLabel(source) { qobuz: 'Qobuz', beatport: 'Beatport', file: 'File Imports', + itunes_link: 'iTunes Link', + listenbrainz: 'ListenBrainz', + lastfm: 'Last.fm Radio', + soulsync_discovery: 'SoulSync Discovery', }; return labels[source] || source || 'Other'; } From e8ee8576a0d540807c875d1a4ef77238d2cf3315 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 15:40:57 -0700 Subject: [PATCH 59/81] Fix Last.fm radios mirrored under wrong source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-part fix for Last.fm Radio playlists showing up in the ListenBrainz group of the Auto-Sync manager + Mirrored tab instead of their own Last.fm group: 1. **Mirror-creation hook** (sync-services.js): the ``_mirrorListenBrainzAfterDiscovery`` helper hardcoded ``source='listenbrainz'`` on every auto-mirror call, even for Last.fm Radio playlists (which share the same MB-track shape + discovery worker but should land under ``source='lastfm'``). ``save_lastfm_radio_playlist`` always prefixes the playlist name with "Last.fm Radio: ", so the helper now keys on that prefix to pick the right mirror source + owner fallback. Going forward, new Last.fm radios mirror correctly the moment discovery completes. 2. **Backfill** (listenbrainz_manager.py): legacy mirror rows created before the fix above are stuck under ``source='listenbrainz'``. Added ``_retag_misrouted_lastfm_radio_mirrors`` to ``_cleanup_old_playlists`` so the next LB refresh re-tags any row whose name starts with "Last.fm Radio:" but is still on ``source='listenbrainz'``. Idempotent — UPDATE only matches misrouted rows. --- core/listenbrainz_manager.py | 32 ++++++++++++++++++++++++++++++++ webui/static/sync-services.js | 17 +++++++++++++---- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/core/listenbrainz_manager.py b/core/listenbrainz_manager.py index c29e4cc6..09855211 100644 --- a/core/listenbrainz_manager.py +++ b/core/listenbrainz_manager.py @@ -326,11 +326,43 @@ class ListenBrainzManager: covers_found = sum(1 for t in track_data_list if t.get('album_cover_url')) logger.info(f"Fetched {covers_found}/{len(track_data_list)} cover art URLs") + def _retag_misrouted_lastfm_radio_mirrors(self, cursor): + """Re-tag mirrored_playlists rows that should be 'lastfm' but + were inserted as 'listenbrainz'. + + Backfill for the Phase 1c.1 bug where the auto-mirror helper + hardcoded ``source='listenbrainz'`` regardless of playlist + origin. Last.fm Radio playlists carry a consistent + "Last.fm Radio: " title prefix from + ``save_lastfm_radio_playlist``, so any mirror row matching + that prefix should sit under the Last.fm group instead of + the ListenBrainz one. Idempotent — only updates rows that + are still misrouted.""" + try: + cursor.execute( + """ + UPDATE mirrored_playlists + SET source = 'lastfm' + WHERE source = 'listenbrainz' + AND name LIKE 'Last.fm Radio:%' + """ + ) + if cursor.rowcount: + logger.info( + f"Re-tagged {cursor.rowcount} Last.fm Radio mirror rows " + "from source='listenbrainz' to source='lastfm'" + ) + except Exception as exc: + logger.debug(f"Last.fm radio mirror retag skipped: {exc}") + def _cleanup_old_playlists(self): """Remove old playlists, keeping only the 25 most recent per type""" conn = self._get_db_connection() cursor = conn.cursor() + # One-shot backfill for legacy misrouting (see method docstring). + self._retag_misrouted_lastfm_radio_mirrors(cursor) + # For each playlist type, keep only the N most recent # lastfm_radio keeps fewer since they're auto-regenerated weekly playlist_type_limits = { diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js index 67f6ded7..a133ef04 100644 --- a/webui/static/sync-services.js +++ b/webui/static/sync-services.js @@ -10855,18 +10855,27 @@ function _mirrorListenBrainzAfterDiscovery(playlistMbid) { return; } + // Last.fm Radio playlists live in the same listenbrainz_playlists + // table but are persisted by ``save_lastfm_radio_playlist`` with + // a "Last.fm Radio: " title prefix and ``playlist_type='lastfm_radio'``. + // Route them to ``source='lastfm'`` so the Auto-Sync manager + // groups them under the Last.fm Radio section + the cascade- + // delete hook targets the right mirror source. + const title = state.playlist.name || 'ListenBrainz Playlist'; + const mirrorSource = title.startsWith('Last.fm Radio:') ? 'lastfm' : 'listenbrainz'; + const ownerFallback = mirrorSource === 'lastfm' ? 'Last.fm' : 'ListenBrainz'; mirrorPlaylist( - 'listenbrainz', + mirrorSource, playlistMbid, - state.playlist.name || 'ListenBrainz Playlist', + title, tracks, { - owner: state.playlist.creator || 'ListenBrainz', + owner: state.playlist.creator || ownerFallback, description: state.playlist.description || '', image_url: state.playlist.image_url || '', } ); - console.log(`🪞 [LB Mirror] Mirrored '${state.playlist.name}' with ${tracks.length} matched tracks`); + console.log(`🪞 [${mirrorSource} Mirror] Mirrored '${title}' with ${tracks.length} matched tracks`); } catch (err) { console.warn('LB mirror-after-discovery failed:', err); } From cf5da04439bfcdba3ebd439bd0a85154e58cb8bc Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 15:49:49 -0700 Subject: [PATCH 60/81] Roll LB Weekly / Top series into single rolling mirrors (Phase 1c.2.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ListenBrainz publishes "Weekly Jams for X" / "Weekly Exploration for X" with a fresh MBID every week, and "Top Discoveries of YYYY for X" / "Top Missed Recordings of YYYY for X" with a fresh MBID every year. Auto-mirroring those per-period yielded one mirrored- playlist row per week/year — useless for Auto-Sync schedules because the underlying LB playlist never updates, only a brand new playlist replaces it. The user accumulates 100+ dead Weekly Jams rows per year if they discover regularly. This commit collapses each family into a single ROLLING mirror keyed by a synthetic ``source_playlist_id`` (e.g. ``lb_weekly_jams_Nezreka``). Each new period UPSERTs into the same row, so the user gets one stable Auto-Sync schedule per series that automatically picks up the latest period's tracks on every refresh. Non-series LB playlists (user-created, collaborative, Last.fm radios for a specific seed) continue to mirror under their per-playlist MBID as before. Per-period LB playlists are still visible + usable on the LB Sync tab — only the mirror layer collapses. - ``core/playlists/lb_series.py`` (new) — series-detect helper with regex patterns + canonical-name + LIKE-pattern template for each known LB family. Exposes ``detect_series(title)``, ``is_series_synthetic_id(id)``, and ``list_series_synthetic_ids()`` so both the JS auto-mirror hook and the LB adapter can speak the same language. - ``GET /api/listenbrainz/series-detect?title=...`` — thin HTTP shim around ``detect_series`` so the auto-mirror JS doesn't duplicate the regex. - ``ListenBrainzPlaylistSource.get_playlist`` now recognizes synthetic series ids — it queries the LB cache for the newest cache row whose title matches the series' LIKE pattern and resolves to that row's MBID before fetching tracks. The mirror's meta keeps the synthetic id so refreshes always re-resolve to the latest period. - ``_mirrorListenBrainzAfterDiscovery`` (sync-services.js) calls the new detect endpoint when discovery completes — if a match comes back it swaps the per-period MBID for the synthetic id + the canonical name. Existing Last.fm radio routing logic stays intact (Last.fm radios aren't a series). - ``ListenBrainzManager._cleanup_per_period_series_mirrors`` — one-shot consolidation sweeper runs in ``_cleanup_old_playlists`` + deletes any legacy per-period mirror rows so the consolidated rolling mirror is the only one left. Idempotent — only matches per-period titles ("Weekly Jams for ..., week of ...") and never the canonical rolling-mirror titles ("ListenBrainz Weekly Jams"). - 11 new tests pin the detector + synthetic-id helpers; 236 total across adapter + automation + lb-series suites green. --- core/listenbrainz_manager.py | 55 +++++++++++ core/playlists/lb_series.py | 125 +++++++++++++++++++++++++ core/playlists/sources/listenbrainz.py | 86 ++++++++++++++++- tests/test_lb_series_detect.py | 89 ++++++++++++++++++ web_server.py | 34 +++++++ webui/static/sync-services.js | 49 ++++++++-- 6 files changed, 427 insertions(+), 11 deletions(-) create mode 100644 core/playlists/lb_series.py create mode 100644 tests/test_lb_series_detect.py diff --git a/core/listenbrainz_manager.py b/core/listenbrainz_manager.py index 09855211..dda2536b 100644 --- a/core/listenbrainz_manager.py +++ b/core/listenbrainz_manager.py @@ -326,6 +326,58 @@ class ListenBrainzManager: covers_found = sum(1 for t in track_data_list if t.get('album_cover_url')) logger.info(f"Fetched {covers_found}/{len(track_data_list)} cover art URLs") + def _cleanup_per_period_series_mirrors(self, cursor): + """Delete mirrored_playlists rows that belong to a rotating LB + series but were created under the per-period MBID instead of + the new synthetic series id. + + Background: pre-Phase-1c.2.1 the auto-mirror hook keyed mirrors + by the per-week (or per-year) MBID, so users accumulated one + mirror per period. The new flow collapses them into a single + rolling mirror per series. This sweeper removes the legacy + per-period rows so the Mirrored / Auto-Sync UIs only show the + consolidated rolling mirror. Idempotent — only matches titles + that were once per-period.""" + # Each pattern's WHERE clause matches per-period titles + # ("Weekly Jams for X, week of YYYY-MM-DD ...") but NOT the + # canonical rolling-mirror titles ("ListenBrainz Weekly Jams"). + per_period_title_patterns = [ + ('listenbrainz', 'Weekly Jams for %, week of %'), + ('listenbrainz', 'Weekly Exploration for %, week of %'), + ('listenbrainz', 'Top Discoveries of % for %'), + ('listenbrainz', 'Top Missed Recordings of % for %'), + ] + try: + total = 0 + for source, like in per_period_title_patterns: + cursor.execute( + """ + SELECT id FROM mirrored_playlists + WHERE source = ? AND name LIKE ? + """, + (source, like), + ) + mirror_ids = [row[0] for row in cursor.fetchall()] + if not mirror_ids: + continue + ph = ','.join('?' * len(mirror_ids)) + cursor.execute( + f"DELETE FROM mirrored_playlist_tracks WHERE playlist_id IN ({ph})", + mirror_ids, + ) + cursor.execute( + f"DELETE FROM mirrored_playlists WHERE id IN ({ph})", + mirror_ids, + ) + total += len(mirror_ids) + if total: + logger.info( + f"Removed {total} legacy per-period LB series mirrors " + "(consolidated into rolling series mirrors)" + ) + except Exception as exc: + logger.debug(f"Per-period series mirror cleanup skipped: {exc}") + def _retag_misrouted_lastfm_radio_mirrors(self, cursor): """Re-tag mirrored_playlists rows that should be 'lastfm' but were inserted as 'listenbrainz'. @@ -362,6 +414,9 @@ class ListenBrainzManager: # One-shot backfill for legacy misrouting (see method docstring). self._retag_misrouted_lastfm_radio_mirrors(cursor) + # Consolidate legacy per-week / per-year LB series mirrors into + # the new rolling series mirrors (Phase 1c.2.1). + self._cleanup_per_period_series_mirrors(cursor) # For each playlist type, keep only the N most recent # lastfm_radio keeps fewer since they're auto-regenerated weekly diff --git a/core/playlists/lb_series.py b/core/playlists/lb_series.py new file mode 100644 index 00000000..611cf16b --- /dev/null +++ b/core/playlists/lb_series.py @@ -0,0 +1,125 @@ +"""ListenBrainz series detection for rolling mirrored playlists. + +ListenBrainz publishes a few playlist families that get a brand new +MBID every period (week or year) — e.g. "Weekly Jams for Nezreka, +week of 2026-05-25 Mon" gets a fresh row each Monday, the previous +Monday's row rotates out of the cache after ~25 weeks. Auto-syncing +the per-period MBID is useless because the underlying ListenBrainz +playlist never updates — only the new period gets new tracks. + +This module lets the auto-mirror code collapse those families into +a single rolling mirror per series. The mirror's +``source_playlist_id`` is a synthetic identifier (e.g. +``lb_weekly_jams_Nezreka``) instead of the per-period MBID, and the +refresh path resolves the synthetic id back to the latest period's +cached playlist at refresh time. + +One-off playlists (user-created, collaborative, Last.fm radios) are +NOT collapsed — they have stable identifiers in their own right. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import List, Optional + + +@dataclass(frozen=True) +class SeriesMatch: + """A playlist whose title matches one of the rotating series.""" + + series_id: str # synthetic id, e.g. "lb_weekly_jams_Nezreka" + canonical_name: str # display name for the rolling mirror + source_for_mirror: str # "listenbrainz" or "lastfm" + title_pattern: str # SQL LIKE pattern for finding members + # (e.g. "Weekly Jams for Nezreka, week of %") + + +# Each series is identified by a regex + a template for the +# canonical mirror name + the source field the resulting mirror +# should sit under. ``user`` is the ListenBrainz username. +_SERIES_PATTERNS = [ + { + "regex": re.compile(r"^Weekly Jams for (?P.+?), week of "), + "series_format": "lb_weekly_jams_{user}", + "canonical_name": "ListenBrainz Weekly Jams", + "source": "listenbrainz", + "like_format": "Weekly Jams for {user}, week of %", + }, + { + "regex": re.compile(r"^Weekly Exploration for (?P.+?), week of "), + "series_format": "lb_weekly_exploration_{user}", + "canonical_name": "ListenBrainz Weekly Exploration", + "source": "listenbrainz", + "like_format": "Weekly Exploration for {user}, week of %", + }, + { + "regex": re.compile(r"^Top Discoveries of (?P\d{4}) for (?P.+)$"), + "series_format": "lb_top_discoveries_{user}", + "canonical_name": "ListenBrainz Top Discoveries (latest year)", + "source": "listenbrainz", + # ``$`` end-anchor on the year means trailing whitespace would + # break the LIKE — but ListenBrainz titles don't have trailing + # whitespace; the % covers the year position. + "like_format": "Top Discoveries of % for {user}", + }, + { + "regex": re.compile(r"^Top Missed Recordings of (?P\d{4}) for (?P.+)$"), + "series_format": "lb_top_missed_{user}", + "canonical_name": "ListenBrainz Top Missed Recordings (latest year)", + "source": "listenbrainz", + "like_format": "Top Missed Recordings of % for {user}", + }, +] + + +def detect_series(title: str) -> Optional[SeriesMatch]: + """Return a ``SeriesMatch`` if ``title`` belongs to a known series, + else ``None``. + + ``title`` is the raw playlist title as stored on the LB cache row + (e.g. ``"Weekly Jams for Nezreka, week of 2026-05-25 Mon"``). + """ + if not title: + return None + for spec in _SERIES_PATTERNS: + m = spec["regex"].match(title) + if not m: + continue + groups = m.groupdict() + # The pattern only ever captures ``user`` (and optionally + # ``year``); ``series_format`` / ``like_format`` reference + # ``user`` so both interpolate cleanly with .format(**groups). + return SeriesMatch( + series_id=spec["series_format"].format(**groups), + canonical_name=spec["canonical_name"], + source_for_mirror=spec["source"], + title_pattern=spec["like_format"].format(**groups), + ) + return None + + +def list_series_synthetic_ids() -> List[str]: + """Return all known series-id PREFIXES (e.g. ``lb_weekly_jams_``). + + Used by callers (e.g. the LB adapter's refresh path) to tell + whether a ``source_playlist_id`` is a synthetic series id and + needs special resolution.""" + return [ + spec["series_format"].format(user="").rstrip("_") + "_" + for spec in _SERIES_PATTERNS + ] + + +def is_series_synthetic_id(source_playlist_id: str) -> bool: + """Cheap check: is the value one of our synthetic series ids? + + All series ids start with ``lb_`` and contain a recognizable + series tag. MusicBrainz MBIDs are 8-4-4-4-12 hex with dashes; no + overlap risk.""" + if not source_playlist_id or not source_playlist_id.startswith("lb_"): + return False + return any( + source_playlist_id.startswith(pref) for pref in list_series_synthetic_ids() + ) diff --git a/core/playlists/sources/listenbrainz.py b/core/playlists/sources/listenbrainz.py index d6c96107..0c5f72ff 100644 --- a/core/playlists/sources/listenbrainz.py +++ b/core/playlists/sources/listenbrainz.py @@ -84,13 +84,38 @@ class ListenBrainzPlaylistSource(PlaylistSource): return out def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: - """``playlist_id`` is the ListenBrainz playlist MBID.""" + """``playlist_id`` is the ListenBrainz playlist MBID, OR a + synthetic series id (e.g. ``lb_weekly_jams_``) that + resolves to the newest member of a rotating series.""" manager = self._manager() if manager is None: return None + + # Rolling-series resolution: synthetic ids look up the + # latest matching cache row and continue with that MBID. + from core.playlists.lb_series import is_series_synthetic_id + if is_series_synthetic_id(playlist_id): + resolved_mbid = self._resolve_series_to_latest_mbid(manager, playlist_id) + if not resolved_mbid: + return None + return self._fetch_playlist_by_mbid(manager, resolved_mbid, override_meta_id=playlist_id) + + return self._fetch_playlist_by_mbid(manager, playlist_id) + + def _fetch_playlist_by_mbid( + self, + manager: Any, + playlist_mbid: str, + override_meta_id: Optional[str] = None, + ) -> Optional[PlaylistDetail]: + """Resolve a real LB playlist MBID into a PlaylistDetail. + + ``override_meta_id`` lets the rolling-series path keep the + synthetic id on the meta object so the caller can write the + mirror row back under that id.""" ptype = "" try: - ptype = manager.get_playlist_type(playlist_id) or "" + ptype = manager.get_playlist_type(playlist_mbid) or "" except Exception: ptype = "" @@ -100,12 +125,12 @@ class ListenBrainzPlaylistSource(PlaylistSource): except Exception: cached_rows = [] meta_row = next( - (r for r in cached_rows if str(r.get("playlist_mbid")) == str(playlist_id)), + (r for r in cached_rows if str(r.get("playlist_mbid")) == str(playlist_mbid)), None, ) try: - tracks_raw = manager.get_cached_tracks(playlist_id) or [] + tracks_raw = manager.get_cached_tracks(playlist_mbid) or [] except Exception: tracks_raw = [] @@ -113,13 +138,64 @@ class ListenBrainzPlaylistSource(PlaylistSource): return None meta = self._meta_from_cache_row( - meta_row or {"playlist_mbid": playlist_id, "track_count": len(tracks_raw)}, + meta_row or {"playlist_mbid": playlist_mbid, "track_count": len(tracks_raw)}, ptype or "listenbrainz", ) + if override_meta_id: + meta.source_playlist_id = override_meta_id meta.track_count = len(tracks_raw) tracks = [self._track_from_cache_row(t, idx) for idx, t in enumerate(tracks_raw)] return PlaylistDetail(meta=meta, tracks=tracks) + def _resolve_series_to_latest_mbid(self, manager: Any, series_id: str) -> Optional[str]: + """Find the newest LB cache row matching a series synthetic id. + + Series synthetic ids encode both the series type and the + ListenBrainz username. We query the LB cache (via the + manager's DB connection) for the row whose title matches the + series' LIKE pattern and has the most recent ``last_updated``, + then return that row's MBID for normal fetching downstream.""" + try: + # The synthetic id alone doesn't carry the title pattern, + # so we re-derive it from any per-period sibling that's + # already in the cache. Iterate the known series specs and + # ask which one this synthetic id belongs to. + from core.playlists.lb_series import _SERIES_PATTERNS + spec = None + user_token = "" + for entry in _SERIES_PATTERNS: + series_prefix = entry["series_format"].format(user="").rstrip("_") + "_" + if series_id.startswith(series_prefix): + spec = entry + user_token = series_id[len(series_prefix):] + break + if spec is None or not user_token: + return None + like_pattern = spec["like_format"].format(user=user_token) + + # Query the LB cache for the newest matching row. The + # manager's connection helper returns a plain sqlite3 + # connection — explicit try/finally for close parity with + # the manager's own usage pattern. + conn = manager._get_db_connection() + try: + cur = conn.cursor() + cur.execute( + """ + SELECT playlist_mbid FROM listenbrainz_playlists + WHERE profile_id = ? AND title LIKE ? + ORDER BY last_updated DESC + LIMIT 1 + """, + (manager.profile_id, like_pattern), + ) + row = cur.fetchone() + finally: + conn.close() + return row[0] if row else None + except Exception: + return None + def discover_tracks(self, tracks: List[NormalizedTrack]) -> List[NormalizedTrack]: """Run each MB-metadata track through the matching engine. diff --git a/tests/test_lb_series_detect.py b/tests/test_lb_series_detect.py new file mode 100644 index 00000000..60498260 --- /dev/null +++ b/tests/test_lb_series_detect.py @@ -0,0 +1,89 @@ +"""Tests for the LB rotating-series detector that powers the +rolling-mirror collapse on the Sync page. + +Pins the title patterns + canonical-name templates so accidental +regex tweaks don't silently break the auto-mirror grouping the +Auto-Sync manager + Mirrored tab rely on. +""" + +from __future__ import annotations + +import pytest + +from core.playlists.lb_series import ( + detect_series, + is_series_synthetic_id, + list_series_synthetic_ids, +) + + +class TestDetectSeries: + def test_weekly_jams_collapses_into_rolling_series(self): + m = detect_series("Weekly Jams for Nezreka, week of 2026-05-25 Mon") + assert m is not None + assert m.series_id == "lb_weekly_jams_Nezreka" + assert m.canonical_name == "ListenBrainz Weekly Jams" + assert m.source_for_mirror == "listenbrainz" + assert m.title_pattern == "Weekly Jams for Nezreka, week of %" + + def test_weekly_exploration_collapses_into_rolling_series(self): + m = detect_series("Weekly Exploration for Nezreka, week of 2026-04-13 Mon") + assert m is not None + assert m.series_id == "lb_weekly_exploration_Nezreka" + assert m.canonical_name == "ListenBrainz Weekly Exploration" + assert m.title_pattern == "Weekly Exploration for Nezreka, week of %" + + def test_top_discoveries_collapses_per_user(self): + m = detect_series("Top Discoveries of 2024 for Nezreka") + assert m is not None + assert m.series_id == "lb_top_discoveries_Nezreka" + assert m.canonical_name == "ListenBrainz Top Discoveries (latest year)" + assert m.title_pattern == "Top Discoveries of % for Nezreka" + + def test_top_missed_collapses_per_user(self): + m = detect_series("Top Missed Recordings of 2025 for Nezreka") + assert m is not None + assert m.series_id == "lb_top_missed_Nezreka" + assert m.canonical_name == "ListenBrainz Top Missed Recordings (latest year)" + + def test_user_with_spaces_in_name(self): + # ListenBrainz allows usernames with spaces; the regex should + # still match and the series id propagates the literal user + # token. Whether SQLite LIKE works on that is the caller's + # problem — we just preserve the captured value. + m = detect_series("Weekly Jams for Some User, week of 2026-01-05 Mon") + assert m is not None + assert m.series_id == "lb_weekly_jams_Some User" + + def test_lastfm_radio_is_not_a_series(self): + # Last.fm radios get their own per-seed MBID — they should NOT + # be collapsed into a rolling series. + assert detect_series("Last.fm Radio: Selfish by Madison Beer") is None + + def test_user_created_playlist_is_not_a_series(self): + assert detect_series("My Custom Playlist") is None + + def test_empty_title_returns_none(self): + assert detect_series("") is None + assert detect_series(None) is None # type: ignore[arg-type] + + +class TestSyntheticIdHelpers: + def test_known_prefixes_listed(self): + prefixes = list_series_synthetic_ids() + assert "lb_weekly_jams_" in prefixes + assert "lb_weekly_exploration_" in prefixes + assert "lb_top_discoveries_" in prefixes + assert "lb_top_missed_" in prefixes + + def test_is_series_synthetic_id_matches_known(self): + assert is_series_synthetic_id("lb_weekly_jams_Nezreka") is True + assert is_series_synthetic_id("lb_weekly_exploration_OtherUser") is True + assert is_series_synthetic_id("lb_top_discoveries_X") is True + + def test_is_series_synthetic_id_rejects_mbids(self): + # Real LB playlist MBIDs are UUID-shaped, never start with ``lb_``. + assert is_series_synthetic_id("4badb5c9-266e-42ef-9d06-879ee311c9e0") is False + assert is_series_synthetic_id("") is False + assert is_series_synthetic_id("lb_") is False # not a real series + assert is_series_synthetic_id("lb_random_thing") is False diff --git a/web_server.py b/web_server.py index 9a84b014..6175cc57 100644 --- a/web_server.py +++ b/web_server.py @@ -30272,6 +30272,40 @@ def get_listenbrainz_lastfm_radio(): # LISTENBRAINZ PLAYLIST MANAGEMENT (Discovery System) # ======================================== +@app.route('/api/listenbrainz/series-detect', methods=['GET']) +def get_listenbrainz_series_detect(): + """Detect whether a LB playlist title belongs to a rotating series. + + Auto-mirror uses this to decide whether the resulting mirror + row should point at a per-playlist MBID (one-off LB playlist) + or a synthetic series id (e.g. ``lb_weekly_jams_``) that + rolls forward as ListenBrainz publishes new periods. + + Query: ``?title=`` + Response on a match: + ``{matched: true, series_id, canonical_name, + source: 'listenbrainz'|'lastfm'}`` + Response on no match: + ``{matched: false}`` + """ + try: + from core.playlists.lb_series import detect_series + + title = (request.args.get('title') or '').strip() + match = detect_series(title) + if match is None: + return jsonify({"matched": False}) + return jsonify({ + "matched": True, + "series_id": match.series_id, + "canonical_name": match.canonical_name, + "source": match.source_for_mirror, + }) + except Exception as e: + logger.error(f"Error detecting LB series: {e}") + return jsonify({"matched": False, "error": str(e)}), 500 + + def _lb_state_key(playlist_mbid, profile_id=None): """Build profile-scoped key for listenbrainz_playlist_states""" if profile_id is None: diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js index a133ef04..c317aba1 100644 --- a/webui/static/sync-services.js +++ b/webui/static/sync-services.js @@ -10813,8 +10813,18 @@ async function resetBeatportChart(urlHash) { * LB tracks only have provider IDs after discovery, so we mirror at * the end. Idempotent (UPSERT on source + source_playlist_id + * profile_id), so calling it twice is a no-op. + * + * Rotating-series collapse: if the playlist title belongs to a + * known LB series (Weekly Jams, Weekly Exploration, Top Discoveries, + * Top Missed Recordings), the mirror is created under a synthetic + * ``source_playlist_id`` like ``lb_weekly_jams_`` with a + * canonical name. The next week / year UPSERTs into the same row, + * so users get one rolling mirror per series instead of accumulating + * one per period. Non-series LB playlists (user-created, + * collaborative, Last.fm radios for a specific seed) continue to + * mirror under their per-playlist MBID. */ -function _mirrorListenBrainzAfterDiscovery(playlistMbid) { +async function _mirrorListenBrainzAfterDiscovery(playlistMbid) { try { const state = listenbrainzPlaylistStates[playlistMbid]; if (!state || !state.playlist) return; @@ -10861,13 +10871,40 @@ function _mirrorListenBrainzAfterDiscovery(playlistMbid) { // Route them to ``source='lastfm'`` so the Auto-Sync manager // groups them under the Last.fm Radio section + the cascade- // delete hook targets the right mirror source. - const title = state.playlist.name || 'ListenBrainz Playlist'; - const mirrorSource = title.startsWith('Last.fm Radio:') ? 'lastfm' : 'listenbrainz'; + const rawTitle = state.playlist.name || 'ListenBrainz Playlist'; + let mirrorSource = rawTitle.startsWith('Last.fm Radio:') ? 'lastfm' : 'listenbrainz'; + let mirrorSourcePlaylistId = playlistMbid; + let mirrorName = rawTitle; + + // Rolling-series detection — backend tells us whether the title + // belongs to a known rotating LB series. If so, collapse this + // mirror onto a synthetic id + canonical name so per-week / + // per-year duplicates roll up into one row. + try { + const seriesResp = await fetch( + `/api/listenbrainz/series-detect?title=${encodeURIComponent(rawTitle)}` + ); + if (seriesResp.ok) { + const seriesData = await seriesResp.json(); + if (seriesData && seriesData.matched) { + mirrorSource = seriesData.source || mirrorSource; + mirrorSourcePlaylistId = seriesData.series_id || mirrorSourcePlaylistId; + mirrorName = seriesData.canonical_name || mirrorName; + console.log( + `🔁 [LB Series] '${rawTitle}' rolled into '${mirrorName}' ` + + `(series id: ${mirrorSourcePlaylistId})` + ); + } + } + } catch (_) { + // Non-fatal — fall through to per-playlist mirror id. + } + const ownerFallback = mirrorSource === 'lastfm' ? 'Last.fm' : 'ListenBrainz'; mirrorPlaylist( mirrorSource, - playlistMbid, - title, + mirrorSourcePlaylistId, + mirrorName, tracks, { owner: state.playlist.creator || ownerFallback, @@ -10875,7 +10912,7 @@ function _mirrorListenBrainzAfterDiscovery(playlistMbid) { image_url: state.playlist.image_url || '', } ); - console.log(`🪞 [${mirrorSource} Mirror] Mirrored '${title}' with ${tracks.length} matched tracks`); + console.log(`🪞 [${mirrorSource} Mirror] Mirrored '${mirrorName}' with ${tracks.length} matched tracks`); } catch (err) { console.warn('LB mirror-after-discovery failed:', err); } From 862cedde9d15d59cc4c6201c69c2dc32b4a0d4f9 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 15:56:24 -0700 Subject: [PATCH 61/81] Auto-Sync manager: exclude Last.fm Radio mirrors from the schedule board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last.fm Radio playlists are seed-track-specific similar-tracks snapshots — they don't update on the Last.fm side once generated, so scheduling one for auto-refresh would just re-discover the same 25 tracks every interval. The mirror still exists (visible in the Mirrored tab) so the user can pull the downloads, but it doesn't belong on the schedule board. ``autoSyncCanSchedulePlaylist`` now rejects ``source='lastfm'`` alongside the existing ``file`` + ``beatport`` exclusions. Cosmetic-only on the frontend; backend mirror creation + Mirrored tab listing are unchanged. --- webui/static/auto-sync.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js index 380c1736..2dd3b21c 100644 --- a/webui/static/auto-sync.js +++ b/webui/static/auto-sync.js @@ -86,7 +86,16 @@ function autoSyncSourceLabel(source) { } function autoSyncCanSchedulePlaylist(playlist) { - return playlist && !['file', 'beatport'].includes(playlist.source || ''); + if (!playlist) return false; + const src = playlist.source || ''; + // ``file`` + ``beatport`` have no external refresh hook. + // ``lastfm`` is excluded because each Last.fm Radio playlist is a + // seed-track-specific similar-tracks snapshot that doesn't update + // on the Last.fm side — auto-syncing it would just re-discover the + // same 25 tracks every interval. Users mirror Last.fm radios once + // to grab the downloads, then move on; they belong in the + // Mirrored / Sync tab but not the Auto-Sync schedule board. + return !['file', 'beatport', 'lastfm'].includes(src); } function autoSyncIsPipelineAutomation(auto) { From d8cc2f5f019ca1fbae1309e47f2061e14828104f Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 16:25:25 -0700 Subject: [PATCH 62/81] =?UTF-8?q?Last.fm=20radio=20cache=20cap:=205=20?= =?UTF-8?q?=E2=86=92=2010?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-visible behavior: at most 10 mirrored Last.fm Radio rows exist at any time. When the cache prunes the 11th-newest + older lastfm_radio rows, the existing cascade-delete hook (``_cascade_delete_mirrored_for_mbids``) removes their matching ``source='lastfm'`` mirror rows in the same transaction. 5 was too aggressive — users seeding multiple radios in a row were losing earlier downloads' provenance before they had time to act on the tracks. 10 gives a few weeks of breathing room without letting the Mirrored tab balloon. --- core/listenbrainz_manager.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/core/listenbrainz_manager.py b/core/listenbrainz_manager.py index dda2536b..3fd124a8 100644 --- a/core/listenbrainz_manager.py +++ b/core/listenbrainz_manager.py @@ -418,13 +418,17 @@ class ListenBrainzManager: # the new rolling series mirrors (Phase 1c.2.1). self._cleanup_per_period_series_mirrors(cursor) - # For each playlist type, keep only the N most recent - # lastfm_radio keeps fewer since they're auto-regenerated weekly + # For each playlist type, keep only the N most recent. + # Last.fm radios are per-seed-track snapshots that don't update + # on the Last.fm side — capping the cache (and via the cascade + # below, the matching mirror rows) keeps the Mirrored tab from + # accumulating one row per random seed track the user ever + # picked. 10 is the user-facing limit. playlist_type_limits = { 'created_for': 25, 'user': 25, 'collaborative': 25, - 'lastfm_radio': 5, + 'lastfm_radio': 10, } for playlist_type, keep_count in playlist_type_limits.items(): From 1eadd9a65eefe77b9dd4d1a315d25a430f65706e Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 16:51:35 -0700 Subject: [PATCH 63/81] Pre-create rolling LB series mirrors when LB cache updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the rolling Weekly Jams / Weekly Exploration / Top Discoveries / Top Missed Recordings mirror entries appear in Auto-Sync's sidebar the moment ListenBrainz first publishes any member of the series — without requiring the user to manually discover a per- period card first. Previously the rolling mirror was only created on discovery completion, so users with cached LB playlists but no discovery history saw an empty ListenBrainz group in the Auto-Sync manager and couldn't schedule the rolling entries. - ``_ensure_rolling_series_mirror(cursor, title)`` new helper on ``ListenBrainzManager``: detect_series + ``INSERT OR IGNORE`` the matching ``mirrored_playlists`` row with the synthetic source_playlist_id, the canonical name, and zero tracks. Idempotent — no-op when the rolling mirror already exists or when the title doesn't belong to a series. - ``_update_playlist`` now calls the helper after the cache row is inserted/updated, so every LB refresh that lands a per- period series member guarantees a rolling mirror exists. First Auto-Sync schedule fired against an empty rolling mirror populates tracks through the existing LB adapter + ``_maybe_discover`` hook — synthetic id resolves to the latest cache row, tracks come back with needs_discovery=True, matching engine runs, mirror gets tracks. No extra wiring needed. 236 tests still green. --- core/listenbrainz_manager.py | 50 ++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/core/listenbrainz_manager.py b/core/listenbrainz_manager.py index 3fd124a8..bd48c352 100644 --- a/core/listenbrainz_manager.py +++ b/core/listenbrainz_manager.py @@ -209,11 +209,61 @@ class ListenBrainzManager: if tracks: self._cache_tracks(playlist_id, playlist_mbid, tracks, cursor) + # Ensure a rolling-series mirror row exists for known LB series + # (Weekly Jams / Weekly Exploration / Top Discoveries / Top + # Missed Recordings). The Auto-Sync sidebar then surfaces the + # rolling entry as schedulable even before the user has + # explicitly discovered any per-period card — first scheduled + # refresh fills tracks via the LB adapter's synthetic-id + # resolution. + self._ensure_rolling_series_mirror(cursor, title) + conn.commit() conn.close() return result_type + def _ensure_rolling_series_mirror(self, cursor, playlist_title: str): + """Upsert a placeholder ``mirrored_playlists`` row for the + rolling series this title belongs to. + + Idempotent — uses ``INSERT OR IGNORE``, so existing rolling + mirrors (which may already have discovered tracks) are not + touched. No-op for non-series titles (Last.fm radios, + user-created playlists, collaborative playlists).""" + try: + # Defer import to avoid a top-level dependency loop — the + # series detector lives in core.playlists which itself + # transitively imports manager-flavor helpers. + from core.playlists.lb_series import detect_series + match = detect_series(playlist_title or "") + if match is None: + return + cursor.execute( + """ + INSERT OR IGNORE INTO mirrored_playlists + (source, source_playlist_id, name, description, owner, image_url, track_count, profile_id, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, + ( + match.source_for_mirror, + match.series_id, + match.canonical_name, + "Rolling ListenBrainz series — refresh resolves to the latest period automatically.", + "ListenBrainz", + "", + 0, + self.profile_id, + ), + ) + if cursor.rowcount: + logger.info( + f"Pre-created rolling mirror placeholder '{match.canonical_name}' " + f"(series id: {match.series_id})" + ) + except Exception as exc: + logger.debug(f"Rolling-series mirror ensure skipped: {exc}") + def _cache_tracks(self, playlist_id: int, playlist_mbid: str, tracks: List[Dict], cursor): """ Cache tracks for a playlist, including fetching cover art URLs in parallel From 4dc70b3611d0845f9ce0d3ae27c65f1c3d712705 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 17:13:36 -0700 Subject: [PATCH 64/81] Rolling LB mirrors: also fire on skipped + bulk catch-all in cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two paths were leaving rolling mirror placeholders uncreated: 1. ``_update_playlist`` short-circuits with status "skipped" when the cached track count matches the API result (the smart- comparison fast path). The Phase 1c.2.1 ``_ensure_rolling_series_mirror`` call sat after the short-circuit, so any user whose LB cache was already up-to-date got zero rolling placeholders inserted — their Auto-Sync sidebar showed no ListenBrainz group after refresh. 2. First-time install of the rolling-mirror code on top of an existing LB cache: every per-playlist call goes "skipped" because nothing has changed, so even with fix #1 the user needs a per-playlist trigger to populate. No good. Fix: - ``_update_playlist`` now runs ``_ensure_rolling_series_mirror`` on the skip path too (with an explicit ``conn.commit()`` since the insert needs to land before the connection closes). - ``_cleanup_old_playlists`` gains ``_ensure_rolling_mirrors_from_cache`` — a one-shot bulk pass that walks every cached LB title and ensures the matching rolling mirror exists. Cheap (single SELECT + idempotent INSERT OR IGNORE per row) and catches the first-run + skipped-everything cases. --- core/listenbrainz_manager.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/core/listenbrainz_manager.py b/core/listenbrainz_manager.py index bd48c352..ddae7ae8 100644 --- a/core/listenbrainz_manager.py +++ b/core/listenbrainz_manager.py @@ -166,6 +166,12 @@ class ListenBrainzManager: # Skip if track count hasn't changed (playlist content likely the same) if db_track_count == track_count: logger.debug(f"Playlist '{title}' unchanged, skipping") + # Even on the skip path, make sure the rolling-series + # mirror placeholder exists — otherwise users whose LB + # cache never has "changed" updates would never see the + # rolling Auto-Sync entries appear. + self._ensure_rolling_series_mirror(cursor, title) + conn.commit() conn.close() return "skipped" @@ -223,6 +229,27 @@ class ListenBrainzManager: return result_type + def _ensure_rolling_mirrors_from_cache(self, cursor): + """Walk every cached LB playlist row + ensure its rolling + series mirror exists. Catch-all that runs regardless of which + ``_update_playlist`` paths fired (skipped vs updated vs new). + + Cheap — one SELECT + per-row helper call, helper is + idempotent INSERT OR IGNORE.""" + try: + cursor.execute( + """ + SELECT DISTINCT title FROM listenbrainz_playlists + WHERE profile_id = ? + """, + (self.profile_id,), + ) + titles = [row[0] for row in cursor.fetchall() if row[0]] + for title in titles: + self._ensure_rolling_series_mirror(cursor, title) + except Exception as exc: + logger.debug(f"Bulk rolling-mirror ensure skipped: {exc}") + def _ensure_rolling_series_mirror(self, cursor, playlist_title: str): """Upsert a placeholder ``mirrored_playlists`` row for the rolling series this title belongs to. @@ -467,6 +494,13 @@ class ListenBrainzManager: # Consolidate legacy per-week / per-year LB series mirrors into # the new rolling series mirrors (Phase 1c.2.1). self._cleanup_per_period_series_mirrors(cursor) + # Safety net: ensure rolling mirror placeholders exist for every + # series with at least one cached LB playlist row. Catches the + # case where every ``_update_playlist`` call took the "skipped" + # short-circuit (unchanged track count) and so the ensure-hook + # in the per-playlist path never fired on first run after the + # rolling feature shipped. + self._ensure_rolling_mirrors_from_cache(cursor) # For each playlist type, keep only the N most recent. # Last.fm radios are per-seed-track snapshots that don't update From 5378b726eee9ba8bedc52773ccb619a2072eecfb Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 17:52:54 -0700 Subject: [PATCH 65/81] Debug logging on LB rolling-mirror bulk ensure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temporary instrumentation — bulk ensure path silently created only one rolling mirror despite multiple known series members existing in the LB cache. Promotes the bulk-ensure summary + per-title match notes to INFO level so the next refresh surfaces in the server log: - ``[LB Rolling] Bulk ensure walking N cached titles for profile X`` - ``[LB Rolling] Title matched series: -> <series_id>`` - ``[LB Rolling] Bulk ensure done — M/N titles matched a series`` Plus the outer ``except`` is bumped from debug to warning so a genuine SELECT failure stops being invisible. Once the root cause is identified the noise can drop back to debug. --- core/listenbrainz_manager.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/core/listenbrainz_manager.py b/core/listenbrainz_manager.py index ddae7ae8..65b06da6 100644 --- a/core/listenbrainz_manager.py +++ b/core/listenbrainz_manager.py @@ -245,10 +245,24 @@ class ListenBrainzManager: (self.profile_id,), ) titles = [row[0] for row in cursor.fetchall() if row[0]] + logger.info( + f"[LB Rolling] Bulk ensure walking {len(titles)} cached titles for profile {self.profile_id}" + ) + from core.playlists.lb_series import detect_series + matched = 0 for title in titles: + m = detect_series(title) + if m is not None: + matched += 1 + logger.info( + f"[LB Rolling] Title matched series: {title!r} -> {m.series_id}" + ) self._ensure_rolling_series_mirror(cursor, title) + logger.info( + f"[LB Rolling] Bulk ensure done — {matched}/{len(titles)} titles matched a series" + ) except Exception as exc: - logger.debug(f"Bulk rolling-mirror ensure skipped: {exc}") + logger.warning(f"Bulk rolling-mirror ensure skipped: {exc}") def _ensure_rolling_series_mirror(self, cursor, playlist_title: str): """Upsert a placeholder ``mirrored_playlists`` row for the From bd91c94f92cb51be7007ed99215a4557f4a3286a Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 19:46:03 -0700 Subject: [PATCH 66/81] Add SoulSync Discovery tab to Sync page (Phase 1c.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last of the three unified-tab phases. Surfaces the user's persisted personalized playlists (decade mixes, hidden gems, popular picks, daily mixes, discovery shuffle, etc.) on the Sync page so they participate in the mirrored-playlist + Auto-Sync pipeline like every other source. Different shape from the LB / Last.fm tabs: - Tracks already carry Spotify / iTunes / Deezer IDs (matched at generation time from the discovery pool), so there is NO MB-style "needs discovery" hop. The mirror is created with fully-populated ``matched_data`` JSON inline, downstream consumers (sync, wishlist) see canonical extra_data immediately. - Click on a card runs the kind's generator (``POST /api/personalized/playlist/<kind>/<variant>/refresh``) + grabs the fresh track snapshot + mirrors under a synthetic id of the form ``ssd_<kind>_<variant>`` (e.g. ``ssd_decade_1980s``, ``ssd_hidden_gems``). Re-clicks UPSERT the same row, so the Auto-Sync schedule survives every refresh. - Sub-tabs / archive concept don't apply here — each personalized playlist is already a singleton per (profile, kind, variant); the manager handles its own rotation. New file: ``webui/static/sync-soulsync-discovery.js`` (~210 lines). ``initializeSyncPage`` learns a new tab branch. CSS adds ``soulsync-discovery-icon`` (star SVG, teal ``#14b8a6``) + ``.soulsync-discovery-playlist-card`` joins the unified card selector group with a matching teal accent. WHATS_NEW entry added under 2.6.3. 236 tests still green; no Python paths touched. --- webui/index.html | 15 ++ webui/static/helper.js | 1 + webui/static/style.css | 44 ++++- webui/static/sync-services.js | 11 ++ webui/static/sync-soulsync-discovery.js | 237 ++++++++++++++++++++++++ 5 files changed, 298 insertions(+), 10 deletions(-) create mode 100644 webui/static/sync-soulsync-discovery.js diff --git a/webui/index.html b/webui/index.html index f4fd8a43..8d70b9e7 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1033,6 +1033,9 @@ <button class="sync-tab-button" data-tab="lastfm-sync"> <span class="tab-icon lastfm-icon"></span> Last.fm </button> + <button class="sync-tab-button" data-tab="soulsync-discovery-sync"> + <span class="tab-icon soulsync-discovery-icon"></span> SoulSync Discovery + </button> <button class="sync-tab-button" data-tab="import-file"> <span class="tab-icon import-file-icon"></span> Import </button> @@ -1918,6 +1921,17 @@ </div> </div> + <!-- SoulSync Discovery Sync Tab Content --> + <div class="sync-tab-content" id="soulsync-discovery-sync-tab-content"> + <div class="playlist-header"> + <h3>SoulSync Discovery Playlists</h3> + <button class="refresh-button soulsync-discovery" id="soulsync-discovery-sync-refresh-btn">🔄 Refresh</button> + </div> + <div class="playlist-scroll-container" id="soulsync-discovery-sync-playlist-container"> + <div class="playlist-placeholder">Click 'Refresh' to load your personalized SoulSync Discovery playlists.</div> + </div> + </div> + <!-- Last.fm Radio Sync Tab Content --> <div class="sync-tab-content" id="lastfm-sync-tab-content"> <div class="playlist-header"> @@ -7997,6 +8011,7 @@ <script src="{{ url_for('static', filename='sync-services.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='sync-listenbrainz.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='sync-lastfm.js', v=static_v) }}"></script> + <script src="{{ url_for('static', filename='sync-soulsync-discovery.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='api-monitor.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='library.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='beatport-ui.js', v=static_v) }}"></script> diff --git a/webui/static/helper.js b/webui/static/helper.js index b6a31d44..bc1bd645 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3420,6 +3420,7 @@ const WHATS_NEW = { { title: 'Discovery folded into the unified source contract', desc: 'next slice of the groundwork. each playlist source can now answer one extra question — "match these raw tracks against Spotify / iTunes" — through the same adapter interface. Spotify / Tidal / Qobuz / YouTube / Deezer / Spotify-public / iTunes-link / SoulSync-Discovery all answer trivially (their tracks already have provider IDs); ListenBrainz + Last.fm run the matching engine. mirror-refresh now calls this automatically when a source returns MB-metadata-only tracks, so when ListenBrainz becomes a Sync-page tab next commit, its mirrors land already discovered + ready to sync — no separate Discover-page round-trip needed.' }, { title: 'ListenBrainz Sync tab', desc: 'new ListenBrainz tab on the Sync page, between Beatport and Import. lists your "For You" / "My Playlists" / "Collaborative" LB playlists in one place. clicking a card kicks off the same discovery → sync → mirror flow you already get from the Discover page (no duplicate UI behind the scenes, just a new entry point). once mirrored, LB playlists participate in Auto-Sync schedules + pipeline automations like any other source. needs ListenBrainz connected in Settings → Connections.', page: 'sync' }, { title: 'Last.fm Radio Sync tab', desc: 'sibling to the ListenBrainz tab — lists your generated Last.fm Radio playlists alongside the rest of the Sync sources. same discovery → mirror flow under the hood, just a different entry point. new Last.fm radios are still generated from the Discover page by picking a seed track; this tab is for syncing existing ones. mirrors auto-trim when Last.fm Radio cache rotates so old radios don\'t pile up.', page: 'sync' }, + { title: 'SoulSync Discovery Sync tab', desc: 'last of the unified-tab trio. surfaces your personalized SoulSync Discovery playlists (decade mixes, hidden gems, popular picks, daily mixes, discovery shuffle, etc.) on the Sync page. clicking a card regenerates the playlist + mirrors it under a stable synthetic id, so the same mirror updates in place every Auto-Sync refresh. tracks come out already matched against Spotify / iTunes / Deezer so there\'s no discovery hop — straight to download / sync.', page: 'sync' }, ], '2.6.2': [ { date: 'May 24, 2026 — 2.6.2 release' }, diff --git a/webui/static/style.css b/webui/static/style.css index 71b9ed5d..4c60f887 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -13280,6 +13280,14 @@ body.helper-mode-active #dashboard-activity-feed:hover { background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23ffffff"><path d="M10.584 17.21l-.88-2.392s-1.43 1.594-3.573 1.594c-1.897 0-3.244-1.649-3.244-4.288 0-3.382 1.705-4.591 3.381-4.591 2.42 0 3.189 1.567 3.849 3.574l.88 2.749c.88 2.667 2.529 4.81 7.285 4.81 3.409 0 5.717-1.044 5.717-3.793 0-2.227-1.27-3.381-3.629-3.931l-1.762-.385c-1.21-.275-1.567-.77-1.567-1.595 0-.935.742-1.484 1.952-1.484 1.32 0 2.034.495 2.144 1.677l2.749-.33c-.22-2.474-1.926-3.491-4.755-3.491-2.502 0-4.948.935-4.948 3.931 0 1.87.907 3.052 3.189 3.601l1.87.44c1.402.33 1.87 1.622 1.87 1.622-.044 1.21-1.21 1.485-2.667 1.485-2.166 0-3.078-1.142-3.601-2.722l-.907-2.722c-1.16-3.601-3.024-4.921-6.708-4.921C2.193 5.948 0 8.642 0 13.21c0 4.398 2.255 6.781 6.296 6.781 3.244 0 4.811-1.539 4.811-1.539-.082.001-.247-.385-.523-1.242z"/></svg>'); } +.soulsync-discovery-icon { + background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%2314b8a6"><path d="M12 1.5l2.7 6.3 6.3.5-4.8 4.2 1.5 6.5L12 15.5l-5.7 3.5 1.5-6.5L3 8.3l6.3-.5z"/></svg>'); +} + +.sync-tab-button.active .soulsync-discovery-icon { + background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23ffffff"><path d="M12 1.5l2.7 6.3 6.3.5-4.8 4.2 1.5 6.5L12 15.5l-5.7 3.5 1.5-6.5L3 8.3l6.3-.5z"/></svg>'); +} + /* ListenBrainz Sync tab sub-tabs (For You / My Playlists / Collaborative). * Neutral dark surface; orange used only as a subtle accent on the * active state — matches the rest of the app's tone. */ @@ -17545,7 +17553,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .deezer-playlist-card, .spotify-public-card, .listenbrainz-playlist-card, -.lastfm-playlist-card { +.lastfm-playlist-card, +.soulsync-discovery-playlist-card { background: rgba(18, 18, 22, 0.9); backdrop-filter: blur(12px); border-radius: 16px; @@ -17568,7 +17577,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .deezer-playlist-card::before, .spotify-public-card::before, .listenbrainz-playlist-card::before, -.lastfm-playlist-card::before { +.lastfm-playlist-card::before, +.soulsync-discovery-playlist-card::before { content: ''; position: absolute; top: 0; @@ -17585,6 +17595,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { .spotify-public-card::before { background: linear-gradient(90deg, transparent, rgba(29, 185, 84, 0.4), transparent); } .listenbrainz-playlist-card::before { background: linear-gradient(90deg, transparent, rgba(235, 116, 59, 0.4), transparent); } .lastfm-playlist-card::before { background: linear-gradient(90deg, transparent, rgba(213, 16, 7, 0.4), transparent); } +.soulsync-discovery-playlist-card::before { background: linear-gradient(90deg, transparent, rgba(20, 184, 166, 0.4), transparent); } /* Hover — brand glow */ .youtube-playlist-card:hover { border-color: rgba(255, 0, 0, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(255, 0, 0, 0.06); transform: translateY(-2px); } @@ -17593,6 +17604,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { .spotify-public-card:hover { border-color: rgba(29, 185, 84, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(29, 185, 84, 0.06); transform: translateY(-2px); } .listenbrainz-playlist-card:hover { border-color: rgba(235, 116, 59, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(235, 116, 59, 0.06); transform: translateY(-2px); } .lastfm-playlist-card:hover { border-color: rgba(213, 16, 7, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(213, 16, 7, 0.06); transform: translateY(-2px); } +.soulsync-discovery-playlist-card:hover { border-color: rgba(20, 184, 166, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(20, 184, 166, 0.06); transform: translateY(-2px); } .youtube-playlist-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(255, 0, 0, 0.7), transparent); } .tidal-playlist-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(255, 102, 0, 0.7), transparent); } @@ -17600,6 +17612,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { .spotify-public-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(29, 185, 84, 0.7), transparent); } .listenbrainz-playlist-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(235, 116, 59, 0.7), transparent); } .lastfm-playlist-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(213, 16, 7, 0.7), transparent); } +.soulsync-discovery-playlist-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(20, 184, 166, 0.7), transparent); } /* Source icons */ .youtube-playlist-card .playlist-card-icon, @@ -17607,7 +17620,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .deezer-playlist-card .playlist-card-icon, .spotify-public-card .playlist-card-icon, .listenbrainz-playlist-card .playlist-card-icon, -.lastfm-playlist-card .playlist-card-icon { +.lastfm-playlist-card .playlist-card-icon, +.soulsync-discovery-playlist-card .playlist-card-icon { width: 40px; height: 40px; border-radius: 10px; @@ -17627,27 +17641,31 @@ body.helper-mode-active #dashboard-activity-feed:hover { .spotify-public-card .playlist-card-icon { background: rgba(29, 185, 84, 0.12); border: 1px solid rgba(29, 185, 84, 0.2); color: #1DB954; } .listenbrainz-playlist-card .playlist-card-icon { background: rgba(235, 116, 59, 0.12); border: 1px solid rgba(235, 116, 59, 0.2); color: #eb743b; } .lastfm-playlist-card .playlist-card-icon { background: rgba(213, 16, 7, 0.12); border: 1px solid rgba(213, 16, 7, 0.2); color: #d51007; } +.soulsync-discovery-playlist-card .playlist-card-icon { background: rgba(20, 184, 166, 0.12); border: 1px solid rgba(20, 184, 166, 0.2); color: #14b8a6; } .youtube-playlist-card .playlist-card-content, .tidal-playlist-card .playlist-card-content, .deezer-playlist-card .playlist-card-content, .spotify-public-card .playlist-card-content, .listenbrainz-playlist-card .playlist-card-content, -.lastfm-playlist-card .playlist-card-content { flex: 1; min-width: 0; } +.lastfm-playlist-card .playlist-card-content, +.soulsync-discovery-playlist-card .playlist-card-content { flex: 1; min-width: 0; } .youtube-playlist-card .playlist-card-name, .tidal-playlist-card .playlist-card-name, .deezer-playlist-card .playlist-card-name, .spotify-public-card .playlist-card-name, .listenbrainz-playlist-card .playlist-card-name, -.lastfm-playlist-card .playlist-card-name { font-size: 15px; font-weight: 600; color: rgba(255, 255, 255, 0.9); margin-bottom: 4px; } +.lastfm-playlist-card .playlist-card-name, +.soulsync-discovery-playlist-card .playlist-card-name { font-size: 15px; font-weight: 600; color: rgba(255, 255, 255, 0.9); margin-bottom: 4px; } .youtube-playlist-card .playlist-card-info, .tidal-playlist-card .playlist-card-info, .deezer-playlist-card .playlist-card-info, .spotify-public-card .playlist-card-info, .listenbrainz-playlist-card .playlist-card-info, -.lastfm-playlist-card .playlist-card-info { font-size: 13px; color: rgba(255, 255, 255, 0.4); display: flex; align-items: center; gap: 10px; } +.lastfm-playlist-card .playlist-card-info, +.soulsync-discovery-playlist-card .playlist-card-info { font-size: 13px; color: rgba(255, 255, 255, 0.4); display: flex; align-items: center; gap: 10px; } .youtube-playlist-card .playlist-card-track-count { color: rgba(255, 255, 255, 0.7); } .youtube-playlist-card .playlist-card-phase-text { font-weight: 500; } @@ -17659,7 +17677,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .deezer-playlist-card .playlist-card-action-btn, .spotify-public-card .playlist-card-action-btn, .listenbrainz-playlist-card .playlist-card-action-btn, -.lastfm-playlist-card .playlist-card-action-btn { +.lastfm-playlist-card .playlist-card-action-btn, +.soulsync-discovery-playlist-card .playlist-card-action-btn { background: linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%); border: 1px solid rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.8); @@ -17680,7 +17699,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .deezer-playlist-card .playlist-card-action-btn::before, .spotify-public-card .playlist-card-action-btn::before, .listenbrainz-playlist-card .playlist-card-action-btn::before, -.lastfm-playlist-card .playlist-card-action-btn::before { +.lastfm-playlist-card .playlist-card-action-btn::before, +.soulsync-discovery-playlist-card .playlist-card-action-btn::before { content: ''; position: absolute; inset: 0; @@ -17695,13 +17715,15 @@ body.helper-mode-active #dashboard-activity-feed:hover { .spotify-public-card .playlist-card-action-btn::before { background: linear-gradient(135deg, rgba(29, 185, 84, 0.2), rgba(29, 185, 84, 0.05)); } .listenbrainz-playlist-card .playlist-card-action-btn::before { background: linear-gradient(135deg, rgba(235, 116, 59, 0.2), rgba(235, 116, 59, 0.05)); } .lastfm-playlist-card .playlist-card-action-btn::before { background: linear-gradient(135deg, rgba(213, 16, 7, 0.2), rgba(213, 16, 7, 0.05)); } +.soulsync-discovery-playlist-card .playlist-card-action-btn::before { background: linear-gradient(135deg, rgba(20, 184, 166, 0.2), rgba(20, 184, 166, 0.05)); } .youtube-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before, .tidal-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before, .deezer-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before, .spotify-public-card .playlist-card-action-btn:hover:not(:disabled)::before, .listenbrainz-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before, -.lastfm-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before { opacity: 1; } +.lastfm-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before, +.soulsync-discovery-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before { opacity: 1; } .youtube-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(255, 0, 0, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(255, 0, 0, 0.15); } .tidal-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(255, 102, 0, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(255, 102, 0, 0.15); } @@ -17709,13 +17731,15 @@ body.helper-mode-active #dashboard-activity-feed:hover { .spotify-public-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(29, 185, 84, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(29, 185, 84, 0.15); } .listenbrainz-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(235, 116, 59, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(235, 116, 59, 0.15); } .lastfm-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(213, 16, 7, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(213, 16, 7, 0.15); } +.soulsync-discovery-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(20, 184, 166, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(20, 184, 166, 0.15); } .youtube-playlist-card .playlist-card-action-btn:disabled, .tidal-playlist-card .playlist-card-action-btn:disabled, .deezer-playlist-card .playlist-card-action-btn:disabled, .spotify-public-card .playlist-card-action-btn:disabled, .listenbrainz-playlist-card .playlist-card-action-btn:disabled, -.lastfm-playlist-card .playlist-card-action-btn:disabled { +.lastfm-playlist-card .playlist-card-action-btn:disabled, +.soulsync-discovery-playlist-card .playlist-card-action-btn:disabled { background: rgba(255, 255, 255, 0.03); border-color: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.2); diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js index c317aba1..63f18be3 100644 --- a/webui/static/sync-services.js +++ b/webui/static/sync-services.js @@ -3764,6 +3764,17 @@ function initializeSyncPage() { _startLbSyncCardRefreshLoop(); } } + + // SoulSync Discovery Sync tab — personalized_playlists pre- + // matched, no discovery hop needed; click → refresh kind → + // mirror under synthetic id. + if (tabId === 'soulsync-discovery-sync') { + if (typeof loadSoulsyncDiscoverySyncPlaylists === 'function' + && !window._soulsyncDiscoverySyncTabLoaded) { + window._soulsyncDiscoverySyncTabLoaded = true; + loadSoulsyncDiscoverySyncPlaylists(); + } + } }); }); diff --git a/webui/static/sync-soulsync-discovery.js b/webui/static/sync-soulsync-discovery.js new file mode 100644 index 00000000..f806acbb --- /dev/null +++ b/webui/static/sync-soulsync-discovery.js @@ -0,0 +1,237 @@ +// =================================================================== +// SOULSYNC DISCOVERY SYNC TAB (Phase 1c.3) +// =================================================================== +// Surfaces the user's persisted SoulSync Discovery / personalized +// playlists (decade mixes, hidden gems, popular picks, daily mixes, +// discovery shuffle, etc.) as a Sync-page tab so they participate +// in the mirrored-playlist + Auto-Sync pipeline like every other +// source. +// +// Different shape from the LB / Last.fm tabs: personalized tracks +// already carry Spotify / iTunes / Deezer IDs (matched at generation +// time from the discovery pool), so there's no MB-style "needs +// discovery" hop. Click → refresh kind → grab tracks → mirror as +// ``source='soulsync_discovery'`` with the matched_data shape +// downstream consumers already expect from auto-discovered Spotify +// mirrors. + +let _soulsyncDiscoverySyncRecords = []; + +async function loadSoulsyncDiscoverySyncPlaylists() { + const container = document.getElementById('soulsync-discovery-sync-playlist-container'); + const refreshBtn = document.getElementById('soulsync-discovery-sync-refresh-btn'); + if (!container) return; + + container.innerHTML = `<div class="playlist-placeholder">🔄 Loading SoulSync Discovery playlists...</div>`; + if (refreshBtn) { + refreshBtn.disabled = true; + refreshBtn.textContent = '🔄 Loading...'; + } + + try { + const resp = await fetch('/api/personalized/playlists'); + const data = await resp.json(); + if (!data.success) { + container.innerHTML = `<div class="playlist-placeholder">❌ ${escapeHtml(data.error || 'Failed to load')}</div>`; + return; + } + _soulsyncDiscoverySyncRecords = data.playlists || []; + renderSoulsyncDiscoverySyncPlaylists(); + console.log(`✨ SoulSync Discovery Sync tab loaded: ${_soulsyncDiscoverySyncRecords.length} playlists`); + } catch (err) { + container.innerHTML = `<div class="playlist-placeholder">❌ Error: ${err.message}</div>`; + if (typeof showToast === 'function') { + showToast(`Error loading SoulSync Discovery: ${err.message}`, 'error'); + } + } finally { + if (refreshBtn) { + refreshBtn.disabled = false; + refreshBtn.textContent = '🔄 Refresh'; + } + } +} + +function renderSoulsyncDiscoverySyncPlaylists() { + const container = document.getElementById('soulsync-discovery-sync-playlist-container'); + if (!container) return; + + if (_soulsyncDiscoverySyncRecords.length === 0) { + container.innerHTML = `<div class="playlist-placeholder">No SoulSync Discovery playlists yet. Open the Discover page and generate a few personalized playlists first.</div>`; + return; + } + + container.innerHTML = _soulsyncDiscoverySyncRecords.map(p => { + const syntheticId = _soulsyncSyntheticId(p.kind, p.variant); + const title = p.name || `${p.kind} ${p.variant || ''}`.trim(); + const subtitle = p.variant ? `${p.kind} · ${p.variant}` : p.kind; + const count = p.track_count || 0; + const stale = !!p.is_stale; + const stalenessText = stale ? 'Stale — refresh to regenerate' : 'Ready'; + const stalenessColor = stale ? '#facc15' : '#14b8a6'; + + return ` + <div class="youtube-playlist-card soulsync-discovery-playlist-card" + id="soulsync-discovery-sync-card-${escapeHtml(syntheticId)}" + data-ssd-kind="${escapeHtml(p.kind)}" + data-ssd-variant="${escapeHtml(p.variant || '')}" + data-ssd-id="${escapeHtml(syntheticId)}" + data-ssd-name="${escapeHtml(title)}"> + <div class="playlist-card-icon">✨</div> + <div class="playlist-card-content"> + <div class="playlist-card-name">${escapeHtml(title)}</div> + <div class="playlist-card-info"> + <span class="playlist-card-track-count">${count} tracks</span> + <span class="playlist-card-owner">${escapeHtml(subtitle)}</span> + <span class="playlist-card-phase-text" style="color: ${stalenessColor};">${stalenessText}</span> + </div> + </div> + <div class="playlist-card-progress hidden"></div> + <button class="playlist-card-action-btn">Refresh & Mirror</button> + </div> + `; + }).join(''); + + container.querySelectorAll('.soulsync-discovery-playlist-card').forEach(card => { + card.addEventListener('click', () => { + const kind = card.dataset.ssdKind; + const variant = card.dataset.ssdVariant; + const name = card.dataset.ssdName; + handleSoulsyncDiscoverySyncCardClick(kind, variant, name, card); + }); + }); +} + +function _soulsyncSyntheticId(kind, variant) { + // Synthetic stable id keyed on (kind, variant) so re-refreshes UPSERT + // the same mirror row instead of duplicating. Empty variant collapses + // cleanly (e.g. hidden_gems with no variant -> "ssd_hidden_gems"). + return `ssd_${kind}${variant ? `_${variant}` : ''}`; +} + +async function handleSoulsyncDiscoverySyncCardClick(kind, variant, name, cardEl) { + if (!kind) { + if (typeof showToast === 'function') showToast('Missing kind', 'error'); + return; + } + const btn = cardEl ? cardEl.querySelector('.playlist-card-action-btn') : null; + const progEl = cardEl ? cardEl.querySelector('.playlist-card-progress') : null; + if (btn) { + btn.disabled = true; + btn.textContent = 'Refreshing…'; + } + if (progEl) progEl.classList.remove('hidden'); + + try { + // Trigger the kind's generator and grab fresh tracks. + const url = variant + ? `/api/personalized/playlist/${encodeURIComponent(kind)}/${encodeURIComponent(variant)}/refresh` + : `/api/personalized/playlist/${encodeURIComponent(kind)}/refresh`; + const resp = await fetch(url, { method: 'POST' }); + const data = await resp.json(); + if (!data.success) { + throw new Error(data.error || 'Generator failed'); + } + + const rec = data.playlist || {}; + const tracks = data.tracks || []; + const finalName = rec.name || name || `${kind} ${variant || ''}`.trim(); + const syntheticId = _soulsyncSyntheticId(kind, variant); + + if (tracks.length === 0) { + if (typeof showToast === 'function') { + showToast(`'${finalName}' generated 0 tracks. Try widening the playlist's config in Discover.`, 'warning'); + } + } + + // Project each track into the mirrorPlaylist contract. Tracks + // already carry provider IDs from the discovery pool, so the + // matched_data block is filled inline — no separate discovery + // worker pass needed. + const mirrorTracks = tracks.map(t => { + const trackId = t.spotify_track_id || t.itunes_track_id || t.deezer_track_id || ''; + const provider = t.spotify_track_id ? 'spotify' + : (t.itunes_track_id ? 'itunes' + : (t.deezer_track_id ? 'deezer' : (t.source || 'unknown'))); + const albumObj = { name: t.album_name || '' }; + if (t.album_cover_url) { + albumObj.images = [{ url: t.album_cover_url, height: 600, width: 600 }]; + } + const extra = trackId ? JSON.stringify({ + discovered: true, + provider, + confidence: 1.0, + matched_data: { + id: trackId, + name: t.track_name || '', + artists: [{ name: t.artist_name || '' }], + album: albumObj, + duration_ms: t.duration_ms || 0, + image_url: t.album_cover_url || null, + source: provider, + }, + }) : null; + return { + track_name: t.track_name || '', + artist_name: t.artist_name || '', + album_name: t.album_name || '', + duration_ms: t.duration_ms || 0, + image_url: t.album_cover_url || null, + source_track_id: trackId, + extra_data: extra, + }; + }); + + if (typeof mirrorPlaylist === 'function') { + mirrorPlaylist( + 'soulsync_discovery', + syntheticId, + finalName, + mirrorTracks, + { + owner: 'SoulSync', + description: `Personalized ${kind}${variant ? ' · ' + variant : ''} — regenerates on Auto-Sync refresh.`, + image_url: '', + }, + ); + } + + if (progEl) { + progEl.textContent = `♪ ${tracks.length} / ✓ ${mirrorTracks.length} / mirrored`; + } + if (btn) { + btn.disabled = false; + btn.textContent = 'Refresh & Mirror'; + } + + // Update the in-memory record so the card displays the new count. + const idx = _soulsyncDiscoverySyncRecords.findIndex( + r => r.kind === kind && (r.variant || '') === (variant || '') + ); + if (idx >= 0) { + _soulsyncDiscoverySyncRecords[idx] = { + ..._soulsyncDiscoverySyncRecords[idx], + ...rec, + track_count: tracks.length, + is_stale: false, + }; + } + + if (typeof showToast === 'function') { + showToast(`Mirrored '${finalName}' with ${mirrorTracks.length} tracks`, 'success'); + } + } catch (err) { + if (btn) { + btn.disabled = false; + btn.textContent = 'Refresh & Mirror'; + } + if (typeof showToast === 'function') { + showToast(`Refresh failed: ${err.message}`, 'error'); + } + console.error('SoulSync Discovery refresh failed:', err); + } +} + +document.addEventListener('DOMContentLoaded', () => { + const btn = document.getElementById('soulsync-discovery-sync-refresh-btn'); + if (btn) btn.addEventListener('click', loadSoulsyncDiscoverySyncPlaylists); +}); From a8e6432e86deeff16e30c846b0fa6625efaef540 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 20:02:50 -0700 Subject: [PATCH 67/81] SoulSync Discovery tab: open mirror detail modal after refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1c.3 left the click flow at "card shows 'mirrored' + toast", which felt incomplete — Tidal / LB / Last.fm all open a follow-up modal after their discovery flow so the user can act on the results (sync to server playlist, queue downloads, etc.). SoulSync Discovery skips the discovery phase (tracks pre-matched), so the natural analog is the mirrored-playlist detail modal — same one the Mirrored tab opens when you click a row. - Inline ``fetch('/api/mirror-playlist', ...)`` in place of the fire-and-forget ``mirrorPlaylist`` helper so we can capture the returned ``playlist_id`` from the response. - After successful mirror creation, call ``openMirroredPlaylistModal(playlist_id)`` (exposed by stats-automations.js) to surface the tracks view. The card itself keeps the ``♪ N / ✓ N / mirrored`` progress text so a quick second click can re-refresh without re-opening the modal each time (just re-runs the generator + re-upserts the mirror). --- webui/static/sync-soulsync-discovery.js | 53 +++++++++++++++++++------ 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/webui/static/sync-soulsync-discovery.js b/webui/static/sync-soulsync-discovery.js index f806acbb..6a707daa 100644 --- a/webui/static/sync-soulsync-discovery.js +++ b/webui/static/sync-soulsync-discovery.js @@ -181,19 +181,37 @@ async function handleSoulsyncDiscoverySyncCardClick(kind, variant, name, cardEl) }; }); - if (typeof mirrorPlaylist === 'function') { - mirrorPlaylist( - 'soulsync_discovery', - syntheticId, - finalName, - mirrorTracks, - { - owner: 'SoulSync', - description: `Personalized ${kind}${variant ? ' · ' + variant : ''} — regenerates on Auto-Sync refresh.`, - image_url: '', - }, - ); + // POST inline so we can capture the returned mirrored_playlists + // row id and open the detail modal afterward. ``mirrorPlaylist`` + // (in stats-automations.js) is fire-and-forget and doesn't + // surface the id, which the next step needs. + const normalizedTracks = mirrorTracks.map(t => ({ + track_name: t.track_name || '', + artist_name: t.artist_name || '', + album_name: t.album_name || '', + duration_ms: t.duration_ms || 0, + image_url: t.image_url || null, + source_track_id: t.source_track_id || '', + extra_data: t.extra_data || null, + })); + const mirrorResp = await fetch('/api/mirror-playlist', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source: 'soulsync_discovery', + source_playlist_id: syntheticId, + name: finalName, + tracks: normalizedTracks, + description: `Personalized ${kind}${variant ? ' · ' + variant : ''} — regenerates on Auto-Sync refresh.`, + owner: 'SoulSync', + image_url: '', + }), + }); + const mirrorData = await mirrorResp.json(); + if (!mirrorData.success) { + throw new Error(mirrorData.error || 'Mirror creation failed'); } + const mirroredId = mirrorData.playlist_id; if (progEl) { progEl.textContent = `♪ ${tracks.length} / ✓ ${mirrorTracks.length} / mirrored`; @@ -219,6 +237,17 @@ async function handleSoulsyncDiscoverySyncCardClick(kind, variant, name, cardEl) if (typeof showToast === 'function') { showToast(`Mirrored '${finalName}' with ${mirrorTracks.length} tracks`, 'success'); } + + // Open the mirrored-playlist detail modal so the user lands on + // the tracks view + can trigger sync / download from there. + // Same flow the Mirrored tab uses when clicking a row. + if (mirroredId && typeof openMirroredPlaylistModal === 'function') { + try { + await openMirroredPlaylistModal(mirroredId); + } catch (e) { + console.warn('Could not open mirrored playlist detail:', e); + } + } } catch (err) { if (btn) { btn.disabled = false; From 80a88a62ace89b0a401fe211586ecfeb7fbc48f0 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 20:12:15 -0700 Subject: [PATCH 68/81] Auto-Sync sidebar: improve playlist card readability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mirrored-playlist cards in the Auto-Sync schedule modal's sidebar were truncating long names with ellipsis on a single line + rendering meta info at 10px, which made entries like "Top Missed Recordings of 2024 for Nezreka" or "ListenBrainz Weekly Exploration" unreadable. - Name wraps to multiple lines instead of ellipsis-truncating (sidebar is narrow; truncation hid critical disambiguating text like the year / week / username). - Bumped name 12px → 13px, meta 10px → 11px with brighter color (0.4 → 0.55 alpha). - Bumped card padding 10px/12px → 12px/14px + spacing 6px → 8px so multi-line entries have breathing room. - Pinned the leading status dot to the first text line via ``margin-top`` so multi-line names flow underneath rather than push the dot off-center. --- webui/static/style.css | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/webui/static/style.css b/webui/static/style.css index 4c60f887..3c6c0843 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -11809,10 +11809,10 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-playlist { - padding: 10px 12px; - margin-bottom: 6px; + padding: 12px 14px; + margin-bottom: 8px; display: flex; - align-items: center; + align-items: flex-start; gap: 10px; } @@ -11823,6 +11823,9 @@ body.helper-mode-active #dashboard-activity-feed:hover { border-radius: 50%; background: rgba(255, 255, 255, 0.18); flex-shrink: 0; + /* Pin the status dot to the first line of the name so multi-line + * playlist titles flow underneath it instead of pushing it down. */ + margin-top: 6px; } .auto-sync-playlist.scheduled::before { @@ -11867,24 +11870,27 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-playlist-name, .auto-sync-scheduled-name { color: #fff; - font-size: 12px; + font-size: 13px; font-weight: 600; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + line-height: 1.35; + /* Wrap long names instead of truncating with ellipsis — the + * sidebar is narrow and long ListenBrainz / Spotify titles were + * getting clipped beyond recognition. */ + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; } .auto-sync-scheduled-name { - white-space: normal; - overflow-wrap: anywhere; line-height: 1.3; } .auto-sync-playlist-meta, .auto-sync-scheduled-meta { - margin-top: 2px; - color: rgba(255, 255, 255, 0.4); - font-size: 10px; + margin-top: 4px; + color: rgba(255, 255, 255, 0.55); + font-size: 11px; + line-height: 1.3; } .auto-sync-scheduled-meta { From f758ae93301be46d7dcfad98341c5c7ce7dbda50 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 20:17:23 -0700 Subject: [PATCH 69/81] Drop `[LB Rolling]` diagnostic logs back to debug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bulk rolling-mirror ensure path was instrumented with INFO lines + a WARNING on SELECT failure (commit 5378b726) while we chased down why only one rolling mirror was being created — turned out the issue was simply needing two refresh cycles after the rolling code shipped. Diagnostic served its purpose, removing the noise from every LB refresh now. - Dropped per-walk + per-match + summary INFO lines from ``_ensure_rolling_mirrors_from_cache`` — the loop is silent. - Reverted the outer SELECT failure catch from ``logger.warning`` back to ``logger.debug``. - Kept the per-placeholder ``Pre-created rolling mirror placeholder`` INFO line in ``_ensure_rolling_series_mirror`` since it's a genuine one-shot event (only fires when a new placeholder is actually inserted, not on every refresh). --- core/listenbrainz_manager.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/core/listenbrainz_manager.py b/core/listenbrainz_manager.py index 65b06da6..ddae7ae8 100644 --- a/core/listenbrainz_manager.py +++ b/core/listenbrainz_manager.py @@ -245,24 +245,10 @@ class ListenBrainzManager: (self.profile_id,), ) titles = [row[0] for row in cursor.fetchall() if row[0]] - logger.info( - f"[LB Rolling] Bulk ensure walking {len(titles)} cached titles for profile {self.profile_id}" - ) - from core.playlists.lb_series import detect_series - matched = 0 for title in titles: - m = detect_series(title) - if m is not None: - matched += 1 - logger.info( - f"[LB Rolling] Title matched series: {title!r} -> {m.series_id}" - ) self._ensure_rolling_series_mirror(cursor, title) - logger.info( - f"[LB Rolling] Bulk ensure done — {matched}/{len(titles)} titles matched a series" - ) except Exception as exc: - logger.warning(f"Bulk rolling-mirror ensure skipped: {exc}") + logger.debug(f"Bulk rolling-mirror ensure skipped: {exc}") def _ensure_rolling_series_mirror(self, cursor, playlist_title: str): """Upsert a placeholder ``mirrored_playlists`` row for the From 85426a210c5a83ae7809259416551fe857d47dde Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 21:04:27 -0700 Subject: [PATCH 70/81] Fix album-bundle downloads landing every track as track 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soulseek album-bundle (and any other release-staging path) was importing every file with ``track_number=1`` because the staging metadata reader used the auto-import-flavor filename extractor: ``extract_track_number_from_filename`` returns 1 when the basename has no ``NN -`` prefix. That's the right default for the loose auto-import flow (single file in, no upstream metadata to lean on), but completely wrong for staging-cache reads: - For an album-bundle download the user has authoritative track numbers in the Spotify track list flowing through to ``track_info`` for each task. - ``try_staging_match`` in ``core/downloads/staging.py`` was meant to use those numbers when the staged file's own metadata doesn't have them. - But the staging cache populated ``track_number=1`` for every untagged bare-title file (e.g. ``Cha-La Head-Cha-La.flac``), the album-bundle resolution branch reads file-side first, sees 1, and short-circuits the rest of the chain. Fix: - New ``extract_explicit_track_number`` in ``core/imports/filename.py`` — strict variant that returns ``0`` when no numeric prefix is visible. Docstring explicitly contrasts with the legacy 1-defaulting helper so future callers pick the right one. - ``read_staging_file_metadata`` in ``core/imports/staging.py`` now uses the strict extractor, so the staging file dict carries ``track_number=0`` ("unknown") instead of ``1`` for untagged bare-title files. - The legacy ``extract_track_number_from_filename`` keeps its 1-default behavior so auto-import callers + the post-process template fallbacks are unchanged; it's now implemented in terms of the strict variant. - Tag-side parsing also tightened to require ``> 0`` before overriding the filename-derived value. 3 new tests pin the contracts: - ``test_extract_explicit_track_number_returns_zero_when_no_prefix`` - ``test_read_staging_file_metadata_returns_zero_track_when_unknown`` - existing ``test_extract_track_number_from_filename_handles_common_patterns`` now explicitly comments why bare filenames keep returning 1. 758 tests across imports + downloads + repair + staging-provenance suites green. WHATS_NEW entry added under 2.6.3. Reported against an album-bundle download of Ryoto's "Cha-La Head-Cha-La" where slskd staged 15 untagged FLAC files named after the song titles only. --- core/imports/filename.py | 30 +++++++++++++++-- core/imports/staging.py | 18 +++++++--- tests/imports/test_import_file_ops.py | 47 ++++++++++++++++++++++++++- webui/static/helper.js | 1 + 4 files changed, 88 insertions(+), 8 deletions(-) diff --git a/core/imports/filename.py b/core/imports/filename.py index 92ff026a..7fa9c420 100644 --- a/core/imports/filename.py +++ b/core/imports/filename.py @@ -15,8 +15,32 @@ _TRACK_PATTERNS = ( def extract_track_number_from_filename(filename: str, title: str = None) -> int: - """Extract track number from a filename. Returns 1 if not found.""" - basename = os.path.splitext(os.path.basename(filename))[0].strip() + """Extract track number from a filename. Returns 1 if not found. + + Use ``extract_explicit_track_number`` instead when the caller needs + to distinguish "track 1" from "unknown" — staging-file readers in + particular MUST NOT conflate a bare title (no numeric prefix) with + track 1, or every untagged album-bundle file gets imported as + ``track_number=1`` and downstream callers can't recover the real + number from authoritative metadata (Spotify track list, etc.). + """ + num = extract_explicit_track_number(filename) + return num if num > 0 else 1 + + +def extract_explicit_track_number(filename: str) -> int: + """Extract a track number only when the filename visibly carries one. + + Returns the parsed track number when the basename starts with a + recognizable numeric prefix (``"01 - Title"``, ``"1-03 Title"``, + ``"(01) Title"``, ``"[01] Title"``); returns ``0`` when no such + prefix is present. This is the contract staging readers want — + "unknown" must stay unknown so a downstream consumer with better + info (Spotify metadata, MusicBrainz, etc.) can fill it in. + """ + basename = os.path.splitext(os.path.basename(str(filename or "")))[0].strip() + if not basename: + return 0 match = re.match(r"^\d[\-\.](\d{1,2})\s*[\-\.]\s*", basename) if match: @@ -30,7 +54,7 @@ def extract_track_number_from_filename(filename: str, title: str = None) -> int: if 1 <= num <= 999: return num - return 1 + return 0 def parse_filename_metadata(filename: str) -> Dict[str, Any]: diff --git a/core/imports/staging.py b/core/imports/staging.py index 748cbb11..76a42beb 100644 --- a/core/imports/staging.py +++ b/core/imports/staging.py @@ -7,7 +7,10 @@ import threading from typing import Any, Dict, Iterable, List, Optional, Tuple from core.imports.paths import docker_resolve_path -from core.imports.filename import extract_track_number_from_filename +from core.imports.filename import ( + extract_explicit_track_number, + extract_track_number_from_filename, +) from utils.logging_config import get_logger logger = get_logger("imports.staging") @@ -103,12 +106,19 @@ def read_staging_file_metadata(file_path: str, filename: Optional[str] = None) - if not albumartist: albumartist = artist - track_number = extract_track_number_from_filename(filename or file_path) + # Use the strict extractor here: when the filename has no visible + # track-number prefix, return 0 instead of pretending it's track 1. + # Downstream consumers (staging match in core/downloads/staging.py) + # will then fall through to authoritative metadata (track_info from + # the original Spotify / API source) rather than locking the import + # to track_number=1 for every file in the bundle. + track_number = extract_explicit_track_number(filename or file_path) try: - # Preserve tag-based numbers when present, but still fall back to the filename parser. tag_track_number = _first_tag("tracknumber", "track_number") if tag_track_number: - track_number = int(str(tag_track_number).split("/")[0].strip() or track_number) + parsed_tag = int(str(tag_track_number).split("/")[0].strip()) + if parsed_tag > 0: + track_number = parsed_tag except (TypeError, ValueError): pass diff --git a/tests/imports/test_import_file_ops.py b/tests/imports/test_import_file_ops.py index 16e68ad7..ce9ecd4a 100644 --- a/tests/imports/test_import_file_ops.py +++ b/tests/imports/test_import_file_ops.py @@ -5,16 +5,40 @@ from core.imports.file_ops import ( cleanup_empty_directories, safe_move_file, ) -from core.imports.filename import extract_track_number_from_filename +from core.imports.filename import ( + extract_explicit_track_number, + extract_track_number_from_filename, +) from core.imports.staging import read_staging_file_metadata def test_extract_track_number_from_filename_handles_common_patterns(): assert extract_track_number_from_filename("01 - Song.mp3") == 1 assert extract_track_number_from_filename("1-03 - Song.mp3") == 3 + # Bare filename keeps the auto-import-friendly default of 1 — there's + # no upstream metadata to recover from in that flow. assert extract_track_number_from_filename("Artist - Song.mp3") == 1 +def test_extract_explicit_track_number_returns_zero_when_no_prefix(): + """Staging readers need to distinguish 'track 1' from 'unknown'. + + Pinned because: + - the legacy extractor defaults to 1 (auto-import semantics), + - staging file scanners that conflate the two end up writing every + file in an untagged album bundle to track_number=1. + """ + # Bare titles with no numeric prefix → 0 (unknown). + assert extract_explicit_track_number("Artist - Song.mp3") == 0 + assert extract_explicit_track_number("Cha-La Head-Cha-La.flac") == 0 + assert extract_explicit_track_number("") == 0 + # Real prefixes still parse correctly. + assert extract_explicit_track_number("01 - Song.mp3") == 1 + assert extract_explicit_track_number("(03) Song.mp3") == 3 + # Disc-track format requires a separator after the track number. + assert extract_explicit_track_number("1-07 - Song.mp3") == 7 + + def test_safe_move_file_replaces_existing_destination(tmp_path): src = tmp_path / "source.flac" dst_dir = tmp_path / "dest" @@ -92,6 +116,27 @@ def test_read_staging_file_metadata_falls_back_to_filename_track_number(monkeypa assert metadata["disc_number"] == 1 +def test_read_staging_file_metadata_returns_zero_track_when_unknown(monkeypatch, tmp_path): + """Bare filename + no tags → track_number=0, not 1. + + Pre-fix this returned 1 because the filename extractor's default + was 1. The bug caused every untagged file in an album-bundle + download to land in the staging cache with track_number=1, which + then short-circuited the downstream resolution chain that should + have picked up the real number from track_info. + """ + file_path = tmp_path / "Cha-La Head-Cha-La.flac" + file_path.write_text("fake") + + fake_mutagen = types.ModuleType("mutagen") + fake_mutagen.File = lambda path, easy=True: None + monkeypatch.setitem(sys.modules, "mutagen", fake_mutagen) + + metadata = read_staging_file_metadata(str(file_path), file_path.name) + + assert metadata["track_number"] == 0 + + def test_read_staging_file_metadata_uses_filename_fallbacks_when_tags_are_invalid(monkeypatch, tmp_path): file_path = tmp_path / "02 - Song Three.flac" file_path.write_text("fake") diff --git a/webui/static/helper.js b/webui/static/helper.js index bc1bd645..ce7a9108 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3421,6 +3421,7 @@ const WHATS_NEW = { { title: 'ListenBrainz Sync tab', desc: 'new ListenBrainz tab on the Sync page, between Beatport and Import. lists your "For You" / "My Playlists" / "Collaborative" LB playlists in one place. clicking a card kicks off the same discovery → sync → mirror flow you already get from the Discover page (no duplicate UI behind the scenes, just a new entry point). once mirrored, LB playlists participate in Auto-Sync schedules + pipeline automations like any other source. needs ListenBrainz connected in Settings → Connections.', page: 'sync' }, { title: 'Last.fm Radio Sync tab', desc: 'sibling to the ListenBrainz tab — lists your generated Last.fm Radio playlists alongside the rest of the Sync sources. same discovery → mirror flow under the hood, just a different entry point. new Last.fm radios are still generated from the Discover page by picking a seed track; this tab is for syncing existing ones. mirrors auto-trim when Last.fm Radio cache rotates so old radios don\'t pile up.', page: 'sync' }, { title: 'SoulSync Discovery Sync tab', desc: 'last of the unified-tab trio. surfaces your personalized SoulSync Discovery playlists (decade mixes, hidden gems, popular picks, daily mixes, discovery shuffle, etc.) on the Sync page. clicking a card regenerates the playlist + mirrors it under a stable synthetic id, so the same mirror updates in place every Auto-Sync refresh. tracks come out already matched against Spotify / iTunes / Deezer so there\'s no discovery hop — straight to download / sync.', page: 'sync' }, + { title: 'Fix: album-bundle downloads all landing as track 1', desc: 'soulseek album-bundle downloads (and any other untagged release-staging path) were importing every track with track_number=1. the staging-file reader was using the auto-import\'s filename extractor that defaults to 1 when no NN- prefix is present in the filename — so for albums like Ryoto\'s "Cha-La Head-Cha-La" where slskd hands you bare titles, every file got "track 1" stamped on it. now the staging path uses a strict extractor that returns 0 when it can\'t see an explicit prefix, so the downstream resolver correctly falls through to the authoritative Spotify metadata and the right track numbers land in the library.' }, ], '2.6.2': [ { date: 'May 24, 2026 — 2.6.2 release' }, From c3b88e6963c9d133b7b985dda2bb9858f5bfd682 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 21:13:34 -0700 Subject: [PATCH 71/81] Wishlist albums cycle: split into per-album bundle batches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-wishlist's "albums" cycle used to dump every missing album track into one batch and run per-track Soulseek / Prowlarr searches for each (~50 searches for a typical scan). The album-bundle dispatch (introduced in 2.5.9 for explicit album downloads) was gated on ``is_album_download=True`` + populated ``album_context``/``artist_context``, none of which the wishlist batch ever set — so wishlist runs always took the per-track flow even when 12 missing tracks all belonged to the same album. Fix: split wishlist albums-cycle tracks into per-album sub-batches at submission time. Each sub-batch carries its own album context, trips the existing dispatch gate, and engages one slskd / torrent / usenet album-bundle search per album. Tracks the helper can't group (no album metadata, no artist) fall through to a residual per-track batch. - New ``core/wishlist/album_grouping.py``: ``group_wishlist_tracks_by_album(tracks)`` returns ``WishlistGroupingResult(album_groups, residual_tracks)``. Pure function — extracts album_id (or name-normalized fallback) + primary artist + album context from each track's nested spotify_data, buckets, and threshold-promotes. Independent of runtime state so it can be unit-tested without the wishlist executor. - ``core/wishlist/processing.py``: when ``current_cycle == 'albums'``, run the grouping helper, submit one batch per album with ``is_album_download=True`` + the group's album/artist context, then a single residual batch for orphans. Singles cycle path unchanged. - 9 new tests in ``test_album_grouping.py`` pin the bucketing contract (empty / single album / multi album / orphan / threshold / nested payloads / no-id fallback / no artist). - 2 new tests in ``test_automation.py`` exercise the per-album split end-to-end through ``process_wishlist_automatically``: multi-album batch → two sub-batches each with album context; mixed orphan + real album → one bundle batch + one residual. 1099 tests across wishlist + imports + downloads + automation + playlist-sources + staging-provenance + track-number-repair suites green. WHATS_NEW entry added under 2.6.3. Now when an auto-wishlist scan finds 12 missing tracks from Ryoto's "Cha-La Head-Cha-La", it runs ONE slskd / Prowlarr album-bundle search for the release instead of 12 per-track searches. --- core/wishlist/album_grouping.py | 201 ++++++++++++++++++++++++++ core/wishlist/processing.py | 142 +++++++++++++----- tests/wishlist/test_album_grouping.py | 159 ++++++++++++++++++++ tests/wishlist/test_automation.py | 99 ++++++++++++- webui/static/helper.js | 1 + 5 files changed, 565 insertions(+), 37 deletions(-) create mode 100644 core/wishlist/album_grouping.py create mode 100644 tests/wishlist/test_album_grouping.py diff --git a/core/wishlist/album_grouping.py b/core/wishlist/album_grouping.py new file mode 100644 index 00000000..148ea263 --- /dev/null +++ b/core/wishlist/album_grouping.py @@ -0,0 +1,201 @@ +"""Wishlist album grouping for the per-album bundle dispatch. + +When the auto-wishlist cycle is ``'albums'`` the user expects each +album with missing tracks to fire ONE album-bundle search instead +of one per-track search per missing track. Track lists in the +wishlist may span multiple albums in one cycle, so we group them +upfront + emit one sub-batch per album. + +Pure function — no IO, no runtime-state dependency — so it can be +unit-tested without standing up the wishlist runner. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +def _extract_track_data(track: Dict[str, Any]) -> Dict[str, Any]: + """Mirror of ``classification._extract_track_data``: unwrap nested + Spotify payloads regardless of which key the wishlist row chose + to stash them under.""" + for key in ("track_data", "spotify_data", "metadata", "track"): + data = track.get(key) + if isinstance(data, str): + try: + data = json.loads(data) + except Exception: + data = {} + if isinstance(data, dict) and data: + nested = ( + data.get("track_data") + or data.get("spotify_data") + or data.get("metadata") + or data.get("track") + ) + if isinstance(nested, str): + try: + nested = json.loads(nested) + except Exception: + nested = {} + if isinstance(nested, dict) and nested: + return nested + return data + return {} + + +def _album_key(spotify_data: Dict[str, Any]) -> Optional[str]: + """Derive a stable grouping key from a track's Spotify metadata. + + Prefers album id (canonical). Falls back to a name-normalized + key when the album row has no id (older wishlist rows can be + missing it). Returns ``None`` when no album information is + available at all — those tracks can't participate in an + album-bundle search and stay on the residual per-track flow. + """ + album = spotify_data.get('album') or {} + if not isinstance(album, dict): + return None + album_id = album.get('id') + if isinstance(album_id, str) and album_id.strip(): + return album_id.strip() + name = album.get('name') + if isinstance(name, str) and name.strip(): + return f"_name_{name.strip().lower()}" + return None + + +def _artist_name_from_track(spotify_data: Dict[str, Any], track: Dict[str, Any]) -> str: + """Pick a primary artist name from the track's metadata. + + Album-bundle search needs an artist string. Prefer the first + Spotify artist (most accurate), fall back to ``track_info['artist']`` + or ``track['artist_name']`` from the wishlist row, then to empty + string (caller will skip the bundle). + """ + artists = spotify_data.get('artists') or [] + if isinstance(artists, list) and artists: + first = artists[0] + if isinstance(first, dict): + name = first.get('name') + if isinstance(name, str) and name.strip(): + return name.strip() + elif isinstance(first, str) and first.strip(): + return first.strip() + for key in ('artist_name', 'artist'): + val = track.get(key) + if isinstance(val, str) and val.strip(): + return val.strip() + return '' + + +@dataclass +class WishlistAlbumGroup: + """One album's worth of wishlist tracks ready for a sub-batch.""" + + album_key: str + album_context: Dict[str, Any] + artist_context: Dict[str, Any] + tracks: List[Dict[str, Any]] = field(default_factory=list) + + +@dataclass +class WishlistGroupingResult: + """Aggregated grouping output. + + - ``album_groups``: one entry per resolvable album. Each carries + enough context to be submitted as an album-bundle batch. + - ``residual_tracks``: tracks that couldn't be grouped (no + album metadata + no artist). They fall through to the normal + per-track flow. + """ + + album_groups: List[WishlistAlbumGroup] = field(default_factory=list) + residual_tracks: List[Dict[str, Any]] = field(default_factory=list) + + +def group_wishlist_tracks_by_album( + tracks: List[Dict[str, Any]], + *, + min_tracks_per_album: int = 1, +) -> WishlistGroupingResult: + """Group wishlist tracks by their owning album. + + ``min_tracks_per_album`` controls the threshold for promoting an + album to its own sub-batch. Default ``1`` means even a single + missing track gets the album-bundle treatment (which is what the + user wants for releases where they only need one track from the + album). Set higher to require multiple missing tracks before + engaging the bundle search. + """ + result = WishlistGroupingResult() + if not tracks: + return result + + # First pass: bucket by album key. + buckets: Dict[str, WishlistAlbumGroup] = {} + unbucketable: List[Dict[str, Any]] = [] + + for track in tracks: + spotify_data = _extract_track_data(track) + key = _album_key(spotify_data) + if key is None: + unbucketable.append(track) + continue + + artist_name = _artist_name_from_track(spotify_data, track) + if not artist_name: + unbucketable.append(track) + continue + + album = spotify_data.get('album') or {} + if not isinstance(album, dict): + album = {} + album_name = album.get('name', '') + if not (isinstance(album_name, str) and album_name.strip()): + unbucketable.append(track) + continue + + group = buckets.get(key) + if group is None: + album_context = { + 'id': album.get('id') or key, + 'name': album_name.strip(), + 'release_date': album.get('release_date', ''), + 'total_tracks': album.get('total_tracks', 0), + 'album_type': album.get('album_type', 'album'), + 'images': album.get('images', []), + 'artists': album.get('artists', []), + } + artist_context = { + 'id': 'wishlist', + 'name': artist_name, + 'genres': [], + } + group = WishlistAlbumGroup( + album_key=key, + album_context=album_context, + artist_context=artist_context, + ) + buckets[key] = group + group.tracks.append(track) + + # Second pass: promote groups meeting the threshold; demote + # smaller groups to residual. + for group in buckets.values(): + if len(group.tracks) >= min_tracks_per_album: + result.album_groups.append(group) + else: + result.residual_tracks.extend(group.tracks) + + result.residual_tracks.extend(unbucketable) + return result + + +__all__ = [ + 'group_wishlist_tracks_by_album', + 'WishlistAlbumGroup', + 'WishlistGroupingResult', +] diff --git a/core/wishlist/processing.py b/core/wishlist/processing.py index c3de917e..9a794098 100644 --- a/core/wishlist/processing.py +++ b/core/wishlist/processing.py @@ -639,45 +639,115 @@ def process_wishlist_automatically(runtime: WishlistAutoProcessingRuntime, autom for i, track in enumerate(wishlist_tracks): track['_original_index'] = i - # Create batch for automatic processing - batch_id = str(uuid.uuid4()) - playlist_name = f"Wishlist (Auto - {current_cycle.capitalize()})" + # When the cycle is 'albums', try to split the wishlist + # into per-album sub-batches so each album fires ONE + # album-bundle search (slskd / torrent / usenet) instead + # of N per-track searches. Residual tracks (no resolvable + # album metadata) fall through to a normal per-track + # batch. Singles cycle keeps its original single-batch + # shape — Spotify already classifies them away from + # albums. + _submitted_batches: list[str] = [] + if current_cycle == 'albums': + from core.wishlist.album_grouping import group_wishlist_tracks_by_album + grouping = group_wishlist_tracks_by_album(wishlist_tracks) + else: + grouping = None - # Create task queue - convert wishlist tracks to expected format - with runtime.tasks_lock: - runtime.download_batches[batch_id] = { - 'phase': 'analysis', - 'playlist_id': playlist_id, - 'playlist_name': playlist_name, - 'queue': [], - 'active_count': 0, - 'max_concurrent': runtime.get_batch_max_concurrent(), # Wishlist always does single-track downloads, not folder grabs - 'queue_index': 0, - 'analysis_total': len(wishlist_tracks), - 'analysis_processed': 0, - 'analysis_results': [], - # Track state management (replicating sync.py) - 'permanently_failed_tracks': [], - 'cancelled_tracks': set(), - # Wishlist tracks are already known-missing — skip the expensive library check - 'force_download_all': True, - # Mark as auto-initiated - 'auto_initiated': True, - 'auto_processing_timestamp': runtime.current_time_fn(), - # Store current cycle for toggling after completion - 'current_cycle': current_cycle, - # Profile context for failed track wishlist re-adds (auto = profile 1 default) - 'profile_id': runtime.profile_id, - } + if grouping and grouping.album_groups: + for album_idx, group in enumerate(grouping.album_groups): + album_batch_id = str(uuid.uuid4()) + album_batch_name = ( + f"Wishlist (Auto - Album: {group.album_context.get('name', 'Unknown')})" + ) + with runtime.tasks_lock: + runtime.download_batches[album_batch_id] = { + 'phase': 'analysis', + 'playlist_id': playlist_id, + 'playlist_name': album_batch_name, + 'queue': [], + 'active_count': 0, + 'max_concurrent': runtime.get_batch_max_concurrent(), + 'queue_index': 0, + 'analysis_total': len(group.tracks), + 'analysis_processed': 0, + 'analysis_results': [], + 'permanently_failed_tracks': [], + 'cancelled_tracks': set(), + 'force_download_all': True, + 'auto_initiated': True, + 'auto_processing_timestamp': runtime.current_time_fn(), + 'current_cycle': current_cycle, + 'profile_id': runtime.profile_id, + # Album-bundle dispatch gate reads these + # three. With them set, the master worker + # routes through slskd / torrent / usenet + # album-bundle search instead of per-track. + 'is_album_download': True, + 'album_context': group.album_context, + 'artist_context': group.artist_context, + } + logger.info( + f"[Auto-Wishlist] Album sub-batch {album_idx + 1}/{len(grouping.album_groups)}: " + f"'{group.album_context.get('name')}' by '{group.artist_context.get('name')}' " + f"({len(group.tracks)} tracks) → {album_batch_id}" + ) + _submitted_batches.append(album_batch_id) + runtime.missing_download_executor.submit( + runtime.run_full_missing_tracks_process, + album_batch_id, playlist_id, group.tracks, + ) - logger.info(f"Starting automatic wishlist batch {batch_id} with {len(wishlist_tracks)} tracks") - runtime.update_automation_progress(automation_id, progress=50, phase=f'Downloading {len(wishlist_tracks)} tracks', - log_line=f'Started batch: {len(wishlist_tracks)} {current_cycle}', log_type='success') + # Residual tracks (no album group could be formed, OR + # singles cycle): one classic per-track batch as before. + residual_tracks = ( + grouping.residual_tracks if grouping is not None else wishlist_tracks + ) + if residual_tracks: + batch_id = str(uuid.uuid4()) + playlist_name = f"Wishlist (Auto - {current_cycle.capitalize()})" + with runtime.tasks_lock: + runtime.download_batches[batch_id] = { + 'phase': 'analysis', + 'playlist_id': playlist_id, + 'playlist_name': playlist_name, + 'queue': [], + 'active_count': 0, + 'max_concurrent': runtime.get_batch_max_concurrent(), + 'queue_index': 0, + 'analysis_total': len(residual_tracks), + 'analysis_processed': 0, + 'analysis_results': [], + 'permanently_failed_tracks': [], + 'cancelled_tracks': set(), + 'force_download_all': True, + 'auto_initiated': True, + 'auto_processing_timestamp': runtime.current_time_fn(), + 'current_cycle': current_cycle, + 'profile_id': runtime.profile_id, + } + _submitted_batches.append(batch_id) + runtime.missing_download_executor.submit( + runtime.run_full_missing_tracks_process, + batch_id, playlist_id, residual_tracks, + ) + logger.info( + f"Starting wishlist residual batch {batch_id} with {len(residual_tracks)} tracks " + f"({'singles' if current_cycle == 'singles' else 'unbucketed albums'})" + ) - # Submit the wishlist processing job using existing infrastructure - runtime.missing_download_executor.submit(runtime.run_full_missing_tracks_process, batch_id, playlist_id, wishlist_tracks) - - # Don't mark auto_processing as False here - let completion handler do it + _summary_parts: list[str] = [] + if grouping and grouping.album_groups: + _summary_parts.append(f"{len(grouping.album_groups)} album batch(es)") + if residual_tracks: + _summary_parts.append(f"{len(residual_tracks)} per-track") + _summary_text = ', '.join(_summary_parts) or 'no batches' + runtime.update_automation_progress( + automation_id, progress=50, + phase=f'Downloading {len(wishlist_tracks)} tracks', + log_line=f'Started: {_summary_text} for cycle {current_cycle}', + log_type='success', + ) except Exception as e: logger.error(f"Error in automatic wishlist processing: {e}") diff --git a/tests/wishlist/test_album_grouping.py b/tests/wishlist/test_album_grouping.py new file mode 100644 index 00000000..9fc3264a --- /dev/null +++ b/tests/wishlist/test_album_grouping.py @@ -0,0 +1,159 @@ +"""Tests for the wishlist-cycle album grouping helper that drives +the per-album bundle dispatch. + +Pins the bucketing contract so future changes to the dispatch flow +don't silently regress the user-visible behavior: wishlist 'albums' +cycle should emit one album-bundle search per missing album, not +one per missing track. +""" + +from __future__ import annotations + +from core.wishlist.album_grouping import ( + WishlistAlbumGroup, + WishlistGroupingResult, + group_wishlist_tracks_by_album, +) + + +def _wt(track_name, artist, album_id, album_name, **extra): + """Build a wishlist row in the shape the wishlist service returns.""" + return { + 'track_name': track_name, + 'artist_name': artist, + 'spotify_data': { + 'name': track_name, + 'artists': [{'name': artist}], + 'album': { + 'id': album_id, + 'name': album_name, + **extra, + }, + }, + } + + +def test_empty_input_returns_empty_result(): + res = group_wishlist_tracks_by_album([]) + assert res.album_groups == [] + assert res.residual_tracks == [] + + +def test_single_album_groups_all_tracks_together(): + tracks = [ + _wt('Dragon Soul', 'Ryoto', 'alb1', 'Cha-La Head-Cha-La'), + _wt('Cha-La Head-Cha-La', 'Ryoto', 'alb1', 'Cha-La Head-Cha-La'), + _wt('Zenkai Power', 'Ryoto', 'alb1', 'Cha-La Head-Cha-La'), + ] + res = group_wishlist_tracks_by_album(tracks) + assert len(res.album_groups) == 1 + g = res.album_groups[0] + assert g.album_key == 'alb1' + assert g.album_context['name'] == 'Cha-La Head-Cha-La' + assert g.artist_context['name'] == 'Ryoto' + assert len(g.tracks) == 3 + + +def test_multiple_albums_emit_separate_groups(): + tracks = [ + _wt('Song A', 'Artist 1', 'alb1', 'Album 1'), + _wt('Song B', 'Artist 1', 'alb1', 'Album 1'), + _wt('Song C', 'Artist 2', 'alb2', 'Album 2'), + ] + res = group_wishlist_tracks_by_album(tracks) + assert len(res.album_groups) == 2 + keys = {g.album_key for g in res.album_groups} + assert keys == {'alb1', 'alb2'} + for g in res.album_groups: + if g.album_key == 'alb1': + assert len(g.tracks) == 2 + else: + assert len(g.tracks) == 1 + + +def test_missing_album_metadata_falls_through_to_residual(): + tracks = [ + # No spotify_data.album at all + {'track_name': 'Orphan', 'artist_name': 'X', 'spotify_data': {'artists': [{'name': 'X'}]}}, + # Empty album dict + {'track_name': 'Empty Album', 'artist_name': 'X', 'spotify_data': {'album': {}, 'artists': [{'name': 'X'}]}}, + ] + res = group_wishlist_tracks_by_album(tracks) + assert res.album_groups == [] + assert len(res.residual_tracks) == 2 + + +def test_missing_artist_demotes_to_residual(): + """Album-bundle search needs an artist; if we can't recover one, + skip the bundle path and let the track go through per-track.""" + tracks = [{ + 'track_name': 'Song', + 'spotify_data': { + 'artists': [], + 'album': {'id': 'a', 'name': 'Album'}, + }, + }] + res = group_wishlist_tracks_by_album(tracks) + assert res.album_groups == [] + assert res.residual_tracks == tracks + + +def test_min_tracks_threshold_demotes_solos(): + """When ``min_tracks_per_album=2``, single-track albums fall to + residual so the user doesn't fire a bundle search for a 1-track + rip when per-track would do.""" + tracks = [ + _wt('Solo Track', 'Artist 1', 'alb1', 'Album 1'), + _wt('Song A', 'Artist 2', 'alb2', 'Album 2'), + _wt('Song B', 'Artist 2', 'alb2', 'Album 2'), + ] + res = group_wishlist_tracks_by_album(tracks, min_tracks_per_album=2) + assert len(res.album_groups) == 1 + assert res.album_groups[0].album_key == 'alb2' + assert len(res.residual_tracks) == 1 + assert res.residual_tracks[0]['track_name'] == 'Solo Track' + + +def test_default_threshold_promotes_solo_albums(): + """Default ``min_tracks_per_album=1`` — even one missing track + triggers the album-bundle path. Matches the user's stated + preference (don't gate on track count).""" + tracks = [_wt('Solo', 'Artist 1', 'alb1', 'Album 1')] + res = group_wishlist_tracks_by_album(tracks) + assert len(res.album_groups) == 1 + assert res.residual_tracks == [] + + +def test_album_without_id_uses_name_normalized_key(): + """Some older wishlist rows are missing the album id. Group by + a name-normalized key so they still bucket together.""" + tracks = [ + _wt('S1', 'Artist', None, 'Same Album'), + _wt('S2', 'Artist', None, 'Same Album'), + ] + # First track has explicit id=None which is filtered; the fallback + # is ``_name_<lowercase trimmed name>``. Build manually so the + # helper sees no id at all. + for t in tracks: + del t['spotify_data']['album']['id'] + res = group_wishlist_tracks_by_album(tracks) + assert len(res.album_groups) == 1 + assert res.album_groups[0].album_key == '_name_same album' + assert len(res.album_groups[0].tracks) == 2 + + +def test_nested_track_data_payloads_normalized(): + """The wishlist service sometimes nests spotify_data under + track_data (JSON-string in DB → re-parsed). Ensure the grouper + digs through the same shapes ``classify_wishlist_track`` does.""" + tracks = [{ + 'track_data': { + 'spotify_data': { + 'artists': [{'name': 'Artist'}], + 'album': {'id': 'a', 'name': 'Album'}, + }, + }, + }] + res = group_wishlist_tracks_by_album(tracks) + assert len(res.album_groups) == 1 + assert res.album_groups[0].album_key == 'a' diff --git a/tests/wishlist/test_automation.py b/tests/wishlist/test_automation.py index fa5fd11d..af9255da 100644 --- a/tests/wishlist/test_automation.py +++ b/tests/wishlist/test_automation.py @@ -233,7 +233,104 @@ def test_process_wishlist_automatically_creates_batch_for_matching_tracks(): assert batch["analysis_total"] == 1 assert any(kwargs.get("progress") == 50 for _args, kwargs in progress_calls) assert guard_events == ["enter", "exit"] - assert any("Starting automatic wishlist batch" in msg for msg in logger.info_messages) + # Track has no album id/name → falls to residual batch path + assert any("Starting wishlist residual batch" in msg for msg in logger.info_messages) + + +def test_wishlist_albums_cycle_splits_into_per_album_batches(): + """Multi-album wishlist run: each album emits its own sub-batch + with ``is_album_download=True`` + populated album/artist context. + Pinned so the album-bundle dispatch gate (which keys on those + fields) engages per album instead of falling through to per-track + on a single mixed batch.""" + batch_map = {} + runtime, _service, _profiles_db, music_db, executor, _logger, _progress, _guards = _build_runtime( + tracks=[ + { + "name": "Song A1", + "artists": [{"name": "Artist 1"}], + "spotify_data": { + "album": {"id": "alb1", "name": "Album One", "album_type": "album"}, + "artists": [{"name": "Artist 1"}], + }, + }, + { + "name": "Song A2", + "artists": [{"name": "Artist 1"}], + "spotify_data": { + "album": {"id": "alb1", "name": "Album One", "album_type": "album"}, + "artists": [{"name": "Artist 1"}], + }, + }, + { + "name": "Song B1", + "artists": [{"name": "Artist 2"}], + "spotify_data": { + "album": {"id": "alb2", "name": "Album Two", "album_type": "album"}, + "artists": [{"name": "Artist 2"}], + }, + }, + ], + cycle_value="albums", + count=3, + batch_map=batch_map, + ) + + process_wishlist_automatically(runtime, automation_id="auto-multi-album") + + # Two album groups → two sub-batches submitted (no residual). + assert len(executor.submissions) == 2 + assert len(batch_map) == 2 + + # Each sub-batch must carry album-bundle dispatch context. + for batch in batch_map.values(): + assert batch.get("is_album_download") is True + assert batch.get("album_context", {}).get("name") in {"Album One", "Album Two"} + assert batch.get("artist_context", {}).get("name") in {"Artist 1", "Artist 2"} + + submitted_track_lists = [submitted_args[2] for _fn, submitted_args, _kw in executor.submissions] + track_counts = sorted(len(tracks) for tracks in submitted_track_lists) + assert track_counts == [1, 2] + + +def test_wishlist_albums_cycle_residual_for_orphan_tracks(): + """Tracks without resolvable album metadata fall to the classic + per-track residual batch (no ``is_album_download`` flag), while + sibling tracks with valid album info still get their own + album-bundle sub-batch.""" + batch_map = {} + runtime, _service, _profiles_db, music_db, executor, _logger, _progress, _guards = _build_runtime( + tracks=[ + { + "name": "Real Album Track", + "artists": [{"name": "Artist 1"}], + "spotify_data": { + "album": {"id": "alb1", "name": "Album One", "album_type": "album"}, + "artists": [{"name": "Artist 1"}], + }, + }, + { + # No album id, no album name — orphan + "name": "Orphan", + "artists": [{"name": "X"}], + "spotify_data": {"album": {"album_type": "album"}, "artists": [{"name": "X"}]}, + }, + ], + cycle_value="albums", + count=2, + batch_map=batch_map, + ) + + process_wishlist_automatically(runtime, automation_id="auto-mixed") + + assert len(executor.submissions) == 2 # 1 album batch + 1 residual + + album_batches = [b for b in batch_map.values() if b.get("is_album_download")] + residual_batches = [b for b in batch_map.values() if not b.get("is_album_download")] + assert len(album_batches) == 1 + assert len(residual_batches) == 1 + assert album_batches[0]["album_context"]["name"] == "Album One" + assert residual_batches[0]["analysis_total"] == 1 def test_process_wishlist_automatically_returns_early_when_already_processing(): diff --git a/webui/static/helper.js b/webui/static/helper.js index ce7a9108..d173c2ba 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3422,6 +3422,7 @@ const WHATS_NEW = { { title: 'Last.fm Radio Sync tab', desc: 'sibling to the ListenBrainz tab — lists your generated Last.fm Radio playlists alongside the rest of the Sync sources. same discovery → mirror flow under the hood, just a different entry point. new Last.fm radios are still generated from the Discover page by picking a seed track; this tab is for syncing existing ones. mirrors auto-trim when Last.fm Radio cache rotates so old radios don\'t pile up.', page: 'sync' }, { title: 'SoulSync Discovery Sync tab', desc: 'last of the unified-tab trio. surfaces your personalized SoulSync Discovery playlists (decade mixes, hidden gems, popular picks, daily mixes, discovery shuffle, etc.) on the Sync page. clicking a card regenerates the playlist + mirrors it under a stable synthetic id, so the same mirror updates in place every Auto-Sync refresh. tracks come out already matched against Spotify / iTunes / Deezer so there\'s no discovery hop — straight to download / sync.', page: 'sync' }, { title: 'Fix: album-bundle downloads all landing as track 1', desc: 'soulseek album-bundle downloads (and any other untagged release-staging path) were importing every track with track_number=1. the staging-file reader was using the auto-import\'s filename extractor that defaults to 1 when no NN- prefix is present in the filename — so for albums like Ryoto\'s "Cha-La Head-Cha-La" where slskd hands you bare titles, every file got "track 1" stamped on it. now the staging path uses a strict extractor that returns 0 when it can\'t see an explicit prefix, so the downstream resolver correctly falls through to the authoritative Spotify metadata and the right track numbers land in the library.' }, + { title: 'Wishlist albums-cycle: one album-bundle search per album', desc: 'auto-wishlist runs in two cycles — albums + singles. previously the albums cycle dumped every missing track from every album into one big batch and ran a separate per-track Soulseek / Prowlarr search for each (~50 searches for a typical scan). now the albums cycle splits the wishlist into per-album sub-batches at submission time, and each one engages the existing slskd / torrent / usenet album-bundle release-first flow with that album\'s context. tracks without resolvable album metadata stay on the classic per-track residual batch so nothing falls off. singles cycle is unchanged.' }, ], '2.6.2': [ { date: 'May 24, 2026 — 2.6.2 release' }, From 7832acba31853b6bf76cc409a579b46219a91ee0 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 21:24:07 -0700 Subject: [PATCH 72/81] Manual wishlist run: also split into per-album sub-batches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase-1 fix (commit c3b88e69) only extended the per-album bundle dispatch to ``process_wishlist_automatically``. The manual "Run Wishlist Now" path goes through ``_prepare_and_run_manual_wishlist_batch`` instead, so the behavior didn't change for users who triggered downloads from the Wishlist tab UI — they still saw N per-track Soulseek searches when N missing tracks all came from one album. Caught in a real-app test: user added Katy Perry's PRISM (Deluxe) to the wishlist + clicked "Download Wishlist" → app log shows ``_prepare_and_run_manual_wishlist_batch:421`` running a single batch with 16 tracks + per-track searches firing one by one ("katy perry prism deluxe legendary lovers", "katy perry prism deluxe roar", etc.), no album-bundle dispatch. Fix: - ``_prepare_and_run_manual_wishlist_batch`` now runs the same ``group_wishlist_tracks_by_album`` helper after filtering. For each detected album, it builds a sub-batch with ``is_album_download=True`` + populated album/artist context. Residual tracks (no resolvable album metadata) land in a single per-track residual batch. - The first sub-batch re-uses the caller-allocated ``batch_id`` so the frontend's existing poll against it keeps working; additional sub-batches get fresh ids materialized into ``download_batches`` so they show up in the Downloads view. - Sub-batches dispatch serially — each ``run_full_missing_tracks_process`` call blocks until the album-bundle staging + per-track tasks complete before the next album's bundle search fires. New test ``test_manual_wishlist_splits_into_per_album_sub_batches`` pins the contract — multi-album wishlist content with nested-spotify_data shape produces N master-worker calls (one per album), each batch carries the album_context, first sub-batch re-uses the original batch_id. 106 wishlist tests + 1099 across the broader suite green. Adding 16 Katy Perry PRISM tracks to wishlist + clicking download should now fire ONE slskd album-bundle search for the release instead of 16 individual searches. --- core/wishlist/processing.py | 107 +++++++++++++++++++++++-- tests/wishlist/test_manual_download.py | 74 +++++++++++++++++ 2 files changed, 174 insertions(+), 7 deletions(-) diff --git a/core/wishlist/processing.py b/core/wishlist/processing.py index 9a794098..0562ed56 100644 --- a/core/wishlist/processing.py +++ b/core/wishlist/processing.py @@ -469,15 +469,108 @@ def _prepare_and_run_manual_wishlist_batch( for i, track in enumerate(wishlist_tracks): track['_original_index'] = i - # Update batch with the real track count now that filtering is done - with runtime.tasks_lock: - if batch_id in runtime.download_batches: - runtime.download_batches[batch_id]['analysis_total'] = len(wishlist_tracks) - runtime.add_activity_item("", "Wishlist Download Started", f"{len(wishlist_tracks)} tracks", "Now") - logger.info(f"Starting wishlist batch {batch_id} with {len(wishlist_tracks)} tracks") - runtime.run_full_missing_tracks_process(batch_id, "wishlist", wishlist_tracks) + # Try to split into per-album sub-batches so each album fires + # ONE slskd / torrent / usenet album-bundle search (gates on + # ``is_album_download`` + populated album/artist context). + # When a single category was requested (or no category filter) + # we apply the same grouping the auto-wishlist path uses. + # Tracks the grouper can't bucket fall through to a residual + # batch with the classic per-track flow. + from core.wishlist.album_grouping import group_wishlist_tracks_by_album + grouping = group_wishlist_tracks_by_album(wishlist_tracks) + + # Build the final payload list (batch_id, tracks, album_context, + # artist_context, is_album). The first payload re-uses the + # caller-allocated ``batch_id`` so the frontend's existing poll + # against it keeps working. Subsequent payloads get fresh ids. + payloads = [] + for group in grouping.album_groups: + payloads.append({ + 'tracks': group.tracks, + 'is_album': True, + 'album_context': group.album_context, + 'artist_context': group.artist_context, + 'display_name': f"Wishlist (Album: {group.album_context.get('name', 'Unknown')})", + }) + if grouping.residual_tracks: + payloads.append({ + 'tracks': grouping.residual_tracks, + 'is_album': False, + 'album_context': None, + 'artist_context': None, + 'display_name': "Wishlist (Residual)", + }) + + if not payloads: + # Nothing to download — clear out the original batch. + with runtime.tasks_lock: + if batch_id in runtime.download_batches: + runtime.download_batches[batch_id]['analysis_total'] = 0 + runtime.download_batches[batch_id]['phase'] = 'complete' + return + + # Attach the original batch_id to the first payload; allocate + # fresh batch_ids for the rest. + payloads[0]['batch_id'] = batch_id + for payload in payloads[1:]: + payload['batch_id'] = str(uuid.uuid4()) + + # Materialize each sub-batch's row state up-front so the + # frontend's polling can see them all under the original + # batch's flow. + with runtime.tasks_lock: + if batch_id in runtime.download_batches: + # Re-purpose the existing row for the first payload. + first = payloads[0] + runtime.download_batches[batch_id]['analysis_total'] = len(first['tracks']) + if first['is_album']: + runtime.download_batches[batch_id]['is_album_download'] = True + runtime.download_batches[batch_id]['album_context'] = first['album_context'] + runtime.download_batches[batch_id]['artist_context'] = first['artist_context'] + runtime.download_batches[batch_id]['playlist_name'] = first['display_name'] + for payload in payloads[1:]: + runtime.download_batches[payload['batch_id']] = { + 'phase': 'analysis', + 'playlist_id': 'wishlist', + 'playlist_name': payload['display_name'], + 'queue': [], + 'active_count': 0, + 'max_concurrent': runtime.get_batch_max_concurrent(), + 'queue_index': 0, + 'analysis_total': len(payload['tracks']), + 'analysis_processed': 0, + 'analysis_results': [], + 'permanently_failed_tracks': [], + 'cancelled_tracks': set(), + 'force_download_all': True, + 'profile_id': runtime.profile_id, + 'is_album_download': bool(payload['is_album']), + 'album_context': payload['album_context'], + 'artist_context': payload['artist_context'], + } + + logger.info( + f"[Manual-Wishlist] Split into {len(payloads)} sub-batch(es) " + f"({sum(1 for p in payloads if p['is_album'])} album + " + f"{sum(1 for p in payloads if not p['is_album'])} residual)" + ) + # Serial dispatch — each album-bundle search happens one at a + # time so the slskd / Prowlarr pipeline doesn't fan out across + # multiple parallel release searches. + for payload in payloads: + label = ( + f"album '{payload['album_context'].get('name')}'" + if payload['is_album'] else 'residual per-track' + ) + logger.info( + f"[Manual-Wishlist] Running sub-batch {payload['batch_id']} " + f"({label}, {len(payload['tracks'])} tracks)" + ) + runtime.run_full_missing_tracks_process( + payload['batch_id'], "wishlist", payload['tracks'], + ) except Exception as exc: logger.error(f"Error preparing manual wishlist batch {batch_id}: {exc}") diff --git a/tests/wishlist/test_manual_download.py b/tests/wishlist/test_manual_download.py index b7a6b35d..36437727 100644 --- a/tests/wishlist/test_manual_download.py +++ b/tests/wishlist/test_manual_download.py @@ -232,6 +232,80 @@ def test_start_manual_wishlist_download_batch_does_not_run_library_cleanup(): assert activity_calls == [("", "Wishlist Download Started", "2 tracks", "Now")] +def test_manual_wishlist_splits_into_per_album_sub_batches(): + """Manual wishlist run with multi-album content splits into one + sub-batch per album. Each sub-batch flips + ``is_album_download=True`` + populates album/artist context so + the slskd / Prowlarr album-bundle dispatch engages. + + Pinned to verify the manual path matches the auto path's + behavior — the user's first real-world test hit the manual + flow, not the auto flow.""" + runtime, _service, _db, executor, _logger, _activity, batch_map, master_calls = _build_runtime( + tracks=[ + { + "id": "trk-a1", + "spotify_track_id": "trk-a1", + "name": "Song A1", + "artists": [{"name": "Artist 1"}], + "spotify_data": { + "album": {"id": "alb1", "name": "Album One", "album_type": "album"}, + "artists": [{"name": "Artist 1"}], + }, + }, + { + "id": "trk-a2", + "spotify_track_id": "trk-a2", + "name": "Song A2", + "artists": [{"name": "Artist 1"}], + "spotify_data": { + "album": {"id": "alb1", "name": "Album One", "album_type": "album"}, + "artists": [{"name": "Artist 1"}], + }, + }, + { + "id": "trk-b1", + "spotify_track_id": "trk-b1", + "name": "Song B1", + "artists": [{"name": "Artist 2"}], + "spotify_data": { + "album": {"id": "alb2", "name": "Album Two", "album_type": "album"}, + "artists": [{"name": "Artist 2"}], + }, + }, + ] + ) + + payload, status = processing.start_manual_wishlist_download_batch(runtime) + assert status == 200 + _run_submitted_bg_job(executor) + + # Two album groups → two master-worker calls. + assert len(master_calls) == 2 + + # First sub-batch uses the caller-allocated batch_id. + first_args, _ = master_calls[0] + assert first_args[0] == payload["batch_id"] + assert batch_map[payload["batch_id"]].get("is_album_download") is True + + # Second sub-batch gets a fresh uuid; its row exists in batch_map. + second_args, _ = master_calls[1] + assert second_args[0] != payload["batch_id"] + assert second_args[0] in batch_map + assert batch_map[second_args[0]].get("is_album_download") is True + + # Track counts across the two sub-batches sum to the wishlist total. + counts = sorted(len(args[2]) for args, _ in master_calls) + assert counts == [1, 2] + + # Both sub-batches carry album context populated from spotify_data. + album_names = { + batch_map[args[0]]["album_context"]["name"] + for args, _ in master_calls + } + assert album_names == {"Album One", "Album Two"} + + def test_bg_job_marks_batch_complete_when_wishlist_genuinely_empty(): """If the wishlist is empty before the manual click, the bg job marks the batch complete.""" runtime, _service, _db, executor, _logger, _activity, batch_map, master_calls = _build_runtime( From c002014f10e18df748c904ad3539a1822febeef9 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 21:50:42 -0700 Subject: [PATCH 73/81] Wishlist: reify run id + gate cycle toggle on last-sibling completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1c.2.1 splits each wishlist invocation into per-album sub- batches so the album-bundle dispatch can engage once per album. Side effect: the completion handler ``finalize_auto_wishlist_completion`` ran end-of-run logic (cycle toggle + state reset + automation event emit) once per BATCH, so a 2-album run fired the cycle toggle twice + emitted two ``wishlist_processing_completed`` events. The cycle landed at the right value either way but the state machine had become per-batch instead of per-run. Fix: reify "wishlist run" as a first-class concept via a shared ``wishlist_run_id`` UUID. Generated once per wishlist invocation in both the auto- and manual-wishlist paths, stamped on every sub-batch row in ``download_batches``. ``finalize_auto_wishlist_completion`` now reads the completing batch's ``wishlist_run_id`` and, when present, scans ``download_batches`` for siblings still in pre-terminal phases. If any sibling is still active, the per-batch summary records but the cycle toggle + state reset + automation emit are deferred. Only the last completing sibling fires the run-level finalization. Legacy single-batch runs (no run_id field) keep their toggle-immediately behavior — back-compat by absence. The run_id also lays groundwork for frontend grouping (one logical row in the Downloads view per wishlist run instead of N sibling rows), but that UX work is deferred. 3 new tests in ``test_processing.py`` pin: defer-when-siblings- active, toggle-when-last-sibling-done, back-compat-without-run_id. 1 new assertion in ``test_automation.py`` confirms all sub-batches of one auto-wishlist invocation share the same run_id. 309 tests across wishlist + automation suites green. Notes: dispatch concurrency unchanged — sub-batches still run via the shared download worker pool. Slskd serializes per-uploader at its own layer (same uploader = automatic queue, different uploaders = legit parallel), so SoulSync-side serial enforcement would duplicate work the right layer already handles. --- core/wishlist/processing.py | 78 +++++++++++++++++++++- tests/wishlist/test_automation.py | 7 ++ tests/wishlist/test_processing.py | 105 ++++++++++++++++++++++++++++++ webui/static/helper.js | 1 + 4 files changed, 188 insertions(+), 3 deletions(-) diff --git a/core/wishlist/processing.py b/core/wishlist/processing.py index 0562ed56..fb2b9ac4 100644 --- a/core/wishlist/processing.py +++ b/core/wishlist/processing.py @@ -183,6 +183,34 @@ def build_wishlist_source_context(batch: Dict[str, Any], current_time: datetime return context +def _wishlist_run_has_siblings_still_active( + download_batches: Dict[str, Dict[str, Any]], + run_id: str, + completing_batch_id: str, +) -> bool: + """Return True if any sibling batch sharing ``run_id`` is still + pre-terminal. + + Used by ``finalize_auto_wishlist_completion`` to gate the run- + level cycle toggle. The caller already holds ``tasks_lock``. + + The completing batch may or may not have its phase flipped to + 'complete' yet by the time we land here; either way we skip it + in the sibling scan since we're handling its completion now.""" + terminal_phases = {'complete', 'error', 'cancelled'} + for sibling_id, sibling in download_batches.items(): + if sibling_id == completing_batch_id: + continue + if not isinstance(sibling, dict): + continue + if sibling.get('wishlist_run_id') != run_id: + continue + if sibling.get('phase') in terminal_phases: + continue + return True + return False + + def finalize_auto_wishlist_completion( batch_id: str, completion_summary: Dict[str, Any], @@ -195,7 +223,17 @@ def finalize_auto_wishlist_completion( db_factory: Callable[[], Any], logger=logger, ) -> Dict[str, Any]: - """Finalize auto wishlist processing after a batch finishes.""" + """Finalize auto wishlist processing after a batch finishes. + + For wishlist runs that split into multiple sub-batches (Phase + 1c.2.1: per-album bundle dispatch), the cycle toggle + state + reset only fire when the LAST sibling sub-batch of the same + ``wishlist_run_id`` completes. Earlier completions just record + their per-batch summary and return without toggling. + + Back-compat: legacy single-batch runs (no ``wishlist_run_id`` + field on the batch) keep the original toggle-immediately + behavior — the gate treats a missing run_id as "lone batch".""" tracks_added = completion_summary.get('tracks_added', 0) total_failed = completion_summary.get('total_failed', 0) logger.error( @@ -205,6 +243,22 @@ def finalize_auto_wishlist_completion( if tracks_added > 0: add_activity_item("", "Wishlist Updated", f"{tracks_added} failed tracks added to wishlist", "Now") + # Run-level gate: if siblings of the same wishlist run are still + # active, defer cycle toggle + state reset until they finish. + with tasks_lock: + run_id = '' + if batch_id in download_batches: + run_id = download_batches[batch_id].get('wishlist_run_id') or '' + siblings_active = bool(run_id) and _wishlist_run_has_siblings_still_active( + download_batches, run_id, batch_id, + ) + if siblings_active: + logger.info( + f"[Auto-Wishlist] Sub-batch {batch_id[:8]} done; waiting on sibling sub-batches " + f"of run {run_id[:8]} before toggling cycle" + ) + return completion_summary + try: with tasks_lock: if batch_id in download_batches: @@ -517,6 +571,13 @@ def _prepare_and_run_manual_wishlist_batch( for payload in payloads[1:]: payload['batch_id'] = str(uuid.uuid4()) + # Reify "wishlist run" — one shared id stamped on every sub- + # batch this manual invocation produces. Mirrors the auto + # path. Note manual wishlist completion currently doesn't + # toggle the cycle (only auto does), but the id is set anyway + # so future code + UI grouping have a consistent hook. + wishlist_run_id = str(uuid.uuid4()) + # Materialize each sub-batch's row state up-front so the # frontend's polling can see them all under the original # batch's flow. @@ -525,6 +586,7 @@ def _prepare_and_run_manual_wishlist_batch( # Re-purpose the existing row for the first payload. first = payloads[0] runtime.download_batches[batch_id]['analysis_total'] = len(first['tracks']) + runtime.download_batches[batch_id]['wishlist_run_id'] = wishlist_run_id if first['is_album']: runtime.download_batches[batch_id]['is_album_download'] = True runtime.download_batches[batch_id]['album_context'] = first['album_context'] @@ -549,6 +611,7 @@ def _prepare_and_run_manual_wishlist_batch( 'is_album_download': bool(payload['is_album']), 'album_context': payload['album_context'], 'artist_context': payload['artist_context'], + 'wishlist_run_id': wishlist_run_id, } logger.info( @@ -747,6 +810,12 @@ def process_wishlist_automatically(runtime: WishlistAutoProcessingRuntime, autom else: grouping = None + # Reify "wishlist run" — one shared id stamped on every + # sub-batch this invocation produces. The completion + # handler uses it to gate the once-per-run cycle toggle + # (so it doesn't fire N times for N sub-batches). + wishlist_run_id = str(uuid.uuid4()) + if grouping and grouping.album_groups: for album_idx, group in enumerate(grouping.album_groups): album_batch_id = str(uuid.uuid4()) @@ -779,11 +848,12 @@ def process_wishlist_automatically(runtime: WishlistAutoProcessingRuntime, autom 'is_album_download': True, 'album_context': group.album_context, 'artist_context': group.artist_context, + 'wishlist_run_id': wishlist_run_id, } logger.info( f"[Auto-Wishlist] Album sub-batch {album_idx + 1}/{len(grouping.album_groups)}: " f"'{group.album_context.get('name')}' by '{group.artist_context.get('name')}' " - f"({len(group.tracks)} tracks) → {album_batch_id}" + f"({len(group.tracks)} tracks) → {album_batch_id} [run {wishlist_run_id[:8]}]" ) _submitted_batches.append(album_batch_id) runtime.missing_download_executor.submit( @@ -818,6 +888,7 @@ def process_wishlist_automatically(runtime: WishlistAutoProcessingRuntime, autom 'auto_processing_timestamp': runtime.current_time_fn(), 'current_cycle': current_cycle, 'profile_id': runtime.profile_id, + 'wishlist_run_id': wishlist_run_id, } _submitted_batches.append(batch_id) runtime.missing_download_executor.submit( @@ -826,7 +897,8 @@ def process_wishlist_automatically(runtime: WishlistAutoProcessingRuntime, autom ) logger.info( f"Starting wishlist residual batch {batch_id} with {len(residual_tracks)} tracks " - f"({'singles' if current_cycle == 'singles' else 'unbucketed albums'})" + f"({'singles' if current_cycle == 'singles' else 'unbucketed albums'}) " + f"[run {wishlist_run_id[:8]}]" ) _summary_parts: list[str] = [] diff --git a/tests/wishlist/test_automation.py b/tests/wishlist/test_automation.py index af9255da..2d01a7ab 100644 --- a/tests/wishlist/test_automation.py +++ b/tests/wishlist/test_automation.py @@ -292,6 +292,13 @@ def test_wishlist_albums_cycle_splits_into_per_album_batches(): track_counts = sorted(len(tracks) for tracks in submitted_track_lists) assert track_counts == [1, 2] + # All sub-batches of one wishlist invocation share a single + # ``wishlist_run_id`` so the completion handler can gate the + # cycle toggle on "all siblings done". + run_ids = {batch.get("wishlist_run_id") for batch in batch_map.values()} + assert len(run_ids) == 1 + assert next(iter(run_ids)) # non-empty string + def test_wishlist_albums_cycle_residual_for_orphan_tracks(): """Tracks without resolvable album metadata fall to the classic diff --git a/tests/wishlist/test_processing.py b/tests/wishlist/test_processing.py index 0b2f9080..3fcc2104 100644 --- a/tests/wishlist/test_processing.py +++ b/tests/wishlist/test_processing.py @@ -182,6 +182,111 @@ def test_recover_uncaptured_failed_tracks_builds_entries(): assert failed[0]["failure_reason"] == "boom" +def test_finalize_auto_wishlist_completion_defers_toggle_when_siblings_active(): + """When the completing batch shares a ``wishlist_run_id`` with + siblings still in pre-terminal phases, finalize must NOT toggle + the cycle yet — that only happens when the LAST sibling done. + Pinned to prevent the regression where every sub-batch's + completion fired its own cycle toggle (Phase 1c.2.1 split path).""" + db = _FakeDB() + automation_engine = _FakeAutomationEngine() + resets = [] + activities = [] + summary = {"tracks_added": 1, "total_failed": 1, "errors": 0} + + # Two sub-batches share the same run id. The first finishes, + # the second is still 'analysis'. + download_batches = { + "batch-A": {"current_cycle": "albums", "wishlist_run_id": "run-1", "phase": "complete"}, + "batch-B": {"current_cycle": "albums", "wishlist_run_id": "run-1", "phase": "analysis"}, + } + + processing.finalize_auto_wishlist_completion( + "batch-A", + summary, + download_batches=download_batches, + tasks_lock=_FakeLock(), + reset_processing_state=lambda: resets.append(True), + add_activity_item=lambda *args: activities.append(args), + automation_engine=automation_engine, + db_factory=lambda: db, + logger=_FakeLogger(), + ) + + # Activity log still fires (it's a per-batch record), but cycle + # toggle + state reset + automation emit are deferred. + assert activities == [("", "Wishlist Updated", "1 failed tracks added to wishlist", "Now")] + assert resets == [] # NOT reset yet — siblings still active + assert automation_engine.events == [] # NOT emitted yet + assert db.connection.cursor_obj.calls == [] # DB cycle-toggle NOT written + + +def test_finalize_auto_wishlist_completion_toggles_when_last_sibling_done(): + """When all siblings of the same run are in terminal phases (or + don't exist), the completing batch IS the last → cycle toggles + + state resets + automation event fires.""" + db = _FakeDB() + automation_engine = _FakeAutomationEngine() + resets = [] + summary = {"tracks_added": 1, "total_failed": 1, "errors": 0} + + download_batches = { + # Both siblings already terminal — current batch is the last. + "batch-A": {"current_cycle": "albums", "wishlist_run_id": "run-1", "phase": "complete"}, + "batch-B": {"current_cycle": "albums", "wishlist_run_id": "run-1", "phase": "complete"}, + "batch-C": {"current_cycle": "albums", "wishlist_run_id": "run-1", "phase": "analysis"}, # the completing one + } + + processing.finalize_auto_wishlist_completion( + "batch-C", + summary, + download_batches=download_batches, + tasks_lock=_FakeLock(), + reset_processing_state=lambda: resets.append(True), + add_activity_item=lambda *_a: None, + automation_engine=automation_engine, + db_factory=lambda: db, + logger=_FakeLogger(), + ) + + assert resets == [True] + assert db.connection.committed is True + assert db.connection.cursor_obj.calls[0][1] == ("singles",) + assert automation_engine.events # event emitted + + +def test_finalize_auto_wishlist_completion_legacy_no_run_id_toggles_immediately(): + """Back-compat: a batch with NO ``wishlist_run_id`` (legacy + single-batch run from before Phase 1c.2.1) should keep firing + the toggle on its own completion regardless of any unrelated + batches in the dict.""" + db = _FakeDB() + automation_engine = _FakeAutomationEngine() + resets = [] + summary = {"tracks_added": 0, "total_failed": 0, "errors": 0} + + download_batches = { + "batch-legacy": {"current_cycle": "albums"}, # no wishlist_run_id + # Even with another unrelated batch active, legacy should toggle. + "unrelated": {"current_cycle": "singles", "phase": "analysis"}, + } + + processing.finalize_auto_wishlist_completion( + "batch-legacy", + summary, + download_batches=download_batches, + tasks_lock=_FakeLock(), + reset_processing_state=lambda: resets.append(True), + add_activity_item=lambda *_a: None, + automation_engine=automation_engine, + db_factory=lambda: db, + logger=_FakeLogger(), + ) + + assert resets == [True] + assert db.connection.cursor_obj.calls[0][1] == ("singles",) + + def test_finalize_auto_wishlist_completion_toggles_cycle_and_resets_state(): db = _FakeDB() automation_engine = _FakeAutomationEngine() diff --git a/webui/static/helper.js b/webui/static/helper.js index d173c2ba..e8a1cd32 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3423,6 +3423,7 @@ const WHATS_NEW = { { title: 'SoulSync Discovery Sync tab', desc: 'last of the unified-tab trio. surfaces your personalized SoulSync Discovery playlists (decade mixes, hidden gems, popular picks, daily mixes, discovery shuffle, etc.) on the Sync page. clicking a card regenerates the playlist + mirrors it under a stable synthetic id, so the same mirror updates in place every Auto-Sync refresh. tracks come out already matched against Spotify / iTunes / Deezer so there\'s no discovery hop — straight to download / sync.', page: 'sync' }, { title: 'Fix: album-bundle downloads all landing as track 1', desc: 'soulseek album-bundle downloads (and any other untagged release-staging path) were importing every track with track_number=1. the staging-file reader was using the auto-import\'s filename extractor that defaults to 1 when no NN- prefix is present in the filename — so for albums like Ryoto\'s "Cha-La Head-Cha-La" where slskd hands you bare titles, every file got "track 1" stamped on it. now the staging path uses a strict extractor that returns 0 when it can\'t see an explicit prefix, so the downstream resolver correctly falls through to the authoritative Spotify metadata and the right track numbers land in the library.' }, { title: 'Wishlist albums-cycle: one album-bundle search per album', desc: 'auto-wishlist runs in two cycles — albums + singles. previously the albums cycle dumped every missing track from every album into one big batch and ran a separate per-track Soulseek / Prowlarr search for each (~50 searches for a typical scan). now the albums cycle splits the wishlist into per-album sub-batches at submission time, and each one engages the existing slskd / torrent / usenet album-bundle release-first flow with that album\'s context. tracks without resolvable album metadata stay on the classic per-track residual batch so nothing falls off. singles cycle is unchanged.' }, + { title: 'Wishlist cycle toggles once per run, not per sub-batch', desc: 'follow-up to the per-album split above. with N sub-batches per wishlist run, the cycle toggle (albums → singles or vice versa) was firing once per sub-batch as each one completed — so the cycle could flip mid-run and the automation completion event got emitted multiple times. each wishlist invocation now stamps every sub-batch with a shared run id; the completion handler waits until the LAST sibling finishes before toggling the cycle + emitting the run-complete event. cleaner state machine, future-proofs frontend grouping of sub-batches under one logical run.' }, ], '2.6.2': [ { date: 'May 24, 2026 — 2.6.2 release' }, From 7f751202d2d1c66d4f53257b208cdc3e7fcc5438 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 22:17:04 -0700 Subject: [PATCH 74/81] Wishlist modal: merge sibling sub-batches into one status response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1c.2.1 splits each wishlist run across multiple ``download_batches`` rows (per-album bundle dispatch). The download-missing modal opens against the original batch_id allocated by ``start_manual_wishlist_download_batch`` / ``process_wishlist_automatically``. Pre-fix that batch_id was just one sibling among N, so the modal went stale as soon as the primary sub-batch finished — subsequent albums downloaded fine but no live status reached the UI. Fix: backend merges every sibling sub-batch's tasks + analysis_results into the response keyed under the originally- requested batch_id. Modal sees one unified view of the whole run without knowing about the split. Frontend untouched. Architecture (Kettui standards): - ``core/downloads/wishlist_aggregator.py`` — pure ``merge_wishlist_run_status(primary, siblings)`` helper. No IO, no runtime state, no globals. Lifted out of ``status.py`` so the merge contract can be pinned via unit tests without standing up the live ``download_batches`` / ``download_tasks`` state. - ``core/downloads/status.py``'s ``build_batched_status`` now pre-indexes ``download_batches`` by ``wishlist_run_id`` inside the existing ``tasks_lock`` snapshot, then runs the merge helper whenever a requested batch has a sibling. Merge rules pinned by 12 tests: - ``track_index`` re-indexed globally 0..N-1 across the merged ``analysis_results`` so the modal's ``data-track-index`` DOM keys don't collide between siblings. Tasks' ``track_index`` follows the same remap so the analysis-results ↔ tasks cross-reference stays intact. - ``task_id`` is uuid per task — no collision concern. - Phase: error is sticky; otherwise the LEAST-complete pre-terminal phase wins (analysis < album_downloading < downloading). All-complete returns ``complete``; mixed complete + active returns ``downloading`` so the modal stays alive until every sibling lands. - ``album_bundle``: picks whichever sibling currently has an active bundle download (state in ``{searching, downloading, downloading_release, staging}``). Falls back to the first non-empty bundle so a completed run still shows a progress bar. - ``analysis_progress`` summed across siblings. - ``active_count`` summed; ``max_concurrent`` keeps primary's value as the representative. - ``playlist_id`` + ``playlist_name`` preserved from the primary (the row the modal originally opened against). Legacy single-batch wishlist runs (no ``wishlist_run_id`` on the batch) skip the merge entirely — passthrough. Back-compat by absence. 1108 tests across downloads + wishlist + automation + imports + playlist-sources + lb-series suites green. 12 new aggregator tests pin the merge contract. Closes the open UX gap from the Phase 1c.2.1 ship — modal now tracks every sibling sub-batch's progress for the full duration of the wishlist run. --- core/downloads/status.py | 51 +++++- core/downloads/wishlist_aggregator.py | 186 ++++++++++++++++++++ tests/downloads/test_wishlist_aggregator.py | 168 ++++++++++++++++++ webui/static/helper.js | 1 + 4 files changed, 404 insertions(+), 2 deletions(-) create mode 100644 core/downloads/wishlist_aggregator.py create mode 100644 tests/downloads/test_wishlist_aggregator.py diff --git a/core/downloads/status.py b/core/downloads/status.py index 7f2230bd..41229e37 100644 --- a/core/downloads/status.py +++ b/core/downloads/status.py @@ -476,7 +476,16 @@ def build_single_batch_status(batch_id: str, deps: StatusDeps) -> tuple[Optional def build_batched_status(requested_batch_ids: list, deps: StatusDeps) -> dict: - """For /api/download_status/batch. Returns the full response dict (always 200).""" + """For /api/download_status/batch. Returns the full response dict (always 200). + + When a requested batch carries a ``wishlist_run_id`` (Phase 1c.2.1 + per-album split), the response merges in every sibling sub-batch + of the same run via ``merge_wishlist_run_status``. The merged view + lands keyed under the originally-requested ``batch_id`` so the + frontend modal (which polls one batch id) sees every sibling's + tasks + progress without needing to know about the split.""" + from core.downloads.wishlist_aggregator import merge_wishlist_run_status + live_transfers_lookup = deps.get_cached_transfer_data() response: dict[str, Any] = {"batches": {}} @@ -489,11 +498,49 @@ def build_batched_status(requested_batch_ids: list, deps: StatusDeps) -> dict: else: target_batches = download_batches.copy() + # Pre-index sibling batch ids by wishlist_run_id so the per- + # batch loop below can find them in O(1). Snapshot under the + # held lock; subsequent dict mutations don't matter for this + # build. + run_id_to_batch_ids: dict[str, list[str]] = {} + for bid, batch_row in download_batches.items(): + run_id = (batch_row or {}).get('wishlist_run_id') if isinstance(batch_row, dict) else None + if run_id: + run_id_to_batch_ids.setdefault(str(run_id), []).append(bid) + for batch_id, batch in target_batches.items(): try: - response["batches"][batch_id] = build_batch_status_data( + primary_status = build_batch_status_data( batch_id, batch, live_transfers_lookup, deps, ) + + # Wishlist-run merge — kicks in only when this batch + # has a run_id AND at least one sibling exists. Falls + # through to legacy single-batch shape otherwise. + run_id = batch.get('wishlist_run_id') if isinstance(batch, dict) else None + sibling_ids = run_id_to_batch_ids.get(str(run_id), []) if run_id else [] + if run_id and len(sibling_ids) > 1: + sibling_statuses = [] + for sib_id in sibling_ids: + if sib_id == batch_id: + continue + sib_batch = download_batches.get(sib_id) + if not isinstance(sib_batch, dict): + continue + try: + sibling_statuses.append( + build_batch_status_data( + sib_id, sib_batch, live_transfers_lookup, deps, + ) + ) + except Exception as sib_err: + logger.warning( + f"[Wishlist Run] Sibling status build failed for {sib_id}: {sib_err}" + ) + merged = merge_wishlist_run_status(primary_status, sibling_statuses) + response["batches"][batch_id] = merged + else: + response["batches"][batch_id] = primary_status except Exception as batch_error: logger.error(f"Error processing batch {batch_id}: {batch_error}") response["batches"][batch_id] = {"error": str(batch_error)} diff --git a/core/downloads/wishlist_aggregator.py b/core/downloads/wishlist_aggregator.py new file mode 100644 index 00000000..25e8bd90 --- /dev/null +++ b/core/downloads/wishlist_aggregator.py @@ -0,0 +1,186 @@ +"""Merge sibling download_batches statuses into one view for the +wishlist-run model. + +When the wishlist runs are split into per-album sub-batches +(Phase 1c.2.1), the frontend modal polls the ORIGINAL batch id +allocated by ``start_manual_wishlist_download_batch`` / +``process_wishlist_automatically``. That batch id is now just one +sibling among N. Without merging, the modal goes blank after the +first sibling finishes because subsequent siblings live under +fresh batch ids the modal never learned about. + +This module is the merge layer: pure function, no IO, no runtime +state. ``build_batched_status`` in ``core/downloads/status.py`` +calls into it when a requested batch has ``wishlist_run_id`` set +and at least one sibling exists. + +Design notes: + +- ``track_index`` re-indexed to a global 0..N-1 across the merged + results so the modal's ``data-track-index`` DOM keys don't + collide between siblings (each sibling locally starts at 0). + Tasks reference their analysis result via track_index, so the + remap is applied to tasks too. +- ``task_id`` is a uuid per task — no collision concern across + siblings. +- Phase aggregation surfaces the LEAST-complete pre-terminal phase + so the modal stays "alive" until every sibling is done. Sticky + ``error`` so failures don't get hidden by a running sibling. +- ``album_bundle`` is picked from whichever sibling currently has + an active bundle download — gives the user a useful progress + bar even when the primary sibling is past its bundle stage. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + + +_PHASE_PRIORITY = ( + 'analysis', + 'album_downloading', + 'downloading', + 'complete', +) +_ACTIVE_BUNDLE_STATES = frozenset({ + 'searching', + 'downloading', + 'downloading_release', + 'staging', +}) + + +def _aggregate_phases(phases: List[str]) -> str: + """Pick the merged phase for a multi-sibling wishlist run. + + Rules: + - ``error`` is sticky — if any sibling errored, surface error. + - Otherwise return the LEAST-complete pre-terminal phase in + priority order (analysis < album_downloading < downloading + < complete). + - If all siblings are ``complete``, return ``complete``. + - Fallback to the first non-empty phase if nothing matches a + known priority. + """ + phases = [p for p in phases if p] + if not phases: + return 'unknown' + if 'error' in phases: + return 'error' + for p in _PHASE_PRIORITY: + if p in phases: + if p == 'complete': + return 'complete' if all(s == 'complete' for s in phases) else 'downloading' + return p + return phases[0] + + +def _pick_active_album_bundle(statuses: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """Return the album_bundle of whichever sibling is currently + staging or downloading. Falls back to the first non-empty + bundle when nothing is active (so a completed bundle still + shows up vs. a totally empty progress bar).""" + fallback = None + for s in statuses: + bundle = s.get('album_bundle') + if not bundle: + continue + if fallback is None: + fallback = bundle + state = (bundle.get('state') or '').lower() + if state in _ACTIVE_BUNDLE_STATES: + return bundle + return fallback + + +def merge_wishlist_run_status( + primary: Dict[str, Any], + siblings: List[Dict[str, Any]], +) -> Dict[str, Any]: + """Return a status dict that merges ``siblings`` into ``primary``. + + Empty ``siblings`` is the legacy single-batch case — primary + is returned unchanged. + + The returned dict has the same shape as a single-batch status + response from ``build_batch_status_data`` so the frontend + modal needs no changes to consume it. Tracks and tasks are + re-indexed globally; phase + progress + active_count + aggregated across the run. + """ + if not siblings: + return primary + + all_statuses = [primary] + list(siblings) + + # Phase aggregation. + merged_phase = _aggregate_phases([s.get('phase', '') for s in all_statuses]) + + # Analysis progress — sum across siblings. + total = 0 + processed = 0 + has_progress = False + for s in all_statuses: + ap = s.get('analysis_progress') + if isinstance(ap, dict): + total += int(ap.get('total') or 0) + processed += int(ap.get('processed') or 0) + has_progress = True + + # Analysis results — concat + re-index. Build a (batch_obj_id, + # old_track_index) -> new_track_index map so tasks can be + # re-indexed consistently. + merged_results: List[Dict[str, Any]] = [] + track_index_remap: Dict[tuple, int] = {} + next_index = 0 + for s in all_statuses: + batch_ref = id(s) + for r in (s.get('analysis_results') or []): + old_idx = int(r.get('track_index') or 0) + track_index_remap[(batch_ref, old_idx)] = next_index + new_r = dict(r) + new_r['track_index'] = next_index + merged_results.append(new_r) + next_index += 1 + + # Tasks — concat + re-index using the remap above. Tasks + # without a remapped entry keep their original track_index + # (defensive — shouldn't happen if analysis_results is + # consistent with the task list). + merged_tasks: List[Dict[str, Any]] = [] + for s in all_statuses: + batch_ref = id(s) + for t in (s.get('tasks') or []): + old_idx = int(t.get('track_index') or 0) + new_t = dict(t) + new_t['track_index'] = track_index_remap.get((batch_ref, old_idx), old_idx) + merged_tasks.append(new_t) + merged_tasks.sort(key=lambda x: x.get('track_index', 0)) + + # Album bundle — pick the active sibling's, fall back to first + # bundle present, omit if none. + merged_bundle = _pick_active_album_bundle(all_statuses) + + # Worker accounting — sum active_count across siblings so the + # modal's overall download progress display reflects total + # in-flight work; max_concurrent stays from primary as + # representative. + active_total = sum(int(s.get('active_count') or 0) for s in all_statuses) + + merged = dict(primary) # keeps playlist_id, playlist_name, error, etc. + merged['phase'] = merged_phase + if has_progress: + merged['analysis_progress'] = {'total': total, 'processed': processed} + merged['analysis_results'] = merged_results + if merged_tasks or 'tasks' in primary: + merged['tasks'] = merged_tasks + if merged_bundle: + merged['album_bundle'] = merged_bundle + elif 'album_bundle' in primary: + merged['album_bundle'] = primary['album_bundle'] + merged['active_count'] = active_total + + return merged + + +__all__ = ['merge_wishlist_run_status'] diff --git a/tests/downloads/test_wishlist_aggregator.py b/tests/downloads/test_wishlist_aggregator.py new file mode 100644 index 00000000..c6cf90d5 --- /dev/null +++ b/tests/downloads/test_wishlist_aggregator.py @@ -0,0 +1,168 @@ +"""Unit tests for ``core/downloads/wishlist_aggregator.merge_wishlist_run_status``. + +Pins the merge contract the wishlist-modal status path depends on +(Phase 1c.2.1 follow-up): when one logical wishlist run is split +across N sub-batches, the frontend modal polls the original +batch_id and expects a unified view that covers every sibling. +""" + +from __future__ import annotations + +from core.downloads.wishlist_aggregator import merge_wishlist_run_status + + +def _status(phase, **kwargs): + """Build a minimal per-batch status dict shaped like + ``build_batch_status_data``'s output.""" + base = { + 'phase': phase, + 'playlist_id': 'wishlist', + 'playlist_name': 'Wishlist', + 'active_count': 0, + 'max_concurrent': 3, + } + base.update(kwargs) + return base + + +def test_empty_siblings_returns_primary_unchanged(): + primary = _status('downloading', tasks=[{'task_id': 't1', 'track_index': 0}]) + out = merge_wishlist_run_status(primary, []) + assert out is primary + + +def test_two_siblings_merge_tasks_with_reindexed_track_index(): + """Both siblings locally start at track_index 0 — after merge, + indices are globally unique 0..N-1.""" + primary = _status( + 'downloading', + analysis_results=[ + {'track_index': 0, 'track': {'name': 'A1'}, 'found': False, 'confidence': 0.0}, + {'track_index': 1, 'track': {'name': 'A2'}, 'found': False, 'confidence': 0.0}, + ], + tasks=[ + {'task_id': 'task-a1', 'track_index': 0, 'status': 'downloading'}, + {'task_id': 'task-a2', 'track_index': 1, 'status': 'downloading'}, + ], + ) + sibling = _status( + 'downloading', + analysis_results=[ + {'track_index': 0, 'track': {'name': 'B1'}, 'found': False, 'confidence': 0.0}, + ], + tasks=[ + {'task_id': 'task-b1', 'track_index': 0, 'status': 'searching'}, + ], + ) + + merged = merge_wishlist_run_status(primary, [sibling]) + + # Three globally-unique track indices. + assert [r['track_index'] for r in merged['analysis_results']] == [0, 1, 2] + # Each task's track_index re-indexed to match its analysis_result. + indices_by_task = {t['task_id']: t['track_index'] for t in merged['tasks']} + assert indices_by_task == {'task-a1': 0, 'task-a2': 1, 'task-b1': 2} + # Tasks sorted by their new track_index. + assert [t['task_id'] for t in merged['tasks']] == ['task-a1', 'task-a2', 'task-b1'] + + +def test_phase_aggregation_least_complete_pre_terminal_wins(): + """analysis + downloading + complete → analysis.""" + primary = _status('complete') + sibling1 = _status('downloading') + sibling2 = _status('analysis') + merged = merge_wishlist_run_status(primary, [sibling1, sibling2]) + assert merged['phase'] == 'analysis' + + +def test_phase_aggregation_album_downloading_wins_over_downloading(): + primary = _status('downloading') + sibling = _status('album_downloading') + merged = merge_wishlist_run_status(primary, [sibling]) + assert merged['phase'] == 'album_downloading' + + +def test_phase_aggregation_all_complete_returns_complete(): + primary = _status('complete') + sibling1 = _status('complete') + merged = merge_wishlist_run_status(primary, [sibling1]) + assert merged['phase'] == 'complete' + + +def test_phase_aggregation_mixed_complete_and_other_returns_downloading(): + """A finished sibling alongside a still-downloading sibling + surfaces 'downloading' (the run isn't done).""" + primary = _status('complete') + sibling = _status('downloading') + merged = merge_wishlist_run_status(primary, [sibling]) + assert merged['phase'] == 'downloading' + + +def test_phase_aggregation_error_is_sticky(): + """If any sibling errored, the merged phase is 'error' even + if other siblings are still running. Modal should show the + failure so the user notices.""" + primary = _status('downloading') + sibling = _status('error') + merged = merge_wishlist_run_status(primary, [sibling]) + assert merged['phase'] == 'error' + + +def test_analysis_progress_summed_across_siblings(): + primary = _status( + 'analysis', + analysis_progress={'total': 10, 'processed': 7}, + ) + sibling = _status( + 'analysis', + analysis_progress={'total': 5, 'processed': 2}, + ) + merged = merge_wishlist_run_status(primary, [sibling]) + assert merged['analysis_progress'] == {'total': 15, 'processed': 9} + + +def test_album_bundle_picks_active_sibling_over_idle(): + """Primary is past its bundle stage (state='staged'); + sibling is currently downloading_release. Merge surfaces the + active sibling's bundle so the progress bar stays useful.""" + primary = _status( + 'downloading', + album_bundle={'state': 'staged', 'progress': 100, 'release': 'PRISM (Deluxe)'}, + ) + sibling = _status( + 'album_downloading', + album_bundle={'state': 'downloading_release', 'progress': 42, 'release': '1432'}, + ) + merged = merge_wishlist_run_status(primary, [sibling]) + assert merged['album_bundle']['release'] == '1432' + assert merged['album_bundle']['progress'] == 42 + + +def test_album_bundle_falls_back_when_no_active_sibling(): + primary = _status( + 'complete', + album_bundle={'state': 'staged', 'progress': 100, 'release': 'PRISM (Deluxe)'}, + ) + sibling = _status( + 'complete', + album_bundle={'state': 'staged', 'progress': 100, 'release': '1432'}, + ) + merged = merge_wishlist_run_status(primary, [sibling]) + # Falls back to primary's bundle (first non-empty). + assert merged['album_bundle']['release'] == 'PRISM (Deluxe)' + + +def test_active_count_summed_across_siblings(): + primary = _status('downloading', active_count=2) + sibling = _status('downloading', active_count=1) + merged = merge_wishlist_run_status(primary, [sibling]) + assert merged['active_count'] == 3 + + +def test_primary_playlist_id_preserved(): + primary = _status('downloading', playlist_id='wishlist', playlist_name='Wishlist (Auto)') + sibling = _status('downloading', playlist_id='wishlist', playlist_name='Wishlist (Album: 1432)') + merged = merge_wishlist_run_status(primary, [sibling]) + # Primary's playlist_name + playlist_id propagate (it's the row the modal opened against). + assert merged['playlist_id'] == 'wishlist' + assert merged['playlist_name'] == 'Wishlist (Auto)' diff --git a/webui/static/helper.js b/webui/static/helper.js index e8a1cd32..be2d1d1a 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3424,6 +3424,7 @@ const WHATS_NEW = { { title: 'Fix: album-bundle downloads all landing as track 1', desc: 'soulseek album-bundle downloads (and any other untagged release-staging path) were importing every track with track_number=1. the staging-file reader was using the auto-import\'s filename extractor that defaults to 1 when no NN- prefix is present in the filename — so for albums like Ryoto\'s "Cha-La Head-Cha-La" where slskd hands you bare titles, every file got "track 1" stamped on it. now the staging path uses a strict extractor that returns 0 when it can\'t see an explicit prefix, so the downstream resolver correctly falls through to the authoritative Spotify metadata and the right track numbers land in the library.' }, { title: 'Wishlist albums-cycle: one album-bundle search per album', desc: 'auto-wishlist runs in two cycles — albums + singles. previously the albums cycle dumped every missing track from every album into one big batch and ran a separate per-track Soulseek / Prowlarr search for each (~50 searches for a typical scan). now the albums cycle splits the wishlist into per-album sub-batches at submission time, and each one engages the existing slskd / torrent / usenet album-bundle release-first flow with that album\'s context. tracks without resolvable album metadata stay on the classic per-track residual batch so nothing falls off. singles cycle is unchanged.' }, { title: 'Wishlist cycle toggles once per run, not per sub-batch', desc: 'follow-up to the per-album split above. with N sub-batches per wishlist run, the cycle toggle (albums → singles or vice versa) was firing once per sub-batch as each one completed — so the cycle could flip mid-run and the automation completion event got emitted multiple times. each wishlist invocation now stamps every sub-batch with a shared run id; the completion handler waits until the LAST sibling finishes before toggling the cycle + emitting the run-complete event. cleaner state machine, future-proofs frontend grouping of sub-batches under one logical run.' }, + { title: 'Wishlist download modal: keeps tracking after first album', desc: 'with the per-album sub-batch split, the modal\'s polling kept hitting the original batch id only and went stale as soon as the first sub-batch finished — subsequent albums downloaded fine but the modal\'s rows stopped updating. backend now merges every sibling sub-batch\'s tasks + analysis results into the response under the original batch id (re-indexes track positions globally so the table doesn\'t collide on row keys, aggregates phase across siblings, picks whichever bundle is currently active). frontend untouched — the modal sees one unified view of the whole run.' }, ], '2.6.2': [ { date: 'May 24, 2026 — 2.6.2 release' }, From 4555ff7eb9ac79cada33467971f86a4833ed2057 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 22:35:53 -0700 Subject: [PATCH 75/81] Wishlist modal: surface most-advanced live phase, not least-complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sibling-merge aggregator from 7f751202 used "least-complete phase wins", which made the modal appear frozen during parallel album bundle downloads. The task table is phase-gated to downloading/complete/error in downloads.js — so whenever any sibling was still in album_downloading, the merged phase stayed there and tasks for the sibling that had advanced past its bundle never rendered. User reported: both albums downloading on slskd, modal blank until one completes fully. Flip the rule: surface the most-advanced live phase so the modal renders task progress as soon as any sibling reaches it. The all-siblings-in-album_downloading case still surfaces album_downloading (bundle progress UI is correct there); error stays sticky. Updated WHATS_NEW under 2.6.3 to describe the corrected behavior. Two new tests pin the regression: - downloading + album_downloading → downloading - album_downloading + album_downloading → album_downloading --- core/downloads/wishlist_aggregator.py | 52 +++++++++++++-------- tests/downloads/test_wishlist_aggregator.py | 32 +++++++++++-- webui/static/helper.js | 2 +- 3 files changed, 61 insertions(+), 25 deletions(-) diff --git a/core/downloads/wishlist_aggregator.py b/core/downloads/wishlist_aggregator.py index 25e8bd90..13092cc5 100644 --- a/core/downloads/wishlist_aggregator.py +++ b/core/downloads/wishlist_aggregator.py @@ -23,8 +23,11 @@ Design notes: remap is applied to tasks too. - ``task_id`` is a uuid per task — no collision concern across siblings. -- Phase aggregation surfaces the LEAST-complete pre-terminal phase - so the modal stays "alive" until every sibling is done. Sticky +- Phase aggregation surfaces the MOST advanced live phase so the + modal renders task rows the moment any sibling reaches the task + stage. The earlier "least-complete" rule made the modal appear + frozen during parallel album_downloading because the task table + is phase-gated to ``downloading``/``complete``/``error``. Sticky ``error`` so failures don't get hidden by a running sibling. - ``album_bundle`` is picked from whichever sibling currently has an active bundle download — gives the user a useful progress @@ -36,12 +39,6 @@ from __future__ import annotations from typing import Any, Dict, List, Optional -_PHASE_PRIORITY = ( - 'analysis', - 'album_downloading', - 'downloading', - 'complete', -) _ACTIVE_BUNDLE_STATES = frozenset({ 'searching', 'downloading', @@ -53,25 +50,40 @@ _ACTIVE_BUNDLE_STATES = frozenset({ def _aggregate_phases(phases: List[str]) -> str: """Pick the merged phase for a multi-sibling wishlist run. + Surfaces the MOST advanced live phase across the run so the + modal renders task progress the moment any sibling reaches + the task stage. Earlier ("least complete") aggregation hid + downloading tasks behind the bundle progress UI when any + sibling was still in ``album_downloading``, so the modal + appeared frozen for the entire duration of the slowest + sibling's bundle phase. + Rules: - - ``error`` is sticky — if any sibling errored, surface error. - - Otherwise return the LEAST-complete pre-terminal phase in - priority order (analysis < album_downloading < downloading - < complete). - - If all siblings are ``complete``, return ``complete``. - - Fallback to the first non-empty phase if nothing matches a - known priority. + + - ``error`` is sticky — if any sibling errored, surface error + so the user notices the failure even mid-run. + - ``downloading``/``complete`` (the phases that produce a + task table on the modal side) WIN over earlier phases. + Any sibling at this stage means "show tasks now". All- + complete returns ``complete``; mixed complete + downloading + stays ``downloading`` so the modal keeps polling. + - ``album_downloading`` wins over ``analysis`` only when no + sibling has reached the task stage. + - ``analysis`` is the last resort. """ phases = [p for p in phases if p] if not phases: return 'unknown' if 'error' in phases: return 'error' - for p in _PHASE_PRIORITY: - if p in phases: - if p == 'complete': - return 'complete' if all(s == 'complete' for s in phases) else 'downloading' - return p + if all(p == 'complete' for p in phases): + return 'complete' + if any(p in ('downloading', 'complete') for p in phases): + return 'downloading' + if 'album_downloading' in phases: + return 'album_downloading' + if 'analysis' in phases: + return 'analysis' return phases[0] diff --git a/tests/downloads/test_wishlist_aggregator.py b/tests/downloads/test_wishlist_aggregator.py index c6cf90d5..5d9714ed 100644 --- a/tests/downloads/test_wishlist_aggregator.py +++ b/tests/downloads/test_wishlist_aggregator.py @@ -66,19 +66,43 @@ def test_two_siblings_merge_tasks_with_reindexed_track_index(): assert [t['task_id'] for t in merged['tasks']] == ['task-a1', 'task-a2', 'task-b1'] -def test_phase_aggregation_least_complete_pre_terminal_wins(): - """analysis + downloading + complete → analysis.""" +def test_phase_aggregation_most_advanced_live_phase_wins(): + """analysis + downloading + complete → downloading. Modal's task + table is phase-gated to downloading/complete/error so we must + surface that phase the moment any sibling has tasks, else the + modal stays in bundle/analysis UI and tasks stay invisible.""" primary = _status('complete') sibling1 = _status('downloading') sibling2 = _status('analysis') merged = merge_wishlist_run_status(primary, [sibling1, sibling2]) - assert merged['phase'] == 'analysis' + assert merged['phase'] == 'downloading' -def test_phase_aggregation_album_downloading_wins_over_downloading(): +def test_phase_aggregation_downloading_wins_over_album_downloading(): + """Regression test for the parallel-bundle modal-blank bug: + one sibling past its bundle into the task stage means the + modal MUST be in 'downloading' phase, otherwise the task + rows for the advanced sibling don't render and the user + sees nothing while both albums actually download on slskd.""" primary = _status('downloading') sibling = _status('album_downloading') merged = merge_wishlist_run_status(primary, [sibling]) + assert merged['phase'] == 'downloading' + + +def test_phase_aggregation_all_album_downloading_stays_album_downloading(): + """No sibling has reached the task stage yet — bundle progress + UI is the right thing to show.""" + primary = _status('album_downloading') + sibling = _status('album_downloading') + merged = merge_wishlist_run_status(primary, [sibling]) + assert merged['phase'] == 'album_downloading' + + +def test_phase_aggregation_album_downloading_wins_over_analysis(): + primary = _status('analysis') + sibling = _status('album_downloading') + merged = merge_wishlist_run_status(primary, [sibling]) assert merged['phase'] == 'album_downloading' diff --git a/webui/static/helper.js b/webui/static/helper.js index be2d1d1a..d4f8fd40 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3424,7 +3424,7 @@ const WHATS_NEW = { { title: 'Fix: album-bundle downloads all landing as track 1', desc: 'soulseek album-bundle downloads (and any other untagged release-staging path) were importing every track with track_number=1. the staging-file reader was using the auto-import\'s filename extractor that defaults to 1 when no NN- prefix is present in the filename — so for albums like Ryoto\'s "Cha-La Head-Cha-La" where slskd hands you bare titles, every file got "track 1" stamped on it. now the staging path uses a strict extractor that returns 0 when it can\'t see an explicit prefix, so the downstream resolver correctly falls through to the authoritative Spotify metadata and the right track numbers land in the library.' }, { title: 'Wishlist albums-cycle: one album-bundle search per album', desc: 'auto-wishlist runs in two cycles — albums + singles. previously the albums cycle dumped every missing track from every album into one big batch and ran a separate per-track Soulseek / Prowlarr search for each (~50 searches for a typical scan). now the albums cycle splits the wishlist into per-album sub-batches at submission time, and each one engages the existing slskd / torrent / usenet album-bundle release-first flow with that album\'s context. tracks without resolvable album metadata stay on the classic per-track residual batch so nothing falls off. singles cycle is unchanged.' }, { title: 'Wishlist cycle toggles once per run, not per sub-batch', desc: 'follow-up to the per-album split above. with N sub-batches per wishlist run, the cycle toggle (albums → singles or vice versa) was firing once per sub-batch as each one completed — so the cycle could flip mid-run and the automation completion event got emitted multiple times. each wishlist invocation now stamps every sub-batch with a shared run id; the completion handler waits until the LAST sibling finishes before toggling the cycle + emitting the run-complete event. cleaner state machine, future-proofs frontend grouping of sub-batches under one logical run.' }, - { title: 'Wishlist download modal: keeps tracking after first album', desc: 'with the per-album sub-batch split, the modal\'s polling kept hitting the original batch id only and went stale as soon as the first sub-batch finished — subsequent albums downloaded fine but the modal\'s rows stopped updating. backend now merges every sibling sub-batch\'s tasks + analysis results into the response under the original batch id (re-indexes track positions globally so the table doesn\'t collide on row keys, aggregates phase across siblings, picks whichever bundle is currently active). frontend untouched — the modal sees one unified view of the whole run.' }, + { title: 'Wishlist download modal: keeps tracking across every album', desc: 'with the per-album sub-batch split, the modal\'s polling kept hitting the original batch id only and went stale as soon as the first sub-batch finished — subsequent albums downloaded fine but the modal\'s rows stopped updating. backend now merges every sibling sub-batch\'s tasks + analysis results into the response under the original batch id (re-indexes track positions globally so the table doesn\'t collide on row keys, picks whichever bundle is currently active, aggregates phase across siblings so the moment ANY sibling reaches the task stage the modal renders task rows — earlier rule waited for the slowest sibling and left the modal looking frozen while both albums were already downloading on slskd). frontend untouched — the modal sees one unified view of the whole run.' }, ], '2.6.2': [ { date: 'May 24, 2026 — 2.6.2 release' }, From acc5eb77ea31aff06244afd3bf3e2209a2ae1bb6 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 23:00:19 -0700 Subject: [PATCH 76/81] Fix popup: anchor artist field in MB search to stop title-collision covers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/api/musicbrainz/search_tracks` powers the Fix popup's auto-search cascade for users on MusicBrainz as primary. When both track + artist fields were filled, `search_tracks_with_artist` always took the bare keyword path (`<track> <artist>` joined as one query string). MB's recording-search scorer weights title matches far above artist matches, so for "Coffee Break" + "Zeds Dead" the top results were Emapea / The Vidalias / West One Orchestra's "Coffee Break" — three unrelated cover- title collisions ahead of the canonical Zeds Dead recording. The endpoint's `rerank_tracks` pass can't fix this when the right answer is below the API's 50-result cutoff. Both-fields mode now uses a strict field-scoped Lucene query first (`recording:"<t>" AND artist:"<a>"`) which anchors the artist and prunes title-collision covers at the source. `min_score=0` because the field-scoped query is itself precise; rerank still does final ordering. Bare query stays as the fallback when strict returns nothing — covers the diacritic / alias cases the original `strict=False` path was added for ("Bjork" query vs canonical "Björk" artist where Lucene phrase match never hits the recording). Single-field mode (track-only or artist-only) is unchanged: still bare- query directly, since there's no artist value to anchor. Also stable-sort results to prefer entries with non-zero `duration_ms`. MB has multiple recordings per song (single release, album release, remasters, compilations) and not every recording carries length data. Without the preference sort, the user sees a 0:00 row first while a sibling recording with the real 3:04 sits two rows below — matches the report where MBID-paste lookup of the canonical recording (length 3:04) contradicted the search-result's 0:00 row for the same song. Tests: - new `test_search_tracks_with_artist_strict_first_when_both_fields` pins the strict=True call when both fields present - new `test_search_tracks_with_artist_falls_back_to_bare_when_strict_empty` pins the Björk-style fall-through path - new `test_search_tracks_with_artist_prefers_results_with_known_length` pins the length-preference sort - existing `..._keeps_low_score_for_rerank` updated to side_effect so the bare-fallback path is exercised; behaviour pinned identically - existing `..._uses_bare_query_mode` renamed + repurposed for strict- first; old name's behaviour no longer accurate --- core/musicbrainz_search.py | 47 +++++++-- tests/metadata/test_musicbrainz_search.py | 123 ++++++++++++++++------ webui/static/helper.js | 1 + 3 files changed, 126 insertions(+), 45 deletions(-) diff --git a/core/musicbrainz_search.py b/core/musicbrainz_search.py index 47e95a84..ff39a827 100644 --- a/core/musicbrainz_search.py +++ b/core/musicbrainz_search.py @@ -757,19 +757,46 @@ class MusicBrainzSearchClient: `search_tracks`'s structured-query dispatch (`Artist - Track` splitting, bare-name artist-first browse). - Uses bare-query mode (`strict=False`) — diacritic-folded, hits - alias/sortname indexes, no `AND`-clause that kills recall when - either side mis-matches. Score floor lowered to 20 (vs the search - tab's 80) so MB recordings whose title doesn't literally contain - the artist name still enter the candidate pool — the endpoint's - `rerank_tracks` pass then sorts by artist-match relevance. Without - this, queries like `Army of Me` + `Bjork` only surface covers - (score 73-100) and miss Björk's canonical recording (score 28). + When both fields are present: strict-first, bare-as-fallback. The + strict pass builds a field-scoped Lucene query (`recording:"<t>" + AND artist:"<a>"`) which anchors the artist and prunes title- + collision covers — fixes the "Coffee Break" + "Zeds Dead" case + where MB's title-text-biased scorer surfaced Emapea / Vidalias / + West One Orchestra ahead of the canonical Zeds Dead recording. + `min_score=0` on strict because the field-scoped query is itself + precise, and the endpoint's `rerank_tracks` pass does the final + ordering. Bare query runs only when strict returns nothing — + catches diacritic / alias mismatches (`Bjork` query vs canonical + `Björk` artist) where strict phrase match never hits. + + When only one field is present: bare-query mode directly — same + recall-over-precision tradeoff the old single-path took. + + Results are stable-sorted to prefer entries with non-zero + `duration_ms`. MB often has several recordings per song (single + release, album release, compilations, remasters) and not every + recording carries length data. Without this, the first match can + be a length-less duplicate while a sibling recording with the + real 3:04 sits two rows down. """ if not track and not artist: return [] - return self._search_tracks_text(track, artist or None, limit, - strict=False, min_score=20) + + if track and artist: + results = self._search_tracks_text( + track, artist, limit, strict=True, min_score=0 + ) + if not results: + results = self._search_tracks_text( + track, artist, limit, strict=False, min_score=20 + ) + else: + results = self._search_tracks_text( + track, artist or None, limit, strict=False, min_score=20 + ) + + results.sort(key=lambda t: 0 if (t.duration_ms or 0) > 0 else 1) + return results def _pick_representative_release(self, releases: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: """Pick the best release out of a release-group's editions. diff --git a/tests/metadata/test_musicbrainz_search.py b/tests/metadata/test_musicbrainz_search.py index e5859707..322cc284 100644 --- a/tests/metadata/test_musicbrainz_search.py +++ b/tests/metadata/test_musicbrainz_search.py @@ -1000,33 +1000,84 @@ def test_get_recording_flat_swallows_client_errors(): # search_tracks_with_artist — Fix-popup cascade adapter # --------------------------------------------------------------------------- -def test_search_tracks_with_artist_uses_bare_query_mode(): - """The Fix-popup cascade needs MB's bare-query mode so diacritics and - bracketed suffixes don't kill recall. The adapter must pass strict=False - through to the underlying search_recording call.""" +def test_search_tracks_with_artist_strict_first_when_both_fields(): + """Both fields present → strict field-scoped Lucene query first + (`recording:"<t>" AND artist:"<a>"`). Fixes the "Coffee Break" + + "Zeds Dead" case where bare query lets MB's title-text-biased + scorer surface unrelated covers ahead of the canonical recording.""" client = MusicBrainzSearchClient() client._client = MagicMock() client._client.search_recording.return_value = [ - {'id': 'rec-1', 'title': 'Army of Me', 'score': 95, - 'releases': [{'id': 'rel-1', 'title': 'Post', 'date': '1995'}], - 'artist-credit': [{'name': 'Björk'}]}, + {'id': 'rec-1', 'title': 'Coffee Break', 'score': 95, + 'length': 184000, + 'releases': [{'id': 'rel-1', 'title': 'Coffee Break', 'date': '2015'}], + 'artist-credit': [{'name': 'Zeds Dead'}]}, ] - tracks = client.search_tracks_with_artist('Army of Me', 'Björk', limit=10) + tracks = client.search_tracks_with_artist('Coffee Break', 'Zeds Dead', limit=10) - # strict=False is the critical bit — fuzzy recall, not phrase precision + # strict=True is the critical bit — anchors artist via Lucene AND clause client._client.search_recording.assert_called_once_with( - 'Army of Me', artist_name='Björk', limit=10, strict=False + 'Coffee Break', artist_name='Zeds Dead', limit=10, strict=True ) assert len(tracks) == 1 - assert tracks[0].name == 'Army of Me' - assert 'Björk' in tracks[0].artists + assert tracks[0].name == 'Coffee Break' + assert 'Zeds Dead' in tracks[0].artists + + +def test_search_tracks_with_artist_falls_back_to_bare_when_strict_empty(): + """Strict phrase match misses diacritic / alias cases ("Bjork" query + vs canonical "Björk" artist). When strict returns nothing, fall + through to bare query so rerank can still surface the right answer.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_recording.side_effect = [ + [], # strict pass → no hits (Lucene phrase match fails on diacritic) + [ # bare pass → recall via alias index + {'id': 'rec-canonical', 'title': 'Army of Me', 'score': 28, + 'releases': [], 'artist-credit': [{'name': 'Björk'}]}, + ], + ] + + tracks = client.search_tracks_with_artist('Army of Me', 'Bjork', limit=10) + + assert client._client.search_recording.call_count == 2 + first_call = client._client.search_recording.call_args_list[0] + second_call = client._client.search_recording.call_args_list[1] + assert first_call.kwargs['strict'] is True + assert second_call.kwargs['strict'] is False + assert len(tracks) == 1 + assert tracks[0].id == 'rec-canonical' + + +def test_search_tracks_with_artist_prefers_results_with_known_length(): + """MB has multiple recordings per song (single release, album release, + compilations) and not every recording carries length data. Stable + sort moves length-known entries ahead of length-zero duplicates so + the user sees the actionable 3:04 row first, not the 0:00 sibling.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_recording.return_value = [ + {'id': 'rec-no-length', 'title': 'Coffee Break', 'score': 100, + 'releases': [], 'artist-credit': [{'name': 'Zeds Dead'}]}, + {'id': 'rec-with-length', 'title': 'Coffee Break', 'score': 90, + 'length': 184000, + 'releases': [], 'artist-credit': [{'name': 'Zeds Dead'}]}, + ] + + tracks = client.search_tracks_with_artist('Coffee Break', 'Zeds Dead', limit=10) + + # rec-with-length must surface first even though MB scored it lower + assert tracks[0].id == 'rec-with-length' + assert tracks[0].duration_ms == 184000 + assert tracks[1].id == 'rec-no-length' def test_search_tracks_with_artist_handles_missing_artist(): - """Track-only query (no artist) still works — empty string becomes - None, and the underlying client searches recordings without an - artist filter.""" + """Track-only query (no artist) still works — single-field path takes + bare-query mode directly (no strict-first round-trip since there's no + artist to anchor). Empty string becomes None so MB drops the AND + clause.""" client = MusicBrainzSearchClient() client._client = MagicMock() client._client.search_recording.return_value = [ @@ -1036,7 +1087,6 @@ def test_search_tracks_with_artist_handles_missing_artist(): client.search_tracks_with_artist('Some Song', '', limit=5) - # Empty artist → None passed to the client so MB drops the AND clause client._client.search_recording.assert_called_once_with( 'Some Song', artist_name=None, limit=5, strict=False ) @@ -1051,33 +1101,33 @@ def test_search_tracks_with_artist_empty_returns_empty_list(): client._client.search_recording.assert_not_called() -def test_search_tracks_with_artist_keeps_low_score_for_rerank(): - """Cascade path uses a low score floor (20) so MB recordings whose - title doesn't literally contain the artist name still enter the - candidate pool — the endpoint's rerank pass surfaces them by - artist-match relevance. Real example: "Army of Me" + "Bjork" — the - canonical Björk recording scores 28 in MB (title doesn't contain - "Bjork"), while title-collision covers like "Army of Me (Bjork)" - score 73-100. Strict 80 floor drops the right answer.""" +def test_search_tracks_with_artist_bare_fallback_keeps_low_score_for_rerank(): + """When strict returns nothing and we fall through to bare, the bare + pass uses a low score floor (20) so MB recordings whose title doesn't + literally contain the artist name still enter the candidate pool — + the endpoint's rerank pass surfaces them by artist-match relevance. + Real example: "Army of Me" + "Bjork" — strict fails on the diacritic + mismatch, bare picks up the canonical Björk recording at score 28 + while filtering true noise at score 5.""" client = MusicBrainzSearchClient() client._client = MagicMock() - client._client.search_recording.return_value = [ - {'id': 'rec-cover', 'title': 'Army of Me (Bjork)', 'score': 100, - 'releases': [], 'artist-credit': [{'name': 'HIRS Collective'}]}, - {'id': 'rec-canonical', 'title': 'Army of Me', 'score': 28, - 'releases': [], 'artist-credit': [{'name': 'Björk'}]}, - {'id': 'rec-noise', 'title': 'Bjork', 'score': 5, - 'releases': [], 'artist-credit': [{'name': 'Random'}]}, + client._client.search_recording.side_effect = [ + [], # strict pass → no hits + [ # bare pass + {'id': 'rec-cover', 'title': 'Army of Me (Bjork)', 'score': 100, + 'releases': [], 'artist-credit': [{'name': 'HIRS Collective'}]}, + {'id': 'rec-canonical', 'title': 'Army of Me', 'score': 28, + 'releases': [], 'artist-credit': [{'name': 'Björk'}]}, + {'id': 'rec-noise', 'title': 'Bjork', 'score': 5, + 'releases': [], 'artist-credit': [{'name': 'Random'}]}, + ], ] tracks = client.search_tracks_with_artist('Army of Me', 'Bjork', limit=50) ids = [t.id for t in tracks] - # Score=28 canonical Björk recording is kept — the endpoint's rerank - # will surface it by artist match. assert 'rec-canonical' in ids assert 'rec-cover' in ids - # Score=5 is below the 20 floor — true garbage still filtered out. assert 'rec-noise' not in ids @@ -1120,7 +1170,10 @@ def test_search_tracks_text_strict_param_default_true(): def test_search_tracks_with_artist_swallows_client_errors(): """MB client raising must not crash the endpoint — return [] so the - Fix-popup cascade falls through to the next source.""" + Fix-popup cascade falls through to the next source. Both strict and + bare passes swallow exceptions independently, so a strict-pass raise + still lets the bare-pass run; a bare-pass raise after empty strict + returns [].""" client = MusicBrainzSearchClient() client._client = MagicMock() client._client.search_recording.side_effect = RuntimeError('network down') diff --git a/webui/static/helper.js b/webui/static/helper.js index d4f8fd40..a34e58b6 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3415,6 +3415,7 @@ function closeHelperSearch() { const WHATS_NEW = { '2.6.3': [ { unreleased: true }, + { title: 'Fix popup: artist + track fields no longer surface unrelated covers', desc: 'separate artist / track inputs in the Fix popup used to dump both into a bare MusicBrainz keyword query — and MB\'s scorer heavily favours title matches, so searching "Coffee Break" + "Zeds Dead" surfaced random "Coffee Break" tracks by other artists ahead of the canonical Zeds Dead one. fields-mode now uses a field-scoped Lucene query that actually anchors the artist, with the old bare query kept as a fallback for diacritic mismatches like "Bjork" vs "Björk" where strict phrase match misses. results also stable-prefer entries with known track length so the canonical 3:04 sibling sits above the 0:00 duplicate.' }, { title: 'Groundwork: unified playlist source layer', desc: 'first slice of a refactor that\'ll let ListenBrainz, Last.fm radio, and SoulSync Discovery playlists live as Sync-page tabs alongside Spotify / Tidal / Qobuz / YouTube — so they can be mirrored + scheduled like the rest. this commit adds the shared adapter layer all those sources will plug into; no UI changes yet. nothing to do on your end.' }, { title: 'Auto-Sync refresh now routes through the unified source layer', desc: 'follow-up to the groundwork above. the mirrored-playlist auto-refresh handler used to have a ~190-line if/elif chain branching per source (one branch each for Spotify, Spotify public, Deezer, Tidal, YouTube). now it asks the source registry for the right adapter and calls one refresh method. behavior identical — same matched_data, same Tidal-skip-on-no-auth log, same Spotify-public-prefers-authed-API fallback. unlocks ListenBrainz / Last.fm / SoulSync Discovery as future Sync-page mirror sources without a fresh elif branch each time.' }, { title: 'Discovery folded into the unified source contract', desc: 'next slice of the groundwork. each playlist source can now answer one extra question — "match these raw tracks against Spotify / iTunes" — through the same adapter interface. Spotify / Tidal / Qobuz / YouTube / Deezer / Spotify-public / iTunes-link / SoulSync-Discovery all answer trivially (their tracks already have provider IDs); ListenBrainz + Last.fm run the matching engine. mirror-refresh now calls this automatically when a source returns MB-metadata-only tracks, so when ListenBrainz becomes a Sync-page tab next commit, its mirrors land already discovered + ready to sync — no separate Discover-page round-trip needed.' }, From 39f582a6908d0f8873a1f09e53496ba6bf6a4907 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 27 May 2026 06:59:58 -0700 Subject: [PATCH 77/81] Mirrored playlist: stop Playlist Pipeline from reverting manual Fix-popup matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported that manually mapping a mirrored-playlist track via the Fix popup (either by search or by pasting an MBID) worked end-to-end once — match saved, library track downloaded — but the next Playlist Pipeline run flipped the track back to "Provider Changed" and forced them to re-do the manual map every cycle. Three independent issues were combining to cause this: 1. Hardcoded `provider: 'spotify'` on manual-fix save `update_youtube_discovery_match` (the endpoint the Fix popup posts to, also used by mirrored playlists since the frontend routes `platform === 'mirrored'` through the YouTube endpoint) always stamped the cached match as Spotify-provided. The Fix-popup cascade actually queries the user's primary metadata source first and falls back to Spotify / Deezer / iTunes / MusicBrainz — so a user on MusicBrainz primary picking an MB result still had it saved as `provider: 'spotify'`. The next prepare-discovery call (which compares cached_provider to the active source) then immediately classified the match as drifted and pending re-discovery. Fixed by deriving `match_source` from `spotify_track.get('source')` (every *_search_tracks endpoint stamps `source` on results) with a fallback to `_get_active_discovery_source()` for the MBID-paste path (which uses the lean flat shape that doesn't carry source). `matched_data['source']` and the mirrored `extra_data['provider']` both now use the derived value. `match_source` is also recomputed in the cache-save except handler so the downstream mirrored-DB save still has it. 2. Discovery worker re-queueing manual matches as "incomplete" `run_playlist_discovery_worker` in `core/discovery/playlist.py` re-adds any track to `undiscovered_tracks` when its `matched_data` lacks `track_number` or `album.id` / `album.release_date`. The check was designed as a legacy-fix backfill for old discoveries that lost those fields to a Track-dataclass stripping bug. But manual fixes from the popup are *intentionally* lean — search- result rows don't include `track_number` (none of the search endpoints return it), and the MBID-lookup flat shape doesn't carry `album.id` / `release_date` (the recording lookup returns only `album.name`). So every manual match looked "incomplete" and got re-discovered every pipeline run, overwriting the user's pick with whatever the auto-search ranked first. Manual matches now short-circuit ahead of the incomplete-data branch. 3. `prepare_mirrored_discovery` ignored the `manual_match` flag Independent of the provider-stamping fix above, the prepare- discovery endpoint that powers the mirrored-playlist UI did its own `cached_provider != current_provider` check and didn't honour manual_match either. Defence in depth — even if a future code path stamps the wrong provider on a manual match, the flag now anchors it as cached. `has_cached` also extended so manual matches with off-provider stamps still count toward the cached tally for phase classification. Tests: - new `test_manual_match_skipped_even_when_matched_data_incomplete` in `tests/discovery/test_discovery_playlist.py` pins the worker short-circuit using a realistic MB-shape matched_data (album dict without id / release_date, no top-level track_number). 16 existing tests still green; 848 across discovery / metadata / automation suites pass. --- core/discovery/playlist.py | 13 +++++++ tests/discovery/test_discovery_playlist.py | 30 ++++++++++++++++ web_server.py | 42 +++++++++++++++++++--- webui/static/helper.js | 1 + 4 files changed, 81 insertions(+), 5 deletions(-) diff --git a/core/discovery/playlist.py b/core/discovery/playlist.py index 4bbbf1c7..f25e2999 100644 --- a/core/discovery/playlist.py +++ b/core/discovery/playlist.py @@ -125,6 +125,19 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD if existing_extra.get('wing_it_fallback'): # Wing It stub — always re-attempt to find a real match undiscovered_tracks.append(track) + elif existing_extra.get('manual_match'): + # User explicitly picked this match via the Fix popup. + # Manual fixes are authoritative: they may lack + # track_number / album.id / release_date (the Fix-popup + # save shape is intentionally lean — search-result rows + # don't include track_number, and the MBID-lookup flat + # shape doesn't carry album.id), but re-running discovery + # against the active source would overwrite the user's + # deliberate pick with whatever the auto-search ranks + # first. Skip — pipeline only re-discovers when the user + # has cleared the match. + pl_skipped += 1 + total_skipped += 1 else: # Check if matched_data is complete — old discoveries may be missing # track_number/release_date due to the Track dataclass stripping them. diff --git a/tests/discovery/test_discovery_playlist.py b/tests/discovery/test_discovery_playlist.py index 8ab18063..8a8a8e9c 100644 --- a/tests/discovery/test_discovery_playlist.py +++ b/tests/discovery/test_discovery_playlist.py @@ -232,6 +232,36 @@ def test_unmatched_by_user_respected(): assert deps._db.extra_data_writes == [] +def test_manual_match_skipped_even_when_matched_data_incomplete(): + """manual_match=True must skip the incomplete-matched_data re-discovery + branch. The Fix-popup save shape is intentionally lean — search-result + rows don't carry track_number, and the MBID-lookup flat shape doesn't + carry album.id / release_date — so a manual fix always looks 'incomplete' + to the old check and used to be re-discovered every pipeline run, + overwriting the user's deliberate pick with whatever the auto-search + ranked first. Pin the fix: manual matches stay put.""" + extra = { + 'discovered': True, + 'manual_match': True, + 'provider': 'musicbrainz', + 'matched_data': { + 'id': 'mb-rec-id', + 'name': 'Coffee Break', + 'artists': ['Zeds Dead'], + 'album': {'name': 'Coffee Break'}, # no id, no release_date + 'source': 'musicbrainz', + # no track_number — Fix-popup shape never has it + }, + } + tracks = [_track(track_id=1, extra_data=extra)] + deps = _build_deps(tracks_by_playlist={'p1': tracks}) + + dp.run_playlist_discovery_worker([_playlist('p1')], deps=deps) + + # No extra_data writes — the manual match wasn't overwritten + assert deps._db.extra_data_writes == [] + + # --------------------------------------------------------------------------- # Cache hit short-circuit # --------------------------------------------------------------------------- diff --git a/web_server.py b/web_server.py index 6175cc57..0fd2ff69 100644 --- a/web_server.py +++ b/web_server.py @@ -24349,6 +24349,20 @@ def update_youtube_discovery_match(): album_obj['image_url'] = image_url album_obj['images'] = [{'url': image_url}] + # Manual fixes can come from any metadata source — the Fix-popup + # cascade queries the user's primary first, then Spotify / Deezer / + # iTunes / MusicBrainz as fallbacks. Each search endpoint stamps + # `source` on its results; the MBID-paste lookup doesn't, so fall + # back to the active discovery source. Hardcoding 'spotify' here + # used to make every non-Spotify manual match look like it had + # provider-drifted on the next prepare-discovery, triggering + # automatic re-discovery that overwrote the user's manual pick. + match_source = ( + spotify_track.get('source') + or _get_active_discovery_source() + or 'spotify' + ) + matched_data = { 'id': spotify_track['id'], 'name': spotify_track['name'], @@ -24356,7 +24370,7 @@ def update_youtube_discovery_match(): 'album': album_obj, 'duration_ms': spotify_track.get('duration_ms', 0), 'image_url': image_url, - 'source': 'spotify', + 'source': match_source, } cache_db = get_database() cache_db.save_discovery_cache_match( @@ -24366,6 +24380,13 @@ def update_youtube_discovery_match(): logger.info(f"Manual fix saved to discovery cache: {original_name} by {original_artist}") except Exception as cache_err: logger.error(f"Error saving manual fix to discovery cache: {cache_err}") + # match_source needs to exist for the mirrored-DB block below even + # if cache save failed — use the same fallback chain. + match_source = ( + spotify_track.get('source') + or _get_active_discovery_source() + or 'spotify' + ) # Persist manual fix to DB for mirrored playlists if identifier.startswith('mirrored_'): @@ -24377,7 +24398,7 @@ def update_youtube_discovery_match(): db = get_database() extra_data = { 'discovered': True, - 'provider': 'spotify', + 'provider': match_source, 'confidence': 1.0, 'matched_data': matched_data, 'manual_match': True, @@ -33671,11 +33692,17 @@ def prepare_mirrored_discovery(playlist_id): extra = track.get('extra_data') if extra and extra.get('discovered'): cached_provider = extra.get('provider', 'spotify') + is_manual = bool(extra.get('manual_match')) # If the cached result was discovered by a different provider than the # currently active one, treat it as pending so re-discovery uses the # correct source (IDs, album data, images differ between providers). - if cached_provider != _current_provider: + # Manual matches are exempt: the user explicitly picked that record, + # so re-discovery would overwrite their intentional fix every cycle. + # Without this guard, fixing a track via the Fix popup correctly + # downloads it once, but the next Playlist Pipeline run re-marks it + # "Provider Changed" and reverts the manual map. + if cached_provider != _current_provider and not is_manual: has_pending = True dur = track.get('duration_ms', 0) pre_discovered_results.append({ @@ -33757,11 +33784,16 @@ def prepare_mirrored_discovery(playlist_id): 'confidence': 0, }) - # Only treat as cached if at least one track was discovered by the current provider + # Treat as cached if at least one track was discovered by the current provider, + # OR if any track carries a manual_match (those are exempt from provider-drift + # re-discovery above, so they count as cached regardless of cached_provider). has_cached = any( t.get('extra_data') and (t['extra_data'].get('discovered') or t['extra_data'].get('discovery_attempted')) and - t['extra_data'].get('provider', 'spotify') == _current_provider + ( + t['extra_data'].get('provider', 'spotify') == _current_provider + or t['extra_data'].get('manual_match') + ) for t in tracks ) diff --git a/webui/static/helper.js b/webui/static/helper.js index a34e58b6..e3d96156 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3415,6 +3415,7 @@ function closeHelperSearch() { const WHATS_NEW = { '2.6.3': [ { unreleased: true }, + { title: 'Fix popup: manual matches survive Playlist Pipeline runs', desc: 'manually mapping a mirrored-playlist track via the Fix popup (search or MBID) saved correctly and the download went through, but the next Playlist Pipeline run kept reverting the match — the track flipped back to "Provider Changed" and you had to map it again. three things were ganging up on it: the manual-fix save always stamped `provider: \'spotify\'` even when the match came from MusicBrainz / iTunes / Deezer, so prepare-discovery saw a provider mismatch on the next run; the discovery worker also re-queued any matched_data that lacked `track_number` / `album.id` / `release_date` for re-discovery, and Fix-popup saves never carry those fields by design (search results don\'t include track_number, MBID lookups don\'t include album.id) — so manual fixes always looked "incomplete" and got overwritten; and the prepare-discovery provider check didn\'t honour the `manual_match` flag at all. all three patched — manual matches now stamp the actual source, the worker short-circuits on `manual_match` before the incomplete-data branch, and prepare-discovery treats manual fixes as cached regardless of provider drift.' }, { title: 'Fix popup: artist + track fields no longer surface unrelated covers', desc: 'separate artist / track inputs in the Fix popup used to dump both into a bare MusicBrainz keyword query — and MB\'s scorer heavily favours title matches, so searching "Coffee Break" + "Zeds Dead" surfaced random "Coffee Break" tracks by other artists ahead of the canonical Zeds Dead one. fields-mode now uses a field-scoped Lucene query that actually anchors the artist, with the old bare query kept as a fallback for diacritic mismatches like "Bjork" vs "Björk" where strict phrase match misses. results also stable-prefer entries with known track length so the canonical 3:04 sibling sits above the 0:00 duplicate.' }, { title: 'Groundwork: unified playlist source layer', desc: 'first slice of a refactor that\'ll let ListenBrainz, Last.fm radio, and SoulSync Discovery playlists live as Sync-page tabs alongside Spotify / Tidal / Qobuz / YouTube — so they can be mirrored + scheduled like the rest. this commit adds the shared adapter layer all those sources will plug into; no UI changes yet. nothing to do on your end.' }, { title: 'Auto-Sync refresh now routes through the unified source layer', desc: 'follow-up to the groundwork above. the mirrored-playlist auto-refresh handler used to have a ~190-line if/elif chain branching per source (one branch each for Spotify, Spotify public, Deezer, Tidal, YouTube). now it asks the source registry for the right adapter and calls one refresh method. behavior identical — same matched_data, same Tidal-skip-on-no-auth log, same Spotify-public-prefers-authed-API fallback. unlocks ListenBrainz / Last.fm / SoulSync Discovery as future Sync-page mirror sources without a fresh elif branch each time.' }, From b67d13164aa6e0a2089c1d1dc1cedca24f23507e Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 27 May 2026 07:08:21 -0700 Subject: [PATCH 78/81] Library: persist Enhanced / Standard view toggle in localStorage User feedback: the Enhanced view toggle on the artist detail page reset to Standard on every artist click, so admins who prefer Enhanced had to re-flip the toggle every single time. Persist the choice in localStorage and reapply on every artist navigation + page reload. - `toggleEnhancedView()` writes `soulsync-library-view-mode` to localStorage on every change. - `navigateToArtistDetail()` reads the saved value after the standard reset block runs; if `enhanced` AND `isEnhancedAdmin()` it calls `toggleEnhancedView(true)` after `loadArtistDetailData` kicks off. The brief Standard render is hidden as soon as the toggle flips. - Gated on `isEnhancedAdmin()` so non-admin profiles (which never see the toggle) can't end up with a stale Enhanced preference being applied silently. - Wrapped in try/catch since localStorage is unavailable in some private-browsing modes. No backend change; no DB migration needed. --- webui/static/helper.js | 1 + webui/static/library.js | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/webui/static/helper.js b/webui/static/helper.js index e3d96156..6ec49872 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3415,6 +3415,7 @@ function closeHelperSearch() { const WHATS_NEW = { '2.6.3': [ { unreleased: true }, + { title: 'Library: Enhanced / Standard view toggle now sticks per browser', desc: 'the artist detail page used to revert to Standard view every time you clicked into a new artist — flipping to Enhanced was a per-click thing with no memory. now the choice is persisted in localStorage and reapplied automatically on every artist open. survives page reloads too. non-admins (who can\'t toggle Enhanced anyway) are unaffected; admins who never touched the Enhanced view stay on Standard since the saved value defaults to it.' }, { title: 'Fix popup: manual matches survive Playlist Pipeline runs', desc: 'manually mapping a mirrored-playlist track via the Fix popup (search or MBID) saved correctly and the download went through, but the next Playlist Pipeline run kept reverting the match — the track flipped back to "Provider Changed" and you had to map it again. three things were ganging up on it: the manual-fix save always stamped `provider: \'spotify\'` even when the match came from MusicBrainz / iTunes / Deezer, so prepare-discovery saw a provider mismatch on the next run; the discovery worker also re-queued any matched_data that lacked `track_number` / `album.id` / `release_date` for re-discovery, and Fix-popup saves never carry those fields by design (search results don\'t include track_number, MBID lookups don\'t include album.id) — so manual fixes always looked "incomplete" and got overwritten; and the prepare-discovery provider check didn\'t honour the `manual_match` flag at all. all three patched — manual matches now stamp the actual source, the worker short-circuits on `manual_match` before the incomplete-data branch, and prepare-discovery treats manual fixes as cached regardless of provider drift.' }, { title: 'Fix popup: artist + track fields no longer surface unrelated covers', desc: 'separate artist / track inputs in the Fix popup used to dump both into a bare MusicBrainz keyword query — and MB\'s scorer heavily favours title matches, so searching "Coffee Break" + "Zeds Dead" surfaced random "Coffee Break" tracks by other artists ahead of the canonical Zeds Dead one. fields-mode now uses a field-scoped Lucene query that actually anchors the artist, with the old bare query kept as a fallback for diacritic mismatches like "Bjork" vs "Björk" where strict phrase match misses. results also stable-prefer entries with known track length so the canonical 3:04 sibling sits above the 0:00 duplicate.' }, { title: 'Groundwork: unified playlist source layer', desc: 'first slice of a refactor that\'ll let ListenBrainz, Last.fm radio, and SoulSync Discovery playlists live as Sync-page tabs alongside Spotify / Tidal / Qobuz / YouTube — so they can be mirrored + scheduled like the rest. this commit adds the shared adapter layer all those sources will plug into; no UI changes yet. nothing to do on your end.' }, diff --git a/webui/static/library.js b/webui/static/library.js index 4e5467bf..dac0467a 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -879,6 +879,15 @@ function navigateToArtistDetail(artistId, artistName, sourceOverride = null, opt const bulkBar = document.getElementById('enhanced-bulk-bar'); if (bulkBar) bulkBar.classList.remove('visible'); + // Restore persisted view preference. Non-admins can't see / toggle the + // Enhanced control so only honour the saved choice for admins; default + // is still Standard. Wrapped in try/catch because localStorage can be + // disabled in private-browsing modes. + let _preferEnhanced = false; + try { + _preferEnhanced = localStorage.getItem('soulsync-library-view-mode') === 'enhanced'; + } catch (_) { /* localStorage unavailable */ } + // Navigate to artist detail page navigateToPage('artist-detail', { artistId, @@ -895,6 +904,13 @@ function navigateToArtistDetail(artistId, artistName, sourceOverride = null, opt // Load artist data loadArtistDetailData(artistId, artistName); + + // Apply persisted Enhanced view after the standard data load is kicked off. + // toggleEnhancedView() triggers its own loadEnhancedViewData() in parallel; + // the brief Standard render is hidden as soon as the toggle flips. + if (_preferEnhanced && isEnhancedAdmin()) { + toggleEnhancedView(true); + } } function _updateArtistDetailBackButtonLabel() { @@ -2905,6 +2921,12 @@ function toggleEnhancedView(enabled) { const bulkBar = document.getElementById('enhanced-bulk-bar'); if (bulkBar) bulkBar.classList.remove('visible'); } + + // Persist the choice so the next artist click (and the next page reload) + // honours it instead of always reverting to Standard. + try { + localStorage.setItem('soulsync-library-view-mode', enabled ? 'enhanced' : 'standard'); + } catch (_) { /* localStorage unavailable */ } } async function loadEnhancedViewData(artistId) { From 8dbbf13c6129ef59ff97f9f96b6816cfd2563d8f Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 27 May 2026 07:43:21 -0700 Subject: [PATCH 79/81] Branch cleanup: lift manual-match helpers, fix length-pref ordering, profile-scope view toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review pass on the prior three commits — kettui-style cleanup that should have landed first time. **Length-preference sort ordering (real bug):** The `search_tracks_with_artist` stable sort that promoted length-known recordings ran in `core/musicbrainz_search.py`, but the MB endpoint in `web_server.py:search_musicbrainz_tracks` runs `rerank_tracks` after it — which re-sorts by relevance score and dropped the length-pref ordering down to tiebreaker-only. For canonical-same-song MB duplicates that all score identically the tiebreaker survived, but the order-of-operations was wrong. Moved into `rerank_tracks` itself via a new `prefer_known_duration` flag. Sort key sits between relevance score and the stable-order tiebreaker so relevance still wins (length only decides ties, never overrides a higher-relevance match). The MB endpoint opts in via `prefer_known_duration=True`; Spotify / iTunes / Deezer callers stay on the default-off path since their search results always include length. Pinned with three new `TestRerankTracks` cases: ties-promote-length, relevance-still-wins, default-off-unchanged. **Route logic lifted to `core/discovery/manual_match.py`:** Two pieces lived as inline route logic in `web_server.py` — the `derive_manual_match_provider` fallback chain (payload.source → active source → 'spotify') used by `update_youtube_discovery_match`, and the `is_drifted_for_redo` predicate (cached provider differs from active AND not manual_match) used by `prepare_mirrored_discovery`. Per kettui's "extract logic from web_server.py, don't AST-parse it" standard, both helpers now live in `core/discovery/manual_match.py` with 12 dedicated unit tests covering fallback resolution order, non-dict payload defenses, manual_match exemption from drift, absent-provider legacy default, and edge cases. Side benefits from the lift: - `match_source` now derived once before the cache-save try block instead of being duplicated in try + except (the except block existed only because the original used `match_source` later — pre-computing killed the duplication). - `prepare_mirrored_discovery`'s `has_cached` check now reuses `is_drifted_for_redo` with inverted polarity instead of restating the field whitelist inline, so a future schema change only has to land in one place. - The mirrored-DB persist block now gates on `matched_data is not None` to avoid a pre-existing latent NameError if the cache-save block raised before matched_data construction. **Enhanced toggle localStorage key now profile-scoped:** `soulsync-library-view-mode` was global — two admin profiles would share one preference. Wrapped in `_libraryViewModeKey()` which appends `:${currentProfile.id}` when a profile is loaded, falls back to the unsuffixed key otherwise (preserves pre-multi-profile saved values). Tests: - 12 new in `tests/discovery/test_manual_match.py` pinning both helpers. - 3 new in `tests/metadata/test_relevance.py` pinning the `prefer_known_duration` semantics. - `test_search_tracks_with_artist_prefers_results_with_known_length` renamed to `_does_not_resort_by_length` since the sort moved out of this method. 664 tests pass across discovery + metadata suites. --- core/discovery/manual_match.py | 70 ++++++++++++++ core/metadata/relevance.py | 18 +++- core/musicbrainz_search.py | 21 ++--- tests/discovery/test_manual_match.py | 106 ++++++++++++++++++++++ tests/metadata/test_musicbrainz_search.py | 18 ++-- tests/metadata/test_relevance.py | 53 ++++++++++- web_server.py | 80 ++++++++-------- webui/static/library.js | 17 +++- 8 files changed, 317 insertions(+), 66 deletions(-) create mode 100644 core/discovery/manual_match.py create mode 100644 tests/discovery/test_manual_match.py diff --git a/core/discovery/manual_match.py b/core/discovery/manual_match.py new file mode 100644 index 00000000..8c56bbdf --- /dev/null +++ b/core/discovery/manual_match.py @@ -0,0 +1,70 @@ +"""Helpers for Fix-popup manual match persistence. + +When the user manually fixes a mirrored-playlist discovery via the Fix +popup, two questions land at the web_server route layer that are easier +to test in isolation: + +1. *Which metadata source did the manual match come from?* — the popup + cascade queries the user's primary source first, then Spotify / + Deezer / iTunes / MusicBrainz as fallbacks; each search endpoint + stamps `source` on its rows but the MBID-paste lookup uses a lean + flat shape that doesn't carry it. `derive_manual_match_provider` + collapses the fallback chain into a single string. + +2. *Should the discovery layer re-run for this track when the current + active provider differs from the cached one?* — re-running silently + overwrites the user's deliberate pick with whatever the auto-search + ranks first, so manual matches are exempt regardless of provider + drift. `is_drifted_for_redo` encapsulates the decision. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + + +def derive_manual_match_provider( + payload_track: Dict[str, Any], + active_provider: Optional[str], +) -> str: + """Return the provider string to stamp on a manually-fixed match. + + Resolution order: + 1. ``payload_track['source']`` — every *_search_tracks endpoint + sets this; the MBID-paste path doesn't. + 2. ``active_provider`` — what the user has configured as their + primary discovery source. + 3. ``'spotify'`` — last-ditch default matching the historic + hardcode (so behaviour is identical when both upstream + signals are absent). + """ + if not isinstance(payload_track, dict): + payload_track = {} + source = payload_track.get('source') + if source: + return source + if active_provider: + return active_provider + return 'spotify' + + +def is_drifted_for_redo( + extra_data: Optional[Dict[str, Any]], + active_provider: Optional[str], +) -> bool: + """Return True when a cached discovery entry should be treated as + stale because the user's active provider has changed since it was + cached AND the entry isn't a manual match. + + Manual matches are *always* considered fresh: re-running discovery + against the current source would overwrite the user's deliberate + pick with whatever auto-search ranks first. The first Playlist + Pipeline run after a manual fix used to clobber it for exactly + this reason — the check lives here now so it's pinned by tests. + """ + if not isinstance(extra_data, dict): + return False + if extra_data.get('manual_match'): + return False + cached_provider = extra_data.get('provider', 'spotify') + return cached_provider != active_provider diff --git a/core/metadata/relevance.py b/core/metadata/relevance.py index 37aed5e0..d3e68935 100644 --- a/core/metadata/relevance.py +++ b/core/metadata/relevance.py @@ -295,6 +295,7 @@ def rerank_tracks( *, expected_title: str, expected_artist: str, + prefer_known_duration: bool = False, ) -> List[Track]: """Return a copy of ``tracks`` sorted by descending relevance score against the expected title + artist. @@ -304,6 +305,15 @@ def rerank_tracks( fallback when two candidates score identically — the source's popularity signal is still useful as a tiebreak). + ``prefer_known_duration``: when True, recordings with non-zero + ``duration_ms`` are ranked ahead of duplicate-score recordings + that lack length data. Used for MusicBrainz which often has + several recordings per song (single edition, album edition, + compilations, remasters) where some carry length and some don't. + Sort key sits between score and the stable-order tiebreaker so + relevance still wins — length is only a tiebreaker on equal + scores, not a global re-shuffle. + No-op when both ``expected_title`` and ``expected_artist`` are empty (no signal to rank against — return input order).""" if not expected_title and not expected_artist: @@ -312,8 +322,12 @@ def rerank_tracks( (score_track(t, expected_title=expected_title, expected_artist=expected_artist), idx, t) for idx, t in enumerate(tracks) ] - # Sort by score desc; idx asc as tiebreaker preserves stable order. - scored.sort(key=lambda x: (-x[0], x[1])) + if prefer_known_duration: + # Sort key: score desc, has-length first (0 before 1), idx asc. + scored.sort(key=lambda x: (-x[0], 0 if (x[2].duration_ms or 0) > 0 else 1, x[1])) + else: + # Sort by score desc; idx asc as tiebreaker preserves stable order. + scored.sort(key=lambda x: (-x[0], x[1])) return [t for _score, _idx, t in scored] diff --git a/core/musicbrainz_search.py b/core/musicbrainz_search.py index ff39a827..8e7bf9bd 100644 --- a/core/musicbrainz_search.py +++ b/core/musicbrainz_search.py @@ -772,12 +772,11 @@ class MusicBrainzSearchClient: When only one field is present: bare-query mode directly — same recall-over-precision tradeoff the old single-path took. - Results are stable-sorted to prefer entries with non-zero - `duration_ms`. MB often has several recordings per song (single - release, album release, compilations, remasters) and not every - recording carries length data. Without this, the first match can - be a length-less duplicate while a sibling recording with the - real 3:04 sits two rows down. + Callers wanting MB-specific length-preference ordering (multi- + edition recordings where some lack length data) should pass + ``prefer_known_duration=True`` to ``rerank_tracks`` downstream + — a stable sort here would just be re-sorted away by the rerank + pass anyway. """ if not track and not artist: return [] @@ -790,13 +789,11 @@ class MusicBrainzSearchClient: results = self._search_tracks_text( track, artist, limit, strict=False, min_score=20 ) - else: - results = self._search_tracks_text( - track, artist or None, limit, strict=False, min_score=20 - ) + return results - results.sort(key=lambda t: 0 if (t.duration_ms or 0) > 0 else 1) - return results + return self._search_tracks_text( + track, artist or None, limit, strict=False, min_score=20 + ) def _pick_representative_release(self, releases: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: """Pick the best release out of a release-group's editions. diff --git a/tests/discovery/test_manual_match.py b/tests/discovery/test_manual_match.py new file mode 100644 index 00000000..8bc34b09 --- /dev/null +++ b/tests/discovery/test_manual_match.py @@ -0,0 +1,106 @@ +"""Tests for core.discovery.manual_match helpers. + +These pin the contract for two route-layer decisions lifted out of +web_server.py so the Fix-popup → mirrored-playlist back-sync flow is +testable in isolation (per kettui's standing rule that web_server.py +behavior is reproduced in core/ modules with real unit tests, not by +AST-parsing the route file). +""" + +from core.discovery.manual_match import ( + derive_manual_match_provider, + is_drifted_for_redo, +) + + +# --------------------------------------------------------------------------- +# derive_manual_match_provider +# --------------------------------------------------------------------------- + + +def test_derive_uses_payload_source_when_present(): + """Search-endpoint payloads always stamp `source` — that's the + authoritative provider for a manual match.""" + payload = {'id': 'rec-1', 'source': 'musicbrainz', 'name': 'Track'} + assert derive_manual_match_provider(payload, 'spotify') == 'musicbrainz' + + +def test_derive_falls_back_to_active_when_payload_missing_source(): + """MBID-paste path returns a lean flat shape without `source`. Fall + back to the user's active discovery source so the cached match + matches whatever provider next compares against it.""" + payload = {'id': 'mb-mbid', 'name': 'Track'} # no `source` + assert derive_manual_match_provider(payload, 'musicbrainz') == 'musicbrainz' + + +def test_derive_falls_back_to_spotify_when_both_missing(): + """Last-ditch default matches the historic hardcode so behaviour is + identical when both upstream signals are absent (e.g. broken + config, missing active source).""" + assert derive_manual_match_provider({}, None) == 'spotify' + assert derive_manual_match_provider({}, '') == 'spotify' + + +def test_derive_handles_non_dict_payload_gracefully(): + """Defensive — caller passes whatever request.get_json() returned.""" + assert derive_manual_match_provider(None, 'spotify') == 'spotify' + assert derive_manual_match_provider('not-a-dict', 'musicbrainz') == 'musicbrainz' + + +def test_derive_payload_source_wins_even_when_active_set(): + """`source` on payload is authoritative — even if the user's active + source changed mid-flow, the match came from whatever the popup + cascade actually queried.""" + payload = {'source': 'itunes'} + assert derive_manual_match_provider(payload, 'spotify') == 'itunes' + + +# --------------------------------------------------------------------------- +# is_drifted_for_redo +# --------------------------------------------------------------------------- + + +def test_drift_redo_when_provider_changed_and_not_manual(): + """Standard provider-drift case: cached provider differs from + active, no manual flag → re-discover so active source's IDs / + artwork take effect.""" + extra = {'discovered': True, 'provider': 'spotify'} + assert is_drifted_for_redo(extra, 'musicbrainz') is True + + +def test_drift_no_redo_when_provider_matches(): + """Same provider → cached entry is fresh, no redo needed.""" + extra = {'discovered': True, 'provider': 'spotify'} + assert is_drifted_for_redo(extra, 'spotify') is False + + +def test_drift_no_redo_when_manual_match_even_if_provider_drifted(): + """The crux of the bug fix: manual matches are exempt from + provider-drift redo. Re-running would overwrite the user's pick.""" + extra = {'discovered': True, 'provider': 'musicbrainz', 'manual_match': True} + assert is_drifted_for_redo(extra, 'spotify') is False + + +def test_drift_no_redo_when_manual_match_with_matching_provider(): + """Manual + provider match: trivially fresh.""" + extra = {'discovered': True, 'provider': 'spotify', 'manual_match': True} + assert is_drifted_for_redo(extra, 'spotify') is False + + +def test_drift_no_redo_when_extra_data_missing(): + """No cached entry → nothing to drift from.""" + assert is_drifted_for_redo(None, 'spotify') is False + assert is_drifted_for_redo({}, 'spotify') is False + + +def test_drift_handles_non_dict_extra_data(): + """Defensive — extra_data deserialisation can land non-dict shapes.""" + assert is_drifted_for_redo('not-a-dict', 'spotify') is False + + +def test_drift_default_provider_is_spotify_when_absent(): + """Historic cached entries may pre-date the provider column being + populated — treat absent provider as 'spotify' (the legacy default).""" + extra = {'discovered': True} # no provider field + assert is_drifted_for_redo(extra, 'spotify') is False + assert is_drifted_for_redo(extra, 'musicbrainz') is True diff --git a/tests/metadata/test_musicbrainz_search.py b/tests/metadata/test_musicbrainz_search.py index 322cc284..96d2c220 100644 --- a/tests/metadata/test_musicbrainz_search.py +++ b/tests/metadata/test_musicbrainz_search.py @@ -1050,11 +1050,12 @@ def test_search_tracks_with_artist_falls_back_to_bare_when_strict_empty(): assert tracks[0].id == 'rec-canonical' -def test_search_tracks_with_artist_prefers_results_with_known_length(): - """MB has multiple recordings per song (single release, album release, - compilations) and not every recording carries length data. Stable - sort moves length-known entries ahead of length-zero duplicates so - the user sees the actionable 3:04 row first, not the 0:00 sibling.""" +def test_search_tracks_with_artist_does_not_resort_by_length(): + """Length-preference ordering lives downstream in + ``rerank_tracks(..., prefer_known_duration=True)`` — sorting here + would be re-sorted away by rerank anyway, so this method preserves + the order MB returned. Pin the contract: this method does not + re-shuffle by duration_ms.""" client = MusicBrainzSearchClient() client._client = MagicMock() client._client.search_recording.return_value = [ @@ -1067,10 +1068,9 @@ def test_search_tracks_with_artist_prefers_results_with_known_length(): tracks = client.search_tracks_with_artist('Coffee Break', 'Zeds Dead', limit=10) - # rec-with-length must surface first even though MB scored it lower - assert tracks[0].id == 'rec-with-length' - assert tracks[0].duration_ms == 184000 - assert tracks[1].id == 'rec-no-length' + # MB's order is preserved here — rerank applies length-pref downstream. + assert tracks[0].id == 'rec-no-length' + assert tracks[1].id == 'rec-with-length' def test_search_tracks_with_artist_handles_missing_artist(): diff --git a/tests/metadata/test_relevance.py b/tests/metadata/test_relevance.py index 38e71555..4c6f9394 100644 --- a/tests/metadata/test_relevance.py +++ b/tests/metadata/test_relevance.py @@ -50,6 +50,7 @@ def _track( album: str = 'Unknown', album_type: str = 'album', track_id: str = 't', + duration_ms: int = 200000, ) -> Track: """Tiny Track factory — keeps test bodies focused on the fields under test.""" @@ -58,7 +59,7 @@ def _track( name=name, artists=[artist], album=album, - duration_ms=200000, + duration_ms=duration_ms, album_type=album_type, ) @@ -366,6 +367,56 @@ class TestRerankTracks: ranked = rerank_tracks([a, b], expected_title='Track', expected_artist='Artist') assert [t.id for t in ranked] == ['first', 'second'] + def test_prefer_known_duration_promotes_length_known_on_ties(self): + """MB has multiple recordings per song where some lack length + data. With ``prefer_known_duration=True`` and equal relevance + scores, the recording with non-zero duration_ms must surface + ahead of the length-less sibling.""" + no_length = _track('Track', artist='Artist', track_id='no-len', duration_ms=0) + with_length = _track('Track', artist='Artist', track_id='with-len', duration_ms=184000) + ranked = rerank_tracks( + [no_length, with_length], + expected_title='Track', + expected_artist='Artist', + prefer_known_duration=True, + ) + assert [t.id for t in ranked] == ['with-len', 'no-len'] + + def test_prefer_known_duration_does_not_override_relevance(self): + """Length-preference is a TIEBREAKER, not a global resort. A + length-less track that scores higher on relevance must still + win — only equal-score pairs use length as the deciding bit.""" + # Wrong-artist length-known: scored low. Right-artist length-less: + # scored high. + length_known_cover = _track( + 'Track', artist='Karaoke Channel', album='Karaoke Hits', + album_type='compilation', track_id='cover', duration_ms=184000, + ) + length_less_real = _track( + 'Track', artist='Real Artist', track_id='real', duration_ms=0, + ) + ranked = rerank_tracks( + [length_known_cover, length_less_real], + expected_title='Track', + expected_artist='Real Artist', + prefer_known_duration=True, + ) + assert ranked[0].id == 'real', "relevance must beat length-pref" + + def test_prefer_known_duration_default_off(self): + """Default behaviour unchanged — length-preference is opt-in + for MB callers; Spotify / iTunes / Deezer don't need it + because their search results always include length.""" + no_length = _track('Track', artist='Artist', track_id='no-len', duration_ms=0) + with_length = _track('Track', artist='Artist', track_id='with-len', duration_ms=184000) + ranked = rerank_tracks( + [no_length, with_length], + expected_title='Track', + expected_artist='Artist', + ) + # Default: stable input order, no length-pref re-shuffle. + assert [t.id for t in ranked] == ['no-len', 'with-len'] + # --------------------------------------------------------------------------- # filter_and_rerank — score floor convenience diff --git a/web_server.py b/web_server.py index 0fd2ff69..7e6209ed 100644 --- a/web_server.py +++ b/web_server.py @@ -19416,12 +19416,19 @@ def search_musicbrainz_tracks(): # Local rerank — same helper Deezer / iTunes use. Penalises # cover / karaoke / tribute patterns + boosts exact-artist match. + # `prefer_known_duration=True` is MB-specific: MB has multiple + # recordings per song (single / album / compilation / remaster + # editions) and not every recording carries length data. The + # flag promotes length-known recordings ahead of length-less + # duplicates when relevance scores tie, so the user sees the + # actionable 3:04 row before the 0:00 sibling. if track_q or artist_q: from core.metadata.relevance import rerank_tracks tracks = rerank_tracks( tracks, expected_title=track_q, expected_artist=artist_q, + prefer_known_duration=True, ) tracks_dict = [{ @@ -24315,6 +24322,20 @@ def update_youtube_discovery_match(): logger.info(f"Manual match updated: youtube - {identifier} - track {track_index}") logger.info(f" → {result['spotify_artist']} - {result['spotify_track']}") + # See core.discovery.manual_match — Fix-popup matches can come from + # any metadata source (primary first, then Spotify / Deezer / iTunes + # / MusicBrainz as fallbacks). Hardcoding 'spotify' here used to + # make every non-Spotify manual match look provider-drifted on the + # next prepare-discovery, which triggered automatic re-discovery + # that overwrote the user's pick. Computed once before the try + # block so both the cache-save path AND the mirrored-DB save below + # (in the except fallback case) see the same value. + from core.discovery.manual_match import derive_manual_match_provider + match_source = derive_manual_match_provider( + spotify_track, _get_active_discovery_source() + ) + matched_data = None + # Save manual fix to discovery cache so it appears in discovery pool try: # Get original track name from the YouTube/source track data @@ -24349,20 +24370,6 @@ def update_youtube_discovery_match(): album_obj['image_url'] = image_url album_obj['images'] = [{'url': image_url}] - # Manual fixes can come from any metadata source — the Fix-popup - # cascade queries the user's primary first, then Spotify / Deezer / - # iTunes / MusicBrainz as fallbacks. Each search endpoint stamps - # `source` on its results; the MBID-paste lookup doesn't, so fall - # back to the active discovery source. Hardcoding 'spotify' here - # used to make every non-Spotify manual match look like it had - # provider-drifted on the next prepare-discovery, triggering - # automatic re-discovery that overwrote the user's manual pick. - match_source = ( - spotify_track.get('source') - or _get_active_discovery_source() - or 'spotify' - ) - matched_data = { 'id': spotify_track['id'], 'name': spotify_track['name'], @@ -24380,16 +24387,12 @@ def update_youtube_discovery_match(): logger.info(f"Manual fix saved to discovery cache: {original_name} by {original_artist}") except Exception as cache_err: logger.error(f"Error saving manual fix to discovery cache: {cache_err}") - # match_source needs to exist for the mirrored-DB block below even - # if cache save failed — use the same fallback chain. - match_source = ( - spotify_track.get('source') - or _get_active_discovery_source() - or 'spotify' - ) - # Persist manual fix to DB for mirrored playlists - if identifier.startswith('mirrored_'): + # Persist manual fix to DB for mirrored playlists. Skips when the + # cache-save block raised before matched_data was constructed — + # without the payload there's nothing to persist, and re-deriving + # it here would duplicate the construction logic above. + if matched_data is not None and identifier.startswith('mirrored_'): try: tracks = state['playlist']['tracks'] if track_index < len(tracks): @@ -33688,21 +33691,20 @@ def prepare_mirrored_discovery(playlist_id): pre_discovered_count = 0 has_pending = False + from core.discovery.manual_match import is_drifted_for_redo + for idx, track in enumerate(tracks): extra = track.get('extra_data') if extra and extra.get('discovered'): cached_provider = extra.get('provider', 'spotify') - is_manual = bool(extra.get('manual_match')) - # If the cached result was discovered by a different provider than the - # currently active one, treat it as pending so re-discovery uses the - # correct source (IDs, album data, images differ between providers). - # Manual matches are exempt: the user explicitly picked that record, - # so re-discovery would overwrite their intentional fix every cycle. - # Without this guard, fixing a track via the Fix popup correctly - # downloads it once, but the next Playlist Pipeline run re-marks it - # "Provider Changed" and reverts the manual map. - if cached_provider != _current_provider and not is_manual: + # See core.discovery.manual_match.is_drifted_for_redo — + # provider-drift triggers re-discovery so the active source's + # IDs / artwork take effect, but manual matches are exempt: + # re-running would overwrite the user's deliberate pick with + # whatever auto-search ranks first. Pre-fix, every Playlist + # Pipeline run clobbered manual fixes for exactly this reason. + if is_drifted_for_redo(extra, _current_provider): has_pending = True dur = track.get('duration_ms', 0) pre_discovered_results.append({ @@ -33784,16 +33786,14 @@ def prepare_mirrored_discovery(playlist_id): 'confidence': 0, }) - # Treat as cached if at least one track was discovered by the current provider, - # OR if any track carries a manual_match (those are exempt from provider-drift - # re-discovery above, so they count as cached regardless of cached_provider). + # Treat as cached when at least one track has a non-drifted cached + # discovery — same predicate the per-track loop above uses (inverse + # polarity), so a future field change only has to land in + # core.discovery.manual_match.is_drifted_for_redo. has_cached = any( t.get('extra_data') and (t['extra_data'].get('discovered') or t['extra_data'].get('discovery_attempted')) and - ( - t['extra_data'].get('provider', 'spotify') == _current_provider - or t['extra_data'].get('manual_match') - ) + not is_drifted_for_redo(t['extra_data'], _current_provider) for t in tracks ) diff --git a/webui/static/library.js b/webui/static/library.js index dac0467a..a979233c 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -885,7 +885,7 @@ function navigateToArtistDetail(artistId, artistName, sourceOverride = null, opt // disabled in private-browsing modes. let _preferEnhanced = false; try { - _preferEnhanced = localStorage.getItem('soulsync-library-view-mode') === 'enhanced'; + _preferEnhanced = localStorage.getItem(_libraryViewModeKey()) === 'enhanced'; } catch (_) { /* localStorage unavailable */ } // Navigate to artist detail page @@ -2925,10 +2925,23 @@ function toggleEnhancedView(enabled) { // Persist the choice so the next artist click (and the next page reload) // honours it instead of always reverting to Standard. try { - localStorage.setItem('soulsync-library-view-mode', enabled ? 'enhanced' : 'standard'); + localStorage.setItem(_libraryViewModeKey(), enabled ? 'enhanced' : 'standard'); } catch (_) { /* localStorage unavailable */ } } +// localStorage key for the Enhanced/Standard toggle, scoped to the active +// profile so different admin profiles can keep different defaults. Falls +// back to an unsuffixed key when no profile is loaded (matches the original +// behaviour for any pre-multi-profile saved value). +function _libraryViewModeKey() { + const pid = (typeof currentProfile === 'object' && currentProfile && currentProfile.id != null) + ? currentProfile.id + : null; + return pid != null + ? `soulsync-library-view-mode:${pid}` + : 'soulsync-library-view-mode'; +} + async function loadEnhancedViewData(artistId) { const container = document.getElementById('enhanced-view-container'); if (!container) return; From 6125ef8834c60ef80c939557bc3f7ba9a0a32b1c Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 27 May 2026 08:13:28 -0700 Subject: [PATCH 80/81] MB rerank: prefer_known_duration is now a score boost, not a tiebreaker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live smoke against `/api/musicbrainz/search_tracks?track=Coffee+Break&artist=Zeds+Dead` exposed the edge case the tiebreaker implementation couldn't reach: The canonical Zeds Dead "Coffee Break" recording (mbid 6e2d4a70, length 184000ms) lives on the Coffee Break Single release — album_type='single', which carries a 0.85 album_type_weight in `score_track`. A sibling length-less recording (mbid 3b89bf3c) lives on an Album release — album_type='album', weight 1.0. After multiplying by EXACT_ARTIST_BOOST the canonical sat at 1.275 while the length-less sibling sat at 1.5. The previous tiebreaker only kicked in on equal scores, so the length-less album edition wins and the user sees 0:00 first instead of the actionable 3:04 row. Bug reproduced: ordering came out length-less / canonical / Omar-LinX-collab. Switched `prefer_known_duration` to a 1.25x score boost on recordings with non-zero duration_ms. The multiplier is sized above the album-vs-single weight spread (0.176) so length-known recordings can overcome an album-type penalty when scores would otherwise tie on title + artist match, but stays small enough that cover/karaoke penalty (0.05) and variant-tag penalty (0.85) still dominate — a length-known tribute still loses to a length-less canonical. Post-fix live response: 6e2d4a70 (canonical, 184000ms) sits first, 8ec2ce3f (Zeds Dead + Omar LinX collab, 153000ms) second, 3b89bf3c (length-less album edition) third. Verified Björk diacritic fallback path unaffected — `Bjork` + `Army of Me` still cascades strict-empty → bare and returns all 10 Björk recordings. 122 metadata tests pass — the three `prefer_known_duration` cases were designed to pin behaviour, not the specific multiplier value, so they all still pass under the boost implementation: ties promote length-known, relevance still beats length-pref, default-off behaviour unchanged. --- core/metadata/relevance.py | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/core/metadata/relevance.py b/core/metadata/relevance.py index d3e68935..43ab430b 100644 --- a/core/metadata/relevance.py +++ b/core/metadata/relevance.py @@ -306,13 +306,20 @@ def rerank_tracks( popularity signal is still useful as a tiebreak). ``prefer_known_duration``: when True, recordings with non-zero - ``duration_ms`` are ranked ahead of duplicate-score recordings - that lack length data. Used for MusicBrainz which often has - several recordings per song (single edition, album edition, - compilations, remasters) where some carry length and some don't. - Sort key sits between score and the stable-order tiebreaker so - relevance still wins — length is only a tiebreaker on equal - scores, not a global re-shuffle. + ``duration_ms`` get a score boost. Used for MusicBrainz, which + often has several recordings per song (single edition, album + edition, compilations, remasters) where some carry length data + and some don't. The boost is set above the album_type weight + spread so length-known recordings can beat length-less + siblings even when the sibling sits on a higher-weighted + album-type — real case: Zeds Dead "Coffee Break" canonical + recording lives on the Single release (album_type='single', + weight 0.85) while a length-less sibling lives on an Album + release (weight 1.0). Without the boost, the length-less album + edition wins and the user sees 0:00 instead of 3:04. Cover / + karaoke penalties dominate the boost (their penalty is 0.05) + so a length-known tribute still loses to a length-less + canonical match. No-op when both ``expected_title`` and ``expected_artist`` are empty (no signal to rank against — return input order).""" @@ -323,11 +330,18 @@ def rerank_tracks( for idx, t in enumerate(tracks) ] if prefer_known_duration: - # Sort key: score desc, has-length first (0 before 1), idx asc. - scored.sort(key=lambda x: (-x[0], 0 if (x[2].duration_ms or 0) > 0 else 1, x[1])) - else: - # Sort by score desc; idx asc as tiebreaker preserves stable order. - scored.sort(key=lambda x: (-x[0], x[1])) + # Multiplier sized above the album-type weight spread (album 1.0 + # vs single 0.85 = ~18%) so length-known recordings can overcome + # the album-vs-single penalty when scores would otherwise tie on + # title + artist match. Penalty multipliers (cover/karaoke=0.05, + # variant=0.85) still dominate, so this only flips order among + # close-relevance siblings — exactly the MB-duplicate case. + scored = [ + (score * 1.25 if (t.duration_ms or 0) > 0 else score, idx, t) + for score, idx, t in scored + ] + # Sort by score desc; idx asc as tiebreaker preserves stable order. + scored.sort(key=lambda x: (-x[0], x[1])) return [t for _score, _idx, t in scored] From 65d7756da27176698a3467edc1b8c92aedb41eac Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 27 May 2026 08:33:36 -0700 Subject: [PATCH 81/81] Resolve pre-existing ruff lint errors blocking CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five pre-existing lint errors on dev baseline (all introduced May 25-26 before this branch was cut) were blocking CI on this PR. Cleared as courtesy fixes so the merge isn't gated on unrelated tech debt: - web_server.py:22613 — F811 duplicate `urlparse` import inside `_parse_itunes_link_url` (already imported at module top, line 20). Removed from the inline `from urllib.parse import parse_qs, urlparse`; kept `parse_qs` since that one is only used here. - core/listenbrainz_manager.py:746 — S110 silenced with `# noqa: S110 — best-effort lookup, delete proceeds either way`. Matches the existing project convention used in web_server.py:1693, core/watchlist/auto_scan.py:463, core/library_reorganize.py:548. - core/playlists/sources/listenbrainz.py:236 — B905 `zip()` without explicit `strict=`. Added `strict=False` — preserves existing behaviour where `matched` can legitimately be shorter than `match_indices` on partial discover failure. - core/playlists/sources/listenbrainz.py:273 — S110 silenced with `# noqa: S110 — caller falls back to last cached playlist on refresh failure`. - core/playlists/sources/soulsync_discovery.py:105 — S110 silenced with `# noqa: S110 — manager persists last_generation_error on failure; surface existing snapshot`. The existing multi-line comment that already explained the swallow was rolled into the noqa justification so the rule + reason live on one line. Ruff `python -m ruff check .` now passes; 664 discovery + metadata tests still pass. --- core/listenbrainz_manager.py | 2 +- core/playlists/sources/listenbrainz.py | 4 ++-- core/playlists/sources/soulsync_discovery.py | 4 +--- web_server.py | 2 +- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/core/listenbrainz_manager.py b/core/listenbrainz_manager.py index ddae7ae8..efa16158 100644 --- a/core/listenbrainz_manager.py +++ b/core/listenbrainz_manager.py @@ -743,7 +743,7 @@ class ListenBrainzManager: ) row = cursor.fetchone() playlist_type = row[0] if row else '' - except Exception: + except Exception: # noqa: S110 — best-effort lookup, delete proceeds either way pass # Delete tracks first (SQLite FK CASCADE requires PRAGMA foreign_keys=ON) diff --git a/core/playlists/sources/listenbrainz.py b/core/playlists/sources/listenbrainz.py index 0c5f72ff..838cda15 100644 --- a/core/playlists/sources/listenbrainz.py +++ b/core/playlists/sources/listenbrainz.py @@ -233,7 +233,7 @@ class ListenBrainzPlaylistSource(PlaylistSource): return tracks out = list(tracks) - for slot_idx, result in zip(match_indices, matched): + for slot_idx, result in zip(match_indices, matched, strict=False): if not result: continue track = out[slot_idx] @@ -270,7 +270,7 @@ class ListenBrainzPlaylistSource(PlaylistSource): return None try: manager.update_all_playlists() - except Exception: + except Exception: # noqa: S110 — caller falls back to last cached playlist on refresh failure pass return self.get_playlist(playlist_id) diff --git a/core/playlists/sources/soulsync_discovery.py b/core/playlists/sources/soulsync_discovery.py index c648830c..d2cfec25 100644 --- a/core/playlists/sources/soulsync_discovery.py +++ b/core/playlists/sources/soulsync_discovery.py @@ -102,9 +102,7 @@ class SoulSyncDiscoveryPlaylistSource(PlaylistSource): variant=record.variant, profile_id=record.profile_id, ) - except Exception: - # Manager already persists ``last_generation_error`` on - # failure; surface the existing snapshot regardless. + except Exception: # noqa: S110 — manager persists last_generation_error on failure; surface existing snapshot pass return self.get_playlist(playlist_id) diff --git a/web_server.py b/web_server.py index 7e6209ed..268ec877 100644 --- a/web_server.py +++ b/web_server.py @@ -22610,7 +22610,7 @@ _APPLE_MUSIC_BUNDLE_SCRAPE_CAP = 8 def _parse_itunes_link_url(url): """Return {'type': 'album'|'track'|'playlist', 'id': str} for supported Apple links.""" import re - from urllib.parse import parse_qs, urlparse + from urllib.parse import parse_qs raw = (url or '').strip() if not raw: