From b30e1f60bd82e69c91304ed843389c9dcd59118f Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:27:18 -0800 Subject: [PATCH] =?UTF-8?q?=20Add=20Mirrored=20Playlists=20=E2=80=94=20per?= =?UTF-8?q?sistent=20cross-service=20playlist=20archive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- database/music_database.py | 144 ++++++++ web_server.py | 163 ++++++++- webui/index.html | 14 + webui/static/script.js | 657 ++++++++++++++++++++++++++++++++++++- webui/static/style.css | 476 +++++++++++++++++++++++++++ 5 files changed, 1440 insertions(+), 14 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index 1f469c0f..da3fe78a 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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() diff --git a/web_server.py b/web_server.py index b28df4c6..bc797e7f 100644 --- a/web_server.py +++ b/web_server.py @@ -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/', 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/', 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//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 = [] diff --git a/webui/index.html b/webui/index.html index 9f502a5b..fbf5917b 100644 --- a/webui/index.html +++ b/webui/index.html @@ -765,6 +765,9 @@ + @@ -1489,6 +1492,17 @@ + + +
+
+

Mirrored Playlists

+ +
+
+
Playlists you parse from any service will appear here as persistent backups.
+
+
diff --git a/webui/static/script.js b/webui/static/script.js index 71bbe046..3f05ad07 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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) { @@ -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 ``; @@ -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 = `
Loading mirrored playlists...
`; + + 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 = `
Playlists you parse from any service will appear here as persistent backups.
`; + 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 = `
Error loading mirrored playlists: ${err.message}
`; + } +} + +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 = `Discovering ${pct}%`; + } else if (phase === 'discovered') { + const matches = state.spotifyMatches || state.spotify_matches || 0; + const total = state.spotify_total || p.track_count; + phaseHtml = `Discovered ${matches}/${total}`; + } else if (phase === 'syncing' || phase === 'sync_complete') { + phaseHtml = `${phase === 'syncing' ? 'Syncing...' : 'Synced'}`; + } else if (phase === 'downloading') { + phaseHtml = `Downloading...`; + } else if (phase === 'download_complete') { + phaseHtml = `Downloaded`; + } + + 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 = ` +
${srcIcon}
+
+
${_esc(p.name)}
+
+ ${_esc(p.source)} + ${p.track_count} tracks + Mirrored ${ago} + ${phaseHtml} +
+
+ + `; + 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 = `Discovering...`; + break; + case 'discovered': + const matches = state?.spotifyMatches || state?.spotify_matches || 0; + const total = state?.spotify_total || 0; + phaseHtml = `Discovered ${matches}/${total}`; + break; + case 'syncing': + phaseHtml = `Syncing...`; + break; + case 'sync_complete': + phaseHtml = `Synced`; + break; + case 'downloading': + phaseHtml = `Downloading...`; + break; + case 'download_complete': + phaseHtml = `Downloaded`; + 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', `Discovering ${s.discovery_progress || 0}%`); + } else if (s.phase === 'discovered') { + metaEl.insertAdjacentHTML('beforeend', `Discovered ${s.spotify_matches || 0}/${s.spotify_total || 0}`); + } else if (s.phase === 'syncing' || s.phase === 'sync_complete') { + metaEl.insertAdjacentHTML('beforeend', `${s.phase === 'syncing' ? 'Syncing...' : 'Synced'}`); + } else if (s.phase === 'downloading') { + metaEl.insertAdjacentHTML('beforeend', `Downloading...`); + } else if (s.phase === 'download_complete') { + metaEl.insertAdjacentHTML('beforeend', `Downloaded`); + } + } + } + + // 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 `
+ ${t.position} + ${_esc(t.track_name)} + ${_esc(t.artist_name)} + ${_esc(t.album_name)} + ${dur} +
`; + }).join(''); + + overlay.innerHTML = ` +
+
+
+
${sourceIcon}
+
+

${_esc(data.name)}

+
+ ${_esc(source)} + ${tracks.length} tracks + · + Mirrored ${timeAgo(data.updated_at || data.mirrored_at)} +
+
+
+ × +
+
+
+ #TrackArtistAlbumTime +
+ ${trackRows} +
+ +
+ `; + + 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) { diff --git a/webui/static/style.css b/webui/static/style.css index a00c69bf..d71cb42a 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -5797,6 +5797,482 @@ body { background-image: url('data:image/svg+xml;charset=utf-8,'); } +.mirrored-icon { + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + +.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,'); +} + +/* 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;