Merge pull request #697 from Nezreka/feat/playlist-auto-sync
Feat/playlist auto sync
This commit is contained in:
commit
5f62152acb
32 changed files with 3773 additions and 300 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
257
core/playlists/pipeline.py
Normal 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',
|
||||
)
|
||||
156
core/playlists/source_refs.py
Normal file
156
core/playlists/source_refs.py
Normal 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
0
core/text/__init__.py
Normal file
41
core/text/normalize.py
Normal file
41
core/text/normalize.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
127
tests/database/test_get_artist_tracks_indexed.py
Normal file
127
tests/database/test_get_artist_tracks_indexed.py
Normal 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'
|
||||
62
tests/database/test_mirrored_playlists.py
Normal file
62
tests/database/test_mirrored_playlists.py
Normal 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"
|
||||
82
tests/playlists/test_source_refs.py
Normal file
82
tests/playlists/test_source_refs.py
Normal 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"
|
||||
394
tests/static/test_auto_sync.mjs
Normal file
394
tests/static/test_auto_sync.mjs
Normal 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({}), '');
|
||||
});
|
||||
});
|
||||
43
tests/sync/test_artist_name_extraction.py
Normal file
43
tests/sync/test_artist_name_extraction.py
Normal 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('') == ''
|
||||
208
tests/sync/test_sync_candidate_pool.py
Normal file
208
tests/sync/test_sync_candidate_pool.py
Normal 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',
|
||||
)
|
||||
72
tests/test_auto_sync_js.py
Normal file
72
tests/test_auto_sync_js.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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
0
tests/text/__init__.py
Normal file
38
tests/text/test_normalize.py
Normal file
38
tests/text/test_normalize.py
Normal 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"
|
||||
268
web_server.py
268
web_server.py
|
|
@ -919,6 +919,12 @@ except Exception as e:
|
|||
|
||||
# --- Automation Progress Tracking ---
|
||||
_scan_library_automation_id = None
|
||||
_automation_deps = None
|
||||
|
||||
# Playlist-native manual pipeline runs share the automation dependency
|
||||
# bundle, but keep their own small progress state for the playlist UI.
|
||||
playlist_pipeline_progress_states = {}
|
||||
playlist_pipeline_progress_lock = threading.Lock()
|
||||
|
||||
|
||||
def _register_automation_handlers():
|
||||
|
|
@ -935,6 +941,8 @@ def _register_automation_handlers():
|
|||
closures still live below until subsequent commits in the same
|
||||
branch finish the lift.
|
||||
"""
|
||||
global _automation_deps
|
||||
|
||||
if not automation_engine:
|
||||
return
|
||||
|
||||
|
|
@ -32183,6 +32191,7 @@ def mirror_playlist_endpoint():
|
|||
def get_mirrored_playlists_endpoint():
|
||||
"""List all mirrored playlists for the active profile."""
|
||||
try:
|
||||
from core.playlists.source_refs import describe_mirrored_source_ref
|
||||
database = get_database()
|
||||
profile_id = get_current_profile_id()
|
||||
playlists = database.get_mirrored_playlists(profile_id=profile_id)
|
||||
|
|
@ -32192,6 +32201,12 @@ def get_mirrored_playlists_endpoint():
|
|||
pl['total_count'] = counts['total']
|
||||
pl['wishlisted_count'] = counts['wishlisted']
|
||||
pl['in_library_count'] = counts['in_library']
|
||||
source_ref = describe_mirrored_source_ref(pl)
|
||||
pl['source_ref'] = source_ref.source_ref
|
||||
pl['source_ref_kind'] = source_ref.source_ref_kind
|
||||
pl['source_ref_status'] = source_ref.source_ref_status
|
||||
pl['source_ref_error'] = source_ref.source_ref_error
|
||||
pl['pipeline_state'] = _snapshot_playlist_pipeline_state(pl['id'])
|
||||
return jsonify(playlists)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting mirrored playlists: {e}")
|
||||
|
|
@ -32201,16 +32216,269 @@ def get_mirrored_playlists_endpoint():
|
|||
def get_mirrored_playlist_endpoint(playlist_id):
|
||||
"""Get a mirrored playlist with its tracks."""
|
||||
try:
|
||||
from core.playlists.source_refs import describe_mirrored_source_ref
|
||||
database = get_database()
|
||||
playlist = database.get_mirrored_playlist(playlist_id)
|
||||
if not playlist:
|
||||
return jsonify({"error": "Playlist not found"}), 404
|
||||
source_ref = describe_mirrored_source_ref(playlist)
|
||||
playlist['source_ref'] = source_ref.source_ref
|
||||
playlist['source_ref_kind'] = source_ref.source_ref_kind
|
||||
playlist['source_ref_status'] = source_ref.source_ref_status
|
||||
playlist['source_ref_error'] = source_ref.source_ref_error
|
||||
playlist['pipeline_state'] = _snapshot_playlist_pipeline_state(playlist_id)
|
||||
playlist['tracks'] = database.get_mirrored_playlist_tracks(playlist_id)
|
||||
return jsonify(playlist)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting mirrored playlist: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/mirrored-playlists/<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."""
|
||||
|
|
|
|||
|
|
@ -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 & 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
585
webui/static/auto-sync.js
Normal 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()">×</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()">×</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()">×</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 · ${_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))} · 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))} · ${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">×</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);
|
||||
}
|
||||
|
|
@ -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.' },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue