pull/15/head
Broque Thomas 9 months ago
parent b6fc2f5f07
commit 252eb32bf9

@ -5538,6 +5538,320 @@ def get_playlist_tracks(playlist_id):
return jsonify({"error": str(e)}), 500
# ===================================================================
# TIDAL PLAYLIST API ENDPOINTS
# ===================================================================
@app.route('/api/tidal/playlists', methods=['GET'])
def get_tidal_playlists():
"""Fetches all user playlists from Tidal with full track data (like sync.py)."""
if not tidal_client or not tidal_client.is_authenticated():
return jsonify({"error": "Tidal not authenticated."}), 401
try:
# Use same method as sync.py - this already includes all track data
playlists = tidal_client.get_user_playlists_metadata_only()
playlist_data = []
for p in playlists:
# Get track count from actual tracks if available
track_count = len(p.tracks) if hasattr(p, 'tracks') and p.tracks else 0
playlist_dict = {
"id": p.id,
"name": p.name,
"owner": getattr(p, 'owner', 'Unknown'),
"track_count": track_count,
"image_url": getattr(p, 'image_url', None),
"description": getattr(p, 'description', ''),
"tracks": [] # Add tracks data like sync.py
}
# Include full track data if available (like sync.py has)
if hasattr(p, 'tracks') and p.tracks:
playlist_dict['tracks'] = [{
'id': t.id,
'name': t.name,
'artists': t.artists or [],
'album': getattr(t, 'album', 'Unknown Album'),
'duration_ms': getattr(t, 'duration_ms', 0),
'track_number': getattr(t, 'track_number', 0)
} for t in p.tracks]
playlist_data.append(playlist_dict)
print(f"🎵 Loaded {len(playlist_data)} Tidal playlists with track data")
return jsonify(playlist_data)
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/tidal/playlist/<playlist_id>', methods=['GET'])
def get_tidal_playlist_tracks(playlist_id):
"""Fetches full track details for a specific Tidal playlist (matches sync.py pattern)."""
if not tidal_client or not tidal_client.is_authenticated():
return jsonify({"error": "Tidal not authenticated."}), 401
try:
print(f"🎵 Getting full Tidal playlist with tracks for: {playlist_id}")
# First check if this playlist exists in metadata list
try:
metadata_playlists = tidal_client.get_user_playlists_metadata_only()
target_playlist = None
for p in metadata_playlists:
if p.id == playlist_id:
target_playlist = p
break
if not target_playlist:
print(f"❌ Playlist {playlist_id} not found in user's Tidal playlists")
return jsonify({"error": "Playlist not found in your Tidal library"}), 404
print(f"🎵 Found playlist in metadata: {target_playlist.name}")
except Exception as e:
print(f"❌ Error checking playlist metadata: {e}")
# Use same method as sync.py: tidal_client.get_playlist(playlist_id)
full_playlist = tidal_client.get_playlist(playlist_id)
if not full_playlist:
return jsonify({"error": "Unable to access this Tidal playlist. This may be due to privacy settings or Tidal API restrictions. Please try a different playlist."}), 403
if not full_playlist.tracks:
return jsonify({"error": "This playlist appears to have no tracks or they cannot be accessed"}), 403
print(f"🎵 Loaded {len(full_playlist.tracks)} tracks from Tidal playlist: {full_playlist.name}")
# Convert playlist to dict (matches sync.py structure)
playlist_dict = {
'id': full_playlist.id,
'name': full_playlist.name,
'description': getattr(full_playlist, 'description', ''),
'owner': getattr(full_playlist, 'owner', 'Unknown'),
'track_count': len(full_playlist.tracks),
'image_url': getattr(full_playlist, 'image_url', None),
'tracks': []
}
# Convert tracks to dict format (for discovery modal)
playlist_dict['tracks'] = [{
'id': t.id,
'name': t.name,
'artists': t.artists or [],
'album': getattr(t, 'album', 'Unknown Album'),
'duration_ms': getattr(t, 'duration_ms', 0),
'track_number': getattr(t, 'track_number', 0)
} for t in full_playlist.tracks]
return jsonify(playlist_dict)
except Exception as e:
print(f"❌ Error getting Tidal playlist tracks: {e}")
return jsonify({"error": str(e)}), 500
# ===================================================================
# TIDAL DISCOVERY API ENDPOINTS
# ===================================================================
# Global state for Tidal playlist discovery management
tidal_discovery_states = {} # Key: playlist_id, Value: discovery state
tidal_discovery_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="tidal_discovery")
@app.route('/api/tidal/discovery/start/<playlist_id>', methods=['POST'])
def start_tidal_discovery(playlist_id):
"""Start Spotify discovery process for a Tidal playlist"""
try:
# Get playlist data from the initial load
if not tidal_client or not tidal_client.is_authenticated():
return jsonify({"error": "Tidal not authenticated."}), 401
# Get playlist from tidal client
playlists = tidal_client.get_user_playlists_metadata_only()
target_playlist = None
for p in playlists:
if p.id == playlist_id:
target_playlist = p
break
if not target_playlist:
return jsonify({"error": "Tidal playlist not found"}), 404
if not target_playlist.tracks:
return jsonify({"error": "Playlist has no tracks"}), 400
# Initialize or update discovery state
if playlist_id in tidal_discovery_states and tidal_discovery_states[playlist_id]['phase'] == 'discovering':
return jsonify({"error": "Discovery already in progress"}), 400
state = {
'playlist': target_playlist,
'phase': 'discovering',
'status': 'discovering',
'discovery_progress': 0,
'spotify_matches': 0,
'spotify_total': len(target_playlist.tracks),
'discovery_results': [],
'last_accessed': time.time()
}
tidal_discovery_states[playlist_id] = state
# Start discovery worker
future = tidal_discovery_executor.submit(_run_tidal_discovery_worker, playlist_id)
state['discovery_future'] = future
print(f"🔍 Started Spotify discovery for Tidal playlist: {target_playlist.name}")
return jsonify({"success": True, "message": "Discovery started"})
except Exception as e:
print(f"❌ Error starting Tidal discovery: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/tidal/discovery/status/<playlist_id>', methods=['GET'])
def get_tidal_discovery_status(playlist_id):
"""Get real-time discovery status for a Tidal playlist"""
try:
if playlist_id not in tidal_discovery_states:
return jsonify({"error": "Tidal discovery not found"}), 404
state = tidal_discovery_states[playlist_id]
state['last_accessed'] = time.time() # Update access time
response = {
'phase': state['phase'],
'status': state['status'],
'progress': state['discovery_progress'],
'spotify_matches': state['spotify_matches'],
'spotify_total': state['spotify_total'],
'results': state['discovery_results'],
'complete': state['phase'] == 'discovered'
}
return jsonify(response)
except Exception as e:
print(f"❌ Error getting Tidal discovery status: {e}")
return jsonify({"error": str(e)}), 500
def _run_tidal_discovery_worker(playlist_id):
"""Background worker for Tidal Spotify discovery process (like sync.py)"""
try:
state = tidal_discovery_states[playlist_id]
playlist = state['playlist']
print(f"🎵 Starting Tidal Spotify discovery for: {playlist.name}")
# Import matching engine for validation (like sync.py)
from core.matching_engine import MusicMatchingEngine
matching_engine = MusicMatchingEngine()
successful_discoveries = 0
for i, tidal_track in enumerate(playlist.tracks):
if state.get('cancelled', False):
break
try:
print(f"🔍 [{i+1}/{len(playlist.tracks)}] Searching: {tidal_track.name} by {', '.join(tidal_track.artists)}")
# Use the same search logic as sync.py TidalSpotifyDiscoveryWorker
spotify_track = _search_spotify_for_tidal_track(tidal_track)
# Create result entry
result = {
'tidal_track': {
'id': tidal_track.id,
'name': tidal_track.name,
'artists': tidal_track.artists or [],
'album': getattr(tidal_track, 'album', 'Unknown Album'),
'duration_ms': getattr(tidal_track, 'duration_ms', 0),
},
'spotify_data': None,
'status': 'not_found'
}
if spotify_track:
result['spotify_data'] = {
'id': spotify_track.id,
'name': spotify_track.name,
'artists': spotify_track.artists, # Already a list of strings
'album': spotify_track.album, # Already a string
'duration_ms': spotify_track.duration_ms,
'external_urls': spotify_track.external_urls
}
result['status'] = 'found'
successful_discoveries += 1
state['spotify_matches'] = successful_discoveries
state['discovery_results'].append(result)
state['discovery_progress'] = int(((i + 1) / len(playlist.tracks)) * 100)
# Add delay between requests (like sync.py)
time.sleep(0.1)
except Exception as e:
print(f"❌ Error processing track {i+1}: {e}")
# Add error result
result = {
'tidal_track': {
'name': tidal_track.name,
'artists': tidal_track.artists or [],
},
'spotify_data': None,
'status': 'error',
'error': str(e)
}
state['discovery_results'].append(result)
state['discovery_progress'] = int(((i + 1) / len(playlist.tracks)) * 100)
# Mark as complete
state['phase'] = 'discovered'
state['status'] = 'discovered'
state['discovery_progress'] = 100
print(f"✅ Tidal discovery complete: {successful_discoveries}/{len(playlist.tracks)} tracks found")
except Exception as e:
print(f"❌ Error in Tidal discovery worker: {e}")
state['phase'] = 'error'
state['status'] = f'error: {str(e)}'
def _search_spotify_for_tidal_track(tidal_track):
"""Search Spotify for a Tidal track (simplified version of sync.py logic)"""
if not spotify_client or not spotify_client.is_authenticated():
return None
try:
# Construct search query like sync.py does
track_name = tidal_track.name
artists = tidal_track.artists or []
if not artists:
return None
# Try different search combinations (like sync.py TidalSpotifyDiscoveryWorker)
search_queries = [
f'track:"{track_name}" artist:"{artists[0]}"',
f'"{track_name}" "{artists[0]}"',
f'{track_name} {artists[0]}'
]
for query in search_queries:
try:
results = spotify_client.search_tracks(query, limit=5)
if results and len(results) > 0:
# Return first match (could add matching logic like sync.py)
return results[0]
except Exception as e:
print(f"❌ Search error for query '{query}': {e}")
continue
return None
except Exception as e:
print(f"❌ Error searching Spotify for Tidal track: {e}")
return None
# ===================================================================
# YOUTUBE PLAYLIST API ENDPOINTS
# ===================================================================

@ -42,6 +42,11 @@ let sequentialSyncManager = null;
let youtubePlaylistStates = {}; // Key: url_hash, Value: playlist state
let activeYouTubePollers = {}; // Key: url_hash, Value: intervalId
// --- Tidal Playlist State Management (Similar to YouTube but loads from API like Spotify) ---
let tidalPlaylists = [];
let tidalPlaylistStates = {}; // Key: playlist_id, Value: playlist state with phases
let tidalPlaylistsLoaded = false;
// --- Wishlist Modal Persistence State Management ---
const WishlistModalState = {
// Track if wishlist modal was visible before page refresh
@ -6321,6 +6326,331 @@ function updateDbProgressUI(state) {
}
}
// ===================================================================
// TIDAL PLAYLIST MANAGEMENT (YouTube-style cards with Tidal colors)
// ===================================================================
async function loadTidalPlaylists() {
const container = document.getElementById('tidal-playlist-container');
const refreshBtn = document.getElementById('tidal-refresh-btn');
container.innerHTML = `<div class="playlist-placeholder">🔄 Loading Tidal playlists...</div>`;
refreshBtn.disabled = true;
refreshBtn.textContent = '🔄 Loading...';
try {
const response = await fetch('/api/tidal/playlists');
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch Tidal playlists');
}
tidalPlaylists = await response.json();
renderTidalPlaylists();
tidalPlaylistsLoaded = true;
console.log(`🎵 Loaded ${tidalPlaylists.length} Tidal playlists`);
} catch (error) {
container.innerHTML = `<div class="playlist-placeholder">❌ Error: ${error.message}</div>`;
showToast(`Error loading Tidal playlists: ${error.message}`, 'error');
} finally {
refreshBtn.disabled = false;
refreshBtn.textContent = '🔄 Refresh';
}
}
function renderTidalPlaylists() {
const container = document.getElementById('tidal-playlist-container');
if (tidalPlaylists.length === 0) {
container.innerHTML = `<div class="playlist-placeholder">No Tidal playlists found.</div>`;
return;
}
container.innerHTML = tidalPlaylists.map(p => {
// Initialize state if not exists (fresh state like sync.py)
if (!tidalPlaylistStates[p.id]) {
tidalPlaylistStates[p.id] = {
phase: 'fresh',
playlist: p
};
}
return createTidalCard(p);
}).join('');
// Add click handlers to cards
tidalPlaylists.forEach(p => {
const card = document.getElementById(`tidal-card-${p.id}`);
if (card) {
card.addEventListener('click', () => handleTidalCardClick(p.id));
}
});
}
function createTidalCard(playlist) {
const state = tidalPlaylistStates[playlist.id];
const phase = state.phase;
// Get phase-specific button text (like YouTube cards)
let buttonText = 'Start Discovery';
let phaseText = 'Ready to discover';
let phaseColor = '#999';
if (phase === 'discovering') {
buttonText = 'View Progress';
phaseText = 'Discovering...';
phaseColor = '#ff6600';
} else if (phase === 'discovered') {
buttonText = 'View Details';
phaseText = 'Discovery Complete';
phaseColor = '#1db954';
}
return `
<div class="youtube-playlist-card tidal-playlist-card" id="tidal-card-${playlist.id}">
<div class="playlist-card-icon">🎵</div>
<div class="playlist-card-content">
<div class="playlist-card-name">${escapeHtml(playlist.name)}</div>
<div class="playlist-card-info">
<span class="playlist-card-track-count">${playlist.track_count} tracks</span>
<span class="playlist-card-phase-text" style="color: ${phaseColor};">${phaseText}</span>
</div>
</div>
<button class="playlist-card-action-btn">${buttonText}</button>
</div>
`;
}
async function handleTidalCardClick(playlistId) {
const state = tidalPlaylistStates[playlistId];
if (!state) return;
console.log(`🎵 Tidal card clicked: ${playlistId}, Phase: ${state.phase}`);
if (state.phase === 'fresh') {
// No need to fetch data - we already have all tracks from initial load (like sync.py)
console.log(`🎵 Using pre-loaded Tidal playlist data for: ${state.playlist.name}`);
console.log(`🎵 Ready with ${state.playlist.tracks.length} Tidal tracks for discovery`);
// Update phase to discovering
state.phase = 'discovering';
// Update card to show discovering state
updateTidalCardPhase(playlistId, 'discovering');
// Open YouTube discovery modal but with Tidal data (exact sync.py pattern)
openTidalDiscoveryModal(playlistId, state.playlist);
} else if (state.phase === 'discovering' || state.phase === 'discovered') {
// Reopen existing modal (like sync.py)
openTidalDiscoveryModal(playlistId, state.playlist);
}
}
function updateTidalCardPhase(playlistId, phase) {
const state = tidalPlaylistStates[playlistId];
if (!state) return;
state.phase = phase;
// Re-render the card with new phase
const card = document.getElementById(`tidal-card-${playlistId}`);
if (card) {
const newCardHtml = createTidalCard(state.playlist);
card.outerHTML = newCardHtml;
// Re-attach click handler
const newCard = document.getElementById(`tidal-card-${playlistId}`);
if (newCard) {
newCard.addEventListener('click', () => handleTidalCardClick(playlistId));
}
}
console.log(`🎵 Updated Tidal card phase: ${playlistId} -> ${phase}`);
}
async function openTidalDiscoveryModal(playlistId, playlistData) {
console.log(`🎵 Opening Tidal discovery modal (reusing YouTube modal): ${playlistData.name}`);
// Create a fake YouTube-style urlHash for the modal system
const fakeUrlHash = `tidal_${playlistId}`;
// Get current Tidal card state to check if discovery is already done
const tidalCardState = tidalPlaylistStates[playlistId];
const isAlreadyDiscovered = tidalCardState && tidalCardState.phase === 'discovered';
// Prepare discovery results in the correct format for modal
let transformedResults = [];
if (isAlreadyDiscovered && tidalCardState.discovery_results) {
transformedResults = tidalCardState.discovery_results.map((result, index) => ({
index: index,
yt_track: result.tidal_track ? result.tidal_track.name : 'Unknown',
yt_artist: result.tidal_track ? (result.tidal_track.artists ? result.tidal_track.artists.join(', ') : 'Unknown') : 'Unknown',
status: result.status === 'found' ? '✅ Found' : '❌ Not Found',
status_class: result.status === 'found' ? 'found' : 'not-found',
spotify_track: result.spotify_data ? result.spotify_data.name : '-',
spotify_artist: result.spotify_data ? result.spotify_data.artists.join(', ') : '-',
spotify_album: result.spotify_data ? result.spotify_data.album : '-'
}));
}
// Create YouTube-compatible state structure
youtubePlaylistStates[fakeUrlHash] = {
phase: isAlreadyDiscovered ? 'discovered' : 'discovering',
playlist: {
name: playlistData.name,
tracks: playlistData.tracks
},
is_tidal_playlist: true, // Flag to identify this as Tidal
tidal_playlist_id: playlistId,
discovery_progress: isAlreadyDiscovered ? (tidalCardState.discovery_progress || 100) : 0,
spotify_matches: isAlreadyDiscovered ? (tidalCardState.spotify_matches || 0) : 0,
spotify_total: playlistData.tracks.length,
discovery_results: transformedResults,
discoveryResults: transformedResults // Both formats for compatibility
};
// Only start discovery if not already discovered
if (!isAlreadyDiscovered) {
// Start Tidal discovery process automatically (like sync.py)
try {
console.log(`🔍 Starting Tidal discovery for: ${playlistData.name}`);
const response = await fetch(`/api/tidal/discovery/start/${playlistId}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
console.error('❌ Error starting Tidal discovery:', result.error);
showToast(`Error starting discovery: ${result.error}`, 'error');
return;
}
console.log('✅ Tidal discovery started, beginning polling...');
// Start polling for progress
startTidalDiscoveryPolling(fakeUrlHash, playlistId);
} catch (error) {
console.error('❌ Error starting Tidal discovery:', error);
showToast(`Error starting discovery: ${error.message}`, 'error');
}
} else {
console.log('✅ Using existing discovery results - no need to re-discover');
}
// Reuse YouTube discovery modal (exact sync.py pattern)
openYouTubeDiscoveryModal(fakeUrlHash);
}
function startTidalDiscoveryPolling(fakeUrlHash, playlistId) {
console.log(`🔄 Starting Tidal discovery polling for: ${playlistId}`);
// Stop any existing polling
if (activeYouTubePollers[fakeUrlHash]) {
clearInterval(activeYouTubePollers[fakeUrlHash]);
}
const pollInterval = setInterval(async () => {
try {
const response = await fetch(`/api/tidal/discovery/status/${playlistId}`);
const status = await response.json();
if (status.error) {
console.error('❌ Error polling Tidal discovery status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[fakeUrlHash];
return;
}
// Update fake YouTube state with Tidal discovery results
const state = youtubePlaylistStates[fakeUrlHash];
if (state) {
state.discovery_progress = status.progress;
state.spotify_matches = status.spotify_matches;
state.discovery_results = status.results;
state.phase = status.phase;
// Transform Tidal results to YouTube modal format
const transformedStatus = {
progress: status.progress,
spotify_matches: status.spotify_matches,
spotify_total: status.spotify_total,
results: status.results.map((result, index) => ({
index: index,
status: result.status === 'found' ? '✅ Found' : '❌ Not Found',
status_class: result.status === 'found' ? 'found' : 'not-found',
spotify_track: result.spotify_data ? result.spotify_data.name : '-',
spotify_artist: result.spotify_data ? result.spotify_data.artists.join(', ') : '-',
spotify_album: result.spotify_data ? result.spotify_data.album : '-'
// Note: No duration column for Tidal (matches GUI version)
}))
};
// Update modal with transformed data (reuse YouTube modal update logic)
updateYouTubeDiscoveryModal(fakeUrlHash, transformedStatus);
// Update Tidal card phase and save discovery results
if (tidalPlaylistStates[playlistId]) {
tidalPlaylistStates[playlistId].phase = status.phase;
tidalPlaylistStates[playlistId].discovery_results = status.results;
tidalPlaylistStates[playlistId].spotify_matches = status.spotify_matches;
tidalPlaylistStates[playlistId].discovery_progress = status.progress;
updateTidalCardPhase(playlistId, status.phase);
}
console.log(`🔄 Tidal discovery progress: ${status.progress}% (${status.spotify_matches}/${status.spotify_total} found)`);
}
// Stop polling when complete
if (status.complete) {
console.log(`✅ Tidal discovery complete: ${status.spotify_matches}/${status.spotify_total} tracks found`);
clearInterval(pollInterval);
delete activeYouTubePollers[fakeUrlHash];
}
} catch (error) {
console.error('❌ Error polling Tidal discovery:', error);
clearInterval(pollInterval);
delete activeYouTubePollers[fakeUrlHash];
}
}, 1000); // Poll every second like YouTube
// Store poller reference (reuse YouTube poller storage)
activeYouTubePollers[fakeUrlHash] = pollInterval;
}
// Tidal-specific sync and download functions (placeholder implementations)
function startTidalPlaylistSync(urlHash) {
console.log(`🎵 Starting Tidal playlist sync for: ${urlHash}`);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.is_tidal_playlist) {
console.error('❌ Invalid Tidal playlist state for sync');
return;
}
// TODO: Implement Tidal playlist sync logic
// For now, show a message that this feature is coming soon
showToast('🔄 Tidal playlist sync functionality coming soon!', 'info');
}
function startTidalDownloadMissing(urlHash) {
console.log(`🎵 Starting Tidal download missing tracks for: ${urlHash}`);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.is_tidal_playlist) {
console.error('❌ Invalid Tidal playlist state for download');
return;
}
// TODO: Implement Tidal download missing tracks logic
// For now, show a message that this feature is coming soon
showToast('🔍 Tidal download missing tracks functionality coming soon!', 'info');
}
// ===============================
// SYNC PAGE FUNCTIONALITY (REDESIGNED)
// ===============================
@ -6352,6 +6682,13 @@ function initializeSyncPage() {
refreshBtn.addEventListener('click', loadSpotifyPlaylists);
}
// Logic for the Tidal refresh button
const tidalRefreshBtn = document.getElementById('tidal-refresh-btn');
if (tidalRefreshBtn) {
tidalRefreshBtn.removeEventListener('click', loadTidalPlaylists);
tidalRefreshBtn.addEventListener('click', loadTidalPlaylists);
}
// Logic for the Start Sync button
const startSyncBtn = document.getElementById('start-sync-btn');
if (startSyncBtn) {
@ -7013,14 +7350,18 @@ function openYouTubeDiscoveryModal(urlHash) {
startYouTubeDiscoveryPolling(urlHash);
}
} else {
// Create new modal
// Create new modal (support both YouTube and Tidal like sync.py)
const isTidal = state.is_tidal_playlist;
const modalTitle = isTidal ? '🎵 Tidal Playlist Discovery' : '🎵 YouTube Playlist Discovery';
const sourceLabel = isTidal ? 'Tidal' : 'YT';
const modalHtml = `
<div class="modal-overlay" id="youtube-discovery-modal-${urlHash}">
<div class="youtube-discovery-modal">
<div class="modal-header">
<h2>🎵 YouTube Playlist Discovery</h2>
<h2>${modalTitle}</h2>
<div class="modal-subtitle">${state.playlist.name} (${state.playlist.tracks.length} tracks)</div>
<div class="modal-description">${getModalDescription(state.phase)}</div>
<div class="modal-description">${getModalDescription(state.phase, isTidal)}</div>
<button class="modal-close-btn" onclick="closeYouTubeDiscoveryModal('${urlHash}')"></button>
</div>
@ -7030,24 +7371,24 @@ function openYouTubeDiscoveryModal(urlHash) {
<div class="progress-bar-container">
<div class="progress-bar-fill" id="youtube-discovery-progress-${urlHash}" style="width: 0%;"></div>
</div>
<div class="progress-text" id="youtube-discovery-progress-text-${urlHash}">${getInitialProgressText(state.phase)}</div>
<div class="progress-text" id="youtube-discovery-progress-text-${urlHash}">${getInitialProgressText(state.phase, isTidal)}</div>
</div>
<div class="discovery-table-container">
<table class="discovery-table">
<thead>
<tr>
<th>YT Track</th>
<th>YT Artist</th>
<th>${sourceLabel} Track</th>
<th>${sourceLabel} Artist</th>
<th>Status</th>
<th>Spotify Track</th>
<th>Spotify Artist</th>
<th>Album</th>
<th>Duration</th>
${isTidal ? '' : '<th>Duration</th>'}
</tr>
</thead>
<tbody id="youtube-discovery-table-${urlHash}">
${generateTableRowsFromState(state)}
${generateTableRowsFromState(state, urlHash)}
</tbody>
</table>
</div>
@ -7093,6 +7434,8 @@ function getModalActionButtons(urlHash, phase, state = null) {
state = youtubePlaylistStates[urlHash];
}
const isTidal = state && state.is_tidal_playlist;
// Validate data availability for buttons
const hasDiscoveryResults = state && state.discoveryResults && state.discoveryResults.length > 0;
const hasSpotifyMatches = state && state.spotifyMatches > 0;
@ -7109,12 +7452,20 @@ function getModalActionButtons(urlHash, phase, state = null) {
// Only show sync button if there are Spotify matches
if (hasSpotifyMatches) {
buttons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubePlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
if (isTidal) {
buttons += `<button class="modal-btn modal-btn-primary" onclick="startTidalPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
} else {
buttons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubePlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
}
}
// Only show download button if we have matches or a converted playlist ID
if (hasSpotifyMatches || hasConvertedPlaylistId) {
buttons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
if (isTidal) {
buttons += `<button class="modal-btn modal-btn-primary" onclick="startTidalDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
} else {
buttons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
}
}
if (!buttons) {
@ -7158,20 +7509,21 @@ function getModalActionButtons(urlHash, phase, state = null) {
}
}
function getModalDescription(phase) {
function getModalDescription(phase, isTidal = false) {
const source = isTidal ? 'Tidal' : 'YouTube';
switch (phase) {
case 'fresh':
return 'Ready to discover clean Spotify metadata for YouTube tracks...';
return `Ready to discover clean Spotify metadata for ${source} tracks...`;
case 'discovering':
return 'Discovering clean Spotify metadata for YouTube tracks...';
return `Discovering clean Spotify metadata for ${source} tracks...`;
case 'discovered':
return 'Discovery complete! View the results below.';
default:
return 'Discovering clean Spotify metadata for YouTube tracks...';
return `Discovering clean Spotify metadata for ${source} tracks...`;
}
}
function getInitialProgressText(phase) {
function getInitialProgressText(phase, isTidal = false) {
switch (phase) {
case 'fresh':
return 'Click Start Discovery to begin...';
@ -7184,36 +7536,38 @@ function getInitialProgressText(phase) {
}
}
function generateTableRowsFromState(state) {
function generateTableRowsFromState(state, urlHash) {
const isTidal = state.is_tidal_playlist;
if (state.discoveryResults && state.discoveryResults.length > 0) {
// Generate rows from existing discovery results
return state.discoveryResults.map((result, index) => `
<tr id="youtube-discovery-row-${result.index}">
<tr id="discovery-row-${urlHash}-${result.index}">
<td class="yt-track">${result.yt_track}</td>
<td class="yt-artist">${result.yt_artist}</td>
<td class="discovery-status ${result.status_class}">${result.status}</td>
<td class="spotify-track">${result.spotify_track || '-'}</td>
<td class="spotify-artist">${result.spotify_artist || '-'}</td>
<td class="spotify-album">${result.spotify_album || '-'}</td>
<td class="duration">${result.duration}</td>
${isTidal ? '' : `<td class="duration">${result.duration}</td>`}
</tr>
`).join('');
} else {
// Generate initial rows from playlist tracks
return generateInitialTableRows(state.playlist.tracks);
return generateInitialTableRows(state.playlist.tracks, isTidal, urlHash);
}
}
function generateInitialTableRows(tracks) {
function generateInitialTableRows(tracks, isTidal = false, urlHash = '') {
return tracks.map((track, index) => `
<tr id="youtube-discovery-row-${index}">
<tr id="discovery-row-${urlHash}-${index}">
<td class="yt-track">${track.name}</td>
<td class="yt-artist">${track.artists[0] || 'Unknown Artist'}</td>
<td class="yt-artist">${track.artists ? (Array.isArray(track.artists) ? track.artists.join(', ') : track.artists) : 'Unknown Artist'}</td>
<td class="discovery-status">🔍 Pending...</td>
<td class="spotify-track">-</td>
<td class="spotify-artist">-</td>
<td class="spotify-album">-</td>
<td class="duration">${formatDuration(track.duration_ms)}</td>
${isTidal ? '' : `<td class="duration">${formatDuration(track.duration_ms)}</td>`}
</tr>
`).join('');
}
@ -7238,7 +7592,7 @@ function updateYouTubeDiscoveryModal(urlHash, status) {
// Update table rows
status.results.forEach(result => {
const row = document.getElementById(`youtube-discovery-row-${result.index}`);
const row = document.getElementById(`discovery-row-${urlHash}-${result.index}`);
if (!row) return;
const statusCell = row.querySelector('.discovery-status');
@ -7267,7 +7621,7 @@ function refreshYouTubeDiscoveryModalTable(urlHash) {
// Update the table body with new discovery results
const tableBody = state.modalElement.querySelector(`#youtube-discovery-table-${urlHash}`);
if (tableBody) {
tableBody.innerHTML = generateTableRowsFromState(state);
tableBody.innerHTML = generateTableRowsFromState(state, urlHash);
console.log(`✅ Modal table refreshed with discovery data`);
} else {
console.warn(`⚠️ Could not find table body for modal ${urlHash}`);

@ -4533,6 +4533,33 @@ body {
transform: none;
}
/* ===============================
TIDAL PLAYLIST CARD STYLES (extends YouTube card styles)
===============================*/
.tidal-playlist-card .playlist-card-icon {
background: rgba(255, 102, 0, 0.2);
border: 1px solid #ff6600;
color: #ff6600;
font-size: 16px;
}
.tidal-playlist-card:hover .playlist-card-icon {
background: rgba(255, 102, 0, 0.3);
border: 1px solid #ff7700;
}
.tidal-playlist-card .playlist-card-action-btn {
background: linear-gradient(135deg, #ff6600, #ff7700);
border: 1px solid #ff6600;
}
.tidal-playlist-card .playlist-card-action-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #ff7700, #ff8800);
border: 1px solid #ff7700;
box-shadow: 0 4px 15px rgba(255, 102, 0, 0.3);
}
/* ===============================
YOUTUBE DISCOVERY MODAL STYLES
=============================== */

Loading…
Cancel
Save