+ 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 10/18] 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 11/18] 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 12/18] 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 13/18] 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 14/18] 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 15/18] 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.
-