From 73bd2db5477490622fd4c50a35b016ea0664ecc5 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 19:31:00 -0700 Subject: [PATCH 01/18] Harden playlist pipeline source refresh Centralize mirrored playlist source reference normalization so edited links and IDs are stored consistently. Preserve URL-backed refresh refs, surface missing-source refresh failures, count background sync failures in pipeline summaries, and retry guarded automation skips after a short delay instead of losing a scheduled run. Add focused coverage for source refs, mirrored playlist source updates, refresh failures, and guarded retry behavior. --- core/automation/handlers/_pipeline_shared.py | 21 +++ core/automation/handlers/refresh_mirrored.py | 22 ++- core/automation_engine.py | 8 +- core/playlists/source_refs.py | 125 ++++++++++++++++++ database/music_database.py | 35 ++++- .../automation/test_handler_error_storage.py | 35 +++++ tests/automation/test_handlers_playlist.py | 42 ++++++ tests/database/test_mirrored_playlists.py | 62 +++++++++ tests/playlists/test_source_refs.py | 45 +++++++ web_server.py | 49 +++++++ webui/static/stats-automations.js | 46 +++++++ webui/static/style.css | 37 ++---- 12 files changed, 497 insertions(+), 30 deletions(-) create mode 100644 core/playlists/source_refs.py create mode 100644 tests/database/test_mirrored_playlists.py create mode 100644 tests/playlists/test_source_refs.py diff --git a/core/automation/handlers/_pipeline_shared.py b/core/automation/handlers/_pipeline_shared.py index c7427f8a..560ae3c3 100644 --- a/core/automation/handlers/_pipeline_shared.py +++ b/core/automation/handlers/_pipeline_shared.py @@ -80,9 +80,11 @@ def run_sync_and_wishlist( if sync_status == 'started': sync_id = sync_id_for_fn(pl) sync_poll_start = time.time() + timed_out = True while time.time() - sync_poll_start < _SYNC_PER_PLAYLIST_TIMEOUT_SECONDS: if (sync_id in sync_states and sync_states[sync_id].get('status') in _SYNC_TERMINAL_STATUSES): + timed_out = False break time.sleep(2) elapsed = int(time.time() - sync_poll_start) @@ -94,6 +96,25 @@ def run_sync_and_wishlist( ) ss = sync_states.get(sync_id, {}) + final_status = ss.get('status') + if timed_out: + sync_errors += 1 + deps.update_progress( + automation_id, + log_line=f'Sync timed out "{pl_name}" after {_SYNC_PER_PLAYLIST_TIMEOUT_SECONDS}s', + log_type='error', + ) + continue + if final_status in ('error', 'failed'): + sync_errors += 1 + reason = ss.get('error') or ss.get('reason') or 'background sync failed' + deps.update_progress( + automation_id, + log_line=f'Sync error "{pl_name}": {reason}', + log_type='error', + ) + continue + ss_result = ss.get('result', ss.get('progress', {})) matched = ss_result.get('matched_tracks', 0) if isinstance(ss_result, dict) else 0 total_synced += int(matched) if matched else 0 diff --git a/core/automation/handlers/refresh_mirrored.py b/core/automation/handlers/refresh_mirrored.py index 76ea32ce..02e546a8 100644 --- a/core/automation/handlers/refresh_mirrored.py +++ b/core/automation/handlers/refresh_mirrored.py @@ -18,6 +18,7 @@ import json from typing import Any, Dict from core.automation.deps import AutomationDeps +from core.playlists.source_refs import require_refresh_url def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]: @@ -129,7 +130,7 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[ # source_playlist_id is an MD5 hash; extract actual Spotify ID from stored description (URL). try: from core.spotify_public_scraper import parse_spotify_url, scrape_spotify_embed - spotify_url = pl.get('description', '') + spotify_url = require_refresh_url(source, pl.get('description', ''), pl.get('name', '')) parsed = parse_spotify_url(spotify_url) if spotify_url else None # If Spotify is authenticated, use the full API (auto-discovers with album art). @@ -185,6 +186,13 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[ # No extra_data — let preservation code keep existing discovery data. except Exception as e: deps.logger.warning(f"Spotify public playlist refresh failed for {source_id}: {e}") + errors.append(f"{pl.get('name', '?')}: {str(e)}") + deps.update_progress( + auto_id, + log_line=f'Refresh failed: "{pl.get("name", "")}" - {str(e)}', + log_type='error', + ) + continue elif source == 'deezer': try: @@ -228,7 +236,7 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[ elif source == 'youtube': # source_playlist_id is now a deterministic hash; use stored description (original URL) for refresh. - yt_url = pl.get('description', '') or f"https://www.youtube.com/playlist?list={source_id}" + yt_url = require_refresh_url(source, pl.get('description', ''), pl.get('name', '')) playlist_data = deps.parse_youtube_playlist(yt_url) if playlist_data and playlist_data.get('tracks'): tracks = [] @@ -242,6 +250,15 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[ 'source_track_id': t.get('id', ''), }) + if tracks is None: + errors.append(f"{pl.get('name', '?')}: no tracks returned from source") + deps.update_progress( + auto_id, + log_line=f'Refresh failed: "{pl.get("name", "")}" - no tracks returned from source', + log_type='error', + ) + continue + if tracks is not None: # Compare old vs new track IDs to detect changes. old_tracks = db.get_mirrored_playlist_tracks(pl['id']) if pl.get('id') else [] @@ -261,6 +278,7 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[ name=pl['name'], tracks=tracks, profile_id=pl.get('profile_id', 1), + description=pl.get('description'), owner=pl.get('owner'), image_url=pl.get('image_url'), ) diff --git a/core/automation_engine.py b/core/automation_engine.py index d19cbb58..61774579 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -617,7 +617,7 @@ class AutomationEngine: self._progress_finish_fn(automation_id, result) except Exception as e: logger.debug("scheduled progress finish (skipped): %s", e) - self._finish_run(auto, automation_id, result, error=None) + self._finish_run(auto, automation_id, result, error=None, retry_delay_seconds=300) return # Initialize progress tracking (skip if already done during delay) @@ -664,7 +664,7 @@ class AutomationEngine: self._finish_run(auto, automation_id, result, error) - def _finish_run(self, auto, automation_id, result, error): + def _finish_run(self, auto, automation_id, result, error, retry_delay_seconds=None): """Update DB with run stats and reschedule.""" next_run_str = None trigger_type = auto.get('trigger_type', '') @@ -672,7 +672,9 @@ class AutomationEngine: if trigger_type in self._trigger_handlers: try: trigger_config = json.loads(auto.get('trigger_config') or '{}') - if trigger_type == 'daily_time': + if retry_delay_seconds: + next_run_str = _utc_after(retry_delay_seconds) + elif trigger_type == 'daily_time': # Next run is tomorrow at the configured time (compute delay from local time, store as UTC) time_str = trigger_config.get('time', '00:00') hour, minute = map(int, time_str.split(':')) diff --git a/core/playlists/source_refs.py b/core/playlists/source_refs.py new file mode 100644 index 00000000..e4886384 --- /dev/null +++ b/core/playlists/source_refs.py @@ -0,0 +1,125 @@ +"""Helpers for mirrored-playlist upstream source references. + +Mirrored playlist rows have two legacy fields: +- ``source_playlist_id``: the stable lookup key used for uniqueness. +- ``description``: for URL-backed mirrors, the original/canonical URL. + +Keeping the normalization here prevents the refresh worker, API endpoint, +and UI repair flow from each inventing a slightly different meaning. +""" + +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass +from typing import Optional +from urllib.parse import parse_qs, urlparse + + +_SPOTIFY_ID_RE = re.compile(r"^[A-Za-z0-9]{16,32}$") + + +@dataclass(frozen=True) +class MirroredSourceRef: + source_playlist_id: str + description: Optional[str] + + +def normalize_mirrored_source_ref( + source: str, + source_ref: str, + existing_description: str = "", +) -> MirroredSourceRef: + """Normalize a user-provided source URL/ID for storage. + + URL-backed sources keep a deterministic hash in ``source_playlist_id`` and + store the canonical URL in ``description``. Direct-ID sources store the ID + directly and preserve the existing description unless a source-specific URL + parser says otherwise. + """ + source = (source or "").strip().lower() + source_ref = (source_ref or "").strip() + existing_description = (existing_description or "").strip() + + if not source_ref: + raise ValueError("Source link or ID is required") + + if source == "spotify_public": + canonical_url = _canonical_spotify_url(source_ref) + return MirroredSourceRef(_short_hash(canonical_url), canonical_url) + + if source == "youtube": + canonical_url = _canonical_youtube_url(source_ref) + return MirroredSourceRef(_short_hash(canonical_url), canonical_url) + + if source == "deezer" and source_ref.startswith(("http://", "https://")): + from core.deezer_client import DeezerClient + + parsed_id = DeezerClient.parse_playlist_url(source_ref) + if not parsed_id: + raise ValueError("Use a valid Deezer playlist URL or playlist ID") + return MirroredSourceRef(str(parsed_id), existing_description or None) + + return MirroredSourceRef(source_ref, existing_description or None) + + +def require_refresh_url(source: str, description: str, playlist_name: str = "") -> str: + """Return a URL required by hash-backed refresh sources, or raise clearly.""" + source = (source or "").strip().lower() + description = (description or "").strip() + if source in {"spotify_public", "youtube"}: + if not description.startswith(("http://", "https://")): + label = f" '{playlist_name}'" if playlist_name else "" + raise ValueError(f"{source} mirror{label} is missing its original source URL") + return description + + +def _canonical_spotify_url(source_ref: str) -> str: + parsed = _parse_spotify_ref(source_ref) + if parsed: + return f"https://open.spotify.com/{parsed['type']}/{parsed['id']}" + + # Repair flow convenience: if the user pastes only a Spotify ID, assume + # playlist. Album URLs still need their URL/URI so the type is explicit. + if _SPOTIFY_ID_RE.match(source_ref): + return f"https://open.spotify.com/playlist/{source_ref}" + + raise ValueError("Use a valid open.spotify.com playlist/album URL, Spotify URI, or playlist ID") + + +def _parse_spotify_ref(source_ref: str) -> Optional[dict]: + uri_match = re.match(r"spotify:(playlist|album):([A-Za-z0-9]+)", source_ref) + if uri_match: + return {"type": uri_match.group(1), "id": uri_match.group(2)} + + url_match = re.search( + r"https?://open\.spotify\.com/(playlist|album)/([A-Za-z0-9]+)", + source_ref, + ) + if url_match: + return {"type": url_match.group(1), "id": url_match.group(2)} + + return None + + +def _canonical_youtube_url(source_ref: str) -> str: + parsed_url = urlparse(source_ref) + playlist_id = "" + + if parsed_url.scheme and parsed_url.netloc: + host = parsed_url.netloc.lower() + if not ("youtube.com" in host or "music.youtube.com" in host): + raise ValueError("Use a valid YouTube playlist URL") + playlist_id = parse_qs(parsed_url.query).get("list", [""])[0] + else: + playlist_id = source_ref + + if not playlist_id: + raise ValueError("YouTube playlist URL must include a list= playlist id") + + return f"https://youtube.com/playlist?list={playlist_id}" + + +def _short_hash(value: str) -> str: + return hashlib.md5(value.encode()).hexdigest()[:12] diff --git a/database/music_database.py b/database/music_database.py index 0171d88e..876904bd 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -11828,7 +11828,7 @@ class MusicDatabase: VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(source, source_playlist_id, profile_id) DO UPDATE SET name = excluded.name, - description = excluded.description, + description = COALESCE(NULLIF(excluded.description, ''), mirrored_playlists.description), owner = excluded.owner, image_url = excluded.image_url, track_count = excluded.track_count, @@ -11939,6 +11939,39 @@ class MusicDatabase: logger.error(f"Error getting mirrored playlist tracks: {e}") return [] + def update_mirrored_playlist_source_ref( + self, + playlist_id: int, + source_playlist_id: str, + description: Optional[str] = None, + ) -> bool: + """Update a mirrored playlist's upstream source reference. + + This intentionally leaves mirrored tracks and discovery extra_data + untouched; refresh/discovery can use the new source reference on the + next run without losing existing local state. + """ + try: + with self._get_connection() as conn: + cursor = conn.cursor() + if description is None: + cursor.execute(""" + UPDATE mirrored_playlists + SET source_playlist_id = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (source_playlist_id, playlist_id)) + else: + cursor.execute(""" + UPDATE mirrored_playlists + SET source_playlist_id = ?, description = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (source_playlist_id, description, playlist_id)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Error updating mirrored playlist source reference: {e}") + return False + def update_mirrored_track_extra_data(self, track_id: int, extra_data_dict: dict) -> bool: """Merge new data into a mirrored track's extra_data JSON field.""" try: diff --git a/tests/automation/test_handler_error_storage.py b/tests/automation/test_handler_error_storage.py index e7eaeefd..04dc93dd 100644 --- a/tests/automation/test_handler_error_storage.py +++ b/tests/automation/test_handler_error_storage.py @@ -132,3 +132,38 @@ def test_skipped_status_records_no_error(engine_with_handler) -> None: engine.run_automation(1, skip_delay=True) kwargs = db_mock.update_automation_run.call_args.kwargs assert kwargs.get('error') is None + + +def test_guarded_scheduled_skip_retries_soon() -> None: + """A busy guarded action should retry soon instead of waiting a full interval.""" + db_mock = MagicMock() + db_mock.get_automation.return_value = { + 'id': 1, + 'name': 'Playlist Pipeline', + 'enabled': True, + 'action_type': 'playlist_pipeline', + 'action_config': '{}', + 'trigger_type': 'schedule', + 'trigger_config': '{"interval": 6, "unit": "hours"}', + } + db_mock.update_automation_run = MagicMock(return_value=True) + + engine = AutomationEngine(db_mock) + engine._running = True + engine.schedule_automation = MagicMock() + engine._action_handlers['playlist_pipeline'] = { + 'handler': lambda config: {'status': 'completed'}, + 'guard': lambda: True, + } + + engine.run_automation(1, skip_delay=True) + + kwargs = db_mock.update_automation_run.call_args.kwargs + assert kwargs.get('error') is None + assert kwargs.get('next_run') is not None + # It should be scheduled for roughly five minutes, not the configured six hours. + from datetime import datetime, timezone + + next_run = datetime.strptime(kwargs['next_run'], '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc) + delay = (next_run - datetime.now(timezone.utc)).total_seconds() + assert 0 < delay <= 360 diff --git a/tests/automation/test_handlers_playlist.py b/tests/automation/test_handlers_playlist.py index 1a1548ef..0bf7628c 100644 --- a/tests/automation/test_handlers_playlist.py +++ b/tests/automation/test_handlers_playlist.py @@ -26,6 +26,7 @@ import pytest from core.automation.deps import AutomationDeps, AutomationState from core.automation.handlers.discover_playlist import auto_discover_playlist +from core.automation.handlers._pipeline_shared import run_sync_and_wishlist from core.automation.handlers.playlist_pipeline import auto_playlist_pipeline from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored from core.automation.handlers.sync_playlist import auto_sync_playlist @@ -266,6 +267,24 @@ class TestRefreshMirrored: assert result['errors'] == '1' assert result['refreshed'] == '0' + def test_spotify_public_missing_source_url_is_reported_as_error(self): + db = _StubDB(playlists=[ + {'id': 1, 'name': 'No URL', 'source': 'spotify_public', 'source_playlist_id': 'hash'}, + ]) + progress = [] + deps = _build_deps( + get_database=lambda: db, + update_progress=lambda *a, **k: progress.append(k), + ) + + result = auto_refresh_mirrored({'playlist_id': '1'}, deps) + + assert result['status'] == 'completed' + assert result['refreshed'] == '0' + assert result['errors'] == '1' + assert db.mirror_calls == [] + assert any(p.get('log_type') == 'error' and 'missing its original source URL' in p.get('log_line', '') for p in progress) + # ─── sync_playlist ─────────────────────────────────────────────────── @@ -398,3 +417,26 @@ class TestPlaylistPipeline: assert result['status'] == 'error' assert result['_manages_own_progress'] is True assert deps.state.pipeline_running is False + + def test_shared_sync_tail_counts_background_sync_errors(self): + progress = [] + sync_states = { + 'auto_mirror_1': {'status': 'error', 'error': 'media server unavailable'}, + } + deps = _build_deps( + get_sync_states=lambda: sync_states, + update_progress=lambda *a, **k: progress.append(k), + ) + + result = run_sync_and_wishlist( + deps, + 'auto-1', + [{'id': 1, 'name': 'Broken'}], + sync_one_fn=lambda _pl: {'status': 'started'}, + sync_id_for_fn=lambda _pl: 'auto_mirror_1', + skip_wishlist=True, + ) + + assert result['errors'] == 1 + assert result['synced'] == 0 + assert any(p.get('log_type') == 'error' and 'media server unavailable' in p.get('log_line', '') for p in progress) diff --git a/tests/database/test_mirrored_playlists.py b/tests/database/test_mirrored_playlists.py new file mode 100644 index 00000000..8da26480 --- /dev/null +++ b/tests/database/test_mirrored_playlists.py @@ -0,0 +1,62 @@ +from database.music_database import MusicDatabase + + +def test_update_mirrored_playlist_source_ref_preserves_tracks(tmp_path): + db = MusicDatabase(str(tmp_path / "music.db")) + playlist_id = db.mirror_playlist( + source="youtube", + source_playlist_id="oldhash", + name="Mirror", + tracks=[ + { + "track_name": "Song", + "artist_name": "Artist", + "source_track_id": "yt1", + "extra_data": {"discovered": True}, + } + ], + profile_id=1, + description="https://youtube.com/playlist?list=old", + ) + + assert playlist_id is not None + + updated = db.update_mirrored_playlist_source_ref( + playlist_id, + "newhash", + "https://youtube.com/playlist?list=new", + ) + + assert updated is True + playlist = db.get_mirrored_playlist(playlist_id) + assert playlist["source_playlist_id"] == "newhash" + assert playlist["description"] == "https://youtube.com/playlist?list=new" + + tracks = db.get_mirrored_playlist_tracks(playlist_id) + assert len(tracks) == 1 + assert tracks[0]["track_name"] == "Song" + assert tracks[0]["extra_data"] is not None + + +def test_mirror_playlist_refresh_preserves_existing_description(tmp_path): + db = MusicDatabase(str(tmp_path / "music.db")) + playlist_id = db.mirror_playlist( + source="spotify_public", + source_playlist_id="hash", + name="Release Radar", + tracks=[{"track_name": "Song", "artist_name": "Artist"}], + profile_id=1, + description="https://open.spotify.com/playlist/abc", + ) + + refreshed_id = db.mirror_playlist( + source="spotify_public", + source_playlist_id="hash", + name="Release Radar", + tracks=[{"track_name": "New Song", "artist_name": "Artist"}], + profile_id=1, + ) + + assert refreshed_id == playlist_id + playlist = db.get_mirrored_playlist(playlist_id) + assert playlist["description"] == "https://open.spotify.com/playlist/abc" diff --git a/tests/playlists/test_source_refs.py b/tests/playlists/test_source_refs.py new file mode 100644 index 00000000..e7867f31 --- /dev/null +++ b/tests/playlists/test_source_refs.py @@ -0,0 +1,45 @@ +import pytest + +from core.playlists.source_refs import ( + normalize_mirrored_source_ref, + require_refresh_url, +) + + +def test_spotify_public_url_stores_hash_and_canonical_url(): + out = normalize_mirrored_source_ref( + "spotify_public", + "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M?si=abc", + ) + + assert out.source_playlist_id == "5e7de827abd1" + assert out.description == "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M" + + +def test_spotify_public_raw_id_defaults_to_playlist_url(): + out = normalize_mirrored_source_ref("spotify_public", "37i9dQZF1DXcBWIGoYBM5M") + + assert out.source_playlist_id == "5e7de827abd1" + assert out.description == "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M" + + +def test_youtube_url_stores_hash_and_canonical_url(): + out = normalize_mirrored_source_ref( + "youtube", + "https://music.youtube.com/playlist?list=PL123&si=abc", + ) + + assert out.source_playlist_id == "e5f5ab31b8f0" + assert out.description == "https://youtube.com/playlist?list=PL123" + + +def test_direct_id_sources_preserve_existing_description(): + out = normalize_mirrored_source_ref("tidal", "abc123", "Original service description") + + assert out.source_playlist_id == "abc123" + assert out.description == "Original service description" + + +def test_hash_backed_refresh_requires_url(): + with pytest.raises(ValueError, match="missing its original source URL"): + require_refresh_url("spotify_public", "", "Release Radar") diff --git a/web_server.py b/web_server.py index fc8c8a2e..da8f96ec 100644 --- a/web_server.py +++ b/web_server.py @@ -32211,6 +32211,55 @@ def get_mirrored_playlist_endpoint(playlist_id): logger.error(f"Error getting mirrored playlist: {e}") return jsonify({"error": str(e)}), 500 +@app.route('/api/mirrored-playlists//source-ref', methods=['PATCH']) +def update_mirrored_playlist_source_ref_endpoint(playlist_id): + """Update the upstream source link/id for a mirrored playlist.""" + try: + data = request.get_json() or {} + source_ref = data.get('source_ref') or data.get('source_playlist_id') or data.get('url') + + database = get_database() + playlist = database.get_mirrored_playlist(playlist_id) + if not playlist: + return jsonify({"error": "Playlist not found"}), 404 + + try: + from core.playlists.source_refs import normalize_mirrored_source_ref + normalized = normalize_mirrored_source_ref( + playlist.get('source'), + source_ref, + playlist.get('description') or '', + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + + existing = [ + pl for pl in database.get_mirrored_playlists(profile_id=playlist.get('profile_id', 1)) + if ( + pl.get('source') == playlist.get('source') + and str(pl.get('source_playlist_id')) == str(normalized.source_playlist_id) + and int(pl.get('id')) != int(playlist_id) + ) + ] + if existing: + return jsonify({ + "error": f"That source is already mirrored as '{existing[0].get('name', 'another playlist')}'" + }), 409 + + ok = database.update_mirrored_playlist_source_ref( + playlist_id, + normalized.source_playlist_id, + normalized.description, + ) + if not ok: + return jsonify({"error": "Failed to update source reference"}), 500 + + updated = database.get_mirrored_playlist(playlist_id) or {} + return jsonify({"success": True, "playlist": updated}) + except Exception as e: + logger.error(f"Error updating mirrored playlist source reference: {e}") + return jsonify({"error": str(e)}), 500 + @app.route('/api/mirrored-playlists/', methods=['DELETE']) def delete_mirrored_playlist_endpoint(playlist_id): """Delete a mirrored playlist.""" diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 9723c549..20f08de0 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -446,6 +446,7 @@ function renderMirroredCard(p, container) { const hash = `mirrored_${p.id}`; const state = youtubePlaylistStates[hash]; const phase = state ? state.phase : null; + const sourceRef = getMirroredSourceRef(p); // Build phase indicator let phaseHtml = ''; @@ -493,6 +494,7 @@ function renderMirroredCard(p, container) { ${disc > 0 ? `` : ''} + `; card.addEventListener('click', () => { @@ -532,6 +534,48 @@ function renderMirroredCard(p, container) { container.appendChild(card); } +function getMirroredSourceRef(p) { + const desc = (p && p.description) ? String(p.description).trim() : ''; + if ((p.source === 'spotify_public' || p.source === 'youtube') && /^https?:\/\//i.test(desc)) { + return desc; + } + return (p && p.source_playlist_id) ? String(p.source_playlist_id) : ''; +} + +async function editMirroredSourceRef(playlistId, name, source, currentRef) { + const label = (source === 'spotify_public' || source === 'youtube') + ? 'original playlist URL' + : 'original playlist ID or URL'; + const nextRef = window.prompt(`Update ${label} for "${name}"`, currentRef || ''); + if (nextRef === null) return; + const trimmed = nextRef.trim(); + if (!trimmed) { + showToast('Source link or ID is required', 'error'); + return; + } + + try { + const res = await fetch(`/api/mirrored-playlists/${playlistId}/source-ref`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source_ref: trimmed }) + }); + const data = await res.json(); + if (!res.ok || data.error) { + throw new Error(data.error || 'Failed to update source reference'); + } + showToast(`Updated source for ${name}`, 'success'); + loadMirroredPlaylists(); + const openModal = document.getElementById('mirrored-track-modal'); + if (openModal) { + closeMirroredModal(); + openMirroredPlaylistModal(playlistId); + } + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } +} + function updateMirroredCardPhase(urlHash, phase) { // Update the state phase (updateYouTubeCardPhase skips this for mirrored playlists due to no cardElement) const state = youtubePlaylistStates[urlHash]; @@ -785,6 +829,7 @@ async function openMirroredPlaylistModal(playlistId) { const tracks = data.tracks || []; const source = data.source || 'unknown'; + const sourceRef = getMirroredSourceRef(data); const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛' }; const sourceIcon = sourceIcons[source] || '📋'; @@ -827,6 +872,7 @@ async function openMirroredPlaylistModal(playlistId) { diff --git a/webui/static/style.css b/webui/static/style.css index 7706efc0..8ab1469c 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -12047,7 +12047,9 @@ body.helper-mode-active #dashboard-activity-feed:hover { letter-spacing: 0.3px; } -.mirrored-card-delete { +.mirrored-card-delete, +.mirrored-card-clear, +.mirrored-card-link { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.08); color: #555; @@ -12065,7 +12067,9 @@ body.helper-mode-active #dashboard-activity-feed:hover { opacity: 0; } -.mirrored-playlist-card:hover .mirrored-card-delete { +.mirrored-playlist-card:hover .mirrored-card-delete, +.mirrored-playlist-card:hover .mirrored-card-clear, +.mirrored-playlist-card:hover .mirrored-card-link { opacity: 1; } @@ -12076,28 +12080,6 @@ body.helper-mode-active #dashboard-activity-feed:hover { transform: scale(1.1); } -.mirrored-card-clear { - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.08); - color: #555; - cursor: pointer; - font-size: 14px; - padding: 0; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - transition: all 0.2s ease; - flex-shrink: 0; - opacity: 0; -} - -.mirrored-playlist-card:hover .mirrored-card-clear { - opacity: 1; -} - .mirrored-card-clear:hover { color: #a78bfa; background: rgba(167, 139, 250, 0.15); @@ -12105,6 +12087,13 @@ body.helper-mode-active #dashboard-activity-feed:hover { transform: scale(1.1); } +.mirrored-card-link:hover { + color: #64b5f6; + background: rgba(100, 181, 246, 0.15); + border-color: rgba(100, 181, 246, 0.3); + transform: scale(1.1); +} + .discovery-ratio { font-size: 0.8em; color: rgba(255, 255, 255, 0.4); From 547e49912170bf96f7eadeb46e6d094c254f59c1 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 19:32:39 -0700 Subject: [PATCH 02/18] Expose mirrored playlist source-ref health Return normalized source_ref metadata from mirrored playlist APIs so the UI no longer has to infer editable refresh links from description fields. Accept Spotify embed URLs during source-ref repair and add coverage for source-ref health reporting. --- core/playlists/source_refs.py | 35 +++++++++++++++++++++++++-- tests/playlists/test_source_refs.py | 37 +++++++++++++++++++++++++++++ web_server.py | 12 ++++++++++ webui/static/stats-automations.js | 1 + 4 files changed, 83 insertions(+), 2 deletions(-) diff --git a/core/playlists/source_refs.py b/core/playlists/source_refs.py index e4886384..22909895 100644 --- a/core/playlists/source_refs.py +++ b/core/playlists/source_refs.py @@ -13,7 +13,7 @@ from __future__ import annotations import hashlib import re from dataclasses import dataclass -from typing import Optional +from typing import Mapping, Optional from urllib.parse import parse_qs, urlparse @@ -26,6 +26,14 @@ class MirroredSourceRef: description: Optional[str] +@dataclass(frozen=True) +class MirroredSourceRefView: + source_ref: str + source_ref_kind: str + source_ref_status: str + source_ref_error: Optional[str] = None + + def normalize_mirrored_source_ref( source: str, source_ref: str, @@ -75,6 +83,29 @@ def require_refresh_url(source: str, description: str, playlist_name: str = "") return description +def describe_mirrored_source_ref(playlist: Mapping[str, object]) -> MirroredSourceRefView: + """Build a UI/API friendly view of a mirrored playlist's refresh ref.""" + source = str(playlist.get("source") or "").strip().lower() + source_playlist_id = str(playlist.get("source_playlist_id") or "").strip() + description = str(playlist.get("description") or "").strip() + name = str(playlist.get("name") or "") + + if source in {"spotify_public", "youtube"}: + if description.startswith(("http://", "https://")): + return MirroredSourceRefView(description, "url", "ok") + try: + require_refresh_url(source, description, name) + except ValueError as exc: + return MirroredSourceRefView( + source_playlist_id, + "url", + "missing", + str(exc), + ) + + return MirroredSourceRefView(source_playlist_id, "id", "ok" if source_playlist_id else "missing") + + def _canonical_spotify_url(source_ref: str) -> str: parsed = _parse_spotify_ref(source_ref) if parsed: @@ -94,7 +125,7 @@ def _parse_spotify_ref(source_ref: str) -> Optional[dict]: return {"type": uri_match.group(1), "id": uri_match.group(2)} url_match = re.search( - r"https?://open\.spotify\.com/(playlist|album)/([A-Za-z0-9]+)", + r"https?://open\.spotify\.com/(?:embed/)?(playlist|album)/([A-Za-z0-9]+)", source_ref, ) if url_match: diff --git a/tests/playlists/test_source_refs.py b/tests/playlists/test_source_refs.py index e7867f31..fb5e32e4 100644 --- a/tests/playlists/test_source_refs.py +++ b/tests/playlists/test_source_refs.py @@ -1,6 +1,7 @@ import pytest from core.playlists.source_refs import ( + describe_mirrored_source_ref, normalize_mirrored_source_ref, require_refresh_url, ) @@ -23,6 +24,16 @@ def test_spotify_public_raw_id_defaults_to_playlist_url(): assert out.description == "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M" +def test_spotify_public_embed_url_canonicalizes_to_open_url(): + out = normalize_mirrored_source_ref( + "spotify_public", + "https://open.spotify.com/embed/playlist/37i9dQZF1DX0kbJZpiYdZl?pi=abc", + ) + + assert out.source_playlist_id == "d29b105efc8e" + assert out.description == "https://open.spotify.com/playlist/37i9dQZF1DX0kbJZpiYdZl" + + def test_youtube_url_stores_hash_and_canonical_url(): out = normalize_mirrored_source_ref( "youtube", @@ -43,3 +54,29 @@ def test_direct_id_sources_preserve_existing_description(): def test_hash_backed_refresh_requires_url(): with pytest.raises(ValueError, match="missing its original source URL"): require_refresh_url("spotify_public", "", "Release Radar") + + +def test_describe_hash_backed_ref_reports_missing_url(): + view = describe_mirrored_source_ref({ + "source": "spotify_public", + "source_playlist_id": "hash", + "description": "", + "name": "Release Radar", + }) + + assert view.source_ref == "hash" + assert view.source_ref_kind == "url" + assert view.source_ref_status == "missing" + assert "missing its original source URL" in view.source_ref_error + + +def test_describe_hash_backed_ref_uses_url_when_present(): + view = describe_mirrored_source_ref({ + "source": "spotify_public", + "source_playlist_id": "hash", + "description": "https://open.spotify.com/playlist/abc", + }) + + assert view.source_ref == "https://open.spotify.com/playlist/abc" + assert view.source_ref_kind == "url" + assert view.source_ref_status == "ok" diff --git a/web_server.py b/web_server.py index da8f96ec..2a90c8e1 100644 --- a/web_server.py +++ b/web_server.py @@ -32183,6 +32183,7 @@ def mirror_playlist_endpoint(): def get_mirrored_playlists_endpoint(): """List all mirrored playlists for the active profile.""" try: + from core.playlists.source_refs import describe_mirrored_source_ref database = get_database() profile_id = get_current_profile_id() playlists = database.get_mirrored_playlists(profile_id=profile_id) @@ -32192,6 +32193,11 @@ def get_mirrored_playlists_endpoint(): pl['total_count'] = counts['total'] pl['wishlisted_count'] = counts['wishlisted'] pl['in_library_count'] = counts['in_library'] + source_ref = describe_mirrored_source_ref(pl) + pl['source_ref'] = source_ref.source_ref + pl['source_ref_kind'] = source_ref.source_ref_kind + pl['source_ref_status'] = source_ref.source_ref_status + pl['source_ref_error'] = source_ref.source_ref_error return jsonify(playlists) except Exception as e: logger.error(f"Error getting mirrored playlists: {e}") @@ -32201,10 +32207,16 @@ def get_mirrored_playlists_endpoint(): def get_mirrored_playlist_endpoint(playlist_id): """Get a mirrored playlist with its tracks.""" try: + from core.playlists.source_refs import describe_mirrored_source_ref database = get_database() playlist = database.get_mirrored_playlist(playlist_id) if not playlist: return jsonify({"error": "Playlist not found"}), 404 + source_ref = describe_mirrored_source_ref(playlist) + playlist['source_ref'] = source_ref.source_ref + playlist['source_ref_kind'] = source_ref.source_ref_kind + playlist['source_ref_status'] = source_ref.source_ref_status + playlist['source_ref_error'] = source_ref.source_ref_error playlist['tracks'] = database.get_mirrored_playlist_tracks(playlist_id) return jsonify(playlist) except Exception as e: diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 20f08de0..f7545036 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -535,6 +535,7 @@ function renderMirroredCard(p, container) { } function getMirroredSourceRef(p) { + if (p && p.source_ref) return String(p.source_ref); const desc = (p && p.description) ? String(p.description).trim() : ''; if ((p.source === 'spotify_public' || p.source === 'youtube') && /^https?:\/\//i.test(desc)) { return desc; From bc6bacb7dae9bcc3847a49c7152be27b42832982 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 19:44:13 -0700 Subject: [PATCH 03/18] Move mirrored playlist pipeline into playlist domain Extract the all-in-one mirrored playlist lifecycle into core/playlists/pipeline.py so automation becomes a thin adapter. Preserve the existing automation action and behavior while making the pipeline reusable by future direct playlist UI controls. --- core/automation/handlers/playlist_pipeline.py | 226 +--------------- core/playlists/pipeline.py | 249 ++++++++++++++++++ 2 files changed, 262 insertions(+), 213 deletions(-) create mode 100644 core/playlists/pipeline.py diff --git a/core/automation/handlers/playlist_pipeline.py b/core/automation/handlers/playlist_pipeline.py index 8fc98df2..eacea479 100644 --- a/core/automation/handlers/playlist_pipeline.py +++ b/core/automation/handlers/playlist_pipeline.py @@ -1,227 +1,27 @@ -"""Automation handler: ``playlist_pipeline`` action. +"""Automation adapter for the mirrored playlist pipeline. -Lifted from ``web_server._register_automation_handlers`` (the -``_auto_playlist_pipeline`` closure). Runs the full playlist -lifecycle in a single trigger: - - Phase 1: REFRESH -- pull fresh track lists from sources - Phase 2: DISCOVER -- look up official Spotify/iTunes metadata - Phase 3: SYNC -- push the result to the active media server - Phase 4: WISHLIST -- queue any missing tracks for download - -Each phase emits its own progress range so the trigger card shows -useful per-phase percentages instead of "loading...". Phase 4 is -optional via ``skip_wishlist`` config. - -Composition: this handler invokes ``auto_refresh_mirrored`` and -``auto_sync_playlist`` directly (passing ``_automation_id: None`` so -the sub-handlers don't hijack pipeline progress) instead of going -through the engine — keeps the four phases observable as one -trigger from the user's perspective. Pipeline-level guard -(``state.pipeline_running``) prevents overlapping runs. +The actual all-in-one playlist lifecycle lives in +``core.playlists.pipeline`` so it can be reused by non-automation UI actions. +This module only wires automation-specific dependencies and handlers. """ from __future__ import annotations -import threading -import time from typing import Any, Dict from core.automation.deps import AutomationDeps from core.automation.handlers._pipeline_shared import run_sync_and_wishlist from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored from core.automation.handlers.sync_playlist import auto_sync_playlist - - -# Per-playlist sync poll cap inside Phase 3. -# Discovery poll cap inside Phase 2. -_DISCOVERY_TIMEOUT_SECONDS = 3600 +from core.playlists.pipeline import run_mirrored_playlist_pipeline def auto_playlist_pipeline(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]: - """Run REFRESH → DISCOVER → SYNC → WISHLIST in sequence. - - Sets / clears ``deps.state.pipeline_running`` around the whole - run so the registration guard can short-circuit overlapping - triggers. - """ - deps.state.set_pipeline_running(True) - automation_id = config.get('_automation_id') - pipeline_start = time.time() - - try: - db = deps.get_database() - playlist_id = config.get('playlist_id') - process_all = config.get('all', False) - skip_wishlist = config.get('skip_wishlist', False) - - # Resolve playlists. - if process_all: - playlists = db.get_mirrored_playlists() - elif playlist_id: - p = db.get_mirrored_playlist(int(playlist_id)) - playlists = [p] if p else [] - else: - deps.state.set_pipeline_running(False) - return {'status': 'error', 'error': 'No playlist specified'} - - playlists = [pl for pl in playlists if pl.get('source', '') not in ('file', 'beatport')] - if not playlists: - deps.state.set_pipeline_running(False) - return {'status': 'error', 'error': 'No refreshable playlists found'} - - pl_names = ', '.join(p.get('name', '?') for p in playlists[:3]) - if len(playlists) > 3: - pl_names += f' (+{len(playlists) - 3} more)' - - deps.update_progress( - automation_id, - progress=2, - phase=f'Pipeline: {len(playlists)} playlist(s)', - log_line=f'Starting pipeline for: {pl_names}', - log_type='info', - ) - - # ── PHASE 1: REFRESH ────────────────────────────────────────── - deps.update_progress( - automation_id, - progress=3, - phase='Phase 1/4: Refreshing playlists...', - log_line='Phase 1: Refresh', - log_type='info', - ) - - refresh_config = dict(config) - refresh_config['_automation_id'] = None # Don't let sub-handler hijack pipeline progress. - refresh_result = auto_refresh_mirrored(refresh_config, deps) - refreshed = int(refresh_result.get('refreshed', 0)) - refresh_errors = int(refresh_result.get('errors', 0)) - - deps.update_progress( - automation_id, - progress=25, - phase='Phase 1/4: Refresh complete', - log_line=f'Phase 1 done: {refreshed} refreshed, {refresh_errors} errors', - log_type='success' if refresh_errors == 0 else 'warning', - ) - - # ── PHASE 2: DISCOVER ───────────────────────────────────────── - deps.update_progress( - automation_id, - progress=26, - phase='Phase 2/4: Discovering metadata...', - log_line='Phase 2: Discover', - log_type='info', - ) - - # Reload playlists (refresh may have updated them). - if process_all: - disc_playlists = db.get_mirrored_playlists() - else: - disc_playlists = [db.get_mirrored_playlist(int(playlist_id))] - disc_playlists = [p for p in disc_playlists if p] - - # Run discovery in a thread and wait for it. - disc_done = threading.Event() - - def _disc_wrapper(pls): - try: - # The worker updates automation_progress internally, - # but we pass None so it doesn't conflict with our - # pipeline progress. - deps.run_playlist_discovery_worker(pls, automation_id=None) - except Exception as e: - deps.logger.error(f"[Pipeline] Discovery error: {e}") - finally: - disc_done.set() - - threading.Thread( - target=_disc_wrapper, args=(disc_playlists,), - daemon=True, name='pipeline-discover', - ).start() - - # Poll for completion with progress updates. - poll_start = time.time() - while not disc_done.wait(timeout=3): - elapsed = int(time.time() - poll_start) - deps.update_progress( - automation_id, - progress=min(26 + elapsed // 4, 54), - phase=f'Phase 2/4: Discovering... ({elapsed}s)', - ) - if elapsed > _DISCOVERY_TIMEOUT_SECONDS: - deps.update_progress( - automation_id, - log_line='Discovery timed out after 1 hour', - log_type='warning', - ) - break - - deps.update_progress( - automation_id, - progress=55, - phase='Phase 2/4: Discovery complete', - log_line='Phase 2 done: discovery complete', - log_type='success', - ) - - # ── PHASE 3 + 4: SYNC + WISHLIST (delegated to shared helper) ── - # Each mirrored playlist payload only needs `id` + `name` for - # the helper; `auto_sync_playlist` reads the rest from the - # mirrored DB by id. - sync_summary = run_sync_and_wishlist( - deps, - automation_id, - [pl for pl in playlists if pl.get('id')], - sync_one_fn=lambda pl: auto_sync_playlist( - {'playlist_id': str(pl['id']), '_automation_id': None}, - deps, - ), - sync_id_for_fn=lambda pl: f"auto_mirror_{pl['id']}", - skip_wishlist=skip_wishlist, - progress_start=56, - progress_end=85, - sync_phase_label='Phase 3/4: Syncing to server...', - sync_phase_start_log='Phase 3: Sync', - wishlist_phase_label='Phase 4/4: Processing wishlist...', - wishlist_phase_start_log='Phase 4: Wishlist', - ) - total_synced = sync_summary['synced'] - total_skipped = sync_summary['skipped'] - sync_errors = sync_summary['errors'] - wishlist_queued = sync_summary['wishlist_queued'] - - # ── COMPLETE ────────────────────────────────────────────────── - duration = int(time.time() - pipeline_start) - deps.update_progress( - automation_id, - status='finished', - progress=100, - phase='Pipeline complete', - log_line=f'Pipeline finished in {duration // 60}m {duration % 60}s', - log_type='success', - ) - - deps.state.set_pipeline_running(False) - return { - 'status': 'completed', - '_manages_own_progress': True, - 'playlists_refreshed': str(refreshed), - 'tracks_discovered': 'completed', - 'tracks_synced': str(total_synced), - 'sync_skipped': str(total_skipped), - 'wishlist_queued': str(wishlist_queued), - 'duration_seconds': str(duration), - } - - except Exception as e: - deps.state.set_pipeline_running(False) - deps.update_progress( - automation_id, - status='error', - progress=100, - phase='Pipeline error', - log_line=f'Pipeline failed: {e}', - log_type='error', - ) - return {'status': 'error', 'error': str(e), '_manages_own_progress': True} + """Run REFRESH -> DISCOVER -> SYNC -> WISHLIST for mirrored playlists.""" + return run_mirrored_playlist_pipeline( + config, + deps, + refresh_fn=auto_refresh_mirrored, + sync_one_fn=auto_sync_playlist, + sync_and_wishlist_fn=run_sync_and_wishlist, + ) diff --git a/core/playlists/pipeline.py b/core/playlists/pipeline.py new file mode 100644 index 00000000..9f279118 --- /dev/null +++ b/core/playlists/pipeline.py @@ -0,0 +1,249 @@ +"""Mirrored playlist lifecycle pipeline. + +This module is the playlist-domain home for the all-in-one mirrored +playlist pipeline: + + refresh source -> discover metadata -> sync to server -> process wishlist + +Automation remains one caller, but the orchestration itself lives here so a +future playlist-card "Run Pipeline" button can call the same command. +""" + +from __future__ import annotations + +import threading +import time +from typing import Any, Callable, Dict, List + + +DISCOVERY_TIMEOUT_SECONDS = 3600 + + +RefreshFn = Callable[[Dict[str, Any], Any], Dict[str, Any]] +SyncOneFn = Callable[[Dict[str, Any], Any], Dict[str, Any]] +SyncAndWishlistFn = Callable[..., Dict[str, int]] + + +def run_mirrored_playlist_pipeline( + config: Dict[str, Any], + deps: Any, + *, + refresh_fn: RefreshFn, + sync_one_fn: SyncOneFn, + sync_and_wishlist_fn: SyncAndWishlistFn, +) -> Dict[str, Any]: + """Run REFRESH -> DISCOVER -> SYNC -> WISHLIST in sequence. + + ``deps`` intentionally uses duck typing. Today it is ``AutomationDeps``; + a future web/UI runner can provide the same small surface without becoming + an automation. + """ + deps.state.set_pipeline_running(True) + automation_id = config.get('_automation_id') + pipeline_start = time.time() + + try: + db = deps.get_database() + playlist_id = config.get('playlist_id') + process_all = config.get('all', False) + skip_wishlist = config.get('skip_wishlist', False) + + playlists = _resolve_pipeline_playlists(db, playlist_id, process_all) + if playlists is None: + deps.state.set_pipeline_running(False) + return {'status': 'error', 'error': 'No playlist specified'} + + playlists = _filter_refreshable_playlists(playlists) + if not playlists: + deps.state.set_pipeline_running(False) + return {'status': 'error', 'error': 'No refreshable playlists found'} + + deps.update_progress( + automation_id, + progress=2, + phase=f'Pipeline: {len(playlists)} playlist(s)', + log_line=f'Starting pipeline for: {_summarize_playlist_names(playlists)}', + log_type='info', + ) + + refreshed, refresh_errors = _run_refresh_phase( + config, + deps, + automation_id, + refresh_fn=refresh_fn, + ) + + _run_discovery_phase( + deps, + automation_id, + db=db, + playlist_id=playlist_id, + process_all=process_all, + ) + + sync_summary = sync_and_wishlist_fn( + deps, + automation_id, + [pl for pl in playlists if pl.get('id')], + sync_one_fn=lambda pl: sync_one_fn( + {'playlist_id': str(pl['id']), '_automation_id': None}, + deps, + ), + sync_id_for_fn=lambda pl: f"auto_mirror_{pl['id']}", + skip_wishlist=skip_wishlist, + progress_start=56, + progress_end=85, + sync_phase_label='Phase 3/4: Syncing to server...', + sync_phase_start_log='Phase 3: Sync', + wishlist_phase_label='Phase 4/4: Processing wishlist...', + wishlist_phase_start_log='Phase 4: Wishlist', + ) + + duration = int(time.time() - pipeline_start) + deps.update_progress( + automation_id, + status='finished', + progress=100, + phase='Pipeline complete', + log_line=f'Pipeline finished in {duration // 60}m {duration % 60}s', + log_type='success', + ) + + deps.state.set_pipeline_running(False) + return { + 'status': 'completed', + '_manages_own_progress': True, + 'playlists_refreshed': str(refreshed), + 'tracks_discovered': 'completed', + 'tracks_synced': str(sync_summary['synced']), + 'sync_skipped': str(sync_summary['skipped']), + 'wishlist_queued': str(sync_summary['wishlist_queued']), + 'duration_seconds': str(duration), + } + + except Exception as e: # noqa: BLE001 - pipeline callers should receive status dicts + deps.state.set_pipeline_running(False) + deps.update_progress( + automation_id, + status='error', + progress=100, + phase='Pipeline error', + log_line=f'Pipeline failed: {e}', + log_type='error', + ) + return {'status': 'error', 'error': str(e), '_manages_own_progress': True} + + +def _resolve_pipeline_playlists(db: Any, playlist_id: Any, process_all: bool) -> List[Dict[str, Any]] | None: + if process_all: + return db.get_mirrored_playlists() + if playlist_id: + playlist = db.get_mirrored_playlist(int(playlist_id)) + return [playlist] if playlist else [] + return None + + +def _filter_refreshable_playlists(playlists: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + return [pl for pl in playlists if pl.get('source', '') not in ('file', 'beatport')] + + +def _summarize_playlist_names(playlists: List[Dict[str, Any]]) -> str: + pl_names = ', '.join(p.get('name', '?') for p in playlists[:3]) + if len(playlists) > 3: + pl_names += f' (+{len(playlists) - 3} more)' + return pl_names + + +def _run_refresh_phase( + config: Dict[str, Any], + deps: Any, + automation_id: Any, + *, + refresh_fn: RefreshFn, +) -> tuple[int, int]: + deps.update_progress( + automation_id, + progress=3, + phase='Phase 1/4: Refreshing playlists...', + log_line='Phase 1: Refresh', + log_type='info', + ) + + refresh_config = dict(config) + refresh_config['_automation_id'] = None + refresh_result = refresh_fn(refresh_config, deps) + refreshed = int(refresh_result.get('refreshed', 0)) + refresh_errors = int(refresh_result.get('errors', 0)) + + deps.update_progress( + automation_id, + progress=25, + phase='Phase 1/4: Refresh complete', + log_line=f'Phase 1 done: {refreshed} refreshed, {refresh_errors} errors', + log_type='success' if refresh_errors == 0 else 'warning', + ) + return refreshed, refresh_errors + + +def _run_discovery_phase( + deps: Any, + automation_id: Any, + *, + db: Any, + playlist_id: Any, + process_all: bool, +) -> None: + deps.update_progress( + automation_id, + progress=26, + phase='Phase 2/4: Discovering metadata...', + log_line='Phase 2: Discover', + log_type='info', + ) + + if process_all: + disc_playlists = db.get_mirrored_playlists() + else: + disc_playlists = [db.get_mirrored_playlist(int(playlist_id))] + disc_playlists = [p for p in disc_playlists if p] + + disc_done = threading.Event() + + def _disc_wrapper(pls): + try: + deps.run_playlist_discovery_worker(pls, automation_id=None) + except Exception as e: # noqa: BLE001 - logged into pipeline progress + deps.logger.error(f"[Pipeline] Discovery error: {e}") + finally: + disc_done.set() + + threading.Thread( + target=_disc_wrapper, + args=(disc_playlists,), + daemon=True, + name='pipeline-discover', + ).start() + + poll_start = time.time() + while not disc_done.wait(timeout=3): + elapsed = int(time.time() - poll_start) + deps.update_progress( + automation_id, + progress=min(26 + elapsed // 4, 54), + phase=f'Phase 2/4: Discovering... ({elapsed}s)', + ) + if elapsed > DISCOVERY_TIMEOUT_SECONDS: + deps.update_progress( + automation_id, + log_line='Discovery timed out after 1 hour', + log_type='warning', + ) + break + + deps.update_progress( + automation_id, + progress=55, + phase='Phase 2/4: Discovery complete', + log_line='Phase 2 done: discovery complete', + log_type='success', + ) From f83c671570bd1db41049b3b524f922eee18a0b75 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 19:54:04 -0700 Subject: [PATCH 04/18] Add direct mirrored playlist pipeline runs Expose playlist-native run and status endpoints that reuse the shared mirrored playlist pipeline engine while routing progress into playlist UI state. Add a Run Pipeline action to mirrored playlist cards and modals with live status polling, and make the shared pipeline lock atomic for manual and scheduled callers. --- core/automation/deps.py | 8 + core/playlists/pipeline.py | 10 +- tests/automation/test_handlers_playlist.py | 13 ++ tests/automation/test_handlers_simple.py | 8 + web_server.py | 207 +++++++++++++++++++++ webui/static/stats-automations.js | 121 +++++++++++- webui/static/style.css | 32 ++++ 7 files changed, 396 insertions(+), 3 deletions(-) diff --git a/core/automation/deps.py b/core/automation/deps.py index a521a496..5811766e 100644 --- a/core/automation/deps.py +++ b/core/automation/deps.py @@ -56,6 +56,14 @@ class AutomationState: with self.lock: return self.pipeline_running + def try_start_pipeline(self) -> bool: + """Atomically mark the shared playlist pipeline as running.""" + with self.lock: + if self.pipeline_running: + return False + self.pipeline_running = True + return True + def set_scan_library_id(self, automation_id: Optional[str]) -> None: with self.lock: self.scan_library_automation_id = automation_id diff --git a/core/playlists/pipeline.py b/core/playlists/pipeline.py index 9f279118..2bf0fc9c 100644 --- a/core/playlists/pipeline.py +++ b/core/playlists/pipeline.py @@ -38,7 +38,15 @@ def run_mirrored_playlist_pipeline( a future web/UI runner can provide the same small surface without becoming an automation. """ - deps.state.set_pipeline_running(True) + if hasattr(deps.state, 'try_start_pipeline'): + if not deps.state.try_start_pipeline(): + return { + 'status': 'skipped', + 'reason': 'playlist_pipeline is already running', + '_manages_own_progress': True, + } + else: + deps.state.set_pipeline_running(True) automation_id = config.get('_automation_id') pipeline_start = time.time() diff --git a/tests/automation/test_handlers_playlist.py b/tests/automation/test_handlers_playlist.py index 0bf7628c..60442bea 100644 --- a/tests/automation/test_handlers_playlist.py +++ b/tests/automation/test_handlers_playlist.py @@ -387,6 +387,19 @@ class TestSyncPlaylist: class TestPlaylistPipeline: + def test_pipeline_skips_when_shared_lock_is_already_running(self): + deps = _build_deps() + deps.state.set_pipeline_running(True) + + result = auto_playlist_pipeline({'all': True}, deps) + + assert result == { + 'status': 'skipped', + 'reason': 'playlist_pipeline is already running', + '_manages_own_progress': True, + } + assert deps.state.pipeline_running is True + def test_no_playlist_specified_returns_error(self): deps = _build_deps() result = auto_playlist_pipeline({}, deps) diff --git a/tests/automation/test_handlers_simple.py b/tests/automation/test_handlers_simple.py index ac8b2ef4..5e535884 100644 --- a/tests/automation/test_handlers_simple.py +++ b/tests/automation/test_handlers_simple.py @@ -311,6 +311,14 @@ class TestAutomationState: s.set_pipeline_running(False) assert s.is_pipeline_running() is False + def test_try_start_pipeline_is_atomic(self): + s = AutomationState() + assert s.try_start_pipeline() is True + assert s.is_pipeline_running() is True + assert s.try_start_pipeline() is False + s.set_pipeline_running(False) + assert s.try_start_pipeline() is True + def test_concurrent_set_safe_via_lock(self): # Smoke test: two threads flipping the same field don't crash. # Lock ensures the final value is consistent. diff --git a/web_server.py b/web_server.py index 2a90c8e1..51303934 100644 --- a/web_server.py +++ b/web_server.py @@ -919,6 +919,12 @@ except Exception as e: # --- Automation Progress Tracking --- _scan_library_automation_id = None +_automation_deps = None + +# Playlist-native manual pipeline runs share the automation dependency +# bundle, but keep their own small progress state for the playlist UI. +playlist_pipeline_progress_states = {} +playlist_pipeline_progress_lock = threading.Lock() def _register_automation_handlers(): @@ -935,6 +941,8 @@ def _register_automation_handlers(): closures still live below until subsequent commits in the same branch finish the lift. """ + global _automation_deps + if not automation_engine: return @@ -32198,6 +32206,7 @@ def get_mirrored_playlists_endpoint(): pl['source_ref_kind'] = source_ref.source_ref_kind pl['source_ref_status'] = source_ref.source_ref_status pl['source_ref_error'] = source_ref.source_ref_error + pl['pipeline_state'] = _snapshot_playlist_pipeline_state(pl['id']) return jsonify(playlists) except Exception as e: logger.error(f"Error getting mirrored playlists: {e}") @@ -32217,6 +32226,7 @@ def get_mirrored_playlist_endpoint(playlist_id): playlist['source_ref_kind'] = source_ref.source_ref_kind playlist['source_ref_status'] = source_ref.source_ref_status playlist['source_ref_error'] = source_ref.source_ref_error + playlist['pipeline_state'] = _snapshot_playlist_pipeline_state(playlist_id) playlist['tracks'] = database.get_mirrored_playlist_tracks(playlist_id) return jsonify(playlist) except Exception as e: @@ -32272,6 +32282,203 @@ def update_mirrored_playlist_source_ref_endpoint(playlist_id): logger.error(f"Error updating mirrored playlist source reference: {e}") return jsonify({"error": str(e)}), 500 + +def _playlist_pipeline_state_key(playlist_id): + return f"mirrored_{int(playlist_id)}" + + +def _snapshot_playlist_pipeline_state(playlist_id): + key = _playlist_pipeline_state_key(playlist_id) + with playlist_pipeline_progress_lock: + state = playlist_pipeline_progress_states.get(key) + return dict(state) if state else None + + +def _replace_playlist_pipeline_state(playlist_id, state): + key = _playlist_pipeline_state_key(playlist_id) + with playlist_pipeline_progress_lock: + playlist_pipeline_progress_states[key] = dict(state) + return dict(playlist_pipeline_progress_states[key]) + + +def _update_playlist_pipeline_progress(playlist_id, **kwargs): + key = _playlist_pipeline_state_key(playlist_id) + with playlist_pipeline_progress_lock: + state = playlist_pipeline_progress_states.setdefault(key, { + 'run_id': key, + 'playlist_id': int(playlist_id), + 'status': 'running', + 'progress': 0, + 'phase': 'Starting pipeline...', + 'log': [], + 'started_at': time.time(), + 'finished_at': None, + 'result': None, + 'error': None, + }) + for field in ('status', 'progress', 'phase', 'result', 'error'): + if field in kwargs: + state[field] = kwargs[field] + if 'log_line' in kwargs and kwargs.get('log_line'): + state.setdefault('log', []).append({ + 'message': kwargs.get('log_line'), + 'type': kwargs.get('log_type') or 'info', + 'timestamp': time.time(), + }) + state['log'] = state['log'][-80:] + if state.get('status') in ('finished', 'error', 'skipped') and not state.get('finished_at'): + state['finished_at'] = time.time() + return dict(state) + + +class _PlaylistPipelineDepsProxy: + """Forward automation deps while routing progress into playlist UI state.""" + + def __init__(self, base_deps, playlist_id): + self._base_deps = base_deps + self._playlist_id = playlist_id + + def __getattr__(self, name): + return getattr(self._base_deps, name) + + def update_progress(self, _automation_id, **kwargs): + _update_playlist_pipeline_progress(self._playlist_id, **kwargs) + + +def _run_mirrored_playlist_pipeline_for_ui(playlist_id, skip_wishlist=False): + try: + if _automation_deps is None: + raise RuntimeError("Automation dependencies are not available") + + from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored + from core.automation.handlers.sync_playlist import auto_sync_playlist + from core.automation.handlers._pipeline_shared import run_sync_and_wishlist + from core.playlists.pipeline import run_mirrored_playlist_pipeline + + deps = _PlaylistPipelineDepsProxy(_automation_deps, playlist_id) + result = run_mirrored_playlist_pipeline( + { + 'playlist_id': str(playlist_id), + 'all': False, + 'skip_wishlist': bool(skip_wishlist), + '_automation_id': _playlist_pipeline_state_key(playlist_id), + }, + deps, + refresh_fn=auto_refresh_mirrored, + sync_one_fn=auto_sync_playlist, + sync_and_wishlist_fn=run_sync_and_wishlist, + ) + + status = result.get('status') + if status == 'completed': + _update_playlist_pipeline_progress( + playlist_id, + status='finished', + progress=100, + phase='Pipeline complete', + result=result, + ) + elif status == 'skipped': + _update_playlist_pipeline_progress( + playlist_id, + status='skipped', + progress=100, + phase='Pipeline already running', + error=result.get('reason') or 'Pipeline already running', + result=result, + log_line=result.get('reason') or 'Pipeline already running', + log_type='warning', + ) + else: + _update_playlist_pipeline_progress( + playlist_id, + status='error', + progress=100, + phase='Pipeline error', + error=result.get('error') or 'Pipeline failed', + result=result, + ) + except Exception as e: + logger.error(f"Manual mirrored playlist pipeline failed for {playlist_id}: {e}") + _update_playlist_pipeline_progress( + playlist_id, + status='error', + progress=100, + phase='Pipeline error', + error=str(e), + log_line=f'Pipeline failed: {e}', + log_type='error', + ) + + +@app.route('/api/mirrored-playlists//pipeline/run', methods=['POST']) +def run_mirrored_playlist_pipeline_endpoint(playlist_id): + """Run the all-in-one mirrored playlist pipeline from the playlist UI.""" + try: + database = get_database() + playlist = database.get_mirrored_playlist(playlist_id) + if not playlist: + return jsonify({"error": "Playlist not found"}), 404 + if playlist.get('source') in ('file', 'beatport'): + return jsonify({"error": "This playlist source cannot be refreshed by the pipeline"}), 400 + if _automation_deps is None: + return jsonify({"error": "Playlist pipeline is not available"}), 503 + if _automation_deps.state.is_pipeline_running(): + return jsonify({"error": "A playlist pipeline is already running"}), 409 + + data = request.get_json(silent=True) or {} + state = _replace_playlist_pipeline_state(playlist_id, { + 'run_id': _playlist_pipeline_state_key(playlist_id), + 'playlist_id': int(playlist_id), + 'playlist_name': playlist.get('name') or '', + 'status': 'running', + 'progress': 0, + 'phase': 'Starting pipeline...', + 'log': [{ + 'message': f"Starting pipeline for {playlist.get('name') or playlist_id}", + 'type': 'info', + 'timestamp': time.time(), + }], + 'started_at': time.time(), + 'finished_at': None, + 'result': None, + 'error': None, + }) + + threading.Thread( + target=_run_mirrored_playlist_pipeline_for_ui, + args=(playlist_id, bool(data.get('skip_wishlist', False))), + daemon=True, + name=f"playlist-pipeline-{playlist_id}", + ).start() + + return jsonify({"success": True, "state": state}) + except Exception as e: + logger.error(f"Error starting mirrored playlist pipeline: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/mirrored-playlists//pipeline/status', methods=['GET']) +def get_mirrored_playlist_pipeline_status_endpoint(playlist_id): + """Return the latest manual pipeline progress for a mirrored playlist.""" + try: + state = _snapshot_playlist_pipeline_state(playlist_id) + if not state: + return jsonify({ + "run_id": _playlist_pipeline_state_key(playlist_id), + "playlist_id": int(playlist_id), + "status": "idle", + "progress": 0, + "phase": "Idle", + "log": [], + "result": None, + "error": None, + }) + return jsonify(state) + except Exception as e: + logger.error(f"Error getting mirrored playlist pipeline status: {e}") + return jsonify({"error": str(e)}), 500 + @app.route('/api/mirrored-playlists/', methods=['DELETE']) def delete_mirrored_playlist_endpoint(playlist_id): """Delete a mirrored playlist.""" diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index f7545036..9d25e8bb 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -380,6 +380,7 @@ function importFileSubmit() { // ── Mirrored Playlists ──────────────────────────────────────────────── let mirroredPlaylistsLoaded = false; +const mirroredPipelinePollers = {}; /** * Fire-and-forget helper: send parsed playlist data to be mirrored on the backend. @@ -444,13 +445,34 @@ async function loadMirroredPlaylists() { function renderMirroredCard(p, container) { const ago = timeAgo(p.updated_at || p.mirrored_at); const hash = `mirrored_${p.id}`; - const state = youtubePlaylistStates[hash]; + const pipelineState = p.pipeline_state || null; + const pipelinePhase = pipelineState && pipelineState.status === 'running' ? 'pipeline_running' + : pipelineState && pipelineState.status === 'finished' ? 'pipeline_complete' + : pipelineState && (pipelineState.status === 'error' || pipelineState.status === 'skipped') ? 'pipeline_error' + : null; + const state = youtubePlaylistStates[hash] || (pipelinePhase ? { + phase: pipelinePhase, + pipeline_status: pipelineState.status, + pipeline_progress: pipelineState.progress || 0, + pipeline_phase: pipelineState.phase || '', + pipeline_error: pipelineState.error || '', + pipeline_log: pipelineState.log || [], + pipeline_result: pipelineState.result || null, + } : null); const phase = state ? state.phase : null; const sourceRef = getMirroredSourceRef(p); // Build phase indicator let phaseHtml = ''; - if (phase === 'discovering') { + if (phase === 'pipeline_running') { + const pct = state.pipeline_progress || state.progress || 0; + const label = state.pipeline_phase || state.phase_label || 'Pipeline running'; + phaseHtml = `${_esc(label)} ${pct}%`; + } else if (phase === 'pipeline_complete') { + phaseHtml = `Pipeline complete`; + } else if (phase === 'pipeline_error') { + phaseHtml = `Pipeline error`; + } else if (phase === 'discovering') { const pct = state.discoveryProgress || state.discovery_progress || 0; phaseHtml = `Discovering ${pct}%`; } else if (phase === 'discovered') { @@ -491,6 +513,7 @@ function renderMirroredCard(p, container) { Mirrored ${ago} ${ratioHtml} ${phaseHtml} + ${disc > 0 ? `` : ''} @@ -532,6 +555,10 @@ function renderMirroredCard(p, container) { } }); container.appendChild(card); + + if (pipelineState && pipelineState.status === 'running' && !mirroredPipelinePollers[hash]) { + pollMirroredPipelineStatus(p.id, p.name); + } } function getMirroredSourceRef(p) { @@ -577,6 +604,84 @@ async function editMirroredSourceRef(playlistId, name, source, currentRef) { } } +function applyMirroredPipelineState(playlistId, state) { + const hash = `mirrored_${playlistId}`; + const existing = youtubePlaylistStates[hash] || {}; + const status = state.status || 'idle'; + let phase = existing.phase; + if (status === 'running') phase = 'pipeline_running'; + else if (status === 'finished') phase = 'pipeline_complete'; + else if (status === 'error' || status === 'skipped') phase = 'pipeline_error'; + + youtubePlaylistStates[hash] = { + ...existing, + phase, + pipeline_status: status, + pipeline_progress: state.progress || 0, + pipeline_phase: state.phase || '', + pipeline_error: state.error || '', + pipeline_log: state.log || [], + pipeline_result: state.result || null, + }; + + updateMirroredCardPhase(hash, phase); +} + +async function runMirroredPlaylistPipeline(playlistId, name) { + try { + const res = await fetch(`/api/mirrored-playlists/${playlistId}/pipeline/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + const data = await res.json(); + if (!res.ok || data.error) { + throw new Error(data.error || 'Failed to start pipeline'); + } + applyMirroredPipelineState(playlistId, data.state || { status: 'running', progress: 0, phase: 'Starting pipeline...' }); + showToast(`Pipeline started for ${name}`, 'success'); + pollMirroredPipelineStatus(playlistId, name); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } +} + +function pollMirroredPipelineStatus(playlistId, name) { + const key = `mirrored_${playlistId}`; + if (mirroredPipelinePollers[key]) clearInterval(mirroredPipelinePollers[key]); + + const tick = async () => { + try { + const res = await fetch(`/api/mirrored-playlists/${playlistId}/pipeline/status`); + const state = await res.json(); + if (!res.ok || state.error) throw new Error(state.error || 'Failed to read pipeline status'); + applyMirroredPipelineState(playlistId, state); + + if (state.status === 'finished') { + clearInterval(mirroredPipelinePollers[key]); + delete mirroredPipelinePollers[key]; + showToast(`Pipeline complete for ${name}`, 'success'); + loadMirroredPlaylists(); + } else if (state.status === 'error' || state.status === 'skipped') { + clearInterval(mirroredPipelinePollers[key]); + delete mirroredPipelinePollers[key]; + showToast(state.error || `Pipeline stopped for ${name}`, 'error'); + loadMirroredPlaylists(); + } else if (state.status === 'idle') { + clearInterval(mirroredPipelinePollers[key]); + delete mirroredPipelinePollers[key]; + } + } catch (err) { + clearInterval(mirroredPipelinePollers[key]); + delete mirroredPipelinePollers[key]; + showToast(`Pipeline status error: ${err.message}`, 'error'); + } + }; + + tick(); + mirroredPipelinePollers[key] = setInterval(tick, 2500); +} + function updateMirroredCardPhase(urlHash, phase) { // Update the state phase (updateYouTubeCardPhase skips this for mirrored playlists due to no cardElement) const state = youtubePlaylistStates[urlHash]; @@ -597,6 +702,17 @@ function updateMirroredCardPhase(urlHash, phase) { // Add new phase indicator let phaseHtml = ''; switch (phase) { + case 'pipeline_running': + const pipelineProgress = state?.pipeline_progress || 0; + const pipelinePhase = state?.pipeline_phase || 'Pipeline running'; + phaseHtml = `${_esc(pipelinePhase)} ${pipelineProgress}%`; + break; + case 'pipeline_complete': + phaseHtml = `Pipeline complete`; + break; + case 'pipeline_error': + phaseHtml = `Pipeline error`; + break; case 'discovering': phaseHtml = `Discovering...`; break; @@ -874,6 +990,7 @@ async function openMirroredPlaylistModal(playlistId) { diff --git a/webui/static/style.css b/webui/static/style.css index 8ab1469c..51c619c6 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -12047,6 +12047,25 @@ body.helper-mode-active #dashboard-activity-feed:hover { letter-spacing: 0.3px; } +.mirrored-card-pipeline { + background: rgba(56, 189, 248, 0.12); + border: 1px solid rgba(56, 189, 248, 0.24); + border-radius: 6px; + color: #7dd3fc; + cursor: pointer; + font-size: 11px; + font-weight: 700; + height: 24px; + padding: 0 10px; + transition: all 0.2s ease; +} + +.mirrored-card-pipeline:hover { + background: rgba(56, 189, 248, 0.2); + border-color: rgba(56, 189, 248, 0.4); + color: #e0f2fe; +} + .mirrored-card-delete, .mirrored-card-clear, .mirrored-card-link { @@ -13226,6 +13245,19 @@ body.helper-mode-active #dashboard-activity-feed:hover { box-shadow: 0 8px 24px rgba(var(--accent-rgb), 0.4); } +.mirrored-btn-pipeline { + background: rgba(56, 189, 248, 0.12); + color: #7dd3fc; + border: 1px solid rgba(56, 189, 248, 0.28) !important; +} + +.mirrored-btn-pipeline:hover { + background: rgba(56, 189, 248, 0.2); + border-color: rgba(56, 189, 248, 0.45) !important; + color: #e0f2fe; + transform: translateY(-1px); +} + .mirrored-btn-delete { background: rgba(244, 67, 54, 0.12); color: #ef5350; From a1409576f49f9723bda1e211a2540b4ad08dd6c3 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 20:09:06 -0700 Subject: [PATCH 05/18] Move mirrored pipeline action into card controls Place the Run Pipeline action alongside the existing mirrored playlist card controls so users can find it next to clear, edit source, and delete. --- webui/static/stats-automations.js | 2 +- webui/static/style.css | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 9d25e8bb..6cf81220 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -513,10 +513,10 @@ function renderMirroredCard(p, container) { Mirrored ${ago} ${ratioHtml} ${phaseHtml} - ${disc > 0 ? `` : ''} + `; diff --git a/webui/static/style.css b/webui/static/style.css index 51c619c6..d633399a 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -12058,6 +12058,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { height: 24px; padding: 0 10px; transition: all 0.2s ease; + flex-shrink: 0; + opacity: 0; } .mirrored-card-pipeline:hover { @@ -12088,7 +12090,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .mirrored-playlist-card:hover .mirrored-card-delete, .mirrored-playlist-card:hover .mirrored-card-clear, -.mirrored-playlist-card:hover .mirrored-card-link { +.mirrored-playlist-card:hover .mirrored-card-link, +.mirrored-playlist-card:hover .mirrored-card-pipeline { opacity: 1; } From 46a0999ca2936bab113d1d1861ae64bec8375f03 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 20:23:37 -0700 Subject: [PATCH 06/18] Clarify mirrored playlist auto-sync action Rename the manual pipeline button to Auto-Sync and make non-JSON endpoint failures show an actionable restart message instead of a raw JSON parse error. --- webui/static/stats-automations.js | 35 ++++++++++++++++++++++--------- webui/static/style.css | 1 + 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 6cf81220..62be137b 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -516,7 +516,7 @@ function renderMirroredCard(p, container) { ${disc > 0 ? `` : ''} - + `; @@ -570,6 +570,25 @@ function getMirroredSourceRef(p) { return (p && p.source_playlist_id) ? String(p.source_playlist_id) : ''; } +async function parseMirroredPipelineResponse(res, fallbackMessage) { + const text = await res.text(); + let data = {}; + if (text) { + try { + data = JSON.parse(text); + } catch (_err) { + const detail = res.status === 404 + ? 'Auto-Sync endpoint not found. Restart the SoulSync server so the new backend routes load.' + : fallbackMessage; + throw new Error(detail); + } + } + if (!res.ok || data.error) { + throw new Error(data.error || fallbackMessage); + } + return data; +} + async function editMirroredSourceRef(playlistId, name, source, currentRef) { const label = (source === 'spotify_public' || source === 'youtube') ? 'original playlist URL' @@ -634,12 +653,9 @@ async function runMirroredPlaylistPipeline(playlistId, name) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); - const data = await res.json(); - if (!res.ok || data.error) { - throw new Error(data.error || 'Failed to start pipeline'); - } + const data = await parseMirroredPipelineResponse(res, 'Failed to start Auto-Sync'); applyMirroredPipelineState(playlistId, data.state || { status: 'running', progress: 0, phase: 'Starting pipeline...' }); - showToast(`Pipeline started for ${name}`, 'success'); + showToast(`Auto-Sync started for ${name}`, 'success'); pollMirroredPipelineStatus(playlistId, name); } catch (err) { showToast(`Error: ${err.message}`, 'error'); @@ -653,14 +669,13 @@ function pollMirroredPipelineStatus(playlistId, name) { const tick = async () => { try { const res = await fetch(`/api/mirrored-playlists/${playlistId}/pipeline/status`); - const state = await res.json(); - if (!res.ok || state.error) throw new Error(state.error || 'Failed to read pipeline status'); + const state = await parseMirroredPipelineResponse(res, 'Failed to read Auto-Sync status'); applyMirroredPipelineState(playlistId, state); if (state.status === 'finished') { clearInterval(mirroredPipelinePollers[key]); delete mirroredPipelinePollers[key]; - showToast(`Pipeline complete for ${name}`, 'success'); + showToast(`Auto-Sync complete for ${name}`, 'success'); loadMirroredPlaylists(); } else if (state.status === 'error' || state.status === 'skipped') { clearInterval(mirroredPipelinePollers[key]); @@ -990,7 +1005,7 @@ async function openMirroredPlaylistModal(playlistId) { diff --git a/webui/static/style.css b/webui/static/style.css index d633399a..0fa9218b 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -12056,6 +12056,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { font-size: 11px; font-weight: 700; height: 24px; + min-width: 76px; padding: 0 10px; transition: all 0.2s ease; flex-shrink: 0; From 854141f9033e499b13027a9bcc93706c33ef2632 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 20:31:03 -0700 Subject: [PATCH 07/18] Add playlist auto-sync schedule board Add a Sync-page Auto-Sync manager with source-grouped mirrored playlists, interval columns, and drag/drop scheduling backed by playlist_pipeline automations. Schedules created by the board are editable there, while existing custom pipeline automations are shown as locked automation-managed entries. --- webui/index.html | 1 + webui/static/stats-automations.js | 313 ++++++++++++++++++++++++++++++ webui/static/style.css | 272 ++++++++++++++++++++++++++ 3 files changed, 586 insertions(+) diff --git a/webui/index.html b/webui/index.html index 709c9b9e..6cee0db2 100644 --- a/webui/index.html +++ b/webui/index.html @@ -916,6 +916,7 @@ server

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

Auto-Sync Schedule

+

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

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

Auto-Sync Schedule

Could not load schedule data.

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

Auto-Sync Schedule

+

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

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

Auto-Sync Manager

+

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

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

Auto-Sync Schedule

-

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

-
- + return ` +
+
+ Drag playlists into an interval + Each placement creates or updates an Auto-Sync-owned playlist-pipeline automation.
- ${managedHtml} + +
${bucketHtml}
+ `; +} + +function renderAutoSyncAutomationPanel(automationPipelines, playlists) { + if (!automationPipelines.length) { + return '
No Automations-page playlist pipelines found.
'; + } + return ` +
+ Read-only Automations-page pipelines + These use the playlist pipeline but are managed from the Automations page, so this modal only displays them. +
+
+ ${automationPipelines.map(auto => autoSyncAutomationCardHtml(auto, playlists)).join('')} +
+ `; +} + +function autoSyncAutomationCardHtml(auto, playlists) { + const cfg = auto.action_config || {}; + const playlistId = autoSyncPlaylistIdFromAutomation(auto); + const playlist = playlistId ? playlists.find(p => parseInt(p.id, 10) === playlistId) : null; + const target = cfg.all === true || cfg.all === 'true' + ? 'All refreshable mirrored playlists' + : playlist ? playlist.name : playlistId ? `Playlist #${playlistId}` : 'Custom pipeline target'; + const trigger = _autoFormatTrigger(auto.trigger_type, auto.trigger_config || {}); + const enabled = auto.enabled !== false && auto.enabled !== 0; + const next = auto.next_run ? autoSyncNextRunLabel(auto.next_run) : 'not scheduled'; + return ` +
+
+
+ ${enabled ? 'Enabled' : 'Disabled'} + ${_esc(auto.name || 'Playlist Pipeline')} +
+
+ ${_esc(trigger)} + ${_esc(target)} + ${_esc(next)} +
+
+
Read only
`; } function autoSyncScheduledCardHtml(playlist, schedule) { const enabled = schedule?.enabled !== false; - const owned = schedule?.owned === true; return `
${_esc(playlist.name)}
-
${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks${schedule?.next_run ? ` · ${autoSyncNextRunLabel(schedule.next_run)}` : ''}${owned ? '' : ' · Automations page'}
+
${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks${schedule?.next_run ? ` · ${autoSyncNextRunLabel(schedule.next_run)}` : ''}
- ${owned - ? `` - : 'Lock'} +
`; } @@ -839,10 +913,6 @@ async function saveAutoSyncPlaylistSchedule(playlistId, hours) { const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); if (!playlist) return; const existing = _autoSyncScheduleState.playlistSchedules[playlistId]; - if (existing && !existing.owned) { - showToast('This playlist pipeline is managed from the Automations page for now.', 'info'); - return; - } const payload = { name: `Auto-Sync: ${playlist.name}`, trigger_type: 'schedule', diff --git a/webui/static/style.css b/webui/static/style.css index 7e77f94f..097f6ff1 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -11196,9 +11196,9 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-modal { - width: min(1420px, calc(100vw - 48px)); - height: min(820px, calc(100vh - 48px)); - background: rgba(18, 20, 28, 0.98); + width: min(1500px, calc(100vw - 40px)); + height: min(860px, calc(100vh - 40px)); + background: rgba(17, 19, 27, 0.98); border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 8px; box-shadow: 0 28px 80px rgba(0, 0, 0, 0.5); @@ -11216,6 +11216,14 @@ body.helper-mode-active #dashboard-activity-feed:hover { border-bottom: 1px solid rgba(255, 255, 255, 0.08); } +.auto-sync-eyebrow { + margin-bottom: 6px; + color: #7dd3fc; + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + .auto-sync-header h3 { margin: 0 0 6px; color: rgba(255, 255, 255, 0.92); @@ -11246,6 +11254,116 @@ body.helper-mode-active #dashboard-activity-feed:hover { color: #fff; } +.auto-sync-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1px; + background: rgba(255, 255, 255, 0.08); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.auto-sync-summary div { + padding: 14px 20px; + background: rgba(255, 255, 255, 0.025); +} + +.auto-sync-summary span { + display: block; + color: rgba(255, 255, 255, 0.92); + font-size: 20px; + font-weight: 800; +} + +.auto-sync-summary small { + display: block; + margin-top: 2px; + color: rgba(255, 255, 255, 0.42); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} + +.auto-sync-tabs { + display: flex; + gap: 8px; + padding: 12px 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.018); +} + +.auto-sync-tabs button { + height: 32px; + padding: 0 14px; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 6px; + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.58); + cursor: pointer; + font-size: 12px; + font-weight: 700; +} + +.auto-sync-tabs button:hover, +.auto-sync-tabs button.active { + border-color: rgba(56, 189, 248, 0.35); + background: rgba(56, 189, 248, 0.12); + color: #e0f2fe; +} + +.auto-sync-tab-panel { + display: none; + min-height: 0; + flex: 1; +} + +.auto-sync-tab-panel.active { + display: flex; + flex-direction: column; +} + +.auto-sync-board-intro, +.auto-sync-automation-intro { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + background: rgba(56, 189, 248, 0.035); +} + +.auto-sync-board-intro strong, +.auto-sync-automation-intro strong { + display: block; + color: rgba(255, 255, 255, 0.86); + font-size: 13px; +} + +.auto-sync-board-intro span, +.auto-sync-automation-intro span { + display: block; + margin-top: 2px; + color: rgba(255, 255, 255, 0.46); + font-size: 12px; +} + +.auto-sync-board-intro button { + height: 30px; + padding: 0 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + font-size: 12px; + font-weight: 700; +} + +.auto-sync-board-intro button:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + .auto-sync-body { min-height: 0; flex: 1; @@ -11262,7 +11380,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-sidebar-title { - padding: 16px 18px 12px; + padding: 16px 18px 10px; color: rgba(255, 255, 255, 0.72); font-size: 12px; font-weight: 700; @@ -11295,7 +11413,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-playlist { - padding: 10px; + padding: 11px 10px; margin-bottom: 8px; } @@ -11331,7 +11449,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { overflow-x: auto; padding: 18px; display: grid; - grid-template-columns: repeat(10, minmax(170px, 1fr)); + grid-template-columns: repeat(10, minmax(185px, 1fr)); gap: 12px; } @@ -11381,6 +11499,22 @@ body.helper-mode-active #dashboard-activity-feed:hover { padding: 18px 10px; } +.auto-sync-drop-hint strong, +.auto-sync-drop-hint span { + display: block; +} + +.auto-sync-drop-hint strong { + color: rgba(255, 255, 255, 0.52); + font-size: 12px; +} + +.auto-sync-drop-hint span { + margin-top: 3px; + color: rgba(255, 255, 255, 0.32); + font-size: 11px; +} + .auto-sync-loading, .auto-sync-error { padding: 48px; @@ -11414,40 +11548,103 @@ body.helper-mode-active #dashboard-activity-feed:hover { background: rgba(239, 68, 68, 0.12); } -.auto-sync-lock { - align-self: flex-start; - padding: 4px 6px; - border-radius: 5px; - background: rgba(255, 255, 255, 0.06); - color: rgba(255, 255, 255, 0.38); +.auto-sync-automation-list { + min-height: 0; + overflow-y: auto; + padding: 18px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.auto-sync-automation-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: rgba(255, 255, 255, 0.035); +} + +.auto-sync-automation-card:hover { + border-color: rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.055); +} + +.auto-sync-automation-main { + min-width: 0; + flex: 1; +} + +.auto-sync-automation-title-row { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.auto-sync-automation-title-row strong { + color: rgba(255, 255, 255, 0.88); + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auto-sync-status { + padding: 3px 7px; + border-radius: 999px; font-size: 10px; - font-weight: 700; + font-weight: 800; text-transform: uppercase; flex-shrink: 0; } -.auto-sync-managed { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 24px; - border-bottom: 1px solid rgba(255, 255, 255, 0.07); - color: rgba(255, 255, 255, 0.48); - font-size: 12px; - overflow-x: auto; +.auto-sync-status.enabled { + background: rgba(34, 197, 94, 0.14); + color: #4ade80; } -.auto-sync-managed-title { - color: rgba(255, 255, 255, 0.72); - font-weight: 700; +.auto-sync-status.disabled { + background: rgba(148, 163, 184, 0.14); + color: #94a3b8; +} + +.auto-sync-automation-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +.auto-sync-automation-meta span { + padding: 4px 8px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.48); + font-size: 11px; +} + +.auto-sync-automation-lock { + padding: 6px 9px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.42); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; flex-shrink: 0; } -.auto-sync-managed span { - padding: 4px 8px; - border-radius: 999px; - background: rgba(255, 255, 255, 0.06); - white-space: nowrap; +.auto-sync-automation-empty { + margin: 24px; + padding: 44px; + border: 1px dashed rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: rgba(255, 255, 255, 0.42); + text-align: center; } /* Enhanced Progress Bar Animation */ From 9a8e7d02a7b859abf7a6b25f14b1c264fe24a19b Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 21:01:46 -0700 Subject: [PATCH 09/18] Make auto-sync schedule modal responsive Constrain Auto-Sync columns inside the modal with per-column vertical scrolling, add responsive layouts for narrower and shorter viewports, and separate schedule interval labels from next-run timing. Also prevents unsupported mirrored sources from being scheduled into the playlist pipeline while still showing them as unavailable in the sidebar. --- webui/static/stats-automations.js | 50 ++++++++++-- webui/static/style.css | 131 +++++++++++++++++++++++++++++- 2 files changed, 173 insertions(+), 8 deletions(-) diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 320113e5..25bc01ce 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -602,6 +602,15 @@ function autoSyncBucketLabel(hours) { return `${hours}h`; } +function autoSyncIntervalLabel(hours) { + if (hours === 168) return 'Every week'; + if (hours >= 24) { + const days = hours / 24; + return `Every ${days} day${days === 1 ? '' : 's'}`; + } + return `Every ${hours} hour${hours === 1 ? '' : 's'}`; +} + function autoSyncSourceLabel(source) { const labels = { spotify: 'Spotify', @@ -616,6 +625,10 @@ function autoSyncSourceLabel(source) { return labels[source] || source || 'Other'; } +function autoSyncCanSchedulePlaylist(playlist) { + return playlist && !['file', 'beatport'].includes(playlist.source || ''); +} + function autoSyncIsPipelineAutomation(auto) { return auto && auto.action_type === 'playlist_pipeline'; } @@ -764,7 +777,9 @@ function setAutoSyncTab(tab) { } function renderAutoSyncSchedulePanel(playlists, playlistSchedules) { - const grouped = playlists.reduce((acc, p) => { + const schedulablePlaylists = playlists.filter(autoSyncCanSchedulePlaylist); + const unavailablePlaylists = playlists.filter(p => !autoSyncCanSchedulePlaylist(p)); + const grouped = schedulablePlaylists.reduce((acc, p) => { const key = p.source || 'other'; if (!acc[key]) acc[key] = []; acc[key].push(p); @@ -777,7 +792,7 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
${_esc(autoSyncSourceLabel(source))}
${grouped[source].map(p => { const schedule = playlistSchedules[p.id]; - const assigned = schedule ? autoSyncBucketLabel(schedule.hours) : 'Unscheduled'; + const assigned = schedule ? autoSyncIntervalLabel(schedule.hours) : 'Unscheduled'; return `
${_esc(p.name)}
@@ -786,15 +801,27 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) { `; }).join('')}
- `).join('') : '
No mirrored playlists yet.
'; + `).join('') : '
No refreshable mirrored playlists yet.
'; + + const unavailableHtml = unavailablePlaylists.length ? ` +
+
Not schedulable
+ ${unavailablePlaylists.map(p => ` +
+
${_esc(p.name)}
+
${_esc(autoSyncSourceLabel(p.source))} · refresh not supported
+
+ `).join('')} +
+ ` : ''; const bucketHtml = AUTO_SYNC_BUCKETS.map(hours => { - const assigned = playlists.filter(p => playlistSchedules[p.id]?.hours === hours); + const assigned = schedulablePlaylists.filter(p => playlistSchedules[p.id]?.hours === hours); return `
${autoSyncBucketLabel(hours)} - ${assigned.length} + ${assigned.length} playlist${assigned.length === 1 ? '' : 's'}
${assigned.length ? assigned.map(p => autoSyncScheduledCardHtml(p, playlistSchedules[p.id])).join('') : '
Drop hereSchedule playlists at this interval
'} @@ -814,7 +841,7 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
${bucketHtml}
@@ -866,11 +893,16 @@ function autoSyncAutomationCardHtml(auto, playlists) { function autoSyncScheduledCardHtml(playlist, schedule) { const enabled = schedule?.enabled !== false; + const nextLabel = schedule?.next_run ? autoSyncNextRunLabel(schedule.next_run) : ''; return `
${_esc(playlist.name)}
-
${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks${schedule?.next_run ? ` · ${autoSyncNextRunLabel(schedule.next_run)}` : ''}
+
${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks
+
+ ${_esc(autoSyncIntervalLabel(schedule?.hours || 24))} + ${nextLabel ? `${_esc(nextLabel)}` : ''} +
@@ -912,6 +944,10 @@ async function autoSyncDrop(event, hours) { async function saveAutoSyncPlaylistSchedule(playlistId, hours) { const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); if (!playlist) return; + if (!autoSyncCanSchedulePlaylist(playlist)) { + showToast('That playlist source cannot be refreshed by Auto-Sync.', 'info'); + return; + } const existing = _autoSyncScheduleState.playlistSchedules[playlistId]; const payload = { name: `Auto-Sync: ${playlist.name}`, diff --git a/webui/static/style.css b/webui/static/style.css index 097f6ff1..d32f772d 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -11214,6 +11214,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { gap: 20px; padding: 22px 24px 18px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); + flex-shrink: 0; } .auto-sync-eyebrow { @@ -11260,6 +11261,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { gap: 1px; background: rgba(255, 255, 255, 0.08); border-bottom: 1px solid rgba(255, 255, 255, 0.08); + flex-shrink: 0; } .auto-sync-summary div { @@ -11289,6 +11291,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { padding: 12px 18px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.018); + flex-shrink: 0; } .auto-sync-tabs button { @@ -11330,6 +11333,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { padding: 12px 18px; border-bottom: 1px solid rgba(255, 255, 255, 0.07); background: rgba(56, 189, 248, 0.035); + flex-shrink: 0; } .auto-sync-board-intro strong, @@ -11427,6 +11431,21 @@ body.helper-mode-active #dashboard-activity-feed:hover { border-color: rgba(34, 197, 94, 0.22); } +.auto-sync-playlist.unavailable { + cursor: default; + opacity: 0.58; +} + +.auto-sync-playlist.unavailable:hover { + border-color: rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.045); +} + +.auto-sync-source-group-disabled { + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + .auto-sync-playlist-name, .auto-sync-scheduled-name { color: rgba(255, 255, 255, 0.86); @@ -11444,17 +11463,48 @@ body.helper-mode-active #dashboard-activity-feed:hover { font-size: 11px; } +.auto-sync-scheduled-timing { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 8px; +} + +.auto-sync-scheduled-timing span, +.auto-sync-scheduled-timing small { + padding: 3px 6px; + border-radius: 999px; + font-size: 10px; + font-weight: 800; + line-height: 1; +} + +.auto-sync-scheduled-timing span { + background: rgba(56, 189, 248, 0.14); + color: #7dd3fc; +} + +.auto-sync-scheduled-timing small { + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.48); +} + .auto-sync-board { min-width: 0; + min-height: 0; overflow-x: auto; + overflow-y: hidden; padding: 18px; display: grid; grid-template-columns: repeat(10, minmax(185px, 1fr)); + grid-auto-rows: minmax(0, 1fr); gap: 12px; } .auto-sync-column { min-height: 0; + max-height: 100%; + overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 8px; background: rgba(255, 255, 255, 0.025); @@ -11470,6 +11520,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { border-bottom: 1px solid rgba(255, 255, 255, 0.07); color: rgba(255, 255, 255, 0.86); font-weight: 800; + flex-shrink: 0; } .auto-sync-column-head small { @@ -11480,10 +11531,27 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-column-list { flex: 1; - min-height: 220px; + min-height: 0; + overflow-y: auto; padding: 10px; } +.auto-sync-column-list::-webkit-scrollbar, +.auto-sync-board::-webkit-scrollbar, +.auto-sync-source-list::-webkit-scrollbar, +.auto-sync-automation-list::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.auto-sync-column-list::-webkit-scrollbar-thumb, +.auto-sync-board::-webkit-scrollbar-thumb, +.auto-sync-source-list::-webkit-scrollbar-thumb, +.auto-sync-automation-list::-webkit-scrollbar-thumb { + background: rgba(125, 211, 252, 0.22); + border-radius: 999px; +} + .auto-sync-drop-hint, .auto-sync-empty, .auto-sync-loading, @@ -11647,6 +11715,67 @@ body.helper-mode-active #dashboard-activity-feed:hover { text-align: center; } +@media (max-width: 1100px) { + .auto-sync-modal { + width: calc(100vw - 18px); + height: calc(100vh - 18px); + } + + .auto-sync-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .auto-sync-body { + grid-template-columns: 1fr; + grid-template-rows: minmax(150px, 30%) 1fr; + } + + .auto-sync-sidebar { + border-right: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + } + + .auto-sync-source-list { + display: flex; + gap: 12px; + overflow-x: auto; + overflow-y: hidden; + padding: 0 12px 12px; + } + + .auto-sync-source-group { + min-width: 220px; + margin-bottom: 0; + } + + .auto-sync-board { + grid-template-columns: repeat(10, minmax(165px, 180px)); + } +} + +@media (max-height: 760px) { + .auto-sync-header { + padding: 14px 18px 12px; + } + + .auto-sync-header p { + display: none; + } + + .auto-sync-summary div { + padding: 9px 16px; + } + + .auto-sync-tabs { + padding: 9px 14px; + } + + .auto-sync-board-intro, + .auto-sync-automation-intro { + padding: 9px 14px; + } +} + /* Enhanced Progress Bar Animation */ .progress-bar-fill { height: 100%; From dc4d157944dfff170e8927563522cebc61077b62 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 22:01:21 -0700 Subject: [PATCH 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.

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

Auto-Sync Schedule

Could not load schedule data.

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

Auto-Sync Manager

+

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

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

Auto-Sync Schedule

-

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

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

Auto-Sync Schedule

Could not load schedule data.

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

Auto-Sync Manager

-

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

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

Tools

-

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

+

Quick Actions

+

Jump into the places that shape how SoulSync runs.

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

Quick Actions

diff --git a/webui/static/style.css b/webui/static/style.css index 75c53d83..df0752c8 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -59815,22 +59815,43 @@ body.reduce-effects .dash-card::after { } .dashboard-action-grid { + position: relative; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 14px; overflow: hidden; + isolation: isolate; background: + radial-gradient(circle at 50% -20%, rgba(var(--accent-rgb), 0.16), transparent 44%), linear-gradient(135deg, rgba(255,255,255,0.12), rgba(255,255,255,0.025)), rgba(255, 255, 255, 0.035); box-shadow: inset 0 1px 0 rgba(255,255,255,0.08), - 0 12px 28px rgba(0,0,0,0.16); + 0 12px 28px rgba(0,0,0,0.16), + 0 0 0 1px rgba(var(--accent-rgb), 0.04); +} + +.dashboard-action-grid::before { + content: ''; + position: absolute; + inset: 0 0 auto; + height: 2px; + background: linear-gradient(90deg, + transparent, + rgba(var(--accent-rgb), 0.4), + rgb(var(--accent-light-rgb)), + rgba(var(--accent-rgb), 0.4), + transparent); + opacity: 0.85; + pointer-events: none; + z-index: 2; } .dashboard-action-lane { position: relative; + z-index: 1; min-width: 0; min-height: 126px; display: flex; @@ -59841,7 +59862,7 @@ body.reduce-effects .dash-card::after { padding: 16px; border: 0; background: - radial-gradient(circle at 22% 10%, var(--lane-glow), transparent 42%), + radial-gradient(circle at var(--lane-glow-x, 22%) 10%, var(--lane-glow), transparent 42%), rgba(255, 255, 255, 0.032); color: rgba(255, 255, 255, 0.88); cursor: pointer; @@ -59866,11 +59887,16 @@ body.reduce-effects .dash-card::after { .dashboard-action-lane:hover, .dashboard-action-lane:focus-visible { background: - radial-gradient(circle at 22% 10%, var(--lane-glow-strong), transparent 46%), + radial-gradient(circle at var(--lane-glow-x, 22%) 10%, var(--lane-glow-strong), transparent 46%), rgba(255, 255, 255, 0.06); transform: translateY(-1px); } +.dashboard-action-lane:focus-visible { + outline: 2px solid rgba(var(--accent-rgb), 0.55); + outline-offset: -4px; +} + .dashboard-action-lane:hover::after, .dashboard-action-lane:focus-visible::after { border-color: var(--lane-border); @@ -59878,24 +59904,27 @@ body.reduce-effects .dash-card::after { } .dashboard-action-lane.tools { - --lane-glow: rgba(56, 189, 248, 0.18); - --lane-glow-strong: rgba(56, 189, 248, 0.3); - --lane-border: rgba(56, 189, 248, 0.28); - --lane-accent: #7dd3fc; + --lane-glow-x: 18%; + --lane-glow: rgba(var(--accent-rgb), 0.12); + --lane-glow-strong: rgba(var(--accent-rgb), 0.22); + --lane-border: rgba(var(--accent-rgb), 0.26); + --lane-accent: rgb(var(--accent-light-rgb)); } .dashboard-action-lane.auto-sync { - --lane-glow: rgba(34, 197, 94, 0.16); - --lane-glow-strong: rgba(34, 197, 94, 0.28); - --lane-border: rgba(34, 197, 94, 0.26); - --lane-accent: #86efac; + --lane-glow-x: 50%; + --lane-glow: rgba(var(--accent-rgb), 0.18); + --lane-glow-strong: rgba(var(--accent-rgb), 0.3); + --lane-border: rgba(var(--accent-rgb), 0.34); + --lane-accent: rgb(var(--accent-light-rgb)); } .dashboard-action-lane.automations { - --lane-glow: rgba(168, 85, 247, 0.18); - --lane-glow-strong: rgba(168, 85, 247, 0.3); - --lane-border: rgba(168, 85, 247, 0.28); - --lane-accent: #d8b4fe; + --lane-glow-x: 82%; + --lane-glow: rgba(var(--accent-rgb), 0.12); + --lane-glow-strong: rgba(var(--accent-rgb), 0.22); + --lane-border: rgba(var(--accent-rgb), 0.26); + --lane-accent: rgb(var(--accent-light-rgb)); } .dashboard-action-icon { @@ -59905,10 +59934,12 @@ body.reduce-effects .dash-card::after { align-items: center; justify-content: center; border-radius: 11px; - background: rgba(255, 255, 255, 0.07); - border: 1px solid rgba(255, 255, 255, 0.11); + background: rgba(var(--accent-rgb), 0.1); + border: 1px solid rgba(var(--accent-rgb), 0.18); color: var(--lane-accent); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 0 16px rgba(var(--accent-rgb), 0.08); } .dashboard-action-label { @@ -59936,6 +59967,7 @@ body.reduce-effects .dash-card::after { color: var(--lane-accent); font-size: 11px; font-weight: 800; + letter-spacing: 0.01em; } .dashboard-action-arrow::after {