diff --git a/core/automation/api.py b/core/automation/api.py index a6f78afe..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 @@ -225,6 +229,12 @@ def update_automation( if cycle_path: return {'error': f'Signal cycle detected: {cycle_path}. This would cause an infinite loop.'}, 400 + # 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) if not success: return {'error': 'Automation not found'}, 404 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/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/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/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/pipeline.py b/core/playlists/pipeline.py new file mode 100644 index 00000000..2bf0fc9c --- /dev/null +++ b/core/playlists/pipeline.py @@ -0,0 +1,257 @@ +"""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. + """ + 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() + + 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', + ) diff --git a/core/playlists/source_refs.py b/core/playlists/source_refs.py new file mode 100644 index 00000000..22909895 --- /dev/null +++ b/core/playlists/source_refs.py @@ -0,0 +1,156 @@ +"""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 Mapping, 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] + + +@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, + 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 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: + 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/(?:embed/)?(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/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 0171d88e..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: @@ -6370,6 +6393,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 +6968,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""" @@ -11828,7 +11889,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 +12000,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: @@ -12083,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: @@ -12138,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/services/sync_service.py b/services/sync_service.py index 5177c59d..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,33 +227,33 @@ 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) - + + # 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" 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 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, + 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 +487,42 @@ 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. 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 + from core.text.normalize import normalize_for_comparison + key = normalize_for_comparison(artist_name) + if key in candidate_pool: + return candidate_pool[key] + try: + # 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, + ) + # 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}") + 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 +530,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 @@ -531,18 +584,19 @@ 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() - 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/tests/automation/test_automation_api.py b/tests/automation/test_automation_api.py index fea7a032..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 @@ -289,6 +291,91 @@ 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' + + +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/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..60442bea 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 ─────────────────────────────────────────────────── @@ -368,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) @@ -398,3 +430,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/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/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/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..fb5e32e4 --- /dev/null +++ b/tests/playlists/test_source_refs.py @@ -0,0 +1,82 @@ +import pytest + +from core.playlists.source_refs import ( + describe_mirrored_source_ref, + 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_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", + "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") + + +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/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/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 new file mode 100644 index 00000000..d233b38d --- /dev/null +++ b/tests/sync/test_sync_candidate_pool.py @@ -0,0 +1,208 @@ +"""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(indexed_returns=None, search_returns=None, raise_on_search=None) -> MagicMock: + """MusicDatabase stub mirroring the contract the helper relies on: + - 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.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 + + +# --------------------------------------------------------------------------- +# 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() + db.get_artist_tracks_indexed.assert_not_called() + + +# --------------------------------------------------------------------------- +# Lazy population +# --------------------------------------------------------------------------- + +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(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='Beyoncé', 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(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_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(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 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 == [] + 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(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() + + +def test_different_artists_get_separate_pool_entries(): + svc = _make_service() + pool: dict = {} + db = _make_db_stub() + 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.get_artist_tracks_indexed.call_count == 2 + + +# --------------------------------------------------------------------------- +# Server source plumbing +# --------------------------------------------------------------------------- + +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 on the fast path.""" + svc = _make_service() + pool: dict = {} + 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/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", ] 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" diff --git a/web_server.py b/web_server.py index fc8c8a2e..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 @@ -32183,6 +32191,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 +32201,12 @@ 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 + pl['pipeline_state'] = _snapshot_playlist_pipeline_state(pl['id']) return jsonify(playlists) except Exception as e: logger.error(f"Error getting mirrored playlists: {e}") @@ -32201,16 +32216,269 @@ 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['pipeline_state'] = _snapshot_playlist_pipeline_state(playlist_id) playlist['tracks'] = database.get_mirrored_playlist_tracks(playlist_id) return jsonify(playlist) except Exception as e: 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 + + +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/index.html b/webui/index.html index 709c9b9e..1fd1df4b 100644 --- a/webui/index.html +++ b/webui/index.html @@ -837,18 +837,37 @@ - -
+ +
-

Tools

-

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

+

Quick Actions

+

Jump into the places that shape how SoulSync runs.

-
-
- -
-
Open Tools
- +
+ + +
@@ -916,6 +935,7 @@ server

+
@@ -7884,6 +7904,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/helper.js b/webui/static/helper.js index 3f9568f6..c6859090 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3413,6 +3413,12 @@ 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: '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.' }, + ], '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 9723c549..3586b247 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -380,6 +380,10 @@ function importFileSubmit() { // ── Mirrored Playlists ──────────────────────────────────────────────── let mirroredPlaylistsLoaded = false; +// 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. @@ -444,12 +448,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') { @@ -493,6 +519,8 @@ function renderMirroredCard(p, container) { ${disc > 0 ? `` : ''} + + `; card.addEventListener('click', () => { @@ -530,6 +558,10 @@ function renderMirroredCard(p, container) { } }); container.appendChild(card); + + if (pipelineState && pipelineState.status === 'running' && !mirroredPipelinePollers[hash]) { + pollMirroredPipelineStatus(p.id, p.name); + } } function updateMirroredCardPhase(urlHash, phase) { @@ -552,6 +584,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; @@ -785,6 +828,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 +871,8 @@ async function openMirroredPlaylistModal(playlistId) { diff --git a/webui/static/style.css b/webui/static/style.css index 7706efc0..df0752c8 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -11178,6 +11178,633 @@ 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: 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 { + 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(1500px, calc(100vw - 40px)); + height: min(860px, calc(100vh - 40px)); + 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; +} + +.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); + flex-shrink: 0; +} + +.auto-sync-eyebrow { + margin-bottom: 6px; + color: rgb(var(--accent-light-rgb)); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.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-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); + flex-shrink: 0; +} + +.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); + flex-shrink: 0; +} + +.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(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 { + 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(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; +} + +.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; + 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 10px; + 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: 11px 10px; + margin-bottom: 8px; +} + +.auto-sync-playlist:hover, +.auto-sync-scheduled-card:hover { + 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(var(--accent-rgb), 0.3); + background: rgba(var(--accent-rgb), 0.05); +} + +.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); + 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-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(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 { + 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); + 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 { + 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; + flex-shrink: 0; +} + +.auto-sync-column-head small { + color: rgb(var(--accent-light-rgb)); + font-size: 11px; + font-weight: 700; +} + +.auto-sync-column-list { + flex: 1; + 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(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, +.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-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; +} + +.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-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: 800; + text-transform: uppercase; + flex-shrink: 0; +} + +.auto-sync-status.enabled { + background: rgba(34, 197, 94, 0.14); + color: #4ade80; +} + +.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-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; +} + +@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%; @@ -12047,7 +12674,31 @@ body.helper-mode-active #dashboard-activity-feed:hover { letter-spacing: 0.3px; } -.mirrored-card-delete { +.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; + min-width: 76px; + padding: 0 10px; + transition: all 0.2s ease; + flex-shrink: 0; + opacity: 0; +} + +.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 { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.08); color: #555; @@ -12065,7 +12716,10 @@ 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, +.mirrored-playlist-card:hover .mirrored-card-pipeline { opacity: 1; } @@ -12076,28 +12730,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 +12737,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); @@ -13237,6 +13876,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; @@ -59153,6 +59805,221 @@ 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 { + 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 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; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + padding: 16px; + border: 0; + background: + 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; + 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 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); + background: rgba(255, 255, 255, 0.025); +} + +.dashboard-action-lane.tools { + --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-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-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 { + width: 38px; + height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 11px; + 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), + 0 0 16px rgba(var(--accent-rgb), 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; + letter-spacing: 0.01em; +} + +.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;