Add Mirrored Playlists — persistent cross-service playlist archive

Automatically mirrors every parsed playlist (Spotify, Tidal, YouTube, Beatport) to a local database so they're always accessible — even if a service subscription lapses or the browser closes.
  - New "Mirrored" tab on the Sync page with source-branded cards showing discovery/download status
  - Auto-mirrors on successful parse (upsert — re-parsing updates the existing mirror, no duplicates)
  - Click any mirrored playlist to browse its full track list, then run it through the discovery pipeline
  - Cards dynamically reflect live state: Discovering → Discovered → Downloading → Downloaded
  - Download modal rehydrates after page refresh — click a "Downloading..." card to resume viewing progress
  - All phase transitions (start, complete, cancel, error, modal close) keep card and backend state in sync
  - Profile-scoped via profile_id, consistent with other features
This commit is contained in:
Broque Thomas 2026-03-04 18:27:18 -08:00
parent b2fa222228
commit b30e1f60bd
5 changed files with 1440 additions and 14 deletions

View file

@ -338,6 +338,43 @@ class MusicDatabase:
self._add_profile_support_v3(cursor)
self._add_profile_support_v4(cursor)
# Mirrored playlists — persistent backup of parsed playlists from any service
cursor.execute("""
CREATE TABLE IF NOT EXISTS mirrored_playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
source_playlist_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
owner TEXT,
image_url TEXT,
track_count INTEGER DEFAULT 0,
profile_id INTEGER DEFAULT 1,
mirrored_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(source, source_playlist_id, profile_id)
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS mirrored_playlist_tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id INTEGER NOT NULL,
position INTEGER NOT NULL,
track_name TEXT NOT NULL,
artist_name TEXT NOT NULL,
album_name TEXT DEFAULT '',
duration_ms INTEGER DEFAULT 0,
image_url TEXT,
source_track_id TEXT,
extra_data TEXT,
FOREIGN KEY (playlist_id) REFERENCES mirrored_playlists(id) ON DELETE CASCADE,
UNIQUE(playlist_id, position)
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_mirrored_playlists_profile ON mirrored_playlists (profile_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_mirrored_playlists_source ON mirrored_playlists (source, source_playlist_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_mirrored_tracks_playlist ON mirrored_playlist_tracks (playlist_id)")
conn.commit()
logger.info("Database initialized successfully")
@ -6339,6 +6376,113 @@ class MusicDatabase:
logger.error(f"API: Error listing albums: {e}")
return {"albums": [], "total": 0}
# ── Mirrored Playlists ───────────────────────────────────────────────
def mirror_playlist(self, source: str, source_playlist_id: str, name: str,
tracks: List[Dict], profile_id: int = 1, **kwargs) -> Optional[int]:
"""Upsert a mirrored playlist and replace all its tracks."""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
# Upsert the playlist row
cursor.execute("""
INSERT INTO mirrored_playlists
(source, source_playlist_id, name, description, owner, image_url, track_count, profile_id, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(source, source_playlist_id, profile_id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
owner = excluded.owner,
image_url = excluded.image_url,
track_count = excluded.track_count,
updated_at = CURRENT_TIMESTAMP
""", (
source, source_playlist_id, name,
kwargs.get('description'), kwargs.get('owner'),
kwargs.get('image_url'), len(tracks), profile_id
))
playlist_id = cursor.execute(
"SELECT id FROM mirrored_playlists WHERE source=? AND source_playlist_id=? AND profile_id=?",
(source, source_playlist_id, profile_id)
).fetchone()['id']
# Replace all tracks
cursor.execute("DELETE FROM mirrored_playlist_tracks WHERE playlist_id=?", (playlist_id,))
for i, t in enumerate(tracks):
extra = t.get('extra_data')
if extra and not isinstance(extra, str):
extra = json.dumps(extra)
cursor.execute("""
INSERT INTO mirrored_playlist_tracks
(playlist_id, position, track_name, artist_name, album_name, duration_ms, image_url, source_track_id, extra_data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
playlist_id, i + 1,
t.get('track_name', ''), t.get('artist_name', ''),
t.get('album_name', ''), t.get('duration_ms', 0),
t.get('image_url'), t.get('source_track_id'), extra
))
conn.commit()
logger.info(f"Mirrored playlist '{name}' ({source}) with {len(tracks)} tracks")
return playlist_id
except Exception as e:
logger.error(f"Error mirroring playlist: {e}")
return None
def get_mirrored_playlists(self, profile_id: int = 1) -> List[Dict]:
"""Return all mirrored playlists for a profile, newest first."""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM mirrored_playlists
WHERE profile_id = ?
ORDER BY updated_at DESC
""", (profile_id,))
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Error getting mirrored playlists: {e}")
return []
def get_mirrored_playlist(self, playlist_id: int) -> Optional[Dict]:
"""Return a single mirrored playlist by id."""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM mirrored_playlists WHERE id = ?", (playlist_id,))
row = cursor.fetchone()
return dict(row) if row else None
except Exception as e:
logger.error(f"Error getting mirrored playlist: {e}")
return None
def get_mirrored_playlist_tracks(self, playlist_id: int) -> List[Dict]:
"""Return all tracks for a mirrored playlist ordered by position."""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM mirrored_playlist_tracks
WHERE playlist_id = ?
ORDER BY position
""", (playlist_id,))
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Error getting mirrored playlist tracks: {e}")
return []
def delete_mirrored_playlist(self, playlist_id: int) -> bool:
"""Delete a mirrored playlist and its tracks (CASCADE)."""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM mirrored_playlists WHERE id = ?", (playlist_id,))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error deleting mirrored playlist: {e}")
return False
# Thread-safe singleton pattern for database access
_database_instances: Dict[int, MusicDatabase] = {} # Thread ID -> Database instance
_database_lock = threading.Lock()

View file

@ -20550,9 +20550,12 @@ def get_all_youtube_playlists():
current_time = time.time()
for url_hash, state in youtube_playlist_states.items():
# Skip mirrored playlist entries — they have their own hydration
if url_hash.startswith('mirrored_'):
continue
# Update access time when requested
state['last_accessed'] = current_time
# Return essential data for card recreation
playlist_info = {
'url_hash': url_hash,
@ -27714,6 +27717,164 @@ def delete_beatport_chart(chart_hash):
logger.error(f"❌ Error deleting Beatport chart: {e}")
return jsonify({"error": str(e)}), 500
# ── Mirrored Playlists ────────────────────────────────────────────────
@app.route('/api/mirror-playlist', methods=['POST'])
def mirror_playlist_endpoint():
"""Save or update a mirrored playlist."""
try:
data = request.get_json()
if not data:
return jsonify({"error": "No data received"}), 400
source = data.get('source')
source_playlist_id = data.get('source_playlist_id')
name = data.get('name')
tracks = data.get('tracks', [])
if not all([source, source_playlist_id, name]):
return jsonify({"error": "source, source_playlist_id, and name are required"}), 400
database = get_database()
profile_id = get_current_profile_id()
playlist_id = database.mirror_playlist(
source=source,
source_playlist_id=str(source_playlist_id),
name=name,
tracks=tracks,
profile_id=profile_id,
description=data.get('description'),
owner=data.get('owner'),
image_url=data.get('image_url')
)
if playlist_id is None:
return jsonify({"error": "Failed to mirror playlist"}), 500
return jsonify({"success": True, "playlist_id": playlist_id})
except Exception as e:
logger.error(f"Error mirroring playlist: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/mirrored-playlists', methods=['GET'])
def get_mirrored_playlists_endpoint():
"""List all mirrored playlists for the active profile."""
try:
database = get_database()
profile_id = get_current_profile_id()
playlists = database.get_mirrored_playlists(profile_id=profile_id)
return jsonify(playlists)
except Exception as e:
logger.error(f"Error getting mirrored playlists: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/mirrored-playlists/<int:playlist_id>', methods=['GET'])
def get_mirrored_playlist_endpoint(playlist_id):
"""Get a mirrored playlist with its tracks."""
try:
database = get_database()
playlist = database.get_mirrored_playlist(playlist_id)
if not playlist:
return jsonify({"error": "Playlist not found"}), 404
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>', methods=['DELETE'])
def delete_mirrored_playlist_endpoint(playlist_id):
"""Delete a mirrored playlist."""
try:
database = get_database()
if database.delete_mirrored_playlist(playlist_id):
return jsonify({"success": True})
return jsonify({"error": "Playlist not found"}), 404
except Exception as e:
logger.error(f"Error deleting mirrored playlist: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/mirrored-playlists/<int:playlist_id>/prepare-discovery', methods=['POST'])
def prepare_mirrored_discovery(playlist_id):
"""Register a mirrored playlist into youtube_playlist_states so the YouTube discovery pipeline can run."""
try:
database = get_database()
playlist = database.get_mirrored_playlist(playlist_id)
if not playlist:
return jsonify({"error": "Playlist not found"}), 404
tracks_data = database.get_mirrored_playlist_tracks(playlist_id)
url_hash = f"mirrored_{playlist_id}"
# Build track list in the format the YouTube discovery worker expects
tracks = [{
'id': t.get('source_track_id') or f"mirrored_{t['id']}",
'name': t['track_name'],
'artists': [t['artist_name']],
'album': t.get('album_name', ''),
'duration_ms': t.get('duration_ms', 0)
} for t in tracks_data]
playlist_data = {
'id': url_hash,
'name': playlist['name'],
'tracks': tracks,
'track_count': len(tracks),
'url': f"mirrored://{playlist['source']}/{playlist['source_playlist_id']}",
'source': playlist['source']
}
youtube_playlist_states[url_hash] = {
'playlist': playlist_data,
'phase': 'fresh',
'discovery_results': [],
'discovery_progress': 0,
'spotify_matches': 0,
'spotify_total': len(tracks),
'status': 'parsed',
'url': playlist_data['url'],
'sync_playlist_id': None,
'converted_spotify_playlist_id': None,
'download_process_id': None,
'created_at': time.time(),
'last_accessed': time.time(),
'discovery_future': None,
'sync_progress': {}
}
logger.info(f"Prepared mirrored playlist for discovery: {playlist['name']} ({len(tracks)} tracks)")
return jsonify({"success": True, "url_hash": url_hash})
except Exception as e:
logger.error(f"Error preparing mirrored discovery: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/mirrored-playlists/discovery-states', methods=['GET'])
def get_mirrored_discovery_states():
"""Return discovery states for any mirrored playlists that have active/completed discoveries."""
try:
states = []
for url_hash, state in youtube_playlist_states.items():
if not url_hash.startswith('mirrored_'):
continue
states.append({
'url_hash': url_hash,
'playlist_id': int(url_hash.replace('mirrored_', '')),
'playlist': state['playlist'],
'phase': state['phase'],
'status': state.get('status', ''),
'discovery_progress': state.get('discovery_progress', 0),
'spotify_matches': state.get('spotify_matches', 0),
'spotify_total': state.get('spotify_total', 0),
'discovery_results': state.get('discovery_results', []),
'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'),
'download_process_id': state.get('download_process_id'),
})
return jsonify({"states": states})
except Exception as e:
logger.error(f"Error getting mirrored discovery states: {e}")
return jsonify({"error": str(e)}), 500
def convert_beatport_results_to_spotify_tracks(discovery_results):
"""Convert Beatport discovery results to Spotify tracks format for sync"""
spotify_tracks = []

View file

@ -765,6 +765,9 @@
<button class="sync-tab-button" data-tab="beatport">
<span class="tab-icon beatport-icon"></span> Beatport
</button>
<button class="sync-tab-button" data-tab="mirrored">
<span class="tab-icon mirrored-icon"></span> Mirrored
</button>
</div>
<!-- Spotify Tab Content -->
@ -1489,6 +1492,17 @@
</div>
</div>
</div>
<!-- Mirrored Playlists Tab Content -->
<div class="sync-tab-content" id="mirrored-tab-content">
<div class="playlist-header">
<h3>Mirrored Playlists</h3>
<button class="refresh-button mirrored" id="mirrored-refresh-btn">Refresh</button>
</div>
<div class="playlist-scroll-container" id="mirrored-playlist-container">
<div class="playlist-placeholder">Playlists you parse from any service will appear here as persistent backups.</div>
</div>
</div>
</div>
<!-- Right Panel: Sidebar with Options & Logging -->

View file

@ -6967,6 +6967,15 @@ async function openPlaylistDetailsModal(event, playlistId) {
playlistTrackCache[playlistId] = fullPlaylist.tracks;
console.log(`Cached ${fullPlaylist.tracks.length} tracks for playlist ${playlistId}.`);
// Auto-mirror this Spotify playlist
mirrorPlaylist('spotify', playlistId, fullPlaylist.name, fullPlaylist.tracks.map(t => ({
track_name: t.name, artist_name: (t.artists && t.artists[0]) ? (typeof t.artists[0] === 'object' ? t.artists[0].name : t.artists[0]) : '',
album_name: t.album ? (typeof t.album === 'object' ? t.album.name : t.album) : '',
duration_ms: t.duration_ms || 0,
image_url: t.album && typeof t.album === 'object' && t.album.images && t.album.images[0] ? t.album.images[0].url : null,
source_track_id: t.id || t.spotify_track_id || ''
})), { description: fullPlaylist.description, owner: fullPlaylist.owner, image_url: fullPlaylist.image_url });
showPlaylistDetailsModal(fullPlaylist);
}
// --- CACHING LOGIC END ---
@ -7867,6 +7876,10 @@ async function closeDownloadMissingModal(playlistId) {
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
updateYouTubeCardPhase(urlHash, 'discovered');
// Also update mirrored playlist card if applicable
if (urlHash.startsWith('mirrored_')) {
updateMirroredCardPhase(urlHash, 'discovered');
}
// Update backend state to prevent rehydration issues on page refresh (similar to Tidal fix)
try {
@ -9334,6 +9347,10 @@ async function startMissingTracksProcess(playlistId) {
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
updateYouTubeCardPhase(urlHash, 'downloading');
// Also update mirrored playlist card if applicable
if (urlHash.startsWith('mirrored_')) {
updateMirroredCardPhase(urlHash, 'downloading');
}
}
// Update Tidal playlist phase to 'downloading' if this is a Tidal playlist
@ -10224,6 +10241,14 @@ function processModalStatusUpdate(playlistId, data) {
}
}
// Update mirrored playlist card phase on client-side completion
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
if (urlHash.startsWith('mirrored_')) {
updateMirroredCardPhase(urlHash, 'download_complete');
}
}
// Auto-save final M3U file for playlists
autoSavePlaylistM3U(playlistId);
@ -10266,6 +10291,9 @@ function processModalStatusUpdate(playlistId, data) {
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
updateYouTubeCardPhase(urlHash, 'discovered');
if (urlHash.startsWith('mirrored_')) {
updateMirroredCardPhase(urlHash, 'discovered');
}
}
showToast(`Process cancelled for ${process.playlist.name}.`, 'info');
@ -10277,6 +10305,9 @@ function processModalStatusUpdate(playlistId, data) {
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
updateYouTubeCardPhase(urlHash, 'discovered');
if (urlHash.startsWith('mirrored_')) {
updateMirroredCardPhase(urlHash, 'discovered');
}
}
showToast(`Process for ${process.playlist.name} failed!`, 'error');
@ -10288,6 +10319,9 @@ function processModalStatusUpdate(playlistId, data) {
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
updateYouTubeCardPhase(urlHash, 'download_complete');
if (urlHash.startsWith('mirrored_')) {
updateMirroredCardPhase(urlHash, 'download_complete');
}
}
// Update Tidal playlist phase to 'download_complete' if this is a Tidal playlist
@ -16665,6 +16699,17 @@ async function loadTidalPlaylists() {
console.log(`🎵 Loaded ${tidalPlaylists.length} Tidal playlists`);
// Auto-mirror Tidal playlists that have tracks
tidalPlaylists.forEach(p => {
if (p.tracks && p.tracks.length > 0) {
mirrorPlaylist('tidal', p.id, p.name, p.tracks.map(t => ({
track_name: t.name || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artists || ''),
album_name: typeof t.album === 'string' ? t.album : '', duration_ms: t.duration_ms || 0,
source_track_id: t.id || ''
})), { owner: p.owner, image_url: p.image_url, description: p.description });
}
});
// Load and apply saved discovery states from backend (like YouTube)
await loadTidalPlaylistStatesFromBackend();
@ -18074,6 +18119,11 @@ function initializeSyncPage() {
syncContentArea.style.gridTemplateColumns = '1fr';
}
}
// Auto-load mirrored playlists on first tab activation
if (tabId === 'mirrored' && !mirroredPlaylistsLoaded) {
loadMirroredPlaylists();
}
});
});
@ -18092,6 +18142,12 @@ function initializeSyncPage() {
tidalRefreshBtn.addEventListener('click', loadTidalPlaylists);
}
// Logic for the Mirrored refresh button
const mirroredRefreshBtn = document.getElementById('mirrored-refresh-btn');
if (mirroredRefreshBtn) {
mirroredRefreshBtn.addEventListener('click', loadMirroredPlaylists);
}
// Logic for the Beatport clear button
const beatportClearBtn = document.getElementById('beatport-clear-btn');
if (beatportClearBtn) {
@ -19398,6 +19454,15 @@ function addBeatportCardToContainer(chartData) {
console.log(`🃏 Created Beatport card: ${chartData.name}`);
// Auto-mirror this Beatport chart
if (chartData.tracks && chartData.tracks.length > 0) {
mirrorPlaylist('beatport', chartData.hash, chartData.name, chartData.tracks.map(t => ({
track_name: t.name || t.title || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artist || ''),
album_name: t.album || '', duration_ms: t.duration_ms || 0,
source_track_id: t.id || '', image_url: t.image_url || null
})));
}
// Update clear button state after creating card
updateBeatportClearButtonState();
}
@ -21219,6 +21284,12 @@ async function parseYouTubePlaylist() {
updateYouTubeCardData(result.url_hash, result);
updateYouTubeCardPhase(result.url_hash, 'fresh');
// Auto-mirror this YouTube playlist
mirrorPlaylist('youtube', result.url_hash, result.name, result.tracks.map(t => ({
track_name: t.name || t.title || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artist || ''),
album_name: '', duration_ms: t.duration_ms || 0, source_track_id: t.id || ''
})));
// Clear input
urlInput.value = '';
@ -21550,14 +21621,16 @@ function startYouTubeDiscoveryPolling(urlHash) {
}
updateYouTubeCardProgress(urlHash, data);
const st = youtubePlaylistStates[urlHash];
if (st) { st.discoveryResults = data.results || []; st.discoveryProgress = data.progress || 0; st.spotifyMatches = data.spotify_matches || 0; }
if (st) { st.discoveryResults = data.results || []; st.discovery_results = data.results || []; st.discoveryProgress = data.progress || 0; st.spotifyMatches = data.spotify_matches || 0; st.spotify_matches = data.spotify_matches || 0; }
updateYouTubeDiscoveryModal(urlHash, data);
if (data.complete) {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash];
// Update phase in state directly (updateYouTubeCardPhase may skip if no cardElement)
if (st) st.phase = 'discovered';
updateYouTubeCardPhase(urlHash, 'discovered');
updateYouTubeModalButtons(urlHash, 'discovered');
showToast('YouTube discovery complete!', 'success');
showToast('Discovery complete!', 'success');
}
};
}
@ -21582,8 +21655,10 @@ function startYouTubeDiscoveryPolling(urlHash) {
const state = youtubePlaylistStates[urlHash];
if (state) {
state.discoveryResults = status.results || [];
state.discovery_results = status.results || [];
state.discoveryProgress = status.progress || 0;
state.spotifyMatches = status.spotify_matches || 0;
state.spotify_matches = status.spotify_matches || 0;
}
// Update modal if open
@ -21594,14 +21669,16 @@ function startYouTubeDiscoveryPolling(urlHash) {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
// Update phase in state directly (updateYouTubeCardPhase may skip if no cardElement)
if (state) state.phase = 'discovered';
// Update card phase to discovered
updateYouTubeCardPhase(urlHash, 'discovered');
// Update modal buttons to show sync and download buttons
updateYouTubeModalButtons(urlHash, 'discovered');
console.log('✅ YouTube discovery complete:', urlHash);
showToast('YouTube discovery complete!', 'success');
console.log('✅ Discovery complete:', urlHash);
showToast('Discovery complete!', 'success');
}
} catch (error) {
@ -21658,15 +21735,18 @@ function openYouTubeDiscoveryModal(urlHash) {
}
}
} else {
// Create new modal (support YouTube, Tidal, Beatport, and ListenBrainz)
// Create new modal (support YouTube, Tidal, Beatport, ListenBrainz, and Mirrored)
const isTidal = state.is_tidal_playlist;
const isBeatport = state.is_beatport_playlist;
const isListenBrainz = state.is_listenbrainz_playlist;
const modalTitle = isTidal ? '🎵 Tidal Playlist Discovery' :
const isMirrored = state.is_mirrored_playlist;
const modalTitle = isMirrored ? '🎵 Mirrored Playlist Discovery' :
isTidal ? '🎵 Tidal Playlist Discovery' :
isBeatport ? '🎵 Beatport Chart Discovery' :
isListenBrainz ? '🎵 ListenBrainz Playlist Discovery' :
'🎵 YouTube Playlist Discovery';
const sourceLabel = isTidal ? 'Tidal' :
const sourceLabel = isMirrored ? (state.mirrored_source ? state.mirrored_source.charAt(0).toUpperCase() + state.mirrored_source.slice(1) : 'Source') :
isTidal ? 'Tidal' :
isBeatport ? 'Beatport' :
isListenBrainz ? 'LB' :
'YT';
@ -21677,7 +21757,7 @@ function openYouTubeDiscoveryModal(urlHash) {
<div class="modal-header">
<h2>${modalTitle}</h2>
<div class="modal-subtitle">${state.playlist.name} (${state.playlist.tracks.length} tracks)</div>
<div class="modal-description">${getModalDescription(state.phase, isTidal, isBeatport, isListenBrainz)}</div>
<div class="modal-description">${getModalDescription(state.phase, isTidal, isBeatport, isListenBrainz, isMirrored)}</div>
<button class="modal-close-btn" onclick="closeYouTubeDiscoveryModal('${urlHash}')"></button>
</div>
@ -21850,6 +21930,8 @@ function getModalActionButtons(urlHash, phase, state = null) {
}
case 'discovered':
case 'downloading':
case 'download_complete':
// Only show buttons if we actually have discovery data
if (!hasDiscoveryResults) {
return `<div class="modal-info">⚠️ No discovery results available. Try starting discovery again.</div>`;
@ -21988,14 +22070,16 @@ function getModalActionButtons(urlHash, phase, state = null) {
}
}
function getModalDescription(phase, isTidal = false, isBeatport = false, isListenBrainz = false) {
const source = isListenBrainz ? 'ListenBrainz' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube'));
function getModalDescription(phase, isTidal = false, isBeatport = false, isListenBrainz = false, isMirrored = false) {
const source = isMirrored ? 'mirrored' : (isListenBrainz ? 'ListenBrainz' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube')));
switch (phase) {
case 'fresh':
return `Ready to discover clean ${currentMusicSourceName} metadata for ${source} tracks...`;
case 'discovering':
return `Discovering clean ${currentMusicSourceName} metadata for ${source} tracks...`;
case 'discovered':
case 'downloading':
case 'download_complete':
return 'Discovery complete! View the results below.';
default:
return `Discovering clean ${currentMusicSourceName} metadata for ${source} tracks...`;
@ -22009,6 +22093,8 @@ function getInitialProgressText(phase, isTidal = false, isBeatport = false, isLi
case 'discovering':
return 'Starting discovery...';
case 'discovered':
case 'downloading':
case 'download_complete':
return 'Discovery completed!';
default:
return 'Starting discovery...';
@ -22019,7 +22105,8 @@ function generateTableRowsFromState(state, urlHash) {
const isTidal = state.is_tidal_playlist;
const isBeatport = state.is_beatport_playlist;
const isListenBrainz = state.is_listenbrainz_playlist;
const platform = isListenBrainz ? 'listenbrainz' : (isTidal ? 'tidal' : (isBeatport ? 'beatport' : 'youtube'));
const isMirrored = state.is_mirrored_playlist;
const platform = isMirrored ? 'mirrored' : (isListenBrainz ? 'listenbrainz' : (isTidal ? 'tidal' : (isBeatport ? 'beatport' : 'youtube')));
// Support both camelCase and snake_case
const discoveryResults = state.discoveryResults || state.discovery_results;
@ -22163,9 +22250,10 @@ function updateYouTubeDiscoveryModal(urlHash, status) {
// Update actions cell with appropriate button
if (actionsCell) {
const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash];
const platform = state?.is_listenbrainz_playlist ? 'listenbrainz' :
const platform = state?.is_mirrored_playlist ? 'mirrored' :
(state?.is_listenbrainz_playlist ? 'listenbrainz' :
(state?.is_tidal_playlist ? 'tidal' :
(state?.is_beatport_playlist ? 'beatport' : 'youtube'));
(state?.is_beatport_playlist ? 'beatport' : 'youtube')));
actionsCell.innerHTML = generateDiscoveryActionButton(result, urlHash, platform);
}
});
@ -41447,6 +41535,549 @@ function importPageClearFinishedJobs() {
_importQueueRender();
}
// ── Mirrored Playlists ────────────────────────────────────────────────
let mirroredPlaylistsLoaded = false;
/**
* Fire-and-forget helper: send parsed playlist data to be mirrored on the backend.
*/
function mirrorPlaylist(source, sourceId, name, tracks, metadata = {}) {
const normalizedTracks = tracks.map(t => ({
track_name: t.track_name || t.name || '',
artist_name: t.artist_name || (Array.isArray(t.artists) ? (typeof t.artists[0] === 'object' ? t.artists[0].name : t.artists[0]) : t.artists || ''),
album_name: t.album_name || (typeof t.album === 'object' ? (t.album && t.album.name) : t.album) || '',
duration_ms: t.duration_ms || 0,
image_url: t.image_url || (t.album && typeof t.album === 'object' && t.album.images && t.album.images[0] ? t.album.images[0].url : null),
source_track_id: t.source_track_id || t.id || t.spotify_track_id || '',
extra_data: t.extra_data || null
}));
fetch('/api/mirror-playlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source,
source_playlist_id: String(sourceId),
name,
tracks: normalizedTracks,
description: metadata.description || '',
owner: metadata.owner || '',
image_url: metadata.image_url || ''
})
}).then(r => r.json()).then(data => {
if (data.success) console.log(`Mirrored ${source} playlist: ${name} (${normalizedTracks.length} tracks)`);
}).catch(err => console.warn('Mirror save failed:', err));
}
/**
* Load and render all mirrored playlists into the Mirrored tab.
*/
async function loadMirroredPlaylists() {
const container = document.getElementById('mirrored-playlist-container');
if (!container) return;
container.innerHTML = `<div class="playlist-placeholder">Loading mirrored playlists...</div>`;
try {
const res = await fetch('/api/mirrored-playlists');
const playlists = await res.json();
if (playlists.error) throw new Error(playlists.error);
if (!playlists.length) {
container.innerHTML = `<div class="playlist-placeholder">Playlists you parse from any service will appear here as persistent backups.</div>`;
return;
}
container.innerHTML = '';
playlists.forEach(p => renderMirroredCard(p, container));
mirroredPlaylistsLoaded = true;
// Hydrate discovery states from backend (survives page refresh)
await hydrateMirroredDiscoveryStates();
} catch (err) {
container.innerHTML = `<div class="playlist-placeholder">Error loading mirrored playlists: ${err.message}</div>`;
}
}
function renderMirroredCard(p, container) {
const ago = timeAgo(p.updated_at || p.mirrored_at);
const hash = `mirrored_${p.id}`;
const state = youtubePlaylistStates[hash];
const phase = state ? state.phase : null;
// Build phase indicator
let phaseHtml = '';
if (phase === 'discovering') {
const pct = state.discoveryProgress || state.discovery_progress || 0;
phaseHtml = `<span style="color:#a78bfa;">Discovering ${pct}%</span>`;
} else if (phase === 'discovered') {
const matches = state.spotifyMatches || state.spotify_matches || 0;
const total = state.spotify_total || p.track_count;
phaseHtml = `<span style="color:#22c55e;">Discovered ${matches}/${total}</span>`;
} else if (phase === 'syncing' || phase === 'sync_complete') {
phaseHtml = `<span style="color:#3b82f6;">${phase === 'syncing' ? 'Syncing...' : 'Synced'}</span>`;
} else if (phase === 'downloading') {
phaseHtml = `<span style="color:#f59e0b;">Downloading...</span>`;
} else if (phase === 'download_complete') {
phaseHtml = `<span style="color:#22c55e;">Downloaded</span>`;
}
const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛' };
const srcIcon = sourceIcons[p.source] || '📋';
const card = document.createElement('div');
card.className = 'mirrored-playlist-card';
card.id = `mirrored-card-${p.id}`;
card.innerHTML = `
<div class="source-icon ${_escAttr(p.source)}">${srcIcon}</div>
<div class="mirrored-card-info">
<div class="card-name">${_esc(p.name)}</div>
<div class="card-meta">
<span class="source-badge ${_escAttr(p.source)}">${_esc(p.source)}</span>
<span>${p.track_count} tracks</span>
<span>Mirrored ${ago}</span>
${phaseHtml}
</div>
</div>
<button class="mirrored-card-delete" onclick="event.stopPropagation(); deleteMirroredPlaylist(${p.id}, '${_escAttr(p.name)}')" title="Delete mirror"></button>
`;
card.addEventListener('click', () => {
const st = youtubePlaylistStates[hash];
if (st && st.phase && st.phase !== 'fresh') {
if (st.phase === 'downloading' || st.phase === 'download_complete') {
// Open download modal directly (follows Tidal/YouTube card click pattern)
const spotifyPlaylistId = st.convertedSpotifyPlaylistId;
if (spotifyPlaylistId && activeDownloadProcesses[spotifyPlaylistId]) {
// Modal already exists — just show it
const process = activeDownloadProcesses[spotifyPlaylistId];
if (process.modalElement) {
if (process.status === 'complete') {
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
}
process.modalElement.style.display = 'flex';
}
} else if (spotifyPlaylistId) {
// Need to rehydrate the download modal
rehydrateMirroredDownloadModal(hash, st);
} else {
// No converted playlist ID yet, fall back to discovery modal
openYouTubeDiscoveryModal(hash);
}
} else {
openYouTubeDiscoveryModal(hash);
if (st.phase === 'discovering' && !activeYouTubePollers[hash]) {
startYouTubeDiscoveryPolling(hash);
}
}
} else {
openMirroredPlaylistModal(p.id);
}
});
container.appendChild(card);
}
function updateMirroredCardPhase(urlHash, phase) {
// Update the state phase (updateYouTubeCardPhase skips this for mirrored playlists due to no cardElement)
const state = youtubePlaylistStates[urlHash];
if (state) state.phase = phase;
// Extract the numeric ID from urlHash (e.g., 'mirrored_3' → '3')
const mirroredId = urlHash.replace('mirrored_', '');
const card = document.getElementById(`mirrored-card-${mirroredId}`);
if (!card) return;
const metaEl = card.querySelector('.card-meta');
if (!metaEl) return;
// Remove old phase indicator
const oldPhase = metaEl.querySelector('span[style]');
if (oldPhase) oldPhase.remove();
// Add new phase indicator
let phaseHtml = '';
switch (phase) {
case 'discovering':
phaseHtml = `<span style="color:#a78bfa;">Discovering...</span>`;
break;
case 'discovered':
const matches = state?.spotifyMatches || state?.spotify_matches || 0;
const total = state?.spotify_total || 0;
phaseHtml = `<span style="color:#22c55e;">Discovered ${matches}/${total}</span>`;
break;
case 'syncing':
phaseHtml = `<span style="color:#3b82f6;">Syncing...</span>`;
break;
case 'sync_complete':
phaseHtml = `<span style="color:#3b82f6;">Synced</span>`;
break;
case 'downloading':
phaseHtml = `<span style="color:#f59e0b;">Downloading...</span>`;
break;
case 'download_complete':
phaseHtml = `<span style="color:#22c55e;">Downloaded</span>`;
break;
}
if (phaseHtml) {
metaEl.insertAdjacentHTML('beforeend', phaseHtml);
}
}
async function rehydrateMirroredDownloadModal(urlHash, state) {
try {
if (!state || !state.playlist) {
showToast('Cannot open download modal - invalid playlist data', 'error');
return;
}
console.log(`💧 [Rehydration] Rehydrating mirrored download modal for: ${state.playlist.name}`);
// Get discovery results from backend if not already loaded
let discoveryRes = state.discoveryResults || state.discovery_results;
if (!discoveryRes || discoveryRes.length === 0) {
console.log(`🔍 Fetching discovery results from backend for mirrored playlist: ${urlHash}`);
const stateResponse = await fetch(`/api/youtube/state/${urlHash}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
state.discovery_results = fullState.discovery_results;
state.discoveryResults = fullState.discovery_results;
state.convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id;
state.download_process_id = fullState.download_process_id;
discoveryRes = fullState.discovery_results;
console.log(`✅ Loaded ${discoveryRes?.length || 0} discovery results from backend`);
} else {
showToast('Error loading playlist data', 'error');
return;
}
}
// Extract Spotify tracks from discovery results
const spotifyTracks = (discoveryRes || [])
.filter(r => r.spotify_data || (r.spotify_track && r.status_class === 'found'))
.map(r => {
if (r.spotify_data) return r.spotify_data;
const albumData = r.spotify_album || 'Unknown Album';
return {
id: r.spotify_id || 'unknown',
name: r.spotify_track || 'Unknown Track',
artists: r.spotify_artist ? [r.spotify_artist] : ['Unknown Artist'],
album: typeof albumData === 'object' ? albumData : { name: albumData, album_type: 'album', images: [] },
duration_ms: 0
};
});
if (spotifyTracks.length === 0) {
showToast('No Spotify matches found for download', 'error');
return;
}
const virtualPlaylistId = state.convertedSpotifyPlaylistId;
const playlistName = state.playlist.name;
// Create the download modal
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
// If we have a download process ID, set up the modal for the running/complete state
if (state.download_process_id) {
const process = activeDownloadProcesses[virtualPlaylistId];
if (process) {
process.status = state.phase === 'download_complete' ? 'complete' : 'running';
process.batchId = state.download_process_id;
const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
if (state.phase === 'downloading') {
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Start polling for live updates
startModalDownloadPolling(virtualPlaylistId);
console.log(`🔄 Started polling for active mirrored download: ${state.download_process_id}`);
} else if (state.phase === 'download_complete') {
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'none';
console.log(`✅ Showing completed mirrored download results: ${state.download_process_id}`);
// Fetch final results to populate the modal
try {
const response = await fetch(`/api/playlists/${state.download_process_id}/download_status`);
if (response.ok) {
const data = await response.json();
if (data.phase === 'complete' && data.tasks) {
updateCompletedModalResults(virtualPlaylistId, data);
}
}
} catch (err) {
console.warn('Could not load completed download results:', err);
}
}
}
}
console.log(`✅ Successfully rehydrated mirrored download modal for: ${state.playlist.name}`);
} catch (error) {
console.error('❌ Error rehydrating mirrored download modal:', error);
showToast('Error opening download modal', 'error');
}
}
async function hydrateMirroredDiscoveryStates() {
try {
const res = await fetch('/api/mirrored-playlists/discovery-states');
const data = await res.json();
if (data.error || !data.states || data.states.length === 0) return;
console.log(`Hydrating ${data.states.length} mirrored discovery states`);
for (const s of data.states) {
const hash = s.url_hash;
youtubePlaylistStates[hash] = {
playlist: s.playlist,
phase: s.phase,
discovery_results: s.discovery_results || [],
discoveryResults: s.discovery_results || [],
discovery_progress: s.discovery_progress || 0,
discoveryProgress: s.discovery_progress || 0,
spotify_matches: s.spotify_matches || 0,
spotifyMatches: s.spotify_matches || 0,
spotify_total: s.spotify_total || 0,
status: s.status || '',
url: s.playlist?.url || '',
sync_playlist_id: null,
converted_spotify_playlist_id: s.converted_spotify_playlist_id,
convertedSpotifyPlaylistId: s.converted_spotify_playlist_id,
download_process_id: s.download_process_id,
created_at: Date.now() / 1000,
last_accessed: Date.now() / 1000,
discovery_future: null,
sync_progress: {},
is_mirrored_playlist: true,
mirrored_source: s.playlist?.source || ''
};
// Update the card to reflect the current phase
const card = document.getElementById(`mirrored-card-${s.playlist_id}`);
if (card) {
const metaEl = card.querySelector('.card-meta');
if (metaEl) {
// Remove old phase span and add new one
const oldPhase = metaEl.querySelector('span[style]');
if (oldPhase) oldPhase.remove();
if (s.phase === 'discovering') {
metaEl.insertAdjacentHTML('beforeend', `<span style="color:#a78bfa;">Discovering ${s.discovery_progress || 0}%</span>`);
} else if (s.phase === 'discovered') {
metaEl.insertAdjacentHTML('beforeend', `<span style="color:#22c55e;">Discovered ${s.spotify_matches || 0}/${s.spotify_total || 0}</span>`);
} else if (s.phase === 'syncing' || s.phase === 'sync_complete') {
metaEl.insertAdjacentHTML('beforeend', `<span style="color:#3b82f6;">${s.phase === 'syncing' ? 'Syncing...' : 'Synced'}</span>`);
} else if (s.phase === 'downloading') {
metaEl.insertAdjacentHTML('beforeend', `<span style="color:#f59e0b;">Downloading...</span>`);
} else if (s.phase === 'download_complete') {
metaEl.insertAdjacentHTML('beforeend', `<span style="color:#22c55e;">Downloaded</span>`);
}
}
}
// Resume polling if discovery is in progress
if (s.phase === 'discovering' && !activeYouTubePollers[hash]) {
startYouTubeDiscoveryPolling(hash);
}
}
} catch (err) {
console.warn('Failed to hydrate mirrored discovery states:', err);
}
}
function timeAgo(dateStr) {
if (!dateStr) return '';
const diff = Date.now() - new Date(dateStr + (dateStr.includes('Z') ? '' : 'Z')).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 30) return `${days}d ago`;
return `${Math.floor(days / 30)}mo ago`;
}
/**
* Open modal showing all tracks in a mirrored playlist.
*/
async function openMirroredPlaylistModal(playlistId) {
showLoadingOverlay('Loading mirrored playlist...');
try {
const res = await fetch(`/api/mirrored-playlists/${playlistId}`);
const data = await res.json();
if (data.error) throw new Error(data.error);
hideLoadingOverlay();
// Remove any existing modal
const old = document.getElementById('mirrored-track-modal');
if (old) old.remove();
const overlay = document.createElement('div');
overlay.id = 'mirrored-track-modal';
overlay.className = 'mirrored-modal-overlay';
const tracks = data.tracks || [];
const source = data.source || 'unknown';
const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛' };
const sourceIcon = sourceIcons[source] || '📋';
const trackRows = tracks.map(t => {
const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : '';
return `<div class="mirrored-track-row">
<span class="track-pos">${t.position}</span>
<span class="track-title">${_esc(t.track_name)}</span>
<span class="track-artist">${_esc(t.artist_name)}</span>
<span class="track-album">${_esc(t.album_name)}</span>
<span class="track-duration">${dur}</span>
</div>`;
}).join('');
overlay.innerHTML = `
<div class="mirrored-modal">
<div class="mirrored-modal-header">
<div class="mirrored-modal-hero">
<div class="mirrored-modal-hero-icon ${_escAttr(source)}">${sourceIcon}</div>
<div class="mirrored-modal-hero-info">
<h2 class="mirrored-modal-hero-title">${_esc(data.name)}</h2>
<div class="mirrored-modal-hero-subtitle">
<span class="mirrored-modal-hero-badge">${_esc(source)}</span>
<span>${tracks.length} tracks</span>
<span>&middot;</span>
<span>Mirrored ${timeAgo(data.updated_at || data.mirrored_at)}</span>
</div>
</div>
</div>
<span class="mirrored-modal-close" onclick="closeMirroredModal()">&times;</span>
</div>
<div class="mirrored-modal-tracks">
<div class="mirrored-track-header">
<span>#</span><span>Track</span><span>Artist</span><span>Album</span><span style="text-align:right">Time</span>
</div>
${trackRows}
</div>
<div class="mirrored-modal-footer">
<div class="mirrored-modal-footer-left">
<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="closeMirroredModal()">Close</button>
<button class="mirrored-btn-discover" onclick="discoverMirroredPlaylist(${playlistId})">Discover</button>
</div>
</div>
</div>
`;
overlay.addEventListener('click', e => { if (e.target === overlay) closeMirroredModal(); });
document.body.appendChild(overlay);
} catch (err) {
hideLoadingOverlay();
showToast(`Error: ${err.message}`, 'error');
}
}
function closeMirroredModal() {
const m = document.getElementById('mirrored-track-modal');
if (m) m.remove();
}
/**
* Delete a mirrored playlist after confirmation.
*/
async function deleteMirroredPlaylist(playlistId, name) {
if (!confirm(`Delete mirrored playlist "${name}"?`)) return;
try {
const res = await fetch(`/api/mirrored-playlists/${playlistId}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
showToast(`Deleted mirror: ${name}`, 'success');
loadMirroredPlaylists();
} else {
showToast(data.error || 'Failed to delete', 'error');
}
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}
/**
* Launch the existing discovery modal for a mirrored playlist by creating
* a temporary entry in youtubePlaylistStates and reusing openYouTubeDiscoveryModal.
*/
async function discoverMirroredPlaylist(playlistId) {
closeMirroredModal();
const tempHash = `mirrored_${playlistId}`;
// If state already exists (discovery in progress or completed), just reopen the modal
const existingState = youtubePlaylistStates[tempHash];
if (existingState && existingState.phase !== 'fresh') {
openYouTubeDiscoveryModal(tempHash);
// Resume polling if discovery is in progress
if (existingState.phase === 'discovering' && !activeYouTubePollers[tempHash]) {
startYouTubeDiscoveryPolling(tempHash);
}
return;
}
showLoadingOverlay('Preparing discovery...');
try {
// Register the mirrored playlist on the backend so the YouTube discovery pipeline can find it
const prepRes = await fetch(`/api/mirrored-playlists/${playlistId}/prepare-discovery`, { method: 'POST' });
const prepData = await prepRes.json();
if (prepData.error) throw new Error(prepData.error);
// Also fetch the full data for the frontend state
const res = await fetch(`/api/mirrored-playlists/${playlistId}`);
const data = await res.json();
if (data.error) throw new Error(data.error);
hideLoadingOverlay();
// Build tracks in the format the discovery modal expects
const tracks = (data.tracks || []).map(t => ({
id: t.source_track_id || `mirrored_${t.id}`,
name: t.track_name,
artists: [t.artist_name],
album: t.album_name || '',
duration_ms: t.duration_ms || 0
}));
// Create state entry reusing the YouTube discovery infrastructure
youtubePlaylistStates[tempHash] = {
playlist: {
name: data.name,
tracks: tracks,
track_count: tracks.length
},
phase: 'fresh',
discovery_results: [],
discovery_progress: 0,
spotify_matches: 0,
spotify_total: tracks.length,
status: 'parsed',
url: `mirrored://${data.source}/${data.source_playlist_id}`,
sync_playlist_id: null,
converted_spotify_playlist_id: null,
download_process_id: null,
created_at: Date.now() / 1000,
last_accessed: Date.now() / 1000,
discovery_future: null,
sync_progress: {},
is_mirrored_playlist: true,
mirrored_source: data.source
};
openYouTubeDiscoveryModal(tempHash);
} catch (err) {
hideLoadingOverlay();
showToast(`Error: ${err.message}`, 'error');
}
}
// --- Helpers ---
function _esc(str) {

View file

@ -5797,6 +5797,482 @@ body {
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23ffffff"><path d="M2 6h20v2H2zm0 5h20v2H2zm0 5h20v2H2z"/></svg>');
}
.mirrored-icon {
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23a78bfa"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM7 17h2V7H7v10zm4 0h2V7h-2v10zm4 0h2V7h-2v10z"/></svg>');
}
.sync-tab-button[data-tab="mirrored"].active {
background: #a78bfa;
color: #fff;
box-shadow: 0 4px 15px rgba(167, 139, 250, 0.3);
}
.sync-tab-button.active .mirrored-icon {
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23ffffff"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM7 17h2V7H7v10zm4 0h2V7h-2v10zm4 0h2V7h-2v10z"/></svg>');
}
/* Mirrored playlist cards */
.mirrored-playlist-card {
background: linear-gradient(135deg, rgba(26, 26, 26, 0.95) 0%, rgba(18, 18, 18, 0.98) 100%);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-top: 1px solid rgba(255, 255, 255, 0.12);
margin: 10px 8px;
padding: 20px 22px;
cursor: pointer;
display: flex;
align-items: center;
gap: 16px;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.4),
0 2px 8px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.mirrored-playlist-card:hover {
background: linear-gradient(135deg, rgba(30, 30, 30, 0.98) 0%, rgba(22, 22, 22, 1.0) 100%);
border-color: rgba(var(--accent-rgb), 0.3);
border-top-color: rgba(var(--accent-rgb), 0.4);
transform: translateY(-3px) scale(1.01);
box-shadow:
0 16px 48px rgba(0, 0, 0, 0.5),
0 8px 16px rgba(0, 0, 0, 0.3),
0 0 20px rgba(var(--accent-rgb), 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.15);
}
.mirrored-playlist-card .source-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.mirrored-playlist-card .source-icon.spotify {
background: linear-gradient(135deg, rgba(29, 185, 84, 0.2) 0%, rgba(29, 185, 84, 0.08) 100%);
border-color: rgba(29, 185, 84, 0.25);
color: #1db954;
}
.mirrored-playlist-card .source-icon.tidal {
background: linear-gradient(135deg, rgba(255, 102, 0, 0.2) 0%, rgba(255, 102, 0, 0.08) 100%);
border-color: rgba(255, 102, 0, 0.25);
color: #ff6600;
}
.mirrored-playlist-card .source-icon.youtube {
background: linear-gradient(135deg, rgba(255, 0, 0, 0.2) 0%, rgba(255, 0, 0, 0.08) 100%);
border-color: rgba(255, 0, 0, 0.25);
color: #ff4444;
}
.mirrored-playlist-card .source-icon.beatport {
background: linear-gradient(135deg, rgba(1, 255, 149, 0.2) 0%, rgba(1, 255, 149, 0.08) 100%);
border-color: rgba(1, 255, 149, 0.25);
color: #01ff95;
}
.mirrored-playlist-card .source-badge {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 3px 8px;
border-radius: 6px;
color: #fff;
display: inline-block;
}
.mirrored-playlist-card .source-badge.spotify { background: #1db954; }
.mirrored-playlist-card .source-badge.tidal { background: #ff6600; }
.mirrored-playlist-card .source-badge.youtube { background: #ff0000; }
.mirrored-playlist-card .source-badge.beatport { background: #01ff95; color: #000; }
.mirrored-card-info {
flex: 1;
min-width: 0;
}
.mirrored-card-info .card-name {
font-size: 16px;
font-weight: 600;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: -0.3px;
line-height: 1.3;
}
.mirrored-card-info .card-meta {
font-size: 13px;
color: #888;
margin-top: 6px;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.mirrored-card-info .card-meta span {
display: inline-flex;
align-items: center;
}
.mirrored-card-info .card-meta span + span::before {
content: '\00b7';
margin-right: 8px;
color: #555;
}
.mirrored-card-info .card-meta span[style]::before {
content: none;
margin-right: 0;
}
.mirrored-card-info .card-meta span[style] {
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.3px;
}
.mirrored-card-delete {
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-delete {
opacity: 1;
}
.mirrored-card-delete:hover {
color: #ff4444;
background: rgba(255, 68, 68, 0.15);
border-color: rgba(255, 68, 68, 0.3);
transform: scale(1.1);
}
/* Mirrored playlist track modal */
.mirrored-modal-overlay {
position: fixed;
inset: 0;
background: rgba(18, 18, 18, 0.85);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(12px);
animation: mirroredOverlayFadeIn 0.3s ease forwards;
}
@keyframes mirroredOverlayFadeIn {
from { opacity: 0; backdrop-filter: blur(0px); }
to { opacity: 1; backdrop-filter: blur(12px); }
}
@keyframes mirroredModalSlideIn {
from { opacity: 0; transform: scale(0.92) translateY(20px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.mirrored-modal {
background: linear-gradient(135deg, rgba(20, 20, 20, 0.95) 0%, rgba(12, 12, 12, 0.98) 100%);
backdrop-filter: blur(20px) saturate(1.2);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-top: 1px solid rgba(255, 255, 255, 0.18);
width: 90%;
max-width: 950px;
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow:
0 25px 80px rgba(0, 0, 0, 0.7),
0 8px 32px rgba(0, 0, 0, 0.4),
0 0 40px rgba(var(--accent-rgb), 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.15);
animation: mirroredModalSlideIn 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.mirrored-modal-header {
background: linear-gradient(135deg, rgba(45, 45, 45, 0.8) 0%, rgba(26, 26, 26, 0.9) 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding: 0;
position: relative;
overflow: hidden;
border-top-left-radius: 20px;
border-top-right-radius: 20px;
}
.mirrored-modal-hero {
display: flex;
align-items: center;
padding: 28px 32px;
padding-right: 60px;
gap: 20px;
position: relative;
z-index: 2;
min-height: 110px;
}
.mirrored-modal-hero-icon {
width: 72px;
height: 72px;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(167, 139, 250, 0.2) 0%, rgba(167, 139, 250, 0.08) 100%);
border: 1px solid rgba(167, 139, 250, 0.3);
box-shadow: 0 8px 24px rgba(167, 139, 250, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.1);
flex-shrink: 0;
color: #a78bfa;
}
.mirrored-modal-hero-icon.spotify { background: linear-gradient(135deg, rgba(29, 185, 84, 0.2) 0%, rgba(29, 185, 84, 0.08) 100%); border-color: rgba(29, 185, 84, 0.3); box-shadow: 0 8px 24px rgba(29, 185, 84, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.1); color: #1db954; }
.mirrored-modal-hero-icon.tidal { background: linear-gradient(135deg, rgba(255, 102, 0, 0.2) 0%, rgba(255, 102, 0, 0.08) 100%); border-color: rgba(255, 102, 0, 0.3); box-shadow: 0 8px 24px rgba(255, 102, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.1); color: #ff6600; }
.mirrored-modal-hero-icon.youtube { background: linear-gradient(135deg, rgba(255, 0, 0, 0.2) 0%, rgba(255, 0, 0, 0.08) 100%); border-color: rgba(255, 0, 0, 0.3); box-shadow: 0 8px 24px rgba(255, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.1); color: #ff4444; }
.mirrored-modal-hero-icon.beatport { background: linear-gradient(135deg, rgba(1, 255, 149, 0.2) 0%, rgba(1, 255, 149, 0.08) 100%); border-color: rgba(1, 255, 149, 0.3); box-shadow: 0 8px 24px rgba(1, 255, 149, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.1); color: #01ff95; }
.mirrored-modal-hero-info {
flex: 1;
min-width: 0;
}
.mirrored-modal-hero-title {
color: #ffffff;
font-size: 22px;
font-weight: 700;
margin: 0;
line-height: 1.3;
letter-spacing: -0.5px;
font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.mirrored-modal-hero-subtitle {
color: #b3b3b3;
font-size: 14px;
margin-top: 6px;
display: flex;
align-items: center;
gap: 10px;
}
.mirrored-modal-hero-badge {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 3px 10px;
border-radius: 6px;
color: rgb(var(--accent-light-rgb));
background: rgba(var(--accent-rgb), 0.1);
border: 1px solid rgba(var(--accent-rgb), 0.2);
}
.mirrored-modal-close {
position: absolute;
top: 24px;
right: 24px;
z-index: 3;
color: #b3b3b3;
font-size: 18px;
font-weight: 300;
cursor: pointer;
transition: all 0.2s ease;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
}
.mirrored-modal-close:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.15);
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.mirrored-modal-tracks {
flex: 1;
overflow-y: auto;
padding: 0;
background: #121212;
}
.mirrored-modal-tracks::-webkit-scrollbar { width: 8px; }
.mirrored-modal-tracks::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.05); }
.mirrored-modal-tracks::-webkit-scrollbar-thumb { background: rgba(var(--accent-rgb), 0.3); border-radius: 4px; }
.mirrored-modal-tracks::-webkit-scrollbar-thumb:hover { background: rgba(var(--accent-rgb), 0.5); }
.mirrored-track-header {
display: grid;
grid-template-columns: 40px 1.6fr 1.4fr 1fr 56px;
align-items: center;
padding: 10px 28px;
font-size: 11px;
font-weight: 600;
color: #666;
text-transform: uppercase;
letter-spacing: 0.8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
position: sticky;
top: 0;
background: #121212;
z-index: 1;
}
.mirrored-track-row {
display: grid;
grid-template-columns: 40px 1.6fr 1.4fr 1fr 56px;
align-items: center;
padding: 11px 28px;
font-size: 13px;
color: #b3b3b3;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
}
.mirrored-track-row:hover {
background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.06) 0%, rgba(var(--accent-rgb), 0.02) 100%);
transform: translateX(2px);
}
.mirrored-track-row .track-pos {
color: #555;
text-align: center;
font-size: 12px;
font-weight: 500;
}
.mirrored-track-row:hover .track-pos {
color: rgb(var(--accent-light-rgb));
}
.mirrored-track-row .track-title {
color: #fff;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-right: 12px;
}
.mirrored-track-row .track-artist {
width: auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-right: 12px;
}
.mirrored-track-row .track-album {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-right: 12px;
color: #777;
}
.mirrored-track-row .track-duration {
text-align: right;
color: #666;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.mirrored-modal-footer {
background: linear-gradient(135deg, rgba(42, 42, 42, 0.9) 0%, rgba(30, 30, 30, 0.95) 100%);
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding: 18px 32px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.mirrored-modal-footer button {
padding: 10px 20px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
min-width: 100px;
}
.mirrored-btn-close {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.15) !important;
}
.mirrored-btn-close:hover {
background: rgba(255, 255, 255, 0.18);
color: #ffffff;
border-color: rgba(255, 255, 255, 0.25) !important;
transform: translateY(-1px);
}
.mirrored-btn-discover {
background: linear-gradient(135deg, rgb(var(--accent-rgb)) 0%, rgb(var(--accent-light-rgb)) 100%);
color: #000;
box-shadow: 0 4px 16px rgba(var(--accent-rgb), 0.3);
border: 1px solid rgba(var(--accent-rgb), 0.3) !important;
}
.mirrored-btn-discover:hover {
transform: translateY(-1px);
box-shadow: 0 8px 24px rgba(var(--accent-rgb), 0.4);
}
.mirrored-btn-delete {
background: rgba(244, 67, 54, 0.12);
color: #ef5350;
border: 1px solid rgba(244, 67, 54, 0.2) !important;
}
.mirrored-btn-delete:hover {
background: rgba(244, 67, 54, 0.2);
color: #ff6659;
border-color: rgba(244, 67, 54, 0.35) !important;
transform: translateY(-1px);
}
.sync-tab-content {
display: none;