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
group_name = data.get('group_name') or None
owned_by = data.get('owned_by') or None
auto_id = database.create_automation(
name, trigger_type, trigger_config, action_type, action_config,
profile_id, notify_type, notify_config, then_actions_json, group_name,
owned_by=owned_by,
)
if auto_id is None:
return {'error': 'Failed to create automation'}, 500
@ -217,6 +219,8 @@ def update_automation(
update_fields['notify_config'] = json.dumps(data['notify_config'])
if 'group_name' in data:
update_fields['group_name'] = data['group_name'] or None
if 'owned_by' in data:
update_fields['owned_by'] = data['owned_by'] or None
if not update_fields:
return {'error': 'No fields to update'}, 400
@ -225,6 +229,12 @@ def update_automation(
if cycle_path:
return {'error': f'Signal cycle detected: {cycle_path}. This would cause an infinite loop.'}, 400
# Schedule-shape changes must invalidate the stored next_run so the
# scheduler recomputes it; otherwise restart-survival logic keeps the
# leftover timestamp from the previous interval.
if {'trigger_type', 'trigger_config'} & update_fields.keys():
update_fields['next_run'] = None
success = database.update_automation(automation_id, **update_fields)
if not success:
return {'error': 'Automation not found'}, 404

View file

@ -56,6 +56,14 @@ class AutomationState:
with self.lock:
return self.pipeline_running
def try_start_pipeline(self) -> bool:
"""Atomically mark the shared playlist pipeline as running."""
with self.lock:
if self.pipeline_running:
return False
self.pipeline_running = True
return True
def set_scan_library_id(self, automation_id: Optional[str]) -> None:
with self.lock:
self.scan_library_automation_id = automation_id

View file

@ -80,9 +80,11 @@ def run_sync_and_wishlist(
if sync_status == 'started':
sync_id = sync_id_for_fn(pl)
sync_poll_start = time.time()
timed_out = True
while time.time() - sync_poll_start < _SYNC_PER_PLAYLIST_TIMEOUT_SECONDS:
if (sync_id in sync_states
and sync_states[sync_id].get('status') in _SYNC_TERMINAL_STATUSES):
timed_out = False
break
time.sleep(2)
elapsed = int(time.time() - sync_poll_start)
@ -94,6 +96,25 @@ def run_sync_and_wishlist(
)
ss = sync_states.get(sync_id, {})
final_status = ss.get('status')
if timed_out:
sync_errors += 1
deps.update_progress(
automation_id,
log_line=f'Sync timed out "{pl_name}" after {_SYNC_PER_PLAYLIST_TIMEOUT_SECONDS}s',
log_type='error',
)
continue
if final_status in ('error', 'failed'):
sync_errors += 1
reason = ss.get('error') or ss.get('reason') or 'background sync failed'
deps.update_progress(
automation_id,
log_line=f'Sync error "{pl_name}": {reason}',
log_type='error',
)
continue
ss_result = ss.get('result', ss.get('progress', {}))
matched = ss_result.get('matched_tracks', 0) if isinstance(ss_result, dict) else 0
total_synced += int(matched) if matched else 0

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
``_auto_playlist_pipeline`` closure). Runs the full playlist
lifecycle in a single trigger:
Phase 1: REFRESH -- pull fresh track lists from sources
Phase 2: DISCOVER -- look up official Spotify/iTunes metadata
Phase 3: SYNC -- push the result to the active media server
Phase 4: WISHLIST -- queue any missing tracks for download
Each phase emits its own progress range so the trigger card shows
useful per-phase percentages instead of "loading...". Phase 4 is
optional via ``skip_wishlist`` config.
Composition: this handler invokes ``auto_refresh_mirrored`` and
``auto_sync_playlist`` directly (passing ``_automation_id: None`` so
the sub-handlers don't hijack pipeline progress) instead of going
through the engine keeps the four phases observable as one
trigger from the user's perspective. Pipeline-level guard
(``state.pipeline_running``) prevents overlapping runs.
The actual all-in-one playlist lifecycle lives in
``core.playlists.pipeline`` so it can be reused by non-automation UI actions.
This module only wires automation-specific dependencies and handlers.
"""
from __future__ import annotations
import threading
import time
from typing import Any, Dict
from core.automation.deps import AutomationDeps
from core.automation.handlers._pipeline_shared import run_sync_and_wishlist
from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored
from core.automation.handlers.sync_playlist import auto_sync_playlist
# Per-playlist sync poll cap inside Phase 3.
# Discovery poll cap inside Phase 2.
_DISCOVERY_TIMEOUT_SECONDS = 3600
from core.playlists.pipeline import run_mirrored_playlist_pipeline
def auto_playlist_pipeline(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
"""Run REFRESH → DISCOVER → SYNC → WISHLIST in sequence.
Sets / clears ``deps.state.pipeline_running`` around the whole
run so the registration guard can short-circuit overlapping
triggers.
"""
deps.state.set_pipeline_running(True)
automation_id = config.get('_automation_id')
pipeline_start = time.time()
try:
db = deps.get_database()
playlist_id = config.get('playlist_id')
process_all = config.get('all', False)
skip_wishlist = config.get('skip_wishlist', False)
# Resolve playlists.
if process_all:
playlists = db.get_mirrored_playlists()
elif playlist_id:
p = db.get_mirrored_playlist(int(playlist_id))
playlists = [p] if p else []
else:
deps.state.set_pipeline_running(False)
return {'status': 'error', 'error': 'No playlist specified'}
playlists = [pl for pl in playlists if pl.get('source', '') not in ('file', 'beatport')]
if not playlists:
deps.state.set_pipeline_running(False)
return {'status': 'error', 'error': 'No refreshable playlists found'}
pl_names = ', '.join(p.get('name', '?') for p in playlists[:3])
if len(playlists) > 3:
pl_names += f' (+{len(playlists) - 3} more)'
deps.update_progress(
automation_id,
progress=2,
phase=f'Pipeline: {len(playlists)} playlist(s)',
log_line=f'Starting pipeline for: {pl_names}',
log_type='info',
)
# ── PHASE 1: REFRESH ──────────────────────────────────────────
deps.update_progress(
automation_id,
progress=3,
phase='Phase 1/4: Refreshing playlists...',
log_line='Phase 1: Refresh',
log_type='info',
)
refresh_config = dict(config)
refresh_config['_automation_id'] = None # Don't let sub-handler hijack pipeline progress.
refresh_result = auto_refresh_mirrored(refresh_config, deps)
refreshed = int(refresh_result.get('refreshed', 0))
refresh_errors = int(refresh_result.get('errors', 0))
deps.update_progress(
automation_id,
progress=25,
phase='Phase 1/4: Refresh complete',
log_line=f'Phase 1 done: {refreshed} refreshed, {refresh_errors} errors',
log_type='success' if refresh_errors == 0 else 'warning',
)
# ── PHASE 2: DISCOVER ─────────────────────────────────────────
deps.update_progress(
automation_id,
progress=26,
phase='Phase 2/4: Discovering metadata...',
log_line='Phase 2: Discover',
log_type='info',
)
# Reload playlists (refresh may have updated them).
if process_all:
disc_playlists = db.get_mirrored_playlists()
else:
disc_playlists = [db.get_mirrored_playlist(int(playlist_id))]
disc_playlists = [p for p in disc_playlists if p]
# Run discovery in a thread and wait for it.
disc_done = threading.Event()
def _disc_wrapper(pls):
try:
# The worker updates automation_progress internally,
# but we pass None so it doesn't conflict with our
# pipeline progress.
deps.run_playlist_discovery_worker(pls, automation_id=None)
except Exception as e:
deps.logger.error(f"[Pipeline] Discovery error: {e}")
finally:
disc_done.set()
threading.Thread(
target=_disc_wrapper, args=(disc_playlists,),
daemon=True, name='pipeline-discover',
).start()
# Poll for completion with progress updates.
poll_start = time.time()
while not disc_done.wait(timeout=3):
elapsed = int(time.time() - poll_start)
deps.update_progress(
automation_id,
progress=min(26 + elapsed // 4, 54),
phase=f'Phase 2/4: Discovering... ({elapsed}s)',
)
if elapsed > _DISCOVERY_TIMEOUT_SECONDS:
deps.update_progress(
automation_id,
log_line='Discovery timed out after 1 hour',
log_type='warning',
)
break
deps.update_progress(
automation_id,
progress=55,
phase='Phase 2/4: Discovery complete',
log_line='Phase 2 done: discovery complete',
log_type='success',
)
# ── PHASE 3 + 4: SYNC + WISHLIST (delegated to shared helper) ──
# Each mirrored playlist payload only needs `id` + `name` for
# the helper; `auto_sync_playlist` reads the rest from the
# mirrored DB by id.
sync_summary = run_sync_and_wishlist(
deps,
automation_id,
[pl for pl in playlists if pl.get('id')],
sync_one_fn=lambda pl: auto_sync_playlist(
{'playlist_id': str(pl['id']), '_automation_id': None},
deps,
),
sync_id_for_fn=lambda pl: f"auto_mirror_{pl['id']}",
skip_wishlist=skip_wishlist,
progress_start=56,
progress_end=85,
sync_phase_label='Phase 3/4: Syncing to server...',
sync_phase_start_log='Phase 3: Sync',
wishlist_phase_label='Phase 4/4: Processing wishlist...',
wishlist_phase_start_log='Phase 4: Wishlist',
)
total_synced = sync_summary['synced']
total_skipped = sync_summary['skipped']
sync_errors = sync_summary['errors']
wishlist_queued = sync_summary['wishlist_queued']
# ── COMPLETE ──────────────────────────────────────────────────
duration = int(time.time() - pipeline_start)
deps.update_progress(
automation_id,
status='finished',
progress=100,
phase='Pipeline complete',
log_line=f'Pipeline finished in {duration // 60}m {duration % 60}s',
log_type='success',
)
deps.state.set_pipeline_running(False)
return {
'status': 'completed',
'_manages_own_progress': True,
'playlists_refreshed': str(refreshed),
'tracks_discovered': 'completed',
'tracks_synced': str(total_synced),
'sync_skipped': str(total_skipped),
'wishlist_queued': str(wishlist_queued),
'duration_seconds': str(duration),
}
except Exception as e:
deps.state.set_pipeline_running(False)
deps.update_progress(
automation_id,
status='error',
progress=100,
phase='Pipeline error',
log_line=f'Pipeline failed: {e}',
log_type='error',
)
return {'status': 'error', 'error': str(e), '_manages_own_progress': True}
"""Run REFRESH -> DISCOVER -> SYNC -> WISHLIST for mirrored playlists."""
return run_mirrored_playlist_pipeline(
config,
deps,
refresh_fn=auto_refresh_mirrored,
sync_one_fn=auto_sync_playlist,
sync_and_wishlist_fn=run_sync_and_wishlist,
)

View file

@ -18,6 +18,7 @@ import json
from typing import Any, Dict
from core.automation.deps import AutomationDeps
from core.playlists.source_refs import require_refresh_url
def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
@ -129,7 +130,7 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[
# source_playlist_id is an MD5 hash; extract actual Spotify ID from stored description (URL).
try:
from core.spotify_public_scraper import parse_spotify_url, scrape_spotify_embed
spotify_url = pl.get('description', '')
spotify_url = require_refresh_url(source, pl.get('description', ''), pl.get('name', ''))
parsed = parse_spotify_url(spotify_url) if spotify_url else None
# If Spotify is authenticated, use the full API (auto-discovers with album art).
@ -185,6 +186,13 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[
# No extra_data — let preservation code keep existing discovery data.
except Exception as e:
deps.logger.warning(f"Spotify public playlist refresh failed for {source_id}: {e}")
errors.append(f"{pl.get('name', '?')}: {str(e)}")
deps.update_progress(
auto_id,
log_line=f'Refresh failed: "{pl.get("name", "")}" - {str(e)}',
log_type='error',
)
continue
elif source == 'deezer':
try:
@ -228,7 +236,7 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[
elif source == 'youtube':
# source_playlist_id is now a deterministic hash; use stored description (original URL) for refresh.
yt_url = pl.get('description', '') or f"https://www.youtube.com/playlist?list={source_id}"
yt_url = require_refresh_url(source, pl.get('description', ''), pl.get('name', ''))
playlist_data = deps.parse_youtube_playlist(yt_url)
if playlist_data and playlist_data.get('tracks'):
tracks = []
@ -242,6 +250,15 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[
'source_track_id': t.get('id', ''),
})
if tracks is None:
errors.append(f"{pl.get('name', '?')}: no tracks returned from source")
deps.update_progress(
auto_id,
log_line=f'Refresh failed: "{pl.get("name", "")}" - no tracks returned from source',
log_type='error',
)
continue
if tracks is not None:
# Compare old vs new track IDs to detect changes.
old_tracks = db.get_mirrored_playlist_tracks(pl['id']) if pl.get('id') else []
@ -261,6 +278,7 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[
name=pl['name'],
tracks=tracks,
profile_id=pl.get('profile_id', 1),
description=pl.get('description'),
owner=pl.get('owner'),
image_url=pl.get('image_url'),
)

View file

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

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_then_actions_column(cursor)
self._add_automation_group_name_column(cursor)
self._add_automation_owned_by_column(cursor)
# Library issues — user-reported problems with tracks/albums/artists
cursor.execute("""
@ -845,6 +846,28 @@ class MusicDatabase:
except Exception as e:
logger.error(f"Error adding automation group_name column: {e}")
def _add_automation_owned_by_column(self, cursor):
"""Add owned_by column so feature surfaces (Auto-Sync schedule
board, future pipeline groups) can recognize automations they
manage without relying on fragile name-prefix string matches."""
try:
cursor.execute("PRAGMA table_info(automations)")
cols = [c[1] for c in cursor.fetchall()]
if 'owned_by' not in cols:
cursor.execute("ALTER TABLE automations ADD COLUMN owned_by TEXT DEFAULT NULL")
logger.info("Added owned_by column to automations table")
# Backfill existing Auto-Sync automations created via the
# name/group-prefix convention so the board keeps managing them.
cursor.execute("""
UPDATE automations
SET owned_by = 'auto_sync'
WHERE (group_name = 'Playlist Auto-Sync' OR name LIKE 'Auto-Sync:%')
AND owned_by IS NULL
""")
logger.info(f"Backfilled {cursor.rowcount} existing Auto-Sync automations with owned_by='auto_sync'")
except Exception as e:
logger.error(f"Error adding automation owned_by column: {e}")
def _add_automation_then_actions_column(self, cursor):
"""Add then_actions column to automations table and migrate existing notify data."""
try:
@ -6370,6 +6393,54 @@ class MusicDatabase:
logger.error(f"Error fetching candidate albums for artist '{artist}': {e}")
return candidates
def get_artist_tracks_indexed(self, name: str, server_source: Optional[str] = None, limit: int = 10000) -> List[DatabaseTrack]:
"""Indexed two-step lookup: artist_id by exact name (then case-insensitive
fallback), then tracks via `artist_id IN (...)`. Avoids the function-in-WHERE
pattern in search_tracks that defeats the artists.name index. Returns []
when the artist isn't in the library — caller can decide to fall back to
the slower LIKE-based path for track_artist / diacritic recall."""
if not name:
return []
try:
conn = self._get_connection()
cursor = conn.cursor()
# Step 1: exact case-sensitive match — hits idx_artists_name in O(log n).
# Spotify's canonical artist names match the library 90%+ of the time.
cursor.execute("SELECT id FROM artists WHERE name = ?", (name,))
artist_ids = [r['id'] for r in cursor.fetchall()]
# Step 2: case-insensitive fallback if exact missed. Full scan, but only
# runs on the (uncommon) miss path so amortized cost stays low.
if not artist_ids:
cursor.execute("SELECT id FROM artists WHERE LOWER(name) = LOWER(?)", (name,))
artist_ids = [r['id'] for r in cursor.fetchall()]
if not artist_ids:
return []
placeholders = ','.join('?' for _ in artist_ids)
where = f"t.artist_id IN ({placeholders})"
params: list = list(artist_ids)
if server_source:
where += " AND t.server_source = ?"
params.append(server_source)
params.append(limit)
cursor.execute(f"""
SELECT t.*, a.name as artist_name, al.title as album_title,
al.thumb_url as album_thumb_url
FROM tracks t
JOIN artists a ON a.id = t.artist_id
JOIN albums al ON al.id = t.album_id
WHERE {where}
LIMIT ?
""", params)
return self._rows_to_tracks(cursor.fetchall())
except Exception as e:
logger.error(f"Error fetching indexed artist tracks for '{name}': {e}")
return []
def get_candidate_tracks_for_albums(self, album_ids: List) -> List[DatabaseTrack]:
"""
Fetch every track belonging to the given set of album IDs in a single query.
@ -6897,22 +6968,12 @@ class MusicDatabase:
return unique_variations
def _normalize_for_comparison(self, text: str) -> str:
"""Normalize text for comparison with Unicode accent handling"""
if not text:
return ""
# Try to use unidecode for accent normalization, fallback to basic if not available
try:
from unidecode import unidecode
# Convert accents: é→e, ñ→n, ü→u, etc.
normalized = unidecode(text)
except ImportError:
# Fallback: basic normalization without accent handling
normalized = text
logger.warning("unidecode not available, accent matching may be limited")
# Convert to lowercase and strip
return normalized.lower().strip()
"""Delegates to `core.text.normalize.normalize_for_comparison`.
Kept as an instance method so existing internal callers don't need
to be touched new code should import the public helper directly.
"""
from core.text.normalize import normalize_for_comparison
return normalize_for_comparison(text)
def _calculate_track_confidence(self, search_title: str, search_artist: str, db_track: DatabaseTrack) -> float:
"""Calculate confidence score for track match with enhanced cleaning and Unicode normalization"""
@ -11828,7 +11889,7 @@ class MusicDatabase:
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(source, source_playlist_id, profile_id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
description = COALESCE(NULLIF(excluded.description, ''), mirrored_playlists.description),
owner = excluded.owner,
image_url = excluded.image_url,
track_count = excluded.track_count,
@ -11939,6 +12000,39 @@ class MusicDatabase:
logger.error(f"Error getting mirrored playlist tracks: {e}")
return []
def update_mirrored_playlist_source_ref(
self,
playlist_id: int,
source_playlist_id: str,
description: Optional[str] = None,
) -> bool:
"""Update a mirrored playlist's upstream source reference.
This intentionally leaves mirrored tracks and discovery extra_data
untouched; refresh/discovery can use the new source reference on the
next run without losing existing local state.
"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
if description is None:
cursor.execute("""
UPDATE mirrored_playlists
SET source_playlist_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (source_playlist_id, playlist_id))
else:
cursor.execute("""
UPDATE mirrored_playlists
SET source_playlist_id = ?, description = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (source_playlist_id, description, playlist_id))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error updating mirrored playlist source reference: {e}")
return False
def update_mirrored_track_extra_data(self, track_id: int, extra_data_dict: dict) -> bool:
"""Merge new data into a mirrored track's extra_data JSON field."""
try:
@ -12083,15 +12177,22 @@ class MusicDatabase:
def create_automation(self, name: str, trigger_type: str, trigger_config: str,
action_type: str, action_config: str, profile_id: int = 1,
notify_type: str = None, notify_config: str = '{}',
then_actions: str = '[]', group_name: str = None):
"""Create a new automation. Returns the new automation ID or None."""
then_actions: str = '[]', group_name: str = None,
owned_by: str = None):
"""Create a new automation. Returns the new automation ID or None.
``owned_by`` tags an automation as managed by a feature surface
(e.g. ``'auto_sync'`` for entries the Playlist Auto-Sync board
creates) so that surface can recognize its own rows without
scraping the display name.
"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO automations (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions, group_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions, group_name))
INSERT INTO automations (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions, group_name, owned_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions, group_name, owned_by))
conn.commit()
return cursor.lastrowid
except Exception as e:
@ -12138,7 +12239,7 @@ class MusicDatabase:
def update_automation(self, automation_id: int, **kwargs) -> bool:
"""Update automation fields."""
allowed = {'name', 'enabled', 'trigger_type', 'trigger_config', 'action_type', 'action_config', 'next_run', 'notify_type', 'notify_config', 'last_result', 'is_system', 'then_actions', 'group_name'}
allowed = {'name', 'enabled', 'trigger_type', 'trigger_config', 'action_type', 'action_config', 'next_run', 'notify_type', 'notify_config', 'last_result', 'is_system', 'then_actions', 'group_name', 'owned_by'}
updates = {k: v for k, v in kwargs.items() if k in allowed}
if not updates:
return False

View file

@ -10,6 +10,24 @@ from core.matching_engine import MusicMatchingEngine, MatchResult
logger = get_logger("sync_service")
# Per-artist track pool cap. High enough that no plausible artist hits it
# (the largest catalogs in our test libraries sit in the low thousands), low
# enough to avoid pathological pulls if the DB ever returns garbage.
_POOL_FETCH_LIMIT = 10000
def _artist_name(artist) -> str:
"""Pull the display name out of a Spotify artist entry — they come back
as bare strings on some endpoints and ``{name: ...}`` dicts on others.
Falls back to ``str(artist)`` so callers never get None."""
if isinstance(artist, str):
return artist
if isinstance(artist, dict):
name = artist.get('name')
if isinstance(name, str):
return name
return str(artist) if artist is not None else ''
@dataclass
class SyncResult:
playlist_name: str
@ -209,33 +227,33 @@ class PlaylistSyncService:
return self._create_error_result(playlist.name, ["Sync cancelled"])
total_tracks = len(playlist.tracks)
media_client, server_type = self._get_active_media_client()
media_client, server_type = self._get_active_media_client()
self._update_progress(playlist.name, f"Matching tracks against {server_type.title()} library", "", 20, 5, 2, total_tracks=total_tracks)
# Empty dict (not None) signals pooling is enabled for this sync;
# entries are filled lazily by `_find_track_in_media_server` so warm
# caches pay zero pool cost.
candidate_pool: Dict[str, list] = {}
# Use the same robust matching approach as "Download Missing Tracks"
match_results = []
for i, track in enumerate(playlist.tracks):
if self._cancelled:
return self._create_error_result(playlist.name, ["Sync cancelled"])
# Update progress for each track
progress_percent = 20 + (40 * (i + 1) / total_tracks) # 20-60% for matching
# Extract artist name from both string and dict formats
if track.artists:
first_artist = track.artists[0]
artist_name = first_artist if isinstance(first_artist, str) else (first_artist.get('name', 'Unknown') if isinstance(first_artist, dict) else str(first_artist))
current_track_name = f"{artist_name} - {track.name}"
current_track_name = f"{_artist_name(track.artists[0]) or 'Unknown'} - {track.name}"
else:
current_track_name = track.name
self._update_progress(playlist.name, "Matching tracks", current_track_name, progress_percent, 5, 2,
self._update_progress(playlist.name, "Matching tracks", current_track_name, progress_percent, 5, 2,
total_tracks=total_tracks,
matched_tracks=len([r for r in match_results if r.is_match]),
failed_tracks=len([r for r in match_results if not r.is_match]))
# Use the robust search approach
plex_match, confidence = await self._find_track_in_media_server(track)
plex_match, confidence = await self._find_track_in_media_server(track, candidate_pool=candidate_pool)
match_result = MatchResult(
spotify_track=track,
@ -469,7 +487,42 @@ class PlaylistSyncService:
self.clear_progress_callback(playlist.name)
self._cancelled = False
async def _find_track_in_media_server(self, spotify_track: SpotifyTrack) -> Tuple[Optional[TrackInfo], float]:
def _get_or_fetch_artist_candidates(self, candidate_pool: Optional[Dict[str, list]], db, artist_name: str, active_server) -> Optional[list]:
"""Lazy per-artist pool fetch. Returns None when pooling is off so the
caller falls back to the per-track SQL loop; otherwise returns the
cached list (possibly empty), fetching on first miss."""
if candidate_pool is None:
return None
from core.text.normalize import normalize_for_comparison
key = normalize_for_comparison(artist_name)
if key in candidate_pool:
return candidate_pool[key]
try:
# Fast path — indexed artist_id lookup. Hits idx_artists_name +
# idx_tracks_artist_id, returns in milliseconds for both hits and
# misses. Handles the 90%+ exact-name case.
candidates = db.get_artist_tracks_indexed(
artist_name,
server_source=active_server,
limit=_POOL_FETCH_LIMIT,
)
# Slow-path fallback only when fast path found nothing. Preserves
# recall for diacritic variants and `tracks.track_artist` features
# (compilations / soundtracks where the per-track artist differs
# from the album artist).
if not candidates:
candidates = db.search_tracks(
artist=artist_name,
limit=_POOL_FETCH_LIMIT,
server_source=active_server,
)
candidate_pool[key] = candidates or []
return candidate_pool[key]
except Exception as fetch_err:
logger.debug(f"Candidate pool fetch failed for '{artist_name}': {fetch_err}")
return None
async def _find_track_in_media_server(self, spotify_track: SpotifyTrack, candidate_pool: Optional[Dict[str, list]] = None) -> Tuple[Optional[TrackInfo], float]:
"""Find a track using the same improved database matching as Download Missing Tracks modal"""
try:
# Check active media server connection
@ -477,7 +530,7 @@ class PlaylistSyncService:
if not media_client or not media_client.is_connected():
logger.warning(f"{server_type.upper()} client not connected")
return None, 0.0
# Use the SAME improved database matching as PlaylistTrackAnalysisWorker
from database.music_database import MusicDatabase
from config.settings import config_manager
@ -531,18 +584,19 @@ class PlaylistSyncService:
if self._cancelled:
return None, 0.0
# Extract artist name from both string and dict formats
if isinstance(artist, str):
artist_name = artist
elif isinstance(artist, dict) and 'name' in artist:
artist_name = artist['name']
else:
artist_name = str(artist)
artist_name = _artist_name(artist)
# Use the improved database check_track_exists method with server awareness
try:
db = MusicDatabase()
db_track, confidence = db.check_track_exists(original_title, artist_name, confidence_threshold=0.7, server_source=active_server)
artist_candidates = self._get_or_fetch_artist_candidates(
candidate_pool, db, artist_name, active_server,
)
db_track, confidence = db.check_track_exists(
original_title, artist_name,
confidence_threshold=0.7, server_source=active_server,
candidate_tracks=artist_candidates,
)
if db_track and confidence >= 0.7:
logger.debug(f"Database match found for '{original_title}' by '{artist_name}': '{db_track.title}' with confidence {confidence:.2f}")

View file

@ -28,7 +28,8 @@ class _FakeDB:
return dict(self.automations[automation_id]) if automation_id in self.automations else None
def create_automation(self, name, trigger_type, trigger_config, action_type, action_config,
profile_id, notify_type, notify_config, then_actions, group_name):
profile_id, notify_type, notify_config, then_actions, group_name,
owned_by=None):
aid = self._next_id
self._next_id += 1
self.automations[aid] = {
@ -37,6 +38,7 @@ class _FakeDB:
'action_config': action_config, 'profile_id': profile_id,
'notify_type': notify_type, 'notify_config': notify_config,
'then_actions': then_actions, 'group_name': group_name,
'owned_by': owned_by,
'enabled': 1, 'is_system': 0,
}
return aid
@ -289,6 +291,91 @@ def test_update_then_actions_clears_notify_when_empty():
assert db.automations[1]['notify_config'] == '{}'
def test_update_trigger_config_change_resets_next_run():
"""Changing the interval must blank stored next_run so the engine
recomputes from scratch otherwise dragging a playlist from the 8h
Auto-Sync column to the 1h column keeps firing at the old 8h mark."""
db = _FakeDB()
db.automations[1] = {
'id': 1, 'name': 'a', 'enabled': 1, 'is_system': 0,
'trigger_type': 'schedule',
'trigger_config': '{"interval": 8, "unit": "hours"}',
'then_actions': '[]',
'next_run': '2026-05-25 05:00:00',
}
eng = _FakeEngine()
body, status = api.update_automation(db, eng, automation_id=1, data={
'trigger_config': {'interval': 1, 'unit': 'hours'},
})
assert status == 200
assert db.automations[1]['next_run'] is None
assert eng.scheduled == [1]
def test_update_trigger_type_change_resets_next_run():
"""Switching from interval to daily_time must also reset next_run."""
db = _FakeDB()
db.automations[1] = {
'id': 1, 'name': 'a', 'enabled': 1, 'is_system': 0,
'trigger_type': 'schedule', 'trigger_config': '{}',
'then_actions': '[]',
'next_run': '2026-05-25 05:00:00',
}
api.update_automation(db, _FakeEngine(), automation_id=1, data={
'trigger_type': 'daily_time',
})
assert db.automations[1]['next_run'] is None
def test_update_non_trigger_field_preserves_next_run():
"""Renaming an automation must NOT disturb its scheduled clock —
the reset is scoped to schedule-shape changes only."""
db = _FakeDB()
db.automations[1] = {
'id': 1, 'name': 'a', 'enabled': 1, 'is_system': 0,
'trigger_type': 'schedule', 'trigger_config': '{}',
'then_actions': '[]',
'next_run': '2026-05-25 05:00:00',
}
api.update_automation(db, _FakeEngine(), automation_id=1, data={'name': 'renamed'})
assert db.automations[1].get('next_run') == '2026-05-25 05:00:00'
def test_create_with_owned_by_persists_marker():
"""Auto-Sync schedule board posts `owned_by: 'auto_sync'` so it can
recognize its own rows on subsequent reads without name-prefix
string scraping."""
db = _FakeDB()
api.create_automation(db, _FakeEngine(), profile_id=1, data={
'name': 'Auto-Sync: Discover Weekly',
'trigger_type': 'schedule',
'trigger_config': {'interval': 1, 'unit': 'hours'},
'action_type': 'playlist_pipeline',
'owned_by': 'auto_sync',
})
assert db.automations[1]['owned_by'] == 'auto_sync'
def test_create_without_owned_by_defaults_to_none():
db = _FakeDB()
api.create_automation(db, _FakeEngine(), profile_id=1, data={
'name': 'Plain', 'trigger_type': 'schedule', 'action_type': 'process_wishlist',
})
assert db.automations[1]['owned_by'] is None
def test_update_can_set_or_clear_owned_by():
db = _FakeDB()
db.automations[1] = {
'id': 1, 'name': 'a', 'enabled': 1, 'is_system': 0,
'trigger_type': 'schedule', 'owned_by': None,
}
api.update_automation(db, _FakeEngine(), automation_id=1, data={'owned_by': 'auto_sync'})
assert db.automations[1]['owned_by'] == 'auto_sync'
api.update_automation(db, _FakeEngine(), automation_id=1, data={'owned_by': None})
assert db.automations[1]['owned_by'] is None
# ---------------------------------------------------------------------------
# batch_update_group
# ---------------------------------------------------------------------------

View file

@ -132,3 +132,38 @@ def test_skipped_status_records_no_error(engine_with_handler) -> None:
engine.run_automation(1, skip_delay=True)
kwargs = db_mock.update_automation_run.call_args.kwargs
assert kwargs.get('error') is None
def test_guarded_scheduled_skip_retries_soon() -> None:
"""A busy guarded action should retry soon instead of waiting a full interval."""
db_mock = MagicMock()
db_mock.get_automation.return_value = {
'id': 1,
'name': 'Playlist Pipeline',
'enabled': True,
'action_type': 'playlist_pipeline',
'action_config': '{}',
'trigger_type': 'schedule',
'trigger_config': '{"interval": 6, "unit": "hours"}',
}
db_mock.update_automation_run = MagicMock(return_value=True)
engine = AutomationEngine(db_mock)
engine._running = True
engine.schedule_automation = MagicMock()
engine._action_handlers['playlist_pipeline'] = {
'handler': lambda config: {'status': 'completed'},
'guard': lambda: True,
}
engine.run_automation(1, skip_delay=True)
kwargs = db_mock.update_automation_run.call_args.kwargs
assert kwargs.get('error') is None
assert kwargs.get('next_run') is not None
# It should be scheduled for roughly five minutes, not the configured six hours.
from datetime import datetime, timezone
next_run = datetime.strptime(kwargs['next_run'], '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc)
delay = (next_run - datetime.now(timezone.utc)).total_seconds()
assert 0 < delay <= 360

View file

@ -26,6 +26,7 @@ import pytest
from core.automation.deps import AutomationDeps, AutomationState
from core.automation.handlers.discover_playlist import auto_discover_playlist
from core.automation.handlers._pipeline_shared import run_sync_and_wishlist
from core.automation.handlers.playlist_pipeline import auto_playlist_pipeline
from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored
from core.automation.handlers.sync_playlist import auto_sync_playlist
@ -266,6 +267,24 @@ class TestRefreshMirrored:
assert result['errors'] == '1'
assert result['refreshed'] == '0'
def test_spotify_public_missing_source_url_is_reported_as_error(self):
db = _StubDB(playlists=[
{'id': 1, 'name': 'No URL', 'source': 'spotify_public', 'source_playlist_id': 'hash'},
])
progress = []
deps = _build_deps(
get_database=lambda: db,
update_progress=lambda *a, **k: progress.append(k),
)
result = auto_refresh_mirrored({'playlist_id': '1'}, deps)
assert result['status'] == 'completed'
assert result['refreshed'] == '0'
assert result['errors'] == '1'
assert db.mirror_calls == []
assert any(p.get('log_type') == 'error' and 'missing its original source URL' in p.get('log_line', '') for p in progress)
# ─── sync_playlist ───────────────────────────────────────────────────
@ -368,6 +387,19 @@ class TestSyncPlaylist:
class TestPlaylistPipeline:
def test_pipeline_skips_when_shared_lock_is_already_running(self):
deps = _build_deps()
deps.state.set_pipeline_running(True)
result = auto_playlist_pipeline({'all': True}, deps)
assert result == {
'status': 'skipped',
'reason': 'playlist_pipeline is already running',
'_manages_own_progress': True,
}
assert deps.state.pipeline_running is True
def test_no_playlist_specified_returns_error(self):
deps = _build_deps()
result = auto_playlist_pipeline({}, deps)
@ -398,3 +430,26 @@ class TestPlaylistPipeline:
assert result['status'] == 'error'
assert result['_manages_own_progress'] is True
assert deps.state.pipeline_running is False
def test_shared_sync_tail_counts_background_sync_errors(self):
progress = []
sync_states = {
'auto_mirror_1': {'status': 'error', 'error': 'media server unavailable'},
}
deps = _build_deps(
get_sync_states=lambda: sync_states,
update_progress=lambda *a, **k: progress.append(k),
)
result = run_sync_and_wishlist(
deps,
'auto-1',
[{'id': 1, 'name': 'Broken'}],
sync_one_fn=lambda _pl: {'status': 'started'},
sync_id_for_fn=lambda _pl: 'auto_mirror_1',
skip_wishlist=True,
)
assert result['errors'] == 1
assert result['synced'] == 0
assert any(p.get('log_type') == 'error' and 'media server unavailable' in p.get('log_line', '') for p in progress)

View file

@ -311,6 +311,14 @@ class TestAutomationState:
s.set_pipeline_running(False)
assert s.is_pipeline_running() is False
def test_try_start_pipeline_is_atomic(self):
s = AutomationState()
assert s.try_start_pipeline() is True
assert s.is_pipeline_running() is True
assert s.try_start_pipeline() is False
s.set_pipeline_running(False)
assert s.try_start_pipeline() is True
def test_concurrent_set_safe_via_lock(self):
# Smoke test: two threads flipping the same field don't crash.
# Lock ensures the final value is consistent.

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",
"enrichment.js",
"stats-automations.js",
"auto-sync.js",
"pages-extra.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 ---
_scan_library_automation_id = None
_automation_deps = None
# Playlist-native manual pipeline runs share the automation dependency
# bundle, but keep their own small progress state for the playlist UI.
playlist_pipeline_progress_states = {}
playlist_pipeline_progress_lock = threading.Lock()
def _register_automation_handlers():
@ -935,6 +941,8 @@ def _register_automation_handlers():
closures still live below until subsequent commits in the same
branch finish the lift.
"""
global _automation_deps
if not automation_engine:
return
@ -32183,6 +32191,7 @@ def mirror_playlist_endpoint():
def get_mirrored_playlists_endpoint():
"""List all mirrored playlists for the active profile."""
try:
from core.playlists.source_refs import describe_mirrored_source_ref
database = get_database()
profile_id = get_current_profile_id()
playlists = database.get_mirrored_playlists(profile_id=profile_id)
@ -32192,6 +32201,12 @@ def get_mirrored_playlists_endpoint():
pl['total_count'] = counts['total']
pl['wishlisted_count'] = counts['wishlisted']
pl['in_library_count'] = counts['in_library']
source_ref = describe_mirrored_source_ref(pl)
pl['source_ref'] = source_ref.source_ref
pl['source_ref_kind'] = source_ref.source_ref_kind
pl['source_ref_status'] = source_ref.source_ref_status
pl['source_ref_error'] = source_ref.source_ref_error
pl['pipeline_state'] = _snapshot_playlist_pipeline_state(pl['id'])
return jsonify(playlists)
except Exception as e:
logger.error(f"Error getting mirrored playlists: {e}")
@ -32201,16 +32216,269 @@ def get_mirrored_playlists_endpoint():
def get_mirrored_playlist_endpoint(playlist_id):
"""Get a mirrored playlist with its tracks."""
try:
from core.playlists.source_refs import describe_mirrored_source_ref
database = get_database()
playlist = database.get_mirrored_playlist(playlist_id)
if not playlist:
return jsonify({"error": "Playlist not found"}), 404
source_ref = describe_mirrored_source_ref(playlist)
playlist['source_ref'] = source_ref.source_ref
playlist['source_ref_kind'] = source_ref.source_ref_kind
playlist['source_ref_status'] = source_ref.source_ref_status
playlist['source_ref_error'] = source_ref.source_ref_error
playlist['pipeline_state'] = _snapshot_playlist_pipeline_state(playlist_id)
playlist['tracks'] = database.get_mirrored_playlist_tracks(playlist_id)
return jsonify(playlist)
except Exception as e:
logger.error(f"Error getting mirrored playlist: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/mirrored-playlists/<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'])
def delete_mirrored_playlist_endpoint(playlist_id):
"""Delete a mirrored playlist."""

View file

@ -837,18 +837,37 @@
</div>
</article>
<!-- Card: Tools (compact, top-right) — entry point to maintenance ops -->
<article class="dash-card dash-card--cta" data-card="tools" onclick="navigateToPage('tools')">
<!-- Card: Quick Actions launcher -->
<article class="dash-card dash-card--quick-actions" data-card="tools">
<header class="dash-card__head">
<h3 class="dash-card__title">Tools</h3>
<p class="dash-card__sub">Database, scanning, backups, cache, maintenance &amp; more.</p>
<h3 class="dash-card__title">Quick Actions</h3>
<p class="dash-card__sub">Jump into the places that shape how SoulSync runs.</p>
</header>
<div class="dash-card__body dash-card__body--cta">
<div class="dash-card__cta-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="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>
<div class="dash-card__cta-label">Open Tools</div>
<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>
<div class="dash-card__body dashboard-action-grid">
<button class="dashboard-action-lane tools" onclick="navigateToPage('tools')" aria-label="Open Tools">
<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="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>
<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>
</article>
@ -916,6 +935,7 @@
server</p>
</div>
<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="openSyncHistoryModal()" title="View sync history">Sync History</button>
</div>
@ -7884,6 +7904,7 @@
<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='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='init.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
// release time and add a real `date:` line at the top of the version block.
const WHATS_NEW = {
'2.6.2': [
{ date: 'May 24, 2026 — 2.6.2 release' },
{ title: 'Playlist sync is way faster on partial-match playlists', desc: 'syncing a playlist where most tracks weren\'t in the library used to take forever — a 30-track playlist with only 9 matches was burning 4+ minutes because every unmatched track ran the full title-variation × artist-variation SQL grid against the tracks table (~30 SQL queries per missed track). cache only covered matches, so misses re-paid the cost every sync. sync now uses a per-artist track pool that fills in lazily — only tracks that miss the sync match cache trigger a one-time fetch of their artist\'s library tracks, and later misses for the same artist reuse the in-memory list. playlists where every track is already cached pay zero pool cost (no upfront delay). benefits every sync entry point — manual, auto-sync, the playlist_pipeline automation action, and the Sync All button.' },
{ title: 'Fix: Auto-Sync "next in 8h" timezone bug', desc: 'scheduled Auto-Sync playlists were all showing "next in 8h" regardless of the interval — Every 1 hour, Every 1 day, anything. backend stores next-run as a naive UTC string and the frontend was parsing it as local time, which on Pacific time offset the displayed countdown by ~8 hours. Auto-Sync now uses the existing UTC-aware parser the rest of the Automations page already uses. as a separate correctness fix, the automation update endpoint also now blanks the stored next-run whenever the trigger type or trigger config changes, so the engine recomputes from scratch instead of preserving a leftover timestamp from the previous schedule.' },
{ title: 'Auto-Sync modal restyled to your accent color', desc: 'the Playlist Auto-Sync manager now picks up your chosen accent (the same one used everywhere else in the app) instead of the hardcoded sky-blue palette it shipped with. tabs, drop zones, scheduled-playlist chips, scrollbars, and the modal glow all follow the accent theme. drop targets also light up clearly while dragging.' },
],
'2.6.1': [
{ date: 'May 24, 2026 — 2.6.1 release' },
{ title: 'React Import page polish', desc: 'Import now runs through the React route stack with album, singles, and auto-import tabs plus the state fixes needed for reliable Vite builds.' },

View file

@ -380,6 +380,10 @@ function importFileSubmit() {
// ── Mirrored Playlists ────────────────────────────────────────────────
let mirroredPlaylistsLoaded = false;
// Auto-Sync state + functions moved to webui/static/auto-sync.js; declared
// as globals there so the older mirrored-playlist render path below can
// still reach `mirroredPipelinePollers`, `runMirroredPlaylistPipeline`,
// `editMirroredSourceRef`, `getMirroredSourceRef`, and `pollMirroredPipelineStatus`.
/**
* Fire-and-forget helper: send parsed playlist data to be mirrored on the backend.
@ -444,12 +448,34 @@ async function loadMirroredPlaylists() {
function renderMirroredCard(p, container) {
const ago = timeAgo(p.updated_at || p.mirrored_at);
const hash = `mirrored_${p.id}`;
const state = youtubePlaylistStates[hash];
const pipelineState = p.pipeline_state || null;
const pipelinePhase = pipelineState && pipelineState.status === 'running' ? 'pipeline_running'
: pipelineState && pipelineState.status === 'finished' ? 'pipeline_complete'
: pipelineState && (pipelineState.status === 'error' || pipelineState.status === 'skipped') ? 'pipeline_error'
: null;
const state = youtubePlaylistStates[hash] || (pipelinePhase ? {
phase: pipelinePhase,
pipeline_status: pipelineState.status,
pipeline_progress: pipelineState.progress || 0,
pipeline_phase: pipelineState.phase || '',
pipeline_error: pipelineState.error || '',
pipeline_log: pipelineState.log || [],
pipeline_result: pipelineState.result || null,
} : null);
const phase = state ? state.phase : null;
const sourceRef = getMirroredSourceRef(p);
// Build phase indicator
let phaseHtml = '';
if (phase === 'discovering') {
if (phase === 'pipeline_running') {
const pct = state.pipeline_progress || state.progress || 0;
const label = state.pipeline_phase || state.phase_label || 'Pipeline running';
phaseHtml = `<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;
phaseHtml = `<span style="color:#a78bfa;">Discovering ${pct}%</span>`;
} else if (phase === 'discovered') {
@ -493,6 +519,8 @@ function renderMirroredCard(p, container) {
</div>
</div>
${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>
`;
card.addEventListener('click', () => {
@ -530,6 +558,10 @@ function renderMirroredCard(p, container) {
}
});
container.appendChild(card);
if (pipelineState && pipelineState.status === 'running' && !mirroredPipelinePollers[hash]) {
pollMirroredPipelineStatus(p.id, p.name);
}
}
function updateMirroredCardPhase(urlHash, phase) {
@ -552,6 +584,17 @@ function updateMirroredCardPhase(urlHash, phase) {
// Add new phase indicator
let phaseHtml = '';
switch (phase) {
case 'pipeline_running':
const pipelineProgress = state?.pipeline_progress || 0;
const pipelinePhase = state?.pipeline_phase || 'Pipeline running';
phaseHtml = `<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':
phaseHtml = `<span style="color:#a78bfa;">Discovering...</span>`;
break;
@ -785,6 +828,7 @@ async function openMirroredPlaylistModal(playlistId) {
const tracks = data.tracks || [];
const source = data.source || 'unknown';
const sourceRef = getMirroredSourceRef(data);
const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛' };
const sourceIcon = sourceIcons[source] || '📋';
@ -827,6 +871,8 @@ async function openMirroredPlaylistModal(playlistId) {
<button class="mirrored-btn-delete" onclick="closeMirroredModal(); deleteMirroredPlaylist(${playlistId}, '${_escAttr(data.name)}')">Delete Mirror</button>
</div>
<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-discover" onclick="discoverMirroredPlaylist(${playlistId})">Discover</button>
</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); }
.auto-sync-manager-btn {
color: rgb(var(--accent-light-rgb));
border-color: rgba(var(--accent-rgb), 0.32);
background: rgba(var(--accent-rgb), 0.12);
}
.auto-sync-manager-btn:hover {
border-color: rgba(var(--accent-rgb), 0.5);
background: rgba(var(--accent-rgb), 0.2);
color: rgb(var(--accent-neon-rgb));
}
.auto-sync-overlay {
position: fixed;
inset: 0;
z-index: 10000;
display: none;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(12px);
}
.auto-sync-modal {
width: min(1500px, calc(100vw - 40px));
height: min(860px, calc(100vh - 40px));
background:
radial-gradient(circle at top right, rgba(var(--accent-rgb), 0.08) 0%, transparent 60%),
rgba(17, 19, 27, 0.98);
border: 1px solid rgba(var(--accent-rgb), 0.2);
border-radius: 10px;
box-shadow:
0 28px 80px rgba(0, 0, 0, 0.5),
0 0 60px rgba(var(--accent-rgb), 0.08);
display: flex;
flex-direction: column;
overflow: hidden;
}
.auto-sync-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding: 22px 24px 18px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
.auto-sync-eyebrow {
margin-bottom: 6px;
color: rgb(var(--accent-light-rgb));
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.auto-sync-header h3 {
margin: 0 0 6px;
color: rgba(255, 255, 255, 0.92);
font-size: 21px;
font-weight: 700;
}
.auto-sync-header p {
margin: 0;
color: rgba(255, 255, 255, 0.48);
font-size: 13px;
}
.auto-sync-close {
width: 32px;
height: 32px;
border: 0;
border-radius: 6px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.65);
cursor: pointer;
font-size: 22px;
line-height: 1;
}
.auto-sync-close:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.auto-sync-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1px;
background: rgba(255, 255, 255, 0.08);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
.auto-sync-summary div {
padding: 14px 20px;
background: rgba(255, 255, 255, 0.025);
}
.auto-sync-summary span {
display: block;
color: rgba(255, 255, 255, 0.92);
font-size: 20px;
font-weight: 800;
}
.auto-sync-summary small {
display: block;
margin-top: 2px;
color: rgba(255, 255, 255, 0.42);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.auto-sync-tabs {
display: flex;
gap: 8px;
padding: 12px 18px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.018);
flex-shrink: 0;
}
.auto-sync-tabs button {
height: 32px;
padding: 0 14px;
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.58);
cursor: pointer;
font-size: 12px;
font-weight: 700;
}
.auto-sync-tabs button:hover,
.auto-sync-tabs button.active {
border-color: rgba(var(--accent-rgb), 0.4);
background: rgba(var(--accent-rgb), 0.14);
color: rgb(var(--accent-neon-rgb));
box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.08), 0 2px 10px rgba(var(--accent-rgb), 0.12);
}
.auto-sync-tab-panel {
display: none;
min-height: 0;
flex: 1;
}
.auto-sync-tab-panel.active {
display: flex;
flex-direction: column;
}
.auto-sync-board-intro,
.auto-sync-automation-intro {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 18px;
border-bottom: 1px solid rgba(var(--accent-rgb), 0.16);
background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.08) 0%, rgba(var(--accent-rgb), 0.03) 60%, transparent 100%);
flex-shrink: 0;
}
.auto-sync-board-intro strong,
.auto-sync-automation-intro strong {
display: block;
color: rgba(255, 255, 255, 0.86);
font-size: 13px;
}
.auto-sync-board-intro span,
.auto-sync-automation-intro span {
display: block;
margin-top: 2px;
color: rgba(255, 255, 255, 0.46);
font-size: 12px;
}
.auto-sync-board-intro button {
height: 30px;
padding: 0 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 12px;
font-weight: 700;
}
.auto-sync-board-intro button:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.auto-sync-body {
min-height: 0;
flex: 1;
display: grid;
grid-template-columns: 300px 1fr;
}
.auto-sync-sidebar {
min-height: 0;
border-right: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.025);
display: flex;
flex-direction: column;
}
.auto-sync-sidebar-title {
padding: 16px 18px 10px;
color: rgba(255, 255, 255, 0.72);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.auto-sync-source-list {
min-height: 0;
overflow-y: auto;
padding: 0 12px 16px;
}
.auto-sync-source-group {
margin-bottom: 16px;
}
.auto-sync-source-title {
padding: 8px 6px;
color: rgba(255, 255, 255, 0.42);
font-size: 12px;
font-weight: 700;
}
.auto-sync-playlist,
.auto-sync-scheduled-card {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 7px;
background: rgba(255, 255, 255, 0.045);
cursor: grab;
}
.auto-sync-playlist {
padding: 11px 10px;
margin-bottom: 8px;
}
.auto-sync-playlist:hover,
.auto-sync-scheduled-card:hover {
border-color: rgba(var(--accent-rgb), 0.4);
background: rgba(var(--accent-rgb), 0.1);
box-shadow: 0 4px 14px rgba(var(--accent-rgb), 0.12);
}
.auto-sync-playlist.scheduled {
border-color: rgba(var(--accent-rgb), 0.3);
background: rgba(var(--accent-rgb), 0.05);
}
.auto-sync-playlist.unavailable {
cursor: default;
opacity: 0.58;
}
.auto-sync-playlist.unavailable:hover {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.045);
}
.auto-sync-source-group-disabled {
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.auto-sync-playlist-name,
.auto-sync-scheduled-name {
color: rgba(255, 255, 255, 0.86);
font-size: 13px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.auto-sync-playlist-meta,
.auto-sync-scheduled-meta {
margin-top: 4px;
color: rgba(255, 255, 255, 0.42);
font-size: 11px;
}
.auto-sync-scheduled-timing {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 8px;
}
.auto-sync-scheduled-timing span,
.auto-sync-scheduled-timing small {
padding: 3px 6px;
border-radius: 999px;
font-size: 10px;
font-weight: 800;
line-height: 1;
}
.auto-sync-scheduled-timing span {
background: rgba(var(--accent-rgb), 0.18);
color: rgb(var(--accent-light-rgb));
border: 1px solid rgba(var(--accent-rgb), 0.25);
}
.auto-sync-scheduled-timing small {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.48);
}
.auto-sync-board {
min-width: 0;
min-height: 0;
overflow-x: auto;
overflow-y: hidden;
padding: 18px;
display: grid;
grid-template-columns: repeat(10, minmax(185px, 1fr));
grid-auto-rows: minmax(0, 1fr);
gap: 12px;
}
.auto-sync-column {
min-height: 0;
max-height: 100%;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.025);
display: flex;
flex-direction: column;
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.auto-sync-column.drag-over {
border-color: rgba(var(--accent-rgb), 0.6);
background: rgba(var(--accent-rgb), 0.08);
box-shadow: inset 0 0 0 1px rgba(var(--accent-rgb), 0.3), 0 6px 20px rgba(var(--accent-rgb), 0.15);
}
.auto-sync-column-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
color: rgba(255, 255, 255, 0.86);
font-weight: 800;
flex-shrink: 0;
}
.auto-sync-column-head small {
color: rgb(var(--accent-light-rgb));
font-size: 11px;
font-weight: 700;
}
.auto-sync-column-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 10px;
}
.auto-sync-column-list::-webkit-scrollbar,
.auto-sync-board::-webkit-scrollbar,
.auto-sync-source-list::-webkit-scrollbar,
.auto-sync-automation-list::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.auto-sync-column-list::-webkit-scrollbar-thumb,
.auto-sync-board::-webkit-scrollbar-thumb,
.auto-sync-source-list::-webkit-scrollbar-thumb,
.auto-sync-automation-list::-webkit-scrollbar-thumb {
background: rgba(var(--accent-rgb), 0.3);
border-radius: 999px;
}
.auto-sync-column-list::-webkit-scrollbar-thumb:hover,
.auto-sync-board::-webkit-scrollbar-thumb:hover,
.auto-sync-source-list::-webkit-scrollbar-thumb:hover,
.auto-sync-automation-list::-webkit-scrollbar-thumb:hover {
background: rgba(var(--accent-rgb), 0.5);
}
.auto-sync-drop-hint,
.auto-sync-empty,
.auto-sync-loading,
.auto-sync-error {
color: rgba(255, 255, 255, 0.38);
font-size: 13px;
text-align: center;
}
.auto-sync-drop-hint {
border: 1px dashed rgba(255, 255, 255, 0.12);
border-radius: 7px;
padding: 18px 10px;
}
.auto-sync-drop-hint strong,
.auto-sync-drop-hint span {
display: block;
}
.auto-sync-drop-hint strong {
color: rgba(255, 255, 255, 0.52);
font-size: 12px;
}
.auto-sync-drop-hint span {
margin-top: 3px;
color: rgba(255, 255, 255, 0.32);
font-size: 11px;
}
.auto-sync-loading,
.auto-sync-error {
padding: 48px;
}
.auto-sync-scheduled-card {
padding: 10px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
gap: 8px;
}
.auto-sync-scheduled-card.disabled {
opacity: 0.52;
}
.auto-sync-scheduled-card button {
width: 24px;
height: 24px;
border: 0;
border-radius: 5px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
flex-shrink: 0;
}
.auto-sync-scheduled-card button:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.12);
}
.auto-sync-automation-list {
min-height: 0;
overflow-y: auto;
padding: 18px;
display: flex;
flex-direction: column;
gap: 10px;
}
.auto-sync-automation-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.035);
}
.auto-sync-automation-card:hover {
border-color: rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.055);
}
.auto-sync-automation-main {
min-width: 0;
flex: 1;
}
.auto-sync-automation-title-row {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.auto-sync-automation-title-row strong {
color: rgba(255, 255, 255, 0.88);
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.auto-sync-status {
padding: 3px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
flex-shrink: 0;
}
.auto-sync-status.enabled {
background: rgba(34, 197, 94, 0.14);
color: #4ade80;
}
.auto-sync-status.disabled {
background: rgba(148, 163, 184, 0.14);
color: #94a3b8;
}
.auto-sync-automation-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.auto-sync-automation-meta span {
padding: 4px 8px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.48);
font-size: 11px;
}
.auto-sync-automation-lock {
padding: 6px 9px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.42);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
flex-shrink: 0;
}
.auto-sync-automation-empty {
margin: 24px;
padding: 44px;
border: 1px dashed rgba(255, 255, 255, 0.12);
border-radius: 8px;
color: rgba(255, 255, 255, 0.42);
text-align: center;
}
@media (max-width: 1100px) {
.auto-sync-modal {
width: calc(100vw - 18px);
height: calc(100vh - 18px);
}
.auto-sync-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.auto-sync-body {
grid-template-columns: 1fr;
grid-template-rows: minmax(150px, 30%) 1fr;
}
.auto-sync-sidebar {
border-right: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.auto-sync-source-list {
display: flex;
gap: 12px;
overflow-x: auto;
overflow-y: hidden;
padding: 0 12px 12px;
}
.auto-sync-source-group {
min-width: 220px;
margin-bottom: 0;
}
.auto-sync-board {
grid-template-columns: repeat(10, minmax(165px, 180px));
}
}
@media (max-height: 760px) {
.auto-sync-header {
padding: 14px 18px 12px;
}
.auto-sync-header p {
display: none;
}
.auto-sync-summary div {
padding: 9px 16px;
}
.auto-sync-tabs {
padding: 9px 14px;
}
.auto-sync-board-intro,
.auto-sync-automation-intro {
padding: 9px 14px;
}
}
/* Enhanced Progress Bar Animation */
.progress-bar-fill {
height: 100%;
@ -12047,7 +12674,31 @@ body.helper-mode-active #dashboard-activity-feed:hover {
letter-spacing: 0.3px;
}
.mirrored-card-delete {
.mirrored-card-pipeline {
background: rgba(56, 189, 248, 0.12);
border: 1px solid rgba(56, 189, 248, 0.24);
border-radius: 6px;
color: #7dd3fc;
cursor: pointer;
font-size: 11px;
font-weight: 700;
height: 24px;
min-width: 76px;
padding: 0 10px;
transition: all 0.2s ease;
flex-shrink: 0;
opacity: 0;
}
.mirrored-card-pipeline:hover {
background: rgba(56, 189, 248, 0.2);
border-color: rgba(56, 189, 248, 0.4);
color: #e0f2fe;
}
.mirrored-card-delete,
.mirrored-card-clear,
.mirrored-card-link {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #555;
@ -12065,7 +12716,10 @@ body.helper-mode-active #dashboard-activity-feed:hover {
opacity: 0;
}
.mirrored-playlist-card:hover .mirrored-card-delete {
.mirrored-playlist-card:hover .mirrored-card-delete,
.mirrored-playlist-card:hover .mirrored-card-clear,
.mirrored-playlist-card:hover .mirrored-card-link,
.mirrored-playlist-card:hover .mirrored-card-pipeline {
opacity: 1;
}
@ -12076,28 +12730,6 @@ body.helper-mode-active #dashboard-activity-feed:hover {
transform: scale(1.1);
}
.mirrored-card-clear {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #555;
cursor: pointer;
font-size: 14px;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s ease;
flex-shrink: 0;
opacity: 0;
}
.mirrored-playlist-card:hover .mirrored-card-clear {
opacity: 1;
}
.mirrored-card-clear:hover {
color: #a78bfa;
background: rgba(167, 139, 250, 0.15);
@ -12105,6 +12737,13 @@ body.helper-mode-active #dashboard-activity-feed:hover {
transform: scale(1.1);
}
.mirrored-card-link:hover {
color: #64b5f6;
background: rgba(100, 181, 246, 0.15);
border-color: rgba(100, 181, 246, 0.3);
transform: scale(1.1);
}
.discovery-ratio {
font-size: 0.8em;
color: rgba(255, 255, 255, 0.4);
@ -13237,6 +13876,19 @@ body.helper-mode-active #dashboard-activity-feed:hover {
box-shadow: 0 8px 24px rgba(var(--accent-rgb), 0.4);
}
.mirrored-btn-pipeline {
background: rgba(56, 189, 248, 0.12);
color: #7dd3fc;
border: 1px solid rgba(56, 189, 248, 0.28) !important;
}
.mirrored-btn-pipeline:hover {
background: rgba(56, 189, 248, 0.2);
border-color: rgba(56, 189, 248, 0.45) !important;
color: #e0f2fe;
transform: translateY(-1px);
}
.mirrored-btn-delete {
background: rgba(244, 67, 54, 0.12);
color: #ef5350;
@ -59153,6 +59805,221 @@ body.reduce-effects .dash-card::after {
/* Body overrides for embedded sub-grids — make them compact for bento */
/* Quick Actions: polished three-lane dashboard launcher */
.dash-card--quick-actions {
overflow: hidden;
}
.dash-card--quick-actions .dash-card__body {
min-height: 128px;
}
.dashboard-action-grid {
position: relative;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
overflow: hidden;
isolation: isolate;
background:
radial-gradient(circle at 50% -20%, rgba(var(--accent-rgb), 0.16), transparent 44%),
linear-gradient(135deg, rgba(255,255,255,0.12), rgba(255,255,255,0.025)),
rgba(255, 255, 255, 0.035);
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.08),
0 12px 28px rgba(0,0,0,0.16),
0 0 0 1px rgba(var(--accent-rgb), 0.04);
}
.dashboard-action-grid::before {
content: '';
position: absolute;
inset: 0 0 auto;
height: 2px;
background: linear-gradient(90deg,
transparent,
rgba(var(--accent-rgb), 0.4),
rgb(var(--accent-light-rgb)),
rgba(var(--accent-rgb), 0.4),
transparent);
opacity: 0.85;
pointer-events: none;
z-index: 2;
}
.dashboard-action-lane {
position: relative;
z-index: 1;
min-width: 0;
min-height: 126px;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding: 16px;
border: 0;
background:
radial-gradient(circle at var(--lane-glow-x, 22%) 10%, var(--lane-glow), transparent 42%),
rgba(255, 255, 255, 0.032);
color: rgba(255, 255, 255, 0.88);
cursor: pointer;
text-align: left;
transition: background 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
}
.dashboard-action-lane + .dashboard-action-lane {
box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.07);
}
.dashboard-action-lane::after {
content: '';
position: absolute;
inset: 10px;
border-radius: 11px;
border: 1px solid transparent;
pointer-events: none;
transition: border-color 0.2s ease, background 0.2s ease;
}
.dashboard-action-lane:hover,
.dashboard-action-lane:focus-visible {
background:
radial-gradient(circle at var(--lane-glow-x, 22%) 10%, var(--lane-glow-strong), transparent 46%),
rgba(255, 255, 255, 0.06);
transform: translateY(-1px);
}
.dashboard-action-lane:focus-visible {
outline: 2px solid rgba(var(--accent-rgb), 0.55);
outline-offset: -4px;
}
.dashboard-action-lane:hover::after,
.dashboard-action-lane:focus-visible::after {
border-color: var(--lane-border);
background: rgba(255, 255, 255, 0.025);
}
.dashboard-action-lane.tools {
--lane-glow-x: 18%;
--lane-glow: rgba(var(--accent-rgb), 0.12);
--lane-glow-strong: rgba(var(--accent-rgb), 0.22);
--lane-border: rgba(var(--accent-rgb), 0.26);
--lane-accent: rgb(var(--accent-light-rgb));
}
.dashboard-action-lane.auto-sync {
--lane-glow-x: 50%;
--lane-glow: rgba(var(--accent-rgb), 0.18);
--lane-glow-strong: rgba(var(--accent-rgb), 0.3);
--lane-border: rgba(var(--accent-rgb), 0.34);
--lane-accent: rgb(var(--accent-light-rgb));
}
.dashboard-action-lane.automations {
--lane-glow-x: 82%;
--lane-glow: rgba(var(--accent-rgb), 0.12);
--lane-glow-strong: rgba(var(--accent-rgb), 0.22);
--lane-border: rgba(var(--accent-rgb), 0.26);
--lane-accent: rgb(var(--accent-light-rgb));
}
.dashboard-action-icon {
width: 38px;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 11px;
background: rgba(var(--accent-rgb), 0.1);
border: 1px solid rgba(var(--accent-rgb), 0.18);
color: var(--lane-accent);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 0 16px rgba(var(--accent-rgb), 0.08);
}
.dashboard-action-label {
display: block;
color: rgba(255, 255, 255, 0.92);
font-size: 14px;
font-weight: 800;
line-height: 1.1;
}
.dashboard-action-copy {
display: block;
min-height: 28px;
margin-top: 4px;
color: rgba(255, 255, 255, 0.48);
font-size: 11px;
font-weight: 600;
line-height: 1.25;
}
.dashboard-action-arrow {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--lane-accent);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.01em;
}
.dashboard-action-arrow::after {
content: '';
font-size: 16px;
line-height: 1;
transform: translateY(-1px);
transition: transform 0.18s ease;
}
.dashboard-action-lane:hover .dashboard-action-arrow::after,
.dashboard-action-lane:focus-visible .dashboard-action-arrow::after {
transform: translate(3px, -1px);
}
@media (max-width: 1180px) {
.dashboard-action-lane {
padding: 14px 12px;
}
.dashboard-action-copy {
min-height: 42px;
}
}
@media (max-width: 760px) {
.dashboard-action-grid {
grid-template-columns: 1fr;
gap: 1px;
}
.dashboard-action-lane {
min-height: 86px;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 12px;
}
.dashboard-action-lane + .dashboard-action-lane {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.07);
}
.dashboard-action-copy {
min-height: 0;
}
.dashboard-action-arrow {
align-self: center;
}
}
/* Service status: 3 service cards side-by-side in a tighter grid */
.dash-card[data-card="services"] .service-status-grid {
display: grid;