tidal
This commit is contained in:
parent
b6fc2f5f07
commit
252eb32bf9
3 changed files with 720 additions and 25 deletions
314
web_server.py
314
web_server.py
|
|
@ -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…
Reference in a new issue