+ Read-only Automations-page pipelines
+ These use the playlist pipeline but are managed from the Automations page, so this modal only displays them.
+
@@ -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.
+ Read-only Automations-page pipelines
+ These use the playlist pipeline but are managed from the Automations page, so this modal only displays them.
+
- Read-only Automations-page pipelines
- These use the playlist pipeline but are managed from the Automations page, so this modal only displays them.
-
+ 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.
+
Playlist pipeline run historyEach run records what changed on the mirrored playlist before and after refresh, discovery, sync, and wishlist processing.
`).join('');
+ // `tracks_discovered` was a status STRING (e.g. "completed"), not a
+ // count — kept it out of the pills so the panel doesn't show a
+ // confusing "Discovered: completed" chip. Same data is already
+ // surfaced as a before/after stat card above.
const resultPills = [
['Refreshed', result.playlists_refreshed],
- ['Discovered', result.tracks_discovered],
['Synced', result.tracks_synced],
['Skipped', result.sync_skipped],
['Wishlisted', result.wishlist_queued],
@@ -779,10 +864,21 @@ function autoSyncHistoryDetailHtml(entry, before, after, result, deltas) {
.map(([k, v]) => `${_esc(k)}${_esc(String(v))}`).join('');
const errorBlock = result.error ? `
@@ -511,6 +527,145 @@ function loadMoreAutoSyncHistory() {
refreshAutoSyncScheduleModal();
}
+function openAutoSyncBulkMenu(event, source) {
+ // Build a transient popover with all the standard buckets + a "Custom…"
+ // entry. Position relative to the button that triggered it.
+ closeAutoSyncBulkMenu();
+ const anchor = event.currentTarget;
+ if (!anchor) return;
+ const menu = document.createElement('div');
+ menu.className = 'auto-sync-bulk-menu';
+ menu.id = 'auto-sync-bulk-menu';
+ const buckets = [...AUTO_SYNC_BUCKETS];
+ const buttons = buckets.map(h => `
+
+ `).join('');
+ menu.innerHTML = `
+
Schedule all ${_esc(autoSyncSourceLabel(source))}
+
${buttons}
+
+
+ `;
+ document.body.appendChild(menu);
+ const rect = anchor.getBoundingClientRect();
+ menu.style.top = `${rect.bottom + 4}px`;
+ menu.style.left = `${Math.max(8, rect.right - menu.offsetWidth)}px`;
+ // Close on outside click
+ setTimeout(() => {
+ document.addEventListener('click', _autoSyncBulkMenuOutsideClick, { once: true });
+ }, 0);
+}
+
+function _autoSyncBulkMenuOutsideClick(event) {
+ const menu = document.getElementById('auto-sync-bulk-menu');
+ if (menu && !menu.contains(event.target)) closeAutoSyncBulkMenu();
+}
+
+function closeAutoSyncBulkMenu() {
+ const existing = document.getElementById('auto-sync-bulk-menu');
+ if (existing) existing.remove();
+}
+
+function promptAutoSyncBulkCustom(source) {
+ closeAutoSyncBulkMenu();
+ const raw = window.prompt('Custom interval in hours (e.g. 6, 36, 96):', '6');
+ if (raw === null) return;
+ const hours = parseInt(raw, 10);
+ if (!Number.isFinite(hours) || hours < 1) {
+ showToast('Interval must be a whole number of hours, 1 or greater', 'error');
+ return;
+ }
+ bulkScheduleAutoSyncSource(source, hours);
+}
+
+async function bulkScheduleAutoSyncSource(source, hours) {
+ closeAutoSyncBulkMenu();
+ const { playlists } = _autoSyncScheduleState;
+ const targets = (playlists || []).filter(p => p.source === source && autoSyncCanSchedulePlaylist(p));
+ if (!targets.length) {
+ showToast(`No schedulable ${autoSyncSourceLabel(source)} playlists`, 'info');
+ return;
+ }
+ if (!await showConfirmDialog({
+ title: `Schedule ${targets.length} ${autoSyncSourceLabel(source)} playlist${targets.length === 1 ? '' : 's'}`,
+ message: `Every ${autoSyncIntervalLabel(hours).toLowerCase().replace(/^every /, '')}. Existing schedules in this source will be updated.`,
+ })) return;
+ let ok = 0, fail = 0;
+ for (const playlist of targets) {
+ try {
+ await saveAutoSyncPlaylistScheduleSilent(playlist.id, hours);
+ ok++;
+ } catch (_err) {
+ fail++;
+ }
+ }
+ showToast(`Scheduled ${ok} ${autoSyncSourceLabel(source)} playlist${ok === 1 ? '' : 's'} at ${autoSyncBucketLabel(hours)}${fail ? ` (${fail} failed)` : ''}`, fail ? 'warning' : 'success');
+ await refreshAutoSyncScheduleModal();
+}
+
+async function bulkUnscheduleAutoSyncSource(source) {
+ closeAutoSyncBulkMenu();
+ const { playlists, playlistSchedules } = _autoSyncScheduleState;
+ const targets = (playlists || []).filter(p => p.source === source && playlistSchedules[p.id]);
+ if (!targets.length) {
+ showToast(`No scheduled ${autoSyncSourceLabel(source)} playlists to unschedule`, 'info');
+ return;
+ }
+ if (!await showConfirmDialog({
+ title: `Unschedule ${targets.length} ${autoSyncSourceLabel(source)} playlist${targets.length === 1 ? '' : 's'}`,
+ message: 'Removes the Auto-Sync schedules. Mirrored playlists themselves stay.',
+ })) return;
+ let ok = 0, fail = 0;
+ for (const playlist of targets) {
+ const schedule = playlistSchedules[playlist.id];
+ if (!schedule) continue;
+ try {
+ const res = await fetch(`/api/automations/${schedule.automation_id}`, { method: 'DELETE' });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ ok++;
+ } catch (_err) {
+ fail++;
+ }
+ }
+ showToast(`Removed ${ok} schedule${ok === 1 ? '' : 's'}${fail ? ` (${fail} failed)` : ''}`, fail ? 'warning' : 'success');
+ await refreshAutoSyncScheduleModal();
+}
+
+async function saveAutoSyncPlaylistScheduleSilent(playlistId, hours) {
+ // Like saveAutoSyncPlaylistSchedule but without toasts/refresh — caller
+ // batches feedback. Re-uses the existing automation row when one already
+ // exists for the playlist.
+ const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10));
+ if (!playlist) throw new Error('playlist not found');
+ 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',
+ };
+ 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');
+ return data;
+}
+
function populateAutoSyncHistoryList(root = document) {
const list = root.querySelector('.auto-sync-history-list');
if (!list) return;
diff --git a/webui/static/helper.js b/webui/static/helper.js
index 5529e794..576046ca 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: 'Auto-Sync: bulk schedule by source + custom interval columns', desc: 'each source group in the sidebar gets a Bulk button — opens a popover that lets you schedule every playlist in that group at a chosen interval in one click (1h / 2h / 4h / 8h / 12h / 16h / 24h / 48h / 72h / weekly) or "Custom interval…" for an arbitrary number of hours. also includes "Unschedule all" to clear a source\'s schedules. on the board itself, a schedule with a non-standard interval (e.g. 6h or 36h, created via Automations page or the custom prompt) now renders as its own dashed-border column instead of disappearing because it didn\'t match a hardcoded bucket.' },
{ title: 'Auto-Sync manager: filter, failure indicators, history filters + load more', desc: 'a handful of UX additions on the Playlist Auto-Sync modal. sidebar gets a "Filter playlists…" search input so you can narrow down a long mirrored-playlist list. scheduled cards on the board now show a red ! badge when the last three pipeline runs all failed (yellow ⚠ if at least one of the last few failed) so chronically broken schedules surface visually instead of getting buried in the run-history tab. run history tab title shows a red error count when there are failed runs in the loaded window. the history tab itself gains All / Errors / Completed filter pills, a "Load more" footer that pulls another 50 entries, and a "Run pipeline again" button inside the expanded detail panel so you can re-trigger a specific playlist without leaving the modal. also dropped the "Discovered: completed" pill — `tracks_discovered` was a status string, not a count, and the same data is already in the before/after stats grid above.' },
{ title: 'Auto-Sync manager redesigned to match the rest of the app', desc: 'the Auto-Sync modal got a top-to-bottom restyle so it stops feeling like it lives in a different app. modal shell now uses the standard SoulSync gradient + accent-tinted border + 20px radius, the KPI summary became inset stat tiles, the live pipeline monitor is auto-fill instead of a fixed 4-col grid, tabs are now underline-style instead of pill buttons, and the schedule board sidebar/columns use the dense dark-card pattern that Automations + Library pages already use. modal also fills more of the screen since there was a lot of unused real estate. run history cards got the same treatment — slim horizontal row matching `.automation-card` (status dot + name + flow chips + meta row), and the expanded panel uses the same stats-grid / log-section structure as the Automations run-history modal.' },
{ title: 'Fix: Auto-Sync manager hung forever on "Loading schedule..."', desc: 'a regression while wiring up the new "in library" count: the join used `COLLATE NOCASE` on `tracks.title` and `artists.name`, which can\'t use the indexes those columns have. on a 300k-track library each playlist took ~18s. with 30 mirrored playlists the modal\'s `/api/mirrored-playlists` call would never come back. switched to case-sensitive equality so SQLite uses `idx_artists_name` + `idx_tracks_title` (6ms per playlist). misses pure-case-different matches but Spotify names are canonical against library imports, so it\'s a rounding-error tradeoff for the 3000× speedup.' },
diff --git a/webui/static/style.css b/webui/static/style.css
index 9d4167f1..e4c31311 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -11682,8 +11682,15 @@ body.helper-mode-active #dashboard-activity-feed:hover {
margin-bottom: 14px;
}
-.auto-sync-source-title {
+.auto-sync-source-group-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 6px;
padding: 6px 4px 4px;
+}
+
+.auto-sync-source-title {
color: rgba(255, 255, 255, 0.38);
font-size: 10px;
font-weight: 700;
@@ -11691,6 +11698,107 @@ body.helper-mode-active #dashboard-activity-feed:hover {
letter-spacing: 0.3px;
}
+.auto-sync-source-bulk-btn {
+ height: 20px;
+ padding: 0 8px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 6px;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.45);
+ font-size: 9px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ cursor: pointer;
+ font-family: inherit;
+ transition: all 0.2s ease;
+}
+
+.auto-sync-source-bulk-btn:hover {
+ background: rgba(var(--accent-rgb), 0.16);
+ border-color: rgba(var(--accent-rgb), 0.35);
+ color: rgb(var(--accent-light-rgb));
+}
+
+/* Bulk-schedule popover for a source group */
+.auto-sync-bulk-menu {
+ position: fixed;
+ z-index: 10001;
+ min-width: 220px;
+ padding: 8px;
+ background: linear-gradient(135deg, #1f1f1f 0%, #161616 100%);
+ border: 1px solid rgba(var(--accent-rgb), 0.25);
+ border-radius: 10px;
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(var(--accent-rgb), 0.08);
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.auto-sync-bulk-menu-title {
+ padding: 6px 8px 4px;
+ color: rgba(255, 255, 255, 0.55);
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+.auto-sync-bulk-menu-buckets {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 4px;
+}
+
+.auto-sync-bulk-menu button {
+ padding: 6px 10px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.03);
+ color: rgba(255, 255, 255, 0.75);
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 600;
+ text-align: left;
+ font-family: inherit;
+ transition: all 0.15s ease;
+}
+
+.auto-sync-bulk-menu button:hover {
+ background: rgba(var(--accent-rgb), 0.16);
+ border-color: rgba(var(--accent-rgb), 0.35);
+ color: rgb(var(--accent-light-rgb));
+}
+
+.auto-sync-bulk-menu-custom {
+ margin-top: 2px;
+}
+
+.auto-sync-bulk-menu-unschedule {
+ color: rgba(255, 255, 255, 0.55) !important;
+}
+
+.auto-sync-bulk-menu-unschedule:hover {
+ background: rgba(239, 68, 68, 0.18) !important;
+ border-color: rgba(239, 68, 68, 0.4) !important;
+ color: #ef4444 !important;
+}
+
+/* Custom-interval column tag */
+.auto-sync-column.custom {
+ border-style: dashed;
+}
+
+.auto-sync-column-head em {
+ font-style: normal;
+ color: rgba(var(--accent-rgb), 0.7);
+ font-size: 9px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ margin-left: 4px;
+}
+
.auto-sync-playlist,
.auto-sync-scheduled-card {
border: 1px solid rgba(255, 255, 255, 0.07);
From f98c1a5997123109c6afbe1db6e0c2ad4a9ca305 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Mon, 25 May 2026 15:12:31 -0700
Subject: [PATCH 36/81] Fix Auto-Sync modal 'total is not defined' regression
Refactor introduced when adding the history filter dropped the
`const total = _autoSyncScheduleState.runHistoryTotal || 0;` line at
the top of populateAutoSyncHistoryList, but line 705's load-more
footer still referenced `total`. ReferenceError bubbled to the
refresh-modal catch and the modal rendered the generic 'Could not
load schedule data' error state instead of the schedule board.
---
webui/static/auto-sync.js | 1 +
1 file changed, 1 insertion(+)
diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js
index 48d897cd..fcd6e2ef 100644
--- a/webui/static/auto-sync.js
+++ b/webui/static/auto-sync.js
@@ -670,6 +670,7 @@ function populateAutoSyncHistoryList(root = document) {
const list = root.querySelector('.auto-sync-history-list');
if (!list) return;
const allHistory = Array.isArray(_autoSyncScheduleState.runHistory) ? _autoSyncScheduleState.runHistory : [];
+ const total = _autoSyncScheduleState.runHistoryTotal || 0;
const filter = _autoSyncHistoryFilter || 'all';
const history = allHistory.filter(h => {
if (filter === 'error') return h.status === 'error' || h.status === 'skipped';
From 82717dec034dd9c447e420040fa965a912f455e4 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Mon, 25 May 2026 15:49:38 -0700
Subject: [PATCH 37/81] Redesign Quick Actions as asymmetric bento with
signature animations
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Auto-Sync hero on the left (spans both rows), Tools + Automations
stacked on the right. Each tile gets a CSS-only ambient animation
that visually represents what that section does — no more three
identical rectangles.
Auto-Sync (hero, 2 rows tall): 20-bar live equalizer animates along
the bottom edge with per-bar offsets so it reads as a real audio
waveform. Foreground has a live status pulse dot + accent kicker,
big 56px icon, large title, description, and a CTA bar separated
by a hairline rule.
Tools (top-right): an oversized gear icon rotates slowly off the
right edge as a watermark. Hover speeds it up (28s -> 12s) and
brightens the tint.
Automations (bottom-right): three nodes connected by gradient lines
pulse in sequence, mimicking trigger -> action -> notify flow. Each
node glows + halos on its phase.
Card recipe (gradient body, top accent stripe, accent border on
hover, multi-layer shadow) is the same library-status-card vocab
the rest of the dashboard already uses. Container query
(container-type: inline-size) drives every dimension via
clamp(min, Ncqw + base, max) so padding, text, icon, and animation
sizes scale with the actual card width — no overflow on narrow
dashboards. Single-column stack at <=560px.
prefers-reduced-motion disables all three signature animations.
---
webui/index.html | 97 ++++++---
webui/static/style.css | 439 ++++++++++++++++++++++++++++++++++++++++-
2 files changed, 511 insertions(+), 25 deletions(-)
diff --git a/webui/index.html b/webui/index.html
index 1fd1df4b..6aa70bc3 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -837,36 +837,85 @@
-
+
Quick Actions
-
Jump into the places that shape how SoulSync runs.
+
Three control rooms inside SoulSync.
-
-
+
${buttonText}
`;
@@ -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 @@
Beatport
-
+
ListenBrainz
@@ -1915,8 +1915,8 @@
-
-
+
+
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 ````
+ tab content container between the LB tab and the existing
Import / Mirrored tabs. Plus a ``
+
diff --git a/webui/static/helper.js b/webui/static/helper.js
index 9c765e1b..b6a31d44 100644
--- a/webui/static/helper.js
+++ b/webui/static/helper.js
@@ -3419,6 +3419,7 @@ const WHATS_NEW = {
{ 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.' },
{ 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' },
],
'2.6.2': [
{ date: 'May 24, 2026 — 2.6.2 release' },
diff --git a/webui/static/style.css b/webui/static/style.css
index 1b9bf4e5..71b9ed5d 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -13272,6 +13272,14 @@ body.helper-mode-active #dashboard-activity-feed:hover {
background-image: url('data:image/svg+xml;charset=utf-8,');
}
+.lastfm-icon {
+ background-image: url('data:image/svg+xml;charset=utf-8,');
+}
+
+.sync-tab-button.active .lastfm-icon {
+ background-image: url('data:image/svg+xml;charset=utf-8,');
+}
+
/* 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. */
@@ -17536,7 +17544,8 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.tidal-playlist-card,
.deezer-playlist-card,
.spotify-public-card,
-.listenbrainz-playlist-card {
+.listenbrainz-playlist-card,
+.lastfm-playlist-card {
background: rgba(18, 18, 22, 0.9);
backdrop-filter: blur(12px);
border-radius: 16px;
@@ -17558,7 +17567,8 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.tidal-playlist-card::before,
.deezer-playlist-card::before,
.spotify-public-card::before,
-.listenbrainz-playlist-card::before {
+.listenbrainz-playlist-card::before,
+.lastfm-playlist-card::before {
content: '';
position: absolute;
top: 0;
@@ -17574,6 +17584,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.deezer-playlist-card::before { background: linear-gradient(90deg, transparent, rgba(162, 56, 255, 0.4), transparent); }
.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); }
/* 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); }
@@ -17581,19 +17592,22 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.deezer-playlist-card:hover { border-color: rgba(162, 56, 255, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(162, 56, 255, 0.06); transform: translateY(-2px); }
.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); }
.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); }
.deezer-playlist-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(162, 56, 255, 0.7), transparent); }
.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); }
/* Source icons */
.youtube-playlist-card .playlist-card-icon,
.tidal-playlist-card .playlist-card-icon,
.deezer-playlist-card .playlist-card-icon,
.spotify-public-card .playlist-card-icon,
-.listenbrainz-playlist-card .playlist-card-icon {
+.listenbrainz-playlist-card .playlist-card-icon,
+.lastfm-playlist-card .playlist-card-icon {
width: 40px;
height: 40px;
border-radius: 10px;
@@ -17612,24 +17626,28 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.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; }
+.lastfm-playlist-card .playlist-card-icon { background: rgba(213, 16, 7, 0.12); border: 1px solid rgba(213, 16, 7, 0.2); color: #d51007; }
.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 { flex: 1; min-width: 0; }
+.listenbrainz-playlist-card .playlist-card-content,
+.lastfm-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 { font-size: 15px; font-weight: 600; color: rgba(255, 255, 255, 0.9); margin-bottom: 4px; }
+.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; }
.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 { font-size: 13px; color: rgba(255, 255, 255, 0.4); display: flex; align-items: center; gap: 10px; }
+.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; }
.youtube-playlist-card .playlist-card-track-count { color: rgba(255, 255, 255, 0.7); }
.youtube-playlist-card .playlist-card-phase-text { font-weight: 500; }
@@ -17640,7 +17658,8 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.tidal-playlist-card .playlist-card-action-btn,
.deezer-playlist-card .playlist-card-action-btn,
.spotify-public-card .playlist-card-action-btn,
-.listenbrainz-playlist-card .playlist-card-action-btn {
+.listenbrainz-playlist-card .playlist-card-action-btn,
+.lastfm-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);
@@ -17660,7 +17679,8 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.tidal-playlist-card .playlist-card-action-btn::before,
.deezer-playlist-card .playlist-card-action-btn::before,
.spotify-public-card .playlist-card-action-btn::before,
-.listenbrainz-playlist-card .playlist-card-action-btn::before {
+.listenbrainz-playlist-card .playlist-card-action-btn::before,
+.lastfm-playlist-card .playlist-card-action-btn::before {
content: '';
position: absolute;
inset: 0;
@@ -17674,24 +17694,28 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.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)); }
+.lastfm-playlist-card .playlist-card-action-btn::before { background: linear-gradient(135deg, rgba(213, 16, 7, 0.2), rgba(213, 16, 7, 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 { opacity: 1; }
+.listenbrainz-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before,
+.lastfm-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); }
+.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); }
.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 {
+.listenbrainz-playlist-card .playlist-card-action-btn:disabled,
+.lastfm-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-lastfm.js b/webui/static/sync-lastfm.js
new file mode 100644
index 00000000..b54379a1
--- /dev/null
+++ b/webui/static/sync-lastfm.js
@@ -0,0 +1,131 @@
+// ===================================================================
+// LAST.FM RADIO SYNC TAB
+// ===================================================================
+// Phase 1c.2 of the Discover-to-Sync unification. Surfaces the user's
+// generated Last.fm Radio playlists as a Sync-page tab so they can be
+// discovered + mirrored alongside ListenBrainz, Tidal, Qobuz, etc.
+//
+// Last.fm Radio playlists live in the same ``listenbrainz_playlists``
+// SQLite table as ListenBrainz playlists (with
+// ``playlist_type='lastfm_radio'``) and run through the same
+// ``openDownloadModalForListenBrainzPlaylist`` discovery flow. So this
+// module is intentionally thin — list + render + click handoff.
+// The refresh loop, discovery polling, sync→mirror creation, and the
+// modal itself are all shared with the ListenBrainz tab.
+//
+// New Last.fm radios are GENERATED from the Discover page (with a
+// seed track). This tab is for listing existing radios + syncing
+// them to a mirror — not for generation.
+
+let _lastfmSyncPlaylists = [];
+
+async function loadLastfmSyncPlaylists() {
+ const container = document.getElementById('lastfm-sync-playlist-container');
+ const refreshBtn = document.getElementById('lastfm-sync-refresh-btn');
+ if (!container) return;
+
+ container.innerHTML = `
+ ${count} tracks
+ by ${escapeHtml(creator)}
+ ${phaseText}
+
+
+
+ ${buttonText}
+
+ `;
+ }).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: -> ``
- ``[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///refresh``)
+ grabs the fresh track snapshot + mirrors under a synthetic
id of the form ``ssd__`` (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 @@
Last.fm
+
+ SoulSync Discovery
+
Import
@@ -1918,6 +1921,17 @@
+
+
+
+
SoulSync Discovery Playlists
+ 🔄 Refresh
+
+
+
Click 'Refresh' to load your personalized SoulSync Discovery playlists.
+
+
+
@@ -7997,6 +8011,7 @@
+
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,');
}
+.soulsync-discovery-icon {
+ background-image: url('data:image/svg+xml;charset=utf-8,');
+}
+
+.sync-tab-button.active .soulsync-discovery-icon {
+ background-image: url('data:image/svg+xml;charset=utf-8,');
+}
+
/* 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 = `
+ `;
+ }).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_``. 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 (`