diff --git a/core/automation/handlers/_pipeline_shared.py b/core/automation/handlers/_pipeline_shared.py index c7427f8a..560ae3c3 100644 --- a/core/automation/handlers/_pipeline_shared.py +++ b/core/automation/handlers/_pipeline_shared.py @@ -80,9 +80,11 @@ def run_sync_and_wishlist( if sync_status == 'started': sync_id = sync_id_for_fn(pl) sync_poll_start = time.time() + timed_out = True while time.time() - sync_poll_start < _SYNC_PER_PLAYLIST_TIMEOUT_SECONDS: if (sync_id in sync_states and sync_states[sync_id].get('status') in _SYNC_TERMINAL_STATUSES): + timed_out = False break time.sleep(2) elapsed = int(time.time() - sync_poll_start) @@ -94,6 +96,25 @@ def run_sync_and_wishlist( ) ss = sync_states.get(sync_id, {}) + final_status = ss.get('status') + if timed_out: + sync_errors += 1 + deps.update_progress( + automation_id, + log_line=f'Sync timed out "{pl_name}" after {_SYNC_PER_PLAYLIST_TIMEOUT_SECONDS}s', + log_type='error', + ) + continue + if final_status in ('error', 'failed'): + sync_errors += 1 + reason = ss.get('error') or ss.get('reason') or 'background sync failed' + deps.update_progress( + automation_id, + log_line=f'Sync error "{pl_name}": {reason}', + log_type='error', + ) + continue + ss_result = ss.get('result', ss.get('progress', {})) matched = ss_result.get('matched_tracks', 0) if isinstance(ss_result, dict) else 0 total_synced += int(matched) if matched else 0 diff --git a/core/automation/handlers/refresh_mirrored.py b/core/automation/handlers/refresh_mirrored.py index 76ea32ce..02e546a8 100644 --- a/core/automation/handlers/refresh_mirrored.py +++ b/core/automation/handlers/refresh_mirrored.py @@ -18,6 +18,7 @@ import json from typing import Any, Dict from core.automation.deps import AutomationDeps +from core.playlists.source_refs import require_refresh_url def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]: @@ -129,7 +130,7 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[ # source_playlist_id is an MD5 hash; extract actual Spotify ID from stored description (URL). try: from core.spotify_public_scraper import parse_spotify_url, scrape_spotify_embed - spotify_url = pl.get('description', '') + spotify_url = require_refresh_url(source, pl.get('description', ''), pl.get('name', '')) parsed = parse_spotify_url(spotify_url) if spotify_url else None # If Spotify is authenticated, use the full API (auto-discovers with album art). @@ -185,6 +186,13 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[ # No extra_data — let preservation code keep existing discovery data. except Exception as e: deps.logger.warning(f"Spotify public playlist refresh failed for {source_id}: {e}") + errors.append(f"{pl.get('name', '?')}: {str(e)}") + deps.update_progress( + auto_id, + log_line=f'Refresh failed: "{pl.get("name", "")}" - {str(e)}', + log_type='error', + ) + continue elif source == 'deezer': try: @@ -228,7 +236,7 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[ elif source == 'youtube': # source_playlist_id is now a deterministic hash; use stored description (original URL) for refresh. - yt_url = pl.get('description', '') or f"https://www.youtube.com/playlist?list={source_id}" + yt_url = require_refresh_url(source, pl.get('description', ''), pl.get('name', '')) playlist_data = deps.parse_youtube_playlist(yt_url) if playlist_data and playlist_data.get('tracks'): tracks = [] @@ -242,6 +250,15 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[ 'source_track_id': t.get('id', ''), }) + if tracks is None: + errors.append(f"{pl.get('name', '?')}: no tracks returned from source") + deps.update_progress( + auto_id, + log_line=f'Refresh failed: "{pl.get("name", "")}" - no tracks returned from source', + log_type='error', + ) + continue + if tracks is not None: # Compare old vs new track IDs to detect changes. old_tracks = db.get_mirrored_playlist_tracks(pl['id']) if pl.get('id') else [] @@ -261,6 +278,7 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[ name=pl['name'], tracks=tracks, profile_id=pl.get('profile_id', 1), + description=pl.get('description'), owner=pl.get('owner'), image_url=pl.get('image_url'), ) diff --git a/core/automation_engine.py b/core/automation_engine.py index d19cbb58..61774579 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -617,7 +617,7 @@ class AutomationEngine: self._progress_finish_fn(automation_id, result) except Exception as e: logger.debug("scheduled progress finish (skipped): %s", e) - self._finish_run(auto, automation_id, result, error=None) + self._finish_run(auto, automation_id, result, error=None, retry_delay_seconds=300) return # Initialize progress tracking (skip if already done during delay) @@ -664,7 +664,7 @@ class AutomationEngine: self._finish_run(auto, automation_id, result, error) - def _finish_run(self, auto, automation_id, result, error): + def _finish_run(self, auto, automation_id, result, error, retry_delay_seconds=None): """Update DB with run stats and reschedule.""" next_run_str = None trigger_type = auto.get('trigger_type', '') @@ -672,7 +672,9 @@ class AutomationEngine: if trigger_type in self._trigger_handlers: try: trigger_config = json.loads(auto.get('trigger_config') or '{}') - if trigger_type == 'daily_time': + if retry_delay_seconds: + next_run_str = _utc_after(retry_delay_seconds) + elif trigger_type == 'daily_time': # Next run is tomorrow at the configured time (compute delay from local time, store as UTC) time_str = trigger_config.get('time', '00:00') hour, minute = map(int, time_str.split(':')) diff --git a/core/playlists/source_refs.py b/core/playlists/source_refs.py new file mode 100644 index 00000000..e4886384 --- /dev/null +++ b/core/playlists/source_refs.py @@ -0,0 +1,125 @@ +"""Helpers for mirrored-playlist upstream source references. + +Mirrored playlist rows have two legacy fields: +- ``source_playlist_id``: the stable lookup key used for uniqueness. +- ``description``: for URL-backed mirrors, the original/canonical URL. + +Keeping the normalization here prevents the refresh worker, API endpoint, +and UI repair flow from each inventing a slightly different meaning. +""" + +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass +from typing import Optional +from urllib.parse import parse_qs, urlparse + + +_SPOTIFY_ID_RE = re.compile(r"^[A-Za-z0-9]{16,32}$") + + +@dataclass(frozen=True) +class MirroredSourceRef: + source_playlist_id: str + description: Optional[str] + + +def normalize_mirrored_source_ref( + source: str, + source_ref: str, + existing_description: str = "", +) -> MirroredSourceRef: + """Normalize a user-provided source URL/ID for storage. + + URL-backed sources keep a deterministic hash in ``source_playlist_id`` and + store the canonical URL in ``description``. Direct-ID sources store the ID + directly and preserve the existing description unless a source-specific URL + parser says otherwise. + """ + source = (source or "").strip().lower() + source_ref = (source_ref or "").strip() + existing_description = (existing_description or "").strip() + + if not source_ref: + raise ValueError("Source link or ID is required") + + if source == "spotify_public": + canonical_url = _canonical_spotify_url(source_ref) + return MirroredSourceRef(_short_hash(canonical_url), canonical_url) + + if source == "youtube": + canonical_url = _canonical_youtube_url(source_ref) + return MirroredSourceRef(_short_hash(canonical_url), canonical_url) + + if source == "deezer" and source_ref.startswith(("http://", "https://")): + from core.deezer_client import DeezerClient + + parsed_id = DeezerClient.parse_playlist_url(source_ref) + if not parsed_id: + raise ValueError("Use a valid Deezer playlist URL or playlist ID") + return MirroredSourceRef(str(parsed_id), existing_description or None) + + return MirroredSourceRef(source_ref, existing_description or None) + + +def require_refresh_url(source: str, description: str, playlist_name: str = "") -> str: + """Return a URL required by hash-backed refresh sources, or raise clearly.""" + source = (source or "").strip().lower() + description = (description or "").strip() + if source in {"spotify_public", "youtube"}: + if not description.startswith(("http://", "https://")): + label = f" '{playlist_name}'" if playlist_name else "" + raise ValueError(f"{source} mirror{label} is missing its original source URL") + return description + + +def _canonical_spotify_url(source_ref: str) -> str: + parsed = _parse_spotify_ref(source_ref) + if parsed: + return f"https://open.spotify.com/{parsed['type']}/{parsed['id']}" + + # Repair flow convenience: if the user pastes only a Spotify ID, assume + # playlist. Album URLs still need their URL/URI so the type is explicit. + if _SPOTIFY_ID_RE.match(source_ref): + return f"https://open.spotify.com/playlist/{source_ref}" + + raise ValueError("Use a valid open.spotify.com playlist/album URL, Spotify URI, or playlist ID") + + +def _parse_spotify_ref(source_ref: str) -> Optional[dict]: + uri_match = re.match(r"spotify:(playlist|album):([A-Za-z0-9]+)", source_ref) + if uri_match: + return {"type": uri_match.group(1), "id": uri_match.group(2)} + + url_match = re.search( + r"https?://open\.spotify\.com/(playlist|album)/([A-Za-z0-9]+)", + source_ref, + ) + if url_match: + return {"type": url_match.group(1), "id": url_match.group(2)} + + return None + + +def _canonical_youtube_url(source_ref: str) -> str: + parsed_url = urlparse(source_ref) + playlist_id = "" + + if parsed_url.scheme and parsed_url.netloc: + host = parsed_url.netloc.lower() + if not ("youtube.com" in host or "music.youtube.com" in host): + raise ValueError("Use a valid YouTube playlist URL") + playlist_id = parse_qs(parsed_url.query).get("list", [""])[0] + else: + playlist_id = source_ref + + if not playlist_id: + raise ValueError("YouTube playlist URL must include a list= playlist id") + + return f"https://youtube.com/playlist?list={playlist_id}" + + +def _short_hash(value: str) -> str: + return hashlib.md5(value.encode()).hexdigest()[:12] diff --git a/database/music_database.py b/database/music_database.py index 0171d88e..876904bd 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -11828,7 +11828,7 @@ class MusicDatabase: VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(source, source_playlist_id, profile_id) DO UPDATE SET name = excluded.name, - description = excluded.description, + description = COALESCE(NULLIF(excluded.description, ''), mirrored_playlists.description), owner = excluded.owner, image_url = excluded.image_url, track_count = excluded.track_count, @@ -11939,6 +11939,39 @@ class MusicDatabase: logger.error(f"Error getting mirrored playlist tracks: {e}") return [] + def update_mirrored_playlist_source_ref( + self, + playlist_id: int, + source_playlist_id: str, + description: Optional[str] = None, + ) -> bool: + """Update a mirrored playlist's upstream source reference. + + This intentionally leaves mirrored tracks and discovery extra_data + untouched; refresh/discovery can use the new source reference on the + next run without losing existing local state. + """ + try: + with self._get_connection() as conn: + cursor = conn.cursor() + if description is None: + cursor.execute(""" + UPDATE mirrored_playlists + SET source_playlist_id = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (source_playlist_id, playlist_id)) + else: + cursor.execute(""" + UPDATE mirrored_playlists + SET source_playlist_id = ?, description = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (source_playlist_id, description, playlist_id)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Error updating mirrored playlist source reference: {e}") + return False + def update_mirrored_track_extra_data(self, track_id: int, extra_data_dict: dict) -> bool: """Merge new data into a mirrored track's extra_data JSON field.""" try: diff --git a/tests/automation/test_handler_error_storage.py b/tests/automation/test_handler_error_storage.py index e7eaeefd..04dc93dd 100644 --- a/tests/automation/test_handler_error_storage.py +++ b/tests/automation/test_handler_error_storage.py @@ -132,3 +132,38 @@ def test_skipped_status_records_no_error(engine_with_handler) -> None: engine.run_automation(1, skip_delay=True) kwargs = db_mock.update_automation_run.call_args.kwargs assert kwargs.get('error') is None + + +def test_guarded_scheduled_skip_retries_soon() -> None: + """A busy guarded action should retry soon instead of waiting a full interval.""" + db_mock = MagicMock() + db_mock.get_automation.return_value = { + 'id': 1, + 'name': 'Playlist Pipeline', + 'enabled': True, + 'action_type': 'playlist_pipeline', + 'action_config': '{}', + 'trigger_type': 'schedule', + 'trigger_config': '{"interval": 6, "unit": "hours"}', + } + db_mock.update_automation_run = MagicMock(return_value=True) + + engine = AutomationEngine(db_mock) + engine._running = True + engine.schedule_automation = MagicMock() + engine._action_handlers['playlist_pipeline'] = { + 'handler': lambda config: {'status': 'completed'}, + 'guard': lambda: True, + } + + engine.run_automation(1, skip_delay=True) + + kwargs = db_mock.update_automation_run.call_args.kwargs + assert kwargs.get('error') is None + assert kwargs.get('next_run') is not None + # It should be scheduled for roughly five minutes, not the configured six hours. + from datetime import datetime, timezone + + next_run = datetime.strptime(kwargs['next_run'], '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc) + delay = (next_run - datetime.now(timezone.utc)).total_seconds() + assert 0 < delay <= 360 diff --git a/tests/automation/test_handlers_playlist.py b/tests/automation/test_handlers_playlist.py index 1a1548ef..0bf7628c 100644 --- a/tests/automation/test_handlers_playlist.py +++ b/tests/automation/test_handlers_playlist.py @@ -26,6 +26,7 @@ import pytest from core.automation.deps import AutomationDeps, AutomationState from core.automation.handlers.discover_playlist import auto_discover_playlist +from core.automation.handlers._pipeline_shared import run_sync_and_wishlist from core.automation.handlers.playlist_pipeline import auto_playlist_pipeline from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored from core.automation.handlers.sync_playlist import auto_sync_playlist @@ -266,6 +267,24 @@ class TestRefreshMirrored: assert result['errors'] == '1' assert result['refreshed'] == '0' + def test_spotify_public_missing_source_url_is_reported_as_error(self): + db = _StubDB(playlists=[ + {'id': 1, 'name': 'No URL', 'source': 'spotify_public', 'source_playlist_id': 'hash'}, + ]) + progress = [] + deps = _build_deps( + get_database=lambda: db, + update_progress=lambda *a, **k: progress.append(k), + ) + + result = auto_refresh_mirrored({'playlist_id': '1'}, deps) + + assert result['status'] == 'completed' + assert result['refreshed'] == '0' + assert result['errors'] == '1' + assert db.mirror_calls == [] + assert any(p.get('log_type') == 'error' and 'missing its original source URL' in p.get('log_line', '') for p in progress) + # ─── sync_playlist ─────────────────────────────────────────────────── @@ -398,3 +417,26 @@ class TestPlaylistPipeline: assert result['status'] == 'error' assert result['_manages_own_progress'] is True assert deps.state.pipeline_running is False + + def test_shared_sync_tail_counts_background_sync_errors(self): + progress = [] + sync_states = { + 'auto_mirror_1': {'status': 'error', 'error': 'media server unavailable'}, + } + deps = _build_deps( + get_sync_states=lambda: sync_states, + update_progress=lambda *a, **k: progress.append(k), + ) + + result = run_sync_and_wishlist( + deps, + 'auto-1', + [{'id': 1, 'name': 'Broken'}], + sync_one_fn=lambda _pl: {'status': 'started'}, + sync_id_for_fn=lambda _pl: 'auto_mirror_1', + skip_wishlist=True, + ) + + assert result['errors'] == 1 + assert result['synced'] == 0 + assert any(p.get('log_type') == 'error' and 'media server unavailable' in p.get('log_line', '') for p in progress) diff --git a/tests/database/test_mirrored_playlists.py b/tests/database/test_mirrored_playlists.py new file mode 100644 index 00000000..8da26480 --- /dev/null +++ b/tests/database/test_mirrored_playlists.py @@ -0,0 +1,62 @@ +from database.music_database import MusicDatabase + + +def test_update_mirrored_playlist_source_ref_preserves_tracks(tmp_path): + db = MusicDatabase(str(tmp_path / "music.db")) + playlist_id = db.mirror_playlist( + source="youtube", + source_playlist_id="oldhash", + name="Mirror", + tracks=[ + { + "track_name": "Song", + "artist_name": "Artist", + "source_track_id": "yt1", + "extra_data": {"discovered": True}, + } + ], + profile_id=1, + description="https://youtube.com/playlist?list=old", + ) + + assert playlist_id is not None + + updated = db.update_mirrored_playlist_source_ref( + playlist_id, + "newhash", + "https://youtube.com/playlist?list=new", + ) + + assert updated is True + playlist = db.get_mirrored_playlist(playlist_id) + assert playlist["source_playlist_id"] == "newhash" + assert playlist["description"] == "https://youtube.com/playlist?list=new" + + tracks = db.get_mirrored_playlist_tracks(playlist_id) + assert len(tracks) == 1 + assert tracks[0]["track_name"] == "Song" + assert tracks[0]["extra_data"] is not None + + +def test_mirror_playlist_refresh_preserves_existing_description(tmp_path): + db = MusicDatabase(str(tmp_path / "music.db")) + playlist_id = db.mirror_playlist( + source="spotify_public", + source_playlist_id="hash", + name="Release Radar", + tracks=[{"track_name": "Song", "artist_name": "Artist"}], + profile_id=1, + description="https://open.spotify.com/playlist/abc", + ) + + refreshed_id = db.mirror_playlist( + source="spotify_public", + source_playlist_id="hash", + name="Release Radar", + tracks=[{"track_name": "New Song", "artist_name": "Artist"}], + profile_id=1, + ) + + assert refreshed_id == playlist_id + playlist = db.get_mirrored_playlist(playlist_id) + assert playlist["description"] == "https://open.spotify.com/playlist/abc" diff --git a/tests/playlists/test_source_refs.py b/tests/playlists/test_source_refs.py new file mode 100644 index 00000000..e7867f31 --- /dev/null +++ b/tests/playlists/test_source_refs.py @@ -0,0 +1,45 @@ +import pytest + +from core.playlists.source_refs import ( + normalize_mirrored_source_ref, + require_refresh_url, +) + + +def test_spotify_public_url_stores_hash_and_canonical_url(): + out = normalize_mirrored_source_ref( + "spotify_public", + "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M?si=abc", + ) + + assert out.source_playlist_id == "5e7de827abd1" + assert out.description == "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M" + + +def test_spotify_public_raw_id_defaults_to_playlist_url(): + out = normalize_mirrored_source_ref("spotify_public", "37i9dQZF1DXcBWIGoYBM5M") + + assert out.source_playlist_id == "5e7de827abd1" + assert out.description == "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M" + + +def test_youtube_url_stores_hash_and_canonical_url(): + out = normalize_mirrored_source_ref( + "youtube", + "https://music.youtube.com/playlist?list=PL123&si=abc", + ) + + assert out.source_playlist_id == "e5f5ab31b8f0" + assert out.description == "https://youtube.com/playlist?list=PL123" + + +def test_direct_id_sources_preserve_existing_description(): + out = normalize_mirrored_source_ref("tidal", "abc123", "Original service description") + + assert out.source_playlist_id == "abc123" + assert out.description == "Original service description" + + +def test_hash_backed_refresh_requires_url(): + with pytest.raises(ValueError, match="missing its original source URL"): + require_refresh_url("spotify_public", "", "Release Radar") diff --git a/web_server.py b/web_server.py index fc8c8a2e..da8f96ec 100644 --- a/web_server.py +++ b/web_server.py @@ -32211,6 +32211,55 @@ def get_mirrored_playlist_endpoint(playlist_id): logger.error(f"Error getting mirrored playlist: {e}") return jsonify({"error": str(e)}), 500 +@app.route('/api/mirrored-playlists//source-ref', methods=['PATCH']) +def update_mirrored_playlist_source_ref_endpoint(playlist_id): + """Update the upstream source link/id for a mirrored playlist.""" + try: + data = request.get_json() or {} + source_ref = data.get('source_ref') or data.get('source_playlist_id') or data.get('url') + + database = get_database() + playlist = database.get_mirrored_playlist(playlist_id) + if not playlist: + return jsonify({"error": "Playlist not found"}), 404 + + try: + from core.playlists.source_refs import normalize_mirrored_source_ref + normalized = normalize_mirrored_source_ref( + playlist.get('source'), + source_ref, + playlist.get('description') or '', + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + + existing = [ + pl for pl in database.get_mirrored_playlists(profile_id=playlist.get('profile_id', 1)) + if ( + pl.get('source') == playlist.get('source') + and str(pl.get('source_playlist_id')) == str(normalized.source_playlist_id) + and int(pl.get('id')) != int(playlist_id) + ) + ] + if existing: + return jsonify({ + "error": f"That source is already mirrored as '{existing[0].get('name', 'another playlist')}'" + }), 409 + + ok = database.update_mirrored_playlist_source_ref( + playlist_id, + normalized.source_playlist_id, + normalized.description, + ) + if not ok: + return jsonify({"error": "Failed to update source reference"}), 500 + + updated = database.get_mirrored_playlist(playlist_id) or {} + return jsonify({"success": True, "playlist": updated}) + except Exception as e: + logger.error(f"Error updating mirrored playlist source reference: {e}") + return jsonify({"error": str(e)}), 500 + @app.route('/api/mirrored-playlists/', methods=['DELETE']) def delete_mirrored_playlist_endpoint(playlist_id): """Delete a mirrored playlist.""" diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 9723c549..20f08de0 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -446,6 +446,7 @@ function renderMirroredCard(p, container) { const hash = `mirrored_${p.id}`; const state = youtubePlaylistStates[hash]; const phase = state ? state.phase : null; + const sourceRef = getMirroredSourceRef(p); // Build phase indicator let phaseHtml = ''; @@ -493,6 +494,7 @@ function renderMirroredCard(p, container) { ${disc > 0 ? `` : ''} + `; card.addEventListener('click', () => { @@ -532,6 +534,48 @@ function renderMirroredCard(p, container) { container.appendChild(card); } +function getMirroredSourceRef(p) { + const desc = (p && p.description) ? String(p.description).trim() : ''; + if ((p.source === 'spotify_public' || p.source === 'youtube') && /^https?:\/\//i.test(desc)) { + return desc; + } + return (p && p.source_playlist_id) ? String(p.source_playlist_id) : ''; +} + +async function editMirroredSourceRef(playlistId, name, source, currentRef) { + const label = (source === 'spotify_public' || source === 'youtube') + ? 'original playlist URL' + : 'original playlist ID or URL'; + const nextRef = window.prompt(`Update ${label} for "${name}"`, currentRef || ''); + if (nextRef === null) return; + const trimmed = nextRef.trim(); + if (!trimmed) { + showToast('Source link or ID is required', 'error'); + return; + } + + try { + const res = await fetch(`/api/mirrored-playlists/${playlistId}/source-ref`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source_ref: trimmed }) + }); + const data = await res.json(); + if (!res.ok || data.error) { + throw new Error(data.error || 'Failed to update source reference'); + } + showToast(`Updated source for ${name}`, 'success'); + loadMirroredPlaylists(); + const openModal = document.getElementById('mirrored-track-modal'); + if (openModal) { + closeMirroredModal(); + openMirroredPlaylistModal(playlistId); + } + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } +} + function updateMirroredCardPhase(urlHash, phase) { // Update the state phase (updateYouTubeCardPhase skips this for mirrored playlists due to no cardElement) const state = youtubePlaylistStates[urlHash]; @@ -785,6 +829,7 @@ async function openMirroredPlaylistModal(playlistId) { const tracks = data.tracks || []; const source = data.source || 'unknown'; + const sourceRef = getMirroredSourceRef(data); const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛' }; const sourceIcon = sourceIcons[source] || '📋'; @@ -827,6 +872,7 @@ async function openMirroredPlaylistModal(playlistId) { diff --git a/webui/static/style.css b/webui/static/style.css index 7706efc0..8ab1469c 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -12047,7 +12047,9 @@ body.helper-mode-active #dashboard-activity-feed:hover { letter-spacing: 0.3px; } -.mirrored-card-delete { +.mirrored-card-delete, +.mirrored-card-clear, +.mirrored-card-link { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.08); color: #555; @@ -12065,7 +12067,9 @@ body.helper-mode-active #dashboard-activity-feed:hover { opacity: 0; } -.mirrored-playlist-card:hover .mirrored-card-delete { +.mirrored-playlist-card:hover .mirrored-card-delete, +.mirrored-playlist-card:hover .mirrored-card-clear, +.mirrored-playlist-card:hover .mirrored-card-link { opacity: 1; } @@ -12076,28 +12080,6 @@ body.helper-mode-active #dashboard-activity-feed:hover { transform: scale(1.1); } -.mirrored-card-clear { - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.08); - color: #555; - cursor: pointer; - font-size: 14px; - padding: 0; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - transition: all 0.2s ease; - flex-shrink: 0; - opacity: 0; -} - -.mirrored-playlist-card:hover .mirrored-card-clear { - opacity: 1; -} - .mirrored-card-clear:hover { color: #a78bfa; background: rgba(167, 139, 250, 0.15); @@ -12105,6 +12087,13 @@ body.helper-mode-active #dashboard-activity-feed:hover { transform: scale(1.1); } +.mirrored-card-link:hover { + color: #64b5f6; + background: rgba(100, 181, 246, 0.15); + border-color: rgba(100, 181, 246, 0.3); + transform: scale(1.1); +} + .discovery-ratio { font-size: 0.8em; color: rgba(255, 255, 255, 0.4);