Merge pull request #697 from Nezreka/feat/playlist-auto-sync

Feat/playlist auto sync
This commit is contained in:
BoulderBadgeDad 2026-05-25 00:40:40 -07:00 committed by GitHub
commit 5f62152acb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 3773 additions and 300 deletions

View file

@ -172,9 +172,11 @@ def create_automation(
return {'error': f'Signal cycle detected: {cycle_path}. This would cause an infinite loop.'}, 400 return {'error': f'Signal cycle detected: {cycle_path}. This would cause an infinite loop.'}, 400
group_name = data.get('group_name') or None group_name = data.get('group_name') or None
owned_by = data.get('owned_by') or None
auto_id = database.create_automation( auto_id = database.create_automation(
name, trigger_type, trigger_config, action_type, action_config, name, trigger_type, trigger_config, action_type, action_config,
profile_id, notify_type, notify_config, then_actions_json, group_name, profile_id, notify_type, notify_config, then_actions_json, group_name,
owned_by=owned_by,
) )
if auto_id is None: if auto_id is None:
return {'error': 'Failed to create automation'}, 500 return {'error': 'Failed to create automation'}, 500
@ -217,6 +219,8 @@ def update_automation(
update_fields['notify_config'] = json.dumps(data['notify_config']) update_fields['notify_config'] = json.dumps(data['notify_config'])
if 'group_name' in data: if 'group_name' in data:
update_fields['group_name'] = data['group_name'] or None 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: if not update_fields:
return {'error': 'No fields to update'}, 400 return {'error': 'No fields to update'}, 400
@ -225,6 +229,12 @@ def update_automation(
if cycle_path: if cycle_path:
return {'error': f'Signal cycle detected: {cycle_path}. This would cause an infinite loop.'}, 400 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) success = database.update_automation(automation_id, **update_fields)
if not success: if not success:
return {'error': 'Automation not found'}, 404 return {'error': 'Automation not found'}, 404

View file

@ -56,6 +56,14 @@ class AutomationState:
with self.lock: with self.lock:
return self.pipeline_running 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: def set_scan_library_id(self, automation_id: Optional[str]) -> None:
with self.lock: with self.lock:
self.scan_library_automation_id = automation_id self.scan_library_automation_id = automation_id

View file

@ -80,9 +80,11 @@ def run_sync_and_wishlist(
if sync_status == 'started': if sync_status == 'started':
sync_id = sync_id_for_fn(pl) sync_id = sync_id_for_fn(pl)
sync_poll_start = time.time() sync_poll_start = time.time()
timed_out = True
while time.time() - sync_poll_start < _SYNC_PER_PLAYLIST_TIMEOUT_SECONDS: while time.time() - sync_poll_start < _SYNC_PER_PLAYLIST_TIMEOUT_SECONDS:
if (sync_id in sync_states if (sync_id in sync_states
and sync_states[sync_id].get('status') in _SYNC_TERMINAL_STATUSES): and sync_states[sync_id].get('status') in _SYNC_TERMINAL_STATUSES):
timed_out = False
break break
time.sleep(2) time.sleep(2)
elapsed = int(time.time() - sync_poll_start) elapsed = int(time.time() - sync_poll_start)
@ -94,6 +96,25 @@ def run_sync_and_wishlist(
) )
ss = sync_states.get(sync_id, {}) 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', {})) ss_result = ss.get('result', ss.get('progress', {}))
matched = ss_result.get('matched_tracks', 0) if isinstance(ss_result, dict) else 0 matched = ss_result.get('matched_tracks', 0) if isinstance(ss_result, dict) else 0
total_synced += int(matched) if matched else 0 total_synced += int(matched) if matched else 0

View file

@ -1,227 +1,27 @@
"""Automation handler: ``playlist_pipeline`` action. """Automation adapter for the mirrored playlist pipeline.
Lifted from ``web_server._register_automation_handlers`` (the The actual all-in-one playlist lifecycle lives in
``_auto_playlist_pipeline`` closure). Runs the full playlist ``core.playlists.pipeline`` so it can be reused by non-automation UI actions.
lifecycle in a single trigger: This module only wires automation-specific dependencies and handlers.
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.
""" """
from __future__ import annotations from __future__ import annotations
import threading
import time
from typing import Any, Dict from typing import Any, Dict
from core.automation.deps import AutomationDeps from core.automation.deps import AutomationDeps
from core.automation.handlers._pipeline_shared import run_sync_and_wishlist 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.refresh_mirrored import auto_refresh_mirrored
from core.automation.handlers.sync_playlist import auto_sync_playlist from core.automation.handlers.sync_playlist import auto_sync_playlist
from core.playlists.pipeline import run_mirrored_playlist_pipeline
# Per-playlist sync poll cap inside Phase 3.
# Discovery poll cap inside Phase 2.
_DISCOVERY_TIMEOUT_SECONDS = 3600
def auto_playlist_pipeline(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]: def auto_playlist_pipeline(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
"""Run REFRESH → DISCOVER → SYNC → WISHLIST in sequence. """Run REFRESH -> DISCOVER -> SYNC -> WISHLIST for mirrored playlists."""
return run_mirrored_playlist_pipeline(
Sets / clears ``deps.state.pipeline_running`` around the whole config,
run so the registration guard can short-circuit overlapping deps,
triggers. refresh_fn=auto_refresh_mirrored,
""" sync_one_fn=auto_sync_playlist,
deps.state.set_pipeline_running(True) sync_and_wishlist_fn=run_sync_and_wishlist,
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}

View file

@ -18,6 +18,7 @@ import json
from typing import Any, Dict from typing import Any, Dict
from core.automation.deps import AutomationDeps 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]: 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). # source_playlist_id is an MD5 hash; extract actual Spotify ID from stored description (URL).
try: try:
from core.spotify_public_scraper import parse_spotify_url, scrape_spotify_embed 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 parsed = parse_spotify_url(spotify_url) if spotify_url else None
# If Spotify is authenticated, use the full API (auto-discovers with album art). # 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. # No extra_data — let preservation code keep existing discovery data.
except Exception as e: except Exception as e:
deps.logger.warning(f"Spotify public playlist refresh failed for {source_id}: {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': elif source == 'deezer':
try: try:
@ -228,7 +236,7 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[
elif source == 'youtube': elif source == 'youtube':
# source_playlist_id is now a deterministic hash; use stored description (original URL) for refresh. # 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) playlist_data = deps.parse_youtube_playlist(yt_url)
if playlist_data and playlist_data.get('tracks'): if playlist_data and playlist_data.get('tracks'):
tracks = [] tracks = []
@ -242,6 +250,15 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[
'source_track_id': t.get('id', ''), '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: if tracks is not None:
# Compare old vs new track IDs to detect changes. # Compare old vs new track IDs to detect changes.
old_tracks = db.get_mirrored_playlist_tracks(pl['id']) if pl.get('id') else [] 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'], name=pl['name'],
tracks=tracks, tracks=tracks,
profile_id=pl.get('profile_id', 1), profile_id=pl.get('profile_id', 1),
description=pl.get('description'),
owner=pl.get('owner'), owner=pl.get('owner'),
image_url=pl.get('image_url'), image_url=pl.get('image_url'),
) )

View file

@ -617,7 +617,7 @@ class AutomationEngine:
self._progress_finish_fn(automation_id, result) self._progress_finish_fn(automation_id, result)
except Exception as e: except Exception as e:
logger.debug("scheduled progress finish (skipped): %s", 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 return
# Initialize progress tracking (skip if already done during delay) # Initialize progress tracking (skip if already done during delay)
@ -664,7 +664,7 @@ class AutomationEngine:
self._finish_run(auto, automation_id, result, error) 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.""" """Update DB with run stats and reschedule."""
next_run_str = None next_run_str = None
trigger_type = auto.get('trigger_type', '') trigger_type = auto.get('trigger_type', '')
@ -672,7 +672,9 @@ class AutomationEngine:
if trigger_type in self._trigger_handlers: if trigger_type in self._trigger_handlers:
try: try:
trigger_config = json.loads(auto.get('trigger_config') or '{}') 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) # Next run is tomorrow at the configured time (compute delay from local time, store as UTC)
time_str = trigger_config.get('time', '00:00') time_str = trigger_config.get('time', '00:00')
hour, minute = map(int, time_str.split(':')) hour, minute = map(int, time_str.split(':'))

257
core/playlists/pipeline.py Normal file
View file

@ -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',
)

View file

@ -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]

0
core/text/__init__.py Normal file
View file

41
core/text/normalize.py Normal file
View file

@ -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()

View file

@ -539,6 +539,7 @@ class MusicDatabase:
self._add_automation_system_column(cursor) self._add_automation_system_column(cursor)
self._add_automation_then_actions_column(cursor) self._add_automation_then_actions_column(cursor)
self._add_automation_group_name_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 # Library issues — user-reported problems with tracks/albums/artists
cursor.execute(""" cursor.execute("""
@ -845,6 +846,28 @@ class MusicDatabase:
except Exception as e: except Exception as e:
logger.error(f"Error adding automation group_name column: {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): def _add_automation_then_actions_column(self, cursor):
"""Add then_actions column to automations table and migrate existing notify data.""" """Add then_actions column to automations table and migrate existing notify data."""
try: try:
@ -6370,6 +6393,54 @@ class MusicDatabase:
logger.error(f"Error fetching candidate albums for artist '{artist}': {e}") logger.error(f"Error fetching candidate albums for artist '{artist}': {e}")
return candidates 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]: 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. Fetch every track belonging to the given set of album IDs in a single query.
@ -6897,22 +6968,12 @@ class MusicDatabase:
return unique_variations return unique_variations
def _normalize_for_comparison(self, text: str) -> str: def _normalize_for_comparison(self, text: str) -> str:
"""Normalize text for comparison with Unicode accent handling""" """Delegates to `core.text.normalize.normalize_for_comparison`.
if not text: Kept as an instance method so existing internal callers don't need
return "" to be touched new code should import the public helper directly.
"""
# Try to use unidecode for accent normalization, fallback to basic if not available from core.text.normalize import normalize_for_comparison
try: return normalize_for_comparison(text)
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()
def _calculate_track_confidence(self, search_title: str, search_artist: str, db_track: DatabaseTrack) -> float: 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""" """Calculate confidence score for track match with enhanced cleaning and Unicode normalization"""
@ -11828,7 +11889,7 @@ class MusicDatabase:
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(source, source_playlist_id, profile_id) DO UPDATE SET ON CONFLICT(source, source_playlist_id, profile_id) DO UPDATE SET
name = excluded.name, name = excluded.name,
description = excluded.description, description = COALESCE(NULLIF(excluded.description, ''), mirrored_playlists.description),
owner = excluded.owner, owner = excluded.owner,
image_url = excluded.image_url, image_url = excluded.image_url,
track_count = excluded.track_count, track_count = excluded.track_count,
@ -11939,6 +12000,39 @@ class MusicDatabase:
logger.error(f"Error getting mirrored playlist tracks: {e}") logger.error(f"Error getting mirrored playlist tracks: {e}")
return [] 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: 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.""" """Merge new data into a mirrored track's extra_data JSON field."""
try: try:
@ -12083,15 +12177,22 @@ class MusicDatabase:
def create_automation(self, name: str, trigger_type: str, trigger_config: str, def create_automation(self, name: str, trigger_type: str, trigger_config: str,
action_type: str, action_config: str, profile_id: int = 1, action_type: str, action_config: str, profile_id: int = 1,
notify_type: str = None, notify_config: str = '{}', notify_type: str = None, notify_config: str = '{}',
then_actions: str = '[]', group_name: str = None): then_actions: str = '[]', group_name: str = None,
"""Create a new automation. Returns the new automation ID or 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: try:
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
INSERT INTO automations (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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions, group_name)) """, (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions, group_name, owned_by))
conn.commit() conn.commit()
return cursor.lastrowid return cursor.lastrowid
except Exception as e: except Exception as e:
@ -12138,7 +12239,7 @@ class MusicDatabase:
def update_automation(self, automation_id: int, **kwargs) -> bool: def update_automation(self, automation_id: int, **kwargs) -> bool:
"""Update automation fields.""" """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} updates = {k: v for k, v in kwargs.items() if k in allowed}
if not updates: if not updates:
return False return False

View file

@ -10,6 +10,24 @@ from core.matching_engine import MusicMatchingEngine, MatchResult
logger = get_logger("sync_service") 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 @dataclass
class SyncResult: class SyncResult:
playlist_name: str playlist_name: str
@ -209,11 +227,14 @@ class PlaylistSyncService:
return self._create_error_result(playlist.name, ["Sync cancelled"]) return self._create_error_result(playlist.name, ["Sync cancelled"])
total_tracks = len(playlist.tracks) total_tracks = len(playlist.tracks)
media_client, server_type = self._get_active_media_client()
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) 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" # Use the same robust matching approach as "Download Missing Tracks"
match_results = [] match_results = []
for i, track in enumerate(playlist.tracks): for i, track in enumerate(playlist.tracks):
@ -222,11 +243,8 @@ class PlaylistSyncService:
# Update progress for each track # Update progress for each track
progress_percent = 20 + (40 * (i + 1) / total_tracks) # 20-60% for matching progress_percent = 20 + (40 * (i + 1) / total_tracks) # 20-60% for matching
# Extract artist name from both string and dict formats
if track.artists: if track.artists:
first_artist = track.artists[0] current_track_name = f"{_artist_name(track.artists[0]) or 'Unknown'} - {track.name}"
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}"
else: else:
current_track_name = track.name 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,
@ -235,7 +253,7 @@ class PlaylistSyncService:
failed_tracks=len([r for r in match_results if not r.is_match])) failed_tracks=len([r for r in match_results if not r.is_match]))
# Use the robust search approach # 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( match_result = MatchResult(
spotify_track=track, spotify_track=track,
@ -469,7 +487,42 @@ class PlaylistSyncService:
self.clear_progress_callback(playlist.name) self.clear_progress_callback(playlist.name)
self._cancelled = False 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""" """Find a track using the same improved database matching as Download Missing Tracks modal"""
try: try:
# Check active media server connection # Check active media server connection
@ -531,18 +584,19 @@ class PlaylistSyncService:
if self._cancelled: if self._cancelled:
return None, 0.0 return None, 0.0
# Extract artist name from both string and dict formats artist_name = _artist_name(artist)
if isinstance(artist, str):
artist_name = artist
elif isinstance(artist, dict) and 'name' in artist:
artist_name = artist['name']
else:
artist_name = str(artist)
# Use the improved database check_track_exists method with server awareness # Use the improved database check_track_exists method with server awareness
try: try:
db = MusicDatabase() 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: 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}") logger.debug(f"Database match found for '{original_title}' by '{artist_name}': '{db_track.title}' with confidence {confidence:.2f}")

View file

@ -28,7 +28,8 @@ class _FakeDB:
return dict(self.automations[automation_id]) if automation_id in self.automations else None 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, 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 aid = self._next_id
self._next_id += 1 self._next_id += 1
self.automations[aid] = { self.automations[aid] = {
@ -37,6 +38,7 @@ class _FakeDB:
'action_config': action_config, 'profile_id': profile_id, 'action_config': action_config, 'profile_id': profile_id,
'notify_type': notify_type, 'notify_config': notify_config, 'notify_type': notify_type, 'notify_config': notify_config,
'then_actions': then_actions, 'group_name': group_name, 'then_actions': then_actions, 'group_name': group_name,
'owned_by': owned_by,
'enabled': 1, 'is_system': 0, 'enabled': 1, 'is_system': 0,
} }
return aid return aid
@ -289,6 +291,91 @@ def test_update_then_actions_clears_notify_when_empty():
assert db.automations[1]['notify_config'] == '{}' 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 # batch_update_group
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -132,3 +132,38 @@ def test_skipped_status_records_no_error(engine_with_handler) -> None:
engine.run_automation(1, skip_delay=True) engine.run_automation(1, skip_delay=True)
kwargs = db_mock.update_automation_run.call_args.kwargs kwargs = db_mock.update_automation_run.call_args.kwargs
assert kwargs.get('error') is None 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

View file

@ -26,6 +26,7 @@ import pytest
from core.automation.deps import AutomationDeps, AutomationState from core.automation.deps import AutomationDeps, AutomationState
from core.automation.handlers.discover_playlist import auto_discover_playlist 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.playlist_pipeline import auto_playlist_pipeline
from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored
from core.automation.handlers.sync_playlist import auto_sync_playlist from core.automation.handlers.sync_playlist import auto_sync_playlist
@ -266,6 +267,24 @@ class TestRefreshMirrored:
assert result['errors'] == '1' assert result['errors'] == '1'
assert result['refreshed'] == '0' 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 ─────────────────────────────────────────────────── # ─── sync_playlist ───────────────────────────────────────────────────
@ -368,6 +387,19 @@ class TestSyncPlaylist:
class TestPlaylistPipeline: 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): def test_no_playlist_specified_returns_error(self):
deps = _build_deps() deps = _build_deps()
result = auto_playlist_pipeline({}, deps) result = auto_playlist_pipeline({}, deps)
@ -398,3 +430,26 @@ class TestPlaylistPipeline:
assert result['status'] == 'error' assert result['status'] == 'error'
assert result['_manages_own_progress'] is True assert result['_manages_own_progress'] is True
assert deps.state.pipeline_running is False 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)

View file

@ -311,6 +311,14 @@ class TestAutomationState:
s.set_pipeline_running(False) s.set_pipeline_running(False)
assert s.is_pipeline_running() is 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): def test_concurrent_set_safe_via_lock(self):
# Smoke test: two threads flipping the same field don't crash. # Smoke test: two threads flipping the same field don't crash.
# Lock ensures the final value is consistent. # Lock ensures the final value is consistent.

View file

@ -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'

View file

@ -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"

View file

@ -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"

View file

@ -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({}), '');
});
});

View file

@ -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('') == ''

View file

@ -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',
)

View file

@ -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,
)

View file

@ -45,6 +45,7 @@ SPLIT_MODULES = [
"discover.js", "discover.js",
"enrichment.js", "enrichment.js",
"stats-automations.js", "stats-automations.js",
"auto-sync.js",
"pages-extra.js", "pages-extra.js",
"init.js", "init.js",
] ]

0
tests/text/__init__.py Normal file
View file

View file

@ -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"

View file

@ -919,6 +919,12 @@ except Exception as e:
# --- Automation Progress Tracking --- # --- Automation Progress Tracking ---
_scan_library_automation_id = None _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(): def _register_automation_handlers():
@ -935,6 +941,8 @@ def _register_automation_handlers():
closures still live below until subsequent commits in the same closures still live below until subsequent commits in the same
branch finish the lift. branch finish the lift.
""" """
global _automation_deps
if not automation_engine: if not automation_engine:
return return
@ -32183,6 +32191,7 @@ def mirror_playlist_endpoint():
def get_mirrored_playlists_endpoint(): def get_mirrored_playlists_endpoint():
"""List all mirrored playlists for the active profile.""" """List all mirrored playlists for the active profile."""
try: try:
from core.playlists.source_refs import describe_mirrored_source_ref
database = get_database() database = get_database()
profile_id = get_current_profile_id() profile_id = get_current_profile_id()
playlists = database.get_mirrored_playlists(profile_id=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['total_count'] = counts['total']
pl['wishlisted_count'] = counts['wishlisted'] pl['wishlisted_count'] = counts['wishlisted']
pl['in_library_count'] = counts['in_library'] 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) return jsonify(playlists)
except Exception as e: except Exception as e:
logger.error(f"Error getting mirrored playlists: {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): def get_mirrored_playlist_endpoint(playlist_id):
"""Get a mirrored playlist with its tracks.""" """Get a mirrored playlist with its tracks."""
try: try:
from core.playlists.source_refs import describe_mirrored_source_ref
database = get_database() database = get_database()
playlist = database.get_mirrored_playlist(playlist_id) playlist = database.get_mirrored_playlist(playlist_id)
if not playlist: if not playlist:
return jsonify({"error": "Playlist not found"}), 404 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) playlist['tracks'] = database.get_mirrored_playlist_tracks(playlist_id)
return jsonify(playlist) return jsonify(playlist)
except Exception as e: except Exception as e:
logger.error(f"Error getting mirrored playlist: {e}") logger.error(f"Error getting mirrored playlist: {e}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@app.route('/api/mirrored-playlists/<int:playlist_id>/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/<int:playlist_id>/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/<int:playlist_id>/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/<int:playlist_id>', methods=['DELETE']) @app.route('/api/mirrored-playlists/<int:playlist_id>', methods=['DELETE'])
def delete_mirrored_playlist_endpoint(playlist_id): def delete_mirrored_playlist_endpoint(playlist_id):
"""Delete a mirrored playlist.""" """Delete a mirrored playlist."""

View file

@ -837,18 +837,37 @@
</div> </div>
</article> </article>
<!-- Card: Tools (compact, top-right) — entry point to maintenance ops --> <!-- Card: Quick Actions launcher -->
<article class="dash-card dash-card--cta" data-card="tools" onclick="navigateToPage('tools')"> <article class="dash-card dash-card--quick-actions" data-card="tools">
<header class="dash-card__head"> <header class="dash-card__head">
<h3 class="dash-card__title">Tools</h3> <h3 class="dash-card__title">Quick Actions</h3>
<p class="dash-card__sub">Database, scanning, backups, cache, maintenance &amp; more.</p> <p class="dash-card__sub">Jump into the places that shape how SoulSync runs.</p>
</header> </header>
<div class="dash-card__body dash-card__body--cta"> <div class="dash-card__body dashboard-action-grid">
<div class="dash-card__cta-icon"> <button class="dashboard-action-lane tools" onclick="navigateToPage('tools')" aria-label="Open Tools">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> <span class="dashboard-action-icon">
</div> <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
<div class="dash-card__cta-label">Open Tools</div> </span>
<svg class="dash-card__cta-arrow" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg> <span class="dashboard-action-label">Tools</span>
<span class="dashboard-action-copy">Maintenance and repair</span>
<span class="dashboard-action-arrow">Open</span>
</button>
<button class="dashboard-action-lane auto-sync" onclick="openAutoSyncScheduleModal()" aria-label="Open Auto-Sync">
<span class="dashboard-action-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 15.5-6.2"/><path d="M18.5 3.5v5h-5"/><path d="M21 12a9 9 0 0 1-15.5 6.2"/><path d="M5.5 20.5v-5h5"/></svg>
</span>
<span class="dashboard-action-label">Auto-Sync</span>
<span class="dashboard-action-copy">Playlist pipeline schedules</span>
<span class="dashboard-action-arrow">Manage</span>
</button>
<button class="dashboard-action-lane automations" onclick="navigateToPage('automations')" aria-label="Open Automations">
<span class="dashboard-action-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h6v6H4z"/><path d="M14 4h6v6h-6z"/><path d="M4 14h6v6H4z"/><path d="M14 14h6v6h-6z"/><path d="M10 7h4"/><path d="M7 10v4"/><path d="M17 10v4"/><path d="M10 17h4"/></svg>
</span>
<span class="dashboard-action-label">Automations</span>
<span class="dashboard-action-copy">Rules and workflows</span>
<span class="dashboard-action-arrow">Build</span>
</button>
</div> </div>
</article> </article>
@ -916,6 +935,7 @@
server</p> server</p>
</div> </div>
<div style="display:flex;gap:8px;align-items:center;"> <div style="display:flex;gap:8px;align-items:center;">
<button class="sync-history-btn auto-sync-manager-btn" onclick="openAutoSyncScheduleModal()" title="Schedule mirrored playlists to refresh, discover, sync, and queue missing tracks">Auto-Sync</button>
<button class="sync-history-btn" onclick="openManualLibraryMatchTool()" title="Manually link source tracks to library tracks">Library Match</button> <button class="sync-history-btn" onclick="openManualLibraryMatchTool()" title="Manually link source tracks to library tracks">Library Match</button>
<button class="sync-history-btn" onclick="openSyncHistoryModal()" title="View sync history">Sync History</button> <button class="sync-history-btn" onclick="openSyncHistoryModal()" title="View sync history">Sync History</button>
</div> </div>
@ -7884,6 +7904,7 @@
<script src="{{ url_for('static', filename='discover.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='discover.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='enrichment.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='enrichment.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='stats-automations.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='stats-automations.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='auto-sync.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='pages-extra.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='pages-extra.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='init.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='init.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='shell-bridge.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='shell-bridge.js', v=static_v) }}"></script>

585
webui/static/auto-sync.js Normal file
View file

@ -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 = `
<div class="auto-sync-modal">
<div class="auto-sync-header">
<div>
<h3>Auto-Sync Schedule</h3>
<p>Drop mirrored playlists onto an interval to schedule refresh, discovery, sync, and wishlist processing.</p>
</div>
<button class="auto-sync-close" onclick="closeAutoSyncScheduleModal()">&times;</button>
</div>
<div class="auto-sync-loading">Loading schedule...</div>
</div>
`;
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 = `
<div class="auto-sync-modal">
<div class="auto-sync-header">
<div><h3>Auto-Sync Schedule</h3><p>Could not load schedule data.</p></div>
<button class="auto-sync-close" onclick="closeAutoSyncScheduleModal()">&times;</button>
</div>
<div class="auto-sync-error">${_esc(err.message)}</div>
</div>
`;
}
}
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 = `
<div class="auto-sync-modal">
<div class="auto-sync-header">
<div>
<div class="auto-sync-eyebrow">Playlist automation</div>
<h3>Auto-Sync Manager</h3>
<p>Schedule mirrored playlists through the same playlist-pipeline engine used by Automations.</p>
</div>
<button class="auto-sync-close" onclick="closeAutoSyncScheduleModal()">&times;</button>
</div>
<div class="auto-sync-summary">
<div><span>${scheduledCount}</span><small>scheduled playlists</small></div>
<div><span>${enabledCount}</span><small>active schedules</small></div>
<div><span>${pipelineCount}</span><small>automation pipelines</small></div>
<div><span>${totalTracks}</span><small>mirrored tracks</small></div>
</div>
<div class="auto-sync-tabs">
<button class="${scheduleActive ? 'active' : ''}" onclick="setAutoSyncTab('schedule')">Schedule Board</button>
<button class="${automationsActive ? 'active' : ''}" onclick="setAutoSyncTab('automations')">Automation Pipelines</button>
</div>
<div class="auto-sync-tab-panel ${scheduleActive ? 'active' : ''}" id="auto-sync-schedule-panel">${schedulePanel}</div>
<div class="auto-sync-tab-panel ${automationsActive ? 'active' : ''}" id="auto-sync-automation-panel">${automationPanel}</div>
</div>
`;
}
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 => `
<div class="auto-sync-source-group">
<div class="auto-sync-source-title">${_esc(autoSyncSourceLabel(source))}</div>
${grouped[source].map(p => {
const schedule = playlistSchedules[p.id];
const assigned = schedule ? autoSyncIntervalLabel(schedule.hours) : 'Unscheduled';
return `
<div class="auto-sync-playlist ${schedule ? 'scheduled' : ''}" draggable="true" data-playlist-id="${p.id}" ondragstart="autoSyncDragStart(event)">
<div class="auto-sync-playlist-name">${_esc(p.name)}</div>
<div class="auto-sync-playlist-meta">${p.track_count || 0} tracks &middot; ${_esc(assigned)}</div>
</div>
`;
}).join('')}
</div>
`).join('') : '<div class="auto-sync-empty">No refreshable mirrored playlists yet.</div>';
const unavailableHtml = unavailablePlaylists.length ? `
<div class="auto-sync-source-group auto-sync-source-group-disabled">
<div class="auto-sync-source-title">Not schedulable</div>
${unavailablePlaylists.map(p => `
<div class="auto-sync-playlist unavailable">
<div class="auto-sync-playlist-name">${_esc(p.name)}</div>
<div class="auto-sync-playlist-meta">${_esc(autoSyncSourceLabel(p.source))} &middot; refresh not supported</div>
</div>
`).join('')}
</div>
` : '';
const bucketHtml = AUTO_SYNC_BUCKETS.map(hours => {
const assigned = schedulablePlaylists.filter(p => playlistSchedules[p.id]?.hours === hours);
return `
<div class="auto-sync-column" data-hours="${hours}" ondragover="autoSyncDragOver(event)" ondragleave="autoSyncDragLeave(event)" ondrop="autoSyncDrop(event, ${hours})">
<div class="auto-sync-column-head">
<span>${autoSyncBucketLabel(hours)}</span>
<small>${assigned.length} playlist${assigned.length === 1 ? '' : 's'}</small>
</div>
<div class="auto-sync-column-list">
${assigned.length ? assigned.map(p => autoSyncScheduledCardHtml(p, playlistSchedules[p.id])).join('') : '<div class="auto-sync-drop-hint"><strong>Drop here</strong><span>Schedule playlists at this interval</span></div>'}
</div>
</div>
`;
}).join('');
return `
<div class="auto-sync-board-intro">
<div>
<strong>Drag playlists into an interval</strong>
<span>Each placement creates or updates an Auto-Sync-owned playlist-pipeline automation.</span>
</div>
<button onclick="refreshAutoSyncScheduleModal()">Refresh</button>
</div>
<div class="auto-sync-body">
<aside class="auto-sync-sidebar">
<div class="auto-sync-sidebar-title">Mirrored playlists</div>
<div class="auto-sync-source-list">${sidebarHtml}${unavailableHtml}</div>
</aside>
<main class="auto-sync-board">${bucketHtml}</main>
</div>
`;
}
function renderAutoSyncAutomationPanel(automationPipelines, playlists) {
if (!automationPipelines.length) {
return '<div class="auto-sync-automation-empty">No Automations-page playlist pipelines found.</div>';
}
return `
<div class="auto-sync-automation-intro">
<strong>Read-only Automations-page pipelines</strong>
<span>These use the playlist pipeline but are managed from the Automations page, so this modal only displays them.</span>
</div>
<div class="auto-sync-automation-list">
${automationPipelines.map(auto => autoSyncAutomationCardHtml(auto, playlists)).join('')}
</div>
`;
}
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 `
<div class="auto-sync-automation-card">
<div class="auto-sync-automation-main">
<div class="auto-sync-automation-title-row">
<span class="auto-sync-status ${enabled ? 'enabled' : 'disabled'}">${enabled ? 'Enabled' : 'Disabled'}</span>
<strong>${_esc(auto.name || 'Playlist Pipeline')}</strong>
</div>
<div class="auto-sync-automation-meta">
<span>${_esc(trigger)}</span>
<span>${_esc(target)}</span>
<span>${_esc(next)}</span>
</div>
</div>
<div class="auto-sync-automation-lock">Read only</div>
</div>
`;
}
function autoSyncScheduledCardHtml(playlist, schedule) {
const enabled = schedule?.enabled !== false;
const nextLabel = schedule?.next_run ? autoSyncNextRunLabel(schedule.next_run) : '';
return `
<div class="auto-sync-scheduled-card ${enabled ? '' : 'disabled'}" draggable="true" data-playlist-id="${playlist.id}" ondragstart="autoSyncDragStart(event)">
<div>
<div class="auto-sync-scheduled-name">${_esc(playlist.name)}</div>
<div class="auto-sync-scheduled-meta">${_esc(autoSyncSourceLabel(playlist.source))} &middot; ${playlist.track_count || 0} tracks</div>
<div class="auto-sync-scheduled-timing">
<span>${_esc(autoSyncIntervalLabel(schedule?.hours || 24))}</span>
${nextLabel ? `<small>${_esc(nextLabel)}</small>` : ''}
</div>
</div>
<button onclick="event.stopPropagation(); unscheduleAutoSyncPlaylist(${playlist.id})" title="Remove this Auto-Sync schedule">&times;</button>
</div>
`;
}
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);
}

View file

@ -3413,6 +3413,12 @@ function closeHelperSearch() {
// projects that span multiple commits before shipping. Strip the flag at // 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. // release time and add a real `date:` line at the top of the version block.
const WHATS_NEW = { 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': [ '2.6.1': [
{ date: 'May 24, 2026 — 2.6.1 release' }, { 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.' }, { 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.' },

View file

@ -380,6 +380,10 @@ function importFileSubmit() {
// ── Mirrored Playlists ──────────────────────────────────────────────── // ── Mirrored Playlists ────────────────────────────────────────────────
let mirroredPlaylistsLoaded = false; 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. * 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) { function renderMirroredCard(p, container) {
const ago = timeAgo(p.updated_at || p.mirrored_at); const ago = timeAgo(p.updated_at || p.mirrored_at);
const hash = `mirrored_${p.id}`; 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 phase = state ? state.phase : null;
const sourceRef = getMirroredSourceRef(p);
// Build phase indicator // Build phase indicator
let phaseHtml = ''; 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 = `<span style="color:#38bdf8;">${_esc(label)} ${pct}%</span>`;
} else if (phase === 'pipeline_complete') {
phaseHtml = `<span style="color:#22c55e;">Pipeline complete</span>`;
} else if (phase === 'pipeline_error') {
phaseHtml = `<span style="color:#ef4444;">Pipeline error</span>`;
} else if (phase === 'discovering') {
const pct = state.discoveryProgress || state.discovery_progress || 0; const pct = state.discoveryProgress || state.discovery_progress || 0;
phaseHtml = `<span style="color:#a78bfa;">Discovering ${pct}%</span>`; phaseHtml = `<span style="color:#a78bfa;">Discovering ${pct}%</span>`;
} else if (phase === 'discovered') { } else if (phase === 'discovered') {
@ -493,6 +519,8 @@ function renderMirroredCard(p, container) {
</div> </div>
</div> </div>
${disc > 0 ? `<button class="mirrored-card-clear" onclick="event.stopPropagation(); clearMirroredDiscovery(${p.id}, '${_escAttr(p.name)}')" title="Clear discovery data">↺</button>` : ''} ${disc > 0 ? `<button class="mirrored-card-clear" onclick="event.stopPropagation(); clearMirroredDiscovery(${p.id}, '${_escAttr(p.name)}')" title="Clear discovery data">↺</button>` : ''}
<button class="mirrored-card-pipeline" onclick="event.stopPropagation(); runMirroredPlaylistPipeline(${p.id}, '${_escAttr(p.name)}')" title="Refresh, discover, sync, and queue missing tracks">Auto-Sync</button>
<button class="mirrored-card-link" onclick="event.stopPropagation(); editMirroredSourceRef(${p.id}, '${_escAttr(p.name)}', '${_escAttr(p.source)}', '${_escAttr(sourceRef)}')" title="Edit original playlist link">🔗</button>
<button class="mirrored-card-delete" onclick="event.stopPropagation(); deleteMirroredPlaylist(${p.id}, '${_escAttr(p.name)}')" title="Delete mirror"></button> <button class="mirrored-card-delete" onclick="event.stopPropagation(); deleteMirroredPlaylist(${p.id}, '${_escAttr(p.name)}')" title="Delete mirror"></button>
`; `;
card.addEventListener('click', () => { card.addEventListener('click', () => {
@ -530,6 +558,10 @@ function renderMirroredCard(p, container) {
} }
}); });
container.appendChild(card); container.appendChild(card);
if (pipelineState && pipelineState.status === 'running' && !mirroredPipelinePollers[hash]) {
pollMirroredPipelineStatus(p.id, p.name);
}
} }
function updateMirroredCardPhase(urlHash, phase) { function updateMirroredCardPhase(urlHash, phase) {
@ -552,6 +584,17 @@ function updateMirroredCardPhase(urlHash, phase) {
// Add new phase indicator // Add new phase indicator
let phaseHtml = ''; let phaseHtml = '';
switch (phase) { switch (phase) {
case 'pipeline_running':
const pipelineProgress = state?.pipeline_progress || 0;
const pipelinePhase = state?.pipeline_phase || 'Pipeline running';
phaseHtml = `<span style="color:#38bdf8;">${_esc(pipelinePhase)} ${pipelineProgress}%</span>`;
break;
case 'pipeline_complete':
phaseHtml = `<span style="color:#22c55e;">Pipeline complete</span>`;
break;
case 'pipeline_error':
phaseHtml = `<span style="color:#ef4444;">Pipeline error</span>`;
break;
case 'discovering': case 'discovering':
phaseHtml = `<span style="color:#a78bfa;">Discovering...</span>`; phaseHtml = `<span style="color:#a78bfa;">Discovering...</span>`;
break; break;
@ -785,6 +828,7 @@ async function openMirroredPlaylistModal(playlistId) {
const tracks = data.tracks || []; const tracks = data.tracks || [];
const source = data.source || 'unknown'; const source = data.source || 'unknown';
const sourceRef = getMirroredSourceRef(data);
const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛' }; const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛' };
const sourceIcon = sourceIcons[source] || '📋'; const sourceIcon = sourceIcons[source] || '📋';
@ -827,6 +871,8 @@ async function openMirroredPlaylistModal(playlistId) {
<button class="mirrored-btn-delete" onclick="closeMirroredModal(); deleteMirroredPlaylist(${playlistId}, '${_escAttr(data.name)}')">Delete Mirror</button> <button class="mirrored-btn-delete" onclick="closeMirroredModal(); deleteMirroredPlaylist(${playlistId}, '${_escAttr(data.name)}')">Delete Mirror</button>
</div> </div>
<div class="mirrored-modal-footer-right" style="display:flex;gap:10px;"> <div class="mirrored-modal-footer-right" style="display:flex;gap:10px;">
<button class="mirrored-btn-close" onclick="editMirroredSourceRef(${playlistId}, '${_escAttr(data.name)}', '${_escAttr(source)}', '${_escAttr(sourceRef)}')">Edit Source</button>
<button class="mirrored-btn-pipeline" onclick="runMirroredPlaylistPipeline(${playlistId}, '${_escAttr(data.name)}')">Auto-Sync</button>
<button class="mirrored-btn-close" onclick="closeMirroredModal()">Close</button> <button class="mirrored-btn-close" onclick="closeMirroredModal()">Close</button>
<button class="mirrored-btn-discover" onclick="discoverMirroredPlaylist(${playlistId})">Discover</button> <button class="mirrored-btn-discover" onclick="discoverMirroredPlaylist(${playlistId})">Discover</button>
</div> </div>

View file

@ -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); } .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 */ /* Enhanced Progress Bar Animation */
.progress-bar-fill { .progress-bar-fill {
height: 100%; height: 100%;
@ -12047,7 +12674,31 @@ body.helper-mode-active #dashboard-activity-feed:hover {
letter-spacing: 0.3px; 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); background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
color: #555; color: #555;
@ -12065,7 +12716,10 @@ body.helper-mode-active #dashboard-activity-feed:hover {
opacity: 0; 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; opacity: 1;
} }
@ -12076,28 +12730,6 @@ body.helper-mode-active #dashboard-activity-feed:hover {
transform: scale(1.1); 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 { .mirrored-card-clear:hover {
color: #a78bfa; color: #a78bfa;
background: rgba(167, 139, 250, 0.15); background: rgba(167, 139, 250, 0.15);
@ -12105,6 +12737,13 @@ body.helper-mode-active #dashboard-activity-feed:hover {
transform: scale(1.1); 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 { .discovery-ratio {
font-size: 0.8em; font-size: 0.8em;
color: rgba(255, 255, 255, 0.4); 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); 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 { .mirrored-btn-delete {
background: rgba(244, 67, 54, 0.12); background: rgba(244, 67, 54, 0.12);
color: #ef5350; color: #ef5350;
@ -59153,6 +59805,221 @@ body.reduce-effects .dash-card::after {
/* Body overrides for embedded sub-grids — make them compact for bento */ /* 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 */ /* Service status: 3 service cards side-by-side in a tighter grid */
.dash-card[data-card="services"] .service-status-grid { .dash-card[data-card="services"] .service-status-grid {
display: grid; display: grid;