Harden playlist pipeline source refresh

Centralize mirrored playlist source reference normalization so edited links and IDs are stored consistently. Preserve URL-backed refresh refs, surface missing-source refresh failures, count background sync failures in pipeline summaries, and retry guarded automation skips after a short delay instead of losing a scheduled run. Add focused coverage for source refs, mirrored playlist source updates, refresh failures, and guarded retry behavior.
This commit is contained in:
Broque Thomas 2026-05-24 19:31:00 -07:00
parent a7ca7ddfad
commit 73bd2db547
12 changed files with 497 additions and 30 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -26,6 +26,7 @@ import pytest
from core.automation.deps import AutomationDeps, AutomationState
from core.automation.handlers.discover_playlist import auto_discover_playlist
from core.automation.handlers._pipeline_shared import run_sync_and_wishlist
from core.automation.handlers.playlist_pipeline import auto_playlist_pipeline
from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored
from core.automation.handlers.sync_playlist import auto_sync_playlist
@ -266,6 +267,24 @@ class TestRefreshMirrored:
assert result['errors'] == '1'
assert result['refreshed'] == '0'
def test_spotify_public_missing_source_url_is_reported_as_error(self):
db = _StubDB(playlists=[
{'id': 1, 'name': 'No URL', 'source': 'spotify_public', 'source_playlist_id': 'hash'},
])
progress = []
deps = _build_deps(
get_database=lambda: db,
update_progress=lambda *a, **k: progress.append(k),
)
result = auto_refresh_mirrored({'playlist_id': '1'}, deps)
assert result['status'] == 'completed'
assert result['refreshed'] == '0'
assert result['errors'] == '1'
assert db.mirror_calls == []
assert any(p.get('log_type') == 'error' and 'missing its original source URL' in p.get('log_line', '') for p in progress)
# ─── sync_playlist ───────────────────────────────────────────────────
@ -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)

View file

@ -0,0 +1,62 @@
from database.music_database import MusicDatabase
def test_update_mirrored_playlist_source_ref_preserves_tracks(tmp_path):
db = MusicDatabase(str(tmp_path / "music.db"))
playlist_id = db.mirror_playlist(
source="youtube",
source_playlist_id="oldhash",
name="Mirror",
tracks=[
{
"track_name": "Song",
"artist_name": "Artist",
"source_track_id": "yt1",
"extra_data": {"discovered": True},
}
],
profile_id=1,
description="https://youtube.com/playlist?list=old",
)
assert playlist_id is not None
updated = db.update_mirrored_playlist_source_ref(
playlist_id,
"newhash",
"https://youtube.com/playlist?list=new",
)
assert updated is True
playlist = db.get_mirrored_playlist(playlist_id)
assert playlist["source_playlist_id"] == "newhash"
assert playlist["description"] == "https://youtube.com/playlist?list=new"
tracks = db.get_mirrored_playlist_tracks(playlist_id)
assert len(tracks) == 1
assert tracks[0]["track_name"] == "Song"
assert tracks[0]["extra_data"] is not None
def test_mirror_playlist_refresh_preserves_existing_description(tmp_path):
db = MusicDatabase(str(tmp_path / "music.db"))
playlist_id = db.mirror_playlist(
source="spotify_public",
source_playlist_id="hash",
name="Release Radar",
tracks=[{"track_name": "Song", "artist_name": "Artist"}],
profile_id=1,
description="https://open.spotify.com/playlist/abc",
)
refreshed_id = db.mirror_playlist(
source="spotify_public",
source_playlist_id="hash",
name="Release Radar",
tracks=[{"track_name": "New Song", "artist_name": "Artist"}],
profile_id=1,
)
assert refreshed_id == playlist_id
playlist = db.get_mirrored_playlist(playlist_id)
assert playlist["description"] == "https://open.spotify.com/playlist/abc"

View file

@ -0,0 +1,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")

View file

@ -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/<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
@app.route('/api/mirrored-playlists/<int:playlist_id>', methods=['DELETE'])
def delete_mirrored_playlist_endpoint(playlist_id):
"""Delete a mirrored playlist."""

View file

@ -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) {
</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-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', () => {
@ -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) {
<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-close" onclick="closeMirroredModal()">Close</button>
<button class="mirrored-btn-discover" onclick="discoverMirroredPlaylist(${playlistId})">Discover</button>
</div>

View file

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