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:
parent
b2fa222228
commit
b30e1f60bd
5 changed files with 1440 additions and 14 deletions
|
|
@ -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()
|
||||
|
|
|
|||
163
web_server.py
163
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/<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 = []
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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>·</span>
|
||||
<span>Mirrored ${timeAgo(data.updated_at || data.mirrored_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="mirrored-modal-close" onclick="closeMirroredModal()">×</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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue