youtube playlists

pull/15/head
Broque Thomas 9 months ago
parent 146543b852
commit f99c550484

@ -5731,6 +5731,143 @@ def _calculate_similarity(str1, str2):
return intersection / union if union > 0 else 0
@app.route('/api/youtube/sync/start/<url_hash>', methods=['POST'])
def start_youtube_sync(url_hash):
"""Start sync process for a YouTube playlist using discovered Spotify tracks"""
try:
if url_hash not in youtube_discovery_states:
return jsonify({"error": "YouTube playlist not found"}), 404
state = youtube_discovery_states[url_hash]
if state['phase'] not in ['discovered', 'sync_complete']:
return jsonify({"error": "YouTube playlist not ready for sync"}), 400
# Convert discovery results to Spotify tracks format
spotify_tracks = convert_youtube_results_to_spotify_tracks(state['discovery_results'])
if not spotify_tracks:
return jsonify({"error": "No Spotify matches found for sync"}), 400
# Create a temporary playlist ID for sync tracking
sync_playlist_id = f"youtube_{url_hash}"
playlist_name = state['playlist']['name']
# Update YouTube state
state['phase'] = 'syncing'
state['sync_playlist_id'] = sync_playlist_id
state['sync_progress'] = {}
# Start the sync using existing sync infrastructure
sync_data = {
'playlist_id': sync_playlist_id,
'playlist_name': f"[YouTube] {playlist_name}",
'tracks': spotify_tracks
}
with sync_lock:
sync_states[sync_playlist_id] = {"status": "starting", "progress": {}}
# Submit sync task
future = sync_executor.submit(_run_sync_task, sync_playlist_id, sync_data['playlist_name'], spotify_tracks)
active_sync_workers[sync_playlist_id] = future
print(f"🔄 Started YouTube sync for: {playlist_name} ({len(spotify_tracks)} tracks)")
return jsonify({"success": True, "sync_playlist_id": sync_playlist_id})
except Exception as e:
print(f"❌ Error starting YouTube sync: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/youtube/sync/status/<url_hash>', methods=['GET'])
def get_youtube_sync_status(url_hash):
"""Get sync status for a YouTube playlist"""
try:
if url_hash not in youtube_discovery_states:
return jsonify({"error": "YouTube playlist not found"}), 404
state = youtube_discovery_states[url_hash]
sync_playlist_id = state.get('sync_playlist_id')
if not sync_playlist_id:
return jsonify({"error": "No sync in progress"}), 404
# Get sync status from existing sync infrastructure
with sync_lock:
sync_state = sync_states.get(sync_playlist_id, {})
response = {
'phase': state['phase'],
'sync_status': sync_state.get('status', 'unknown'),
'progress': sync_state.get('progress', {}),
'complete': sync_state.get('status') == 'finished',
'error': sync_state.get('error')
}
# Update YouTube state if sync completed
if sync_state.get('status') == 'finished':
state['phase'] = 'sync_complete'
state['sync_progress'] = sync_state.get('progress', {})
elif sync_state.get('status') == 'error':
state['phase'] = 'discovered' # Revert on error
return jsonify(response)
except Exception as e:
print(f"❌ Error getting YouTube sync status: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/youtube/sync/cancel/<url_hash>', methods=['POST'])
def cancel_youtube_sync(url_hash):
"""Cancel sync for a YouTube playlist"""
try:
if url_hash not in youtube_discovery_states:
return jsonify({"error": "YouTube playlist not found"}), 404
state = youtube_discovery_states[url_hash]
sync_playlist_id = state.get('sync_playlist_id')
if sync_playlist_id:
# Cancel the sync using existing sync infrastructure
with sync_lock:
sync_states[sync_playlist_id] = {"status": "cancelled"}
# Clean up sync worker
if sync_playlist_id in active_sync_workers:
del active_sync_workers[sync_playlist_id]
# Revert YouTube state
state['phase'] = 'discovered'
state['sync_playlist_id'] = None
state['sync_progress'] = {}
return jsonify({"success": True, "message": "YouTube sync cancelled"})
except Exception as e:
print(f"❌ Error cancelling YouTube sync: {e}")
return jsonify({"error": str(e)}), 500
def convert_youtube_results_to_spotify_tracks(discovery_results):
"""Convert YouTube discovery results to Spotify tracks format for sync"""
spotify_tracks = []
for result in discovery_results:
if result.get('spotify_data'):
spotify_data = result['spotify_data']
# Create track object matching the expected format
track = {
'id': spotify_data['id'],
'name': spotify_data['name'],
'artists': spotify_data['artists'],
'album': spotify_data['album'],
'duration_ms': spotify_data['duration_ms']
}
spotify_tracks.append(track)
print(f"🔄 Converted {len(spotify_tracks)} YouTube matches to Spotify tracks for sync")
return spotify_tracks
# Add these new endpoints to the end of web_server.py

@ -5993,6 +5993,38 @@ function updateYouTubeCardPhase(urlHash, phase) {
actionBtn.disabled = false;
progressElement.classList.add('hidden');
break;
case 'syncing':
phaseTextElement.textContent = 'Syncing...';
phaseTextElement.style.color = '#ffa500'; // Orange
actionBtn.textContent = 'View Progress';
actionBtn.disabled = false;
progressElement.classList.remove('hidden');
break;
case 'sync_complete':
phaseTextElement.textContent = 'Sync Complete';
phaseTextElement.style.color = '#1db954'; // Green
actionBtn.textContent = 'View Details';
actionBtn.disabled = false;
progressElement.classList.add('hidden');
break;
case 'downloading':
phaseTextElement.textContent = 'Downloading...';
phaseTextElement.style.color = '#ffa500'; // Orange
actionBtn.textContent = 'View Downloads';
actionBtn.disabled = false;
progressElement.classList.remove('hidden');
break;
case 'download_complete':
phaseTextElement.textContent = 'Download Complete';
phaseTextElement.style.color = '#1db954'; // Green
actionBtn.textContent = 'View Results';
actionBtn.disabled = false;
progressElement.classList.add('hidden');
break;
}
console.log('🃏 Updated YouTube card phase:', urlHash, phase);
@ -6013,10 +6045,26 @@ function handleYouTubeCardClick(urlHash) {
case 'discovering':
case 'discovered':
// Subsequent clicks: Just open modal with preserved state
console.log('🎬 Opening existing YouTube modal:', urlHash);
case 'syncing':
case 'sync_complete':
// Open discovery modal with current state
console.log('🎬 Opening YouTube discovery modal:', urlHash);
openYouTubeDiscoveryModal(urlHash);
break;
case 'downloading':
case 'download_complete':
// Open download missing tracks modal
console.log('🎬 Opening download modal for YouTube playlist:', urlHash);
// Need to get playlist ID from converted Spotify data
const spotifyPlaylistId = state.convertedSpotifyPlaylistId;
if (spotifyPlaylistId) {
openDownloadMissingModal(spotifyPlaylistId);
} else {
console.error('❌ No converted Spotify playlist ID found for downloads');
showToast('Unable to open download modal - missing playlist data', 'error');
}
break;
}
}
@ -6127,6 +6175,9 @@ function startYouTubeDiscoveryPolling(urlHash) {
// 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');
}
@ -6213,7 +6264,12 @@ function openYouTubeDiscoveryModal(urlHash) {
</div>
<div class="modal-footer">
<button class="modal-btn modal-btn-secondary" onclick="closeYouTubeDiscoveryModal('${urlHash}')">🏠 Close</button>
<div class="modal-footer-left">
${getModalActionButtons(urlHash, state.phase)}
</div>
<div class="modal-footer-right">
<button class="modal-btn modal-btn-secondary" onclick="closeYouTubeDiscoveryModal('${urlHash}')">🏠 Close</button>
</div>
</div>
</div>
</div>
@ -6241,6 +6297,36 @@ function openYouTubeDiscoveryModal(urlHash) {
}
}
function getModalActionButtons(urlHash, phase) {
switch (phase) {
case 'discovered':
return `
<button class="modal-btn modal-btn-primary" onclick="startYouTubePlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>
<button class="modal-btn modal-btn-primary" onclick="startYouTubeDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>
`;
case 'syncing':
return `
<button class="modal-btn modal-btn-danger" onclick="cancelYouTubeSync('${urlHash}')"> Cancel Sync</button>
<div class="playlist-modal-sync-status" id="youtube-sync-status-${urlHash}" style="display: flex;">
<span class="sync-stat total-tracks"> <span id="youtube-total-${urlHash}">0</span></span>
<span class="sync-separator">/</span>
<span class="sync-stat matched-tracks"> <span id="youtube-matched-${urlHash}">0</span></span>
<span class="sync-separator">/</span>
<span class="sync-stat failed-tracks"> <span id="youtube-failed-${urlHash}">0</span></span>
<span class="sync-stat percentage">(<span id="youtube-percentage-${urlHash}">0</span>%)</span>
</div>
`;
case 'sync_complete':
return `
<button class="modal-btn modal-btn-primary" onclick="startYouTubePlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>
<button class="modal-btn modal-btn-primary" onclick="startYouTubeDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>
<button class="modal-btn modal-btn-secondary" onclick="resetYouTubePlaylist('${urlHash}')">🔄 Reset</button>
`;
default:
return '';
}
}
function getModalDescription(phase) {
switch (phase) {
case 'fresh':
@ -6319,10 +6405,28 @@ function updateYouTubeDiscoveryModal(urlHash, status) {
progressBar.style.width = `${status.progress}%`;
progressText.textContent = `${status.spotify_matches} / ${status.spotify_total} tracks matched (${status.progress}%)`;
// Update table rows
// Update table rows - create missing rows if needed
status.results.forEach(result => {
const row = document.getElementById(`youtube-discovery-row-${result.index}`);
if (!row) return;
let row = document.getElementById(`youtube-discovery-row-${result.index}`);
// Create missing row if it doesn't exist
if (!row) {
const rowHtml = `
<tr id="youtube-discovery-row-${result.index}">
<td class="yt-track">${result.yt_track}</td>
<td class="yt-artist">${result.yt_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">${result.duration || '0:00'}</td>
</tr>
`;
tableBody.insertAdjacentHTML('beforeend', rowHtml);
row = document.getElementById(`youtube-discovery-row-${result.index}`);
}
if (!row) return; // Safety check
const statusCell = row.querySelector('.discovery-status');
const spotifyTrackCell = row.querySelector('.spotify-track');
@ -6350,6 +6454,277 @@ function closeYouTubeDiscoveryModal(urlHash) {
// Discovery polling continues in background if active
}
// ===============================
// YOUTUBE SYNC FUNCTIONALITY
// ===============================
async function startYouTubePlaylistSync(urlHash) {
try {
console.log('🔄 Starting YouTube playlist sync:', urlHash);
const response = await fetch(`/api/youtube/sync/start/${urlHash}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
showToast(`Error starting sync: ${result.error}`, 'error');
return;
}
// Update card and modal to syncing phase
updateYouTubeCardPhase(urlHash, 'syncing');
// Update modal buttons if modal is open
updateYouTubeModalButtons(urlHash, 'syncing');
// Start sync polling
startYouTubeSyncPolling(urlHash);
showToast('YouTube playlist sync started!', 'success');
} catch (error) {
console.error('❌ Error starting YouTube sync:', error);
showToast(`Error starting sync: ${error.message}`, 'error');
}
}
function startYouTubeSyncPolling(urlHash) {
// Stop any existing polling
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
}
const pollInterval = setInterval(async () => {
try {
const response = await fetch(`/api/youtube/sync/status/${urlHash}`);
const status = await response.json();
if (status.error) {
console.error('❌ Error polling YouTube sync status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
return;
}
// Update card progress with sync stats
updateYouTubeCardSyncProgress(urlHash, status.progress);
// Update modal sync display if open
updateYouTubeModalSyncProgress(urlHash, status.progress);
// Check if complete
if (status.complete) {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
// Update card phase to sync complete
updateYouTubeCardPhase(urlHash, 'sync_complete');
// Update modal buttons
updateYouTubeModalButtons(urlHash, 'sync_complete');
console.log('✅ YouTube sync complete:', urlHash);
showToast('YouTube playlist sync complete!', 'success');
} else if (status.sync_status === 'error') {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
// Revert to discovered phase on error
updateYouTubeCardPhase(urlHash, 'discovered');
updateYouTubeModalButtons(urlHash, 'discovered');
showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('❌ Error polling YouTube sync:', error);
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
}
}, 1000);
activeYouTubePollers[urlHash] = pollInterval;
}
async function cancelYouTubeSync(urlHash) {
try {
console.log('❌ Cancelling YouTube sync:', urlHash);
const response = await fetch(`/api/youtube/sync/cancel/${urlHash}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
showToast(`Error cancelling sync: ${result.error}`, 'error');
return;
}
// Stop polling
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
// Revert to discovered phase
updateYouTubeCardPhase(urlHash, 'discovered');
updateYouTubeModalButtons(urlHash, 'discovered');
showToast('YouTube sync cancelled', 'info');
} catch (error) {
console.error('❌ Error cancelling YouTube sync:', error);
showToast(`Error cancelling sync: ${error.message}`, 'error');
}
}
function updateYouTubeCardSyncProgress(urlHash, progress) {
const state = youtubePlaylistStates[urlHash];
if (!state || !state.cardElement || !progress) return;
const card = state.cardElement;
const progressElement = card.querySelector('.playlist-card-progress');
// Build clean status counter HTML exactly like Spotify cards
let statusCounterHTML = '';
if (progress && progress.total_tracks > 0) {
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const total = progress.total_tracks || 0;
const processed = matched + failed;
const percentage = total > 0 ? Math.round((processed / total) * 100) : 0;
statusCounterHTML = `
<div class="playlist-card-sync-status">
<span class="sync-stat total-tracks"> ${total}</span>
<span class="sync-separator">/</span>
<span class="sync-stat matched-tracks"> ${matched}</span>
<span class="sync-separator">/</span>
<span class="sync-stat failed-tracks"> ${failed}</span>
<span class="sync-stat percentage">(${percentage}%)</span>
</div>
`;
}
progressElement.innerHTML = statusCounterHTML || '<div class="playlist-card-sync-status">🔄 Starting...</div>';
console.log(`🔄 Updated YouTube sync progress: ♪ ${progress?.total_tracks || 0} / ✓ ${progress?.matched_tracks || 0} / ✗ ${progress?.failed_tracks || 0}`);
}
function updateYouTubeModalSyncProgress(urlHash, progress) {
const statusDisplay = document.getElementById(`youtube-sync-status-${urlHash}`);
if (!statusDisplay || !progress) return;
console.log(`📊 Updating YouTube modal sync progress for ${urlHash}:`, progress);
// Update individual counters exactly like Spotify sync
const totalEl = document.getElementById(`youtube-total-${urlHash}`);
const matchedEl = document.getElementById(`youtube-matched-${urlHash}`);
const failedEl = document.getElementById(`youtube-failed-${urlHash}`);
const percentageEl = document.getElementById(`youtube-percentage-${urlHash}`);
const total = progress.total_tracks || 0;
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
if (totalEl) totalEl.textContent = total;
if (matchedEl) matchedEl.textContent = matched;
if (failedEl) failedEl.textContent = failed;
// Calculate percentage like Spotify sync
if (total > 0) {
const processed = matched + failed;
const percentage = Math.round((processed / total) * 100);
if (percentageEl) percentageEl.textContent = percentage;
}
console.log(`📊 YouTube modal updated: ♪ ${total} / ✓ ${matched} / ✗ ${failed} (${Math.round((matched + failed) / total * 100)}%)`);
}
function updateYouTubeModalButtons(urlHash, phase) {
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (!modal) return;
const footerLeft = modal.querySelector('.modal-footer-left');
if (footerLeft) {
footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
}
}
// ===============================
// YOUTUBE DOWNLOAD MISSING TRACKS
// ===============================
async function startYouTubeDownloadMissing(urlHash) {
try {
console.log('🔍 Starting download missing tracks for YouTube playlist:', urlHash);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.discoveryResults) {
showToast('No discovery results available for download', 'error');
return;
}
// Convert YouTube results to a format compatible with the download modal
const spotifyTracks = state.discoveryResults
.filter(result => result.spotify_data)
.map(result => result.spotify_data);
if (spotifyTracks.length === 0) {
showToast('No Spotify matches found for download', 'error');
return;
}
// Create a virtual playlist for the download system
const virtualPlaylistId = `youtube_${urlHash}`;
const playlistName = `[YouTube] ${state.playlist.name}`;
// Store reference for card navigation
state.convertedSpotifyPlaylistId = virtualPlaylistId;
// Open the existing download missing tracks modal
await openDownloadMissingModal(virtualPlaylistId, playlistName, spotifyTracks);
// Update YouTube card phase when download process starts
updateYouTubeCardPhase(urlHash, 'downloading');
} catch (error) {
console.error('❌ Error starting download missing tracks:', error);
showToast(`Error starting downloads: ${error.message}`, 'error');
}
}
function resetYouTubePlaylist(urlHash) {
const state = youtubePlaylistStates[urlHash];
if (!state) return;
// Stop any active polling
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
// Reset to fresh phase
state.phase = 'fresh';
state.discoveryResults = [];
state.discoveryProgress = 0;
state.spotifyMatches = 0;
state.syncPlaylistId = null;
state.syncProgress = {};
state.convertedSpotifyPlaylistId = null;
// Update card
updateYouTubeCardPhase(urlHash, 'fresh');
// Close modal
closeYouTubeDiscoveryModal(urlHash);
showToast('YouTube playlist reset to fresh state', 'info');
}
// --- Global Cleanup on Page Unload ---
// Note: Automatic wishlist processing now runs server-side and continues even when browser is closed

@ -4754,9 +4754,20 @@ body {
.youtube-discovery-modal .modal-footer {
padding: 30px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
}
.youtube-discovery-modal .modal-footer-left {
display: flex;
gap: 12px;
align-items: center;
}
.youtube-discovery-modal .modal-footer-right {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.youtube-discovery-modal .modal-btn {
@ -4780,6 +4791,42 @@ body {
border-color: rgba(255, 255, 255, 0.3);
}
.youtube-discovery-modal .modal-btn-primary {
background: linear-gradient(135deg, #1db954 0%, #1ed760 100%);
color: #ffffff;
border: 1px solid rgba(29, 185, 84, 0.3);
}
.youtube-discovery-modal .modal-btn-primary:hover {
background: linear-gradient(135deg, #1ed760 0%, #1fbc56 100%);
border-color: rgba(29, 185, 84, 0.5);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(29, 185, 84, 0.3);
}
.youtube-discovery-modal .modal-btn-danger {
background: linear-gradient(135deg, #ff6b6b 0%, #ff5555 100%);
color: #ffffff;
border: 1px solid rgba(255, 107, 107, 0.3);
}
.youtube-discovery-modal .modal-btn-danger:hover {
background: linear-gradient(135deg, #ff5555 0%, #ff4444 100%);
border-color: rgba(255, 107, 107, 0.5);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3);
}
.youtube-discovery-modal .sync-progress-display {
color: #1ed760;
font-size: 14px;
font-weight: 500;
padding: 8px 12px;
background: rgba(29, 185, 84, 0.1);
border-radius: 8px;
border: 1px solid rgba(29, 185, 84, 0.2);
}
/* Modal state management */
.modal-overlay.hidden {
display: none !important;

Loading…
Cancel
Save