diff --git a/web_server.py b/web_server.py index 37d15e89..68193608 100644 --- a/web_server.py +++ b/web_server.py @@ -12853,6 +12853,344 @@ def library_delete_track(track_id): return jsonify({"success": False, "error": str(e)}), 500 +# ================================================================================== +# TRACK REDOWNLOAD — Search metadata, search download sources, start redownload +# ================================================================================== + +@app.route('/api/library/track//redownload/search-metadata', methods=['POST']) +def redownload_search_metadata(track_id): + """Search all available metadata sources for a track to find the correct version.""" + try: + database = get_database() + conn = database._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT t.id, t.title, t.file_path, t.bitrate, t.duration, + ar.name AS artist_name, al.title AS album_title, + t.spotify_track_id, t.deezer_id + FROM tracks t + JOIN artists ar ON t.artist_id = ar.id + JOIN albums al ON t.album_id = al.id + WHERE t.id = ? + """, (track_id,)) + row = cursor.fetchone() + conn.close() + + if not row: + return jsonify({"success": False, "error": "Track not found"}), 404 + + track_title = row['title'] + artist_name = row['artist_name'] + query = f"{artist_name} {track_title}" + + # Resolve file info + file_path = row['file_path'] or '' + ext = os.path.splitext(file_path)[1].lstrip('.').upper() if file_path else '' + fmt = ext if ext in ('FLAC', 'MP3', 'OPUS', 'OGG', 'M4A', 'WAV') else '' + + current_track = { + 'id': row['id'], + 'title': track_title, + 'artist': artist_name, + 'album': row['album_title'], + 'duration_ms': row['duration'] or 0, + 'file_path': file_path, + 'format': fmt, + 'bitrate': row['bitrate'] or 0, + 'spotify_track_id': row['spotify_track_id'], + 'deezer_id': row['deezer_id'], + } + + # Search all available metadata sources in parallel + from concurrent.futures import ThreadPoolExecutor, as_completed + from difflib import SequenceMatcher + + def _score_metadata_match(result): + """Score a metadata result against the current track.""" + title_sim = SequenceMatcher(None, track_title.lower(), (result.get('name') or '').lower()).ratio() + artist_sim = SequenceMatcher(None, artist_name.lower(), (result.get('artist') or '').lower()).ratio() + dur_diff = abs((row['duration'] or 0) - (result.get('duration_ms') or 0)) + dur_score = max(0, 1 - dur_diff / 30000) if row['duration'] else 0.5 + return round((title_sim * 0.5 + artist_sim * 0.35 + dur_score * 0.15), 3) + + metadata_results = {} + sources_to_search = [] + + if spotify_client and spotify_client.is_authenticated(): + sources_to_search.append(('spotify', spotify_client)) + try: + from core.itunes_client import iTunesClient + sources_to_search.append(('itunes', iTunesClient())) + except Exception: + pass + try: + from core.deezer_client import DeezerClient + sources_to_search.append(('deezer', DeezerClient())) + except Exception: + pass + + def _search_source(source_name, client): + try: + track_objs = client.search_tracks(query, limit=10) + results = [] + for t in track_objs: + r = { + 'id': str(t.id), + 'name': t.name, + 'artist': ', '.join(t.artists) if t.artists else '', + 'album': t.album or '', + 'duration_ms': t.duration_ms or 0, + 'image_url': t.image_url or '', + 'is_current_match': False, + } + # Flag current match + if source_name == 'spotify' and row['spotify_track_id'] and str(t.id) == str(row['spotify_track_id']): + r['is_current_match'] = True + elif source_name == 'deezer' and row['deezer_id'] and str(t.id) == str(row['deezer_id']): + r['is_current_match'] = True + r['match_score'] = _score_metadata_match(r) + results.append(r) + results.sort(key=lambda x: (-int(x['is_current_match']), -x['match_score'])) + return source_name, results + except Exception as e: + logger.debug(f"Metadata search failed for {source_name}: {e}") + return source_name, [] + + with ThreadPoolExecutor(max_workers=3) as pool: + futures = {pool.submit(_search_source, name, client): name for name, client in sources_to_search} + for future in as_completed(futures): + source_name, results = future.result() + metadata_results[source_name] = results + + # Find best overall match + best_match = None + for source, results in metadata_results.items(): + if results: + top = results[0] + if not best_match or top['match_score'] > best_match['score']: + best_match = {'source': source, 'index': 0, 'score': top['match_score']} + + return jsonify({ + "success": True, + "current_track": current_track, + "metadata_results": metadata_results, + "best_match": best_match, + }) + except Exception as e: + logger.error(f"Error in redownload metadata search: {e}", exc_info=True) + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/library/track//redownload/search-sources', methods=['POST']) +def redownload_search_sources(track_id): + """Search all active download sources for a track using the selected metadata.""" + try: + data = request.get_json() + metadata = data.get('metadata', {}) + if not metadata.get('name'): + return jsonify({"success": False, "error": "metadata with name required"}), 400 + + # Build a track-like object for query generation + from core.itunes_client import Track as MetaTrack + track_obj = MetaTrack( + id=metadata.get('id', ''), + name=metadata['name'], + artists=[metadata.get('artist', '')], + album=metadata.get('album', ''), + duration_ms=metadata.get('duration_ms', 0), + ) + + # Generate search queries + search_queries = matching_engine.generate_download_queries(track_obj) + if not search_queries: + search_queries = [f"{metadata.get('artist', '')} {metadata['name']}".strip()] + + # Limit to first 3 queries for speed + search_queries = search_queries[:3] + + # Search download sources + candidates = [] + database = get_database() + + for qi, query in enumerate(search_queries): + try: + tracks_result, _ = run_async(soulseek_client.search(query, timeout=25)) + if not tracks_result: + continue + + valid = get_valid_candidates(tracks_result, track_obj, query) + for candidate in valid: + # Check blacklist + is_bl = database.is_blacklisted(candidate.username, candidate.filename) + + # Extract display name from filename + display_name = os.path.basename(candidate.filename.replace('\\', '/')) + ext = os.path.splitext(display_name)[1].lstrip('.').upper() + quality = ext if ext in ('FLAC', 'MP3', 'OPUS', 'OGG', 'M4A', 'WAV') else candidate.quality or '' + + candidates.append({ + 'username': candidate.username, + 'filename': candidate.filename, + 'display_name': display_name, + 'size': candidate.size or 0, + 'size_display': f"{(candidate.size or 0) / 1048576:.1f} MB", + 'bitrate': candidate.bitrate or 0, + 'quality': quality, + 'duration': candidate.duration or 0, + 'confidence': round(getattr(candidate, 'confidence', 0), 3), + 'source_query': query, + 'blacklisted': is_bl, + 'free_upload_slots': getattr(candidate, 'free_upload_slots', 0), + 'upload_speed': getattr(candidate, 'upload_speed', 0), + 'queue_length': getattr(candidate, 'queue_length', 0), + }) + except Exception as e: + logger.debug(f"Download source search failed for query '{query}': {e}") + + # Deduplicate by username+filename + seen = set() + unique = [] + for c in candidates: + key = f"{c['username']}|{c['filename']}" + if key not in seen: + seen.add(key) + unique.append(c) + candidates = unique + + # Sort: non-blacklisted first, then by confidence + candidates.sort(key=lambda c: (-int(not c['blacklisted']), -c['confidence'])) + + best_idx = next((i for i, c in enumerate(candidates) if not c['blacklisted']), 0) if candidates else -1 + + return jsonify({ + "success": True, + "candidates": candidates, + "best_candidate_index": best_idx, + "queries_used": search_queries, + }) + except Exception as e: + logger.error(f"Error in redownload source search: {e}", exc_info=True) + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/library/track//redownload/start', methods=['POST']) +def redownload_start(track_id): + """Start downloading a specific track from a selected source to replace the current file.""" + try: + data = request.get_json() + metadata = data.get('metadata', {}) + candidate = data.get('candidate', {}) + delete_old = data.get('delete_old_file', True) + + if not candidate.get('username') or not candidate.get('filename'): + return jsonify({"success": False, "error": "candidate with username and filename required"}), 400 + + # Get current track info for old file path + database = get_database() + conn = database._get_connection() + cursor = conn.cursor() + cursor.execute("SELECT file_path FROM tracks WHERE id = ?", (track_id,)) + row = cursor.fetchone() + conn.close() + + old_file_path = None + if row and row['file_path'] and delete_old: + old_file_path = _resolve_library_file_path(row['file_path']) + + # Build track data for the download worker + import uuid + task_id = f"redownload_{track_id}_{int(time.time())}" + batch_id = f"redownload_batch_{track_id}" + + # Create a track dict compatible with the download worker + track_data = { + 'id': metadata.get('id', ''), + 'name': metadata.get('name', ''), + 'artists': [{'name': metadata.get('artist', '')}], + 'album': {'name': metadata.get('album', '')}, + 'duration_ms': metadata.get('duration_ms', 0), + } + + # Create batch + with tasks_lock: + download_batches[batch_id] = { + 'queue': [task_id], + 'queue_index': 0, + 'active_count': 0, + 'max_concurrent': 1, + 'playlist_id': f'redownload_{track_id}', + 'playlist_name': f"Redownload: {metadata.get('artist', '')} - {metadata.get('name', '')}", + 'phase': 'downloading', + 'total_tracks': 1, + 'completed_count': 0, + 'failed_count': 0, + 'force_download': True, + 'auto_initiated': False, + } + + download_tasks[task_id] = { + 'status': 'queued', + 'track_info': track_data, + 'playlist_id': f'redownload_{track_id}', + 'batch_id': batch_id, + 'track_index': 0, + 'download_id': None, + 'username': None, + 'filename': None, + 'retry_count': 0, + 'cached_candidates': [], + 'used_sources': set(), + 'status_change_time': time.time(), + 'metadata_enhanced': False, + 'error_message': None, + '_redownload_context': { + 'library_track_id': track_id, + 'old_file_path': old_file_path, + 'delete_old_file': delete_old, + }, + } + + # Build a TrackResult-like candidate and submit to download + def _run_redownload(): + try: + from core.soulseek_client import TrackResult + tr = TrackResult( + username=candidate['username'], + filename=candidate['filename'], + size=candidate.get('size', 0), + bitrate=candidate.get('bitrate', 0), + duration=candidate.get('duration', 0), + quality=candidate.get('quality', ''), + free_upload_slots=candidate.get('free_upload_slots', 0), + upload_speed=candidate.get('upload_speed', 0), + queue_length=candidate.get('queue_length', 0), + ) + # Set track metadata on the candidate + tr.artist = metadata.get('artist', '') + tr.title = metadata.get('name', '') + tr.album = metadata.get('album', '') + + _attempt_download_with_candidates(task_id, [tr], track_data, batch_id) + except Exception as e: + logger.error(f"Redownload failed: {e}", exc_info=True) + with tasks_lock: + if task_id in download_tasks: + download_tasks[task_id]['status'] = 'failed' + download_tasks[task_id]['error_message'] = str(e) + + missing_download_executor.submit(_run_redownload) + + return jsonify({ + "success": True, + "task_id": task_id, + "batch_id": batch_id, + "message": "Redownload started", + }) + except Exception as e: + logger.error(f"Error starting redownload: {e}", exc_info=True) + return jsonify({"success": False, "error": str(e)}), 500 + + @app.route('/api/library/artist//sync', methods=['POST']) def sync_artist_library(artist_id): """Validate an artist's library entries — remove stale tracks/albums, recount.""" @@ -17657,6 +17995,31 @@ def _post_process_matched_download_with_verification(context_key, context, file_ _mark_task_completed(task_id, context.get('track_info')) download_tasks[task_id]['metadata_enhanced'] = True + # Redownload hook: delete old file and update DB path + redownload_ctx = download_tasks[task_id].get('_redownload_context') + if redownload_ctx: + try: + old_path = redownload_ctx.get('old_file_path') + lib_track_id = redownload_ctx.get('library_track_id') + if redownload_ctx.get('delete_old_file') and old_path and os.path.exists(old_path): + if os.path.normpath(old_path) != os.path.normpath(expected_final_path): + os.remove(old_path) + logger.info(f"[Redownload] Deleted old file: {old_path}") + # Update DB track with new file path + if lib_track_id and expected_final_path: + _rd_db = get_database() + _rd_conn = _rd_db._get_connection() + _rd_cursor = _rd_conn.cursor() + _rd_cursor.execute(""" + UPDATE tracks SET file_path = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (expected_final_path, lib_track_id)) + _rd_conn.commit() + _rd_conn.close() + logger.info(f"[Redownload] Updated DB path for track {lib_track_id}") + except Exception as e: + logger.error(f"[Redownload] Post-processing hook error: {e}") + with matched_context_lock: if context_key in matched_downloads_context: del matched_downloads_context[context_key] @@ -25468,6 +25831,15 @@ def _attempt_download_with_candidates(task_id, candidates, track, batch_id=None) if source_key in used_sources: print(f"⏭️ [Modal Worker] Skipping already tried source: {source_key}") continue + + # Blacklist check — skip sources the user has flagged as bad matches + try: + _bl_db = get_database() + if _bl_db.is_blacklisted(candidate.username, candidate.filename): + print(f"⛔ [Modal Worker] Skipping blacklisted source: {source_key}") + continue + except Exception: + pass # CRITICAL: Add source to used_sources IMMEDIATELY to prevent race conditions # This must happen BEFORE starting download to prevent multiple retries from picking same source diff --git a/webui/static/script.js b/webui/static/script.js index 0cbfd8fd..20820b26 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -42191,6 +42191,16 @@ function _buildTrackRow(track, album, admin) { } tr.appendChild(tagTd); + // Redownload button (admin only) + const rdTd = document.createElement('td'); + rdTd.className = 'col-redownload'; + const rdBtn = document.createElement('button'); + rdBtn.className = 'enhanced-redownload-btn'; + rdBtn.innerHTML = '↻'; + rdBtn.title = 'Redownload this track'; + rdTd.appendChild(rdBtn); + tr.appendChild(rdTd); + // Delete button (admin only) const delTd = document.createElement('td'); delTd.className = 'col-delete'; @@ -42350,6 +42360,13 @@ function _attachTableDelegation(table, album) { return; } + // Redownload button (admin) + if (target.closest('.enhanced-redownload-btn')) { + e.stopPropagation(); + showTrackRedownloadModal(track, album); + return; + } + // Delete button (admin) if (target.closest('.enhanced-delete-btn')) { e.stopPropagation(); @@ -42406,6 +42423,7 @@ function _showMobileTrackActions(track, album) { actions.push({ icon: '✎', label: 'Write Tags', action: () => showTagPreview(track.id) }); } if (admin) { + actions.push({ icon: '↻', label: 'Redownload Track', action: () => showTrackRedownloadModal(track, album) }); actions.push({ icon: '✕', label: 'Delete Track', cls: 'popover-delete', action: () => deleteLibraryTrack(track.id, album.id) }); } @@ -42649,6 +42667,311 @@ function _showSmartDeleteDialog() { }); } +// ================================================================================== +// TRACK REDOWNLOAD MODAL — Multi-step: metadata selection → source selection → download +// ================================================================================== + +async function showTrackRedownloadModal(track, album) { + const overlay = document.createElement('div'); + overlay.id = 'redownload-overlay'; + overlay.className = 'modal-overlay'; + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center;'; + overlay.onclick = e => { if (e.target === overlay) overlay.remove(); }; + + const artistName = artistDetailPageState.enhancedData?.artist?.name || ''; + const ext = (track.file_path || '').split('.').pop().toUpperCase(); + const fmt = ['FLAC','MP3','OPUS','OGG','M4A','WAV'].includes(ext) ? ext : ''; + + overlay.innerHTML = ` +
+
+

Redownload Track

+ +
+
+
${_esc(track.title)}
+
${_esc(artistName)} · ${_esc(album?.title || '')}${fmt ? ` · ${fmt}` : ''}${track.bitrate ? ` · ${track.bitrate}kbps` : ''}
+
+
+
1. Metadata
+
+
2. Source
+
+
3. Download
+
+
+
+
+ Searching metadata sources... +
+
+
+ `; + + // Escape to close + const escH = e => { if (e.key === 'Escape') { document.removeEventListener('keydown', escH); overlay.remove(); } }; + document.addEventListener('keydown', escH); + + document.body.appendChild(overlay); + + // Auto-search metadata + try { + const res = await fetch(`/api/library/track/${track.id}/redownload/search-metadata`, { method: 'POST' }); + const data = await res.json(); + if (!data.success) throw new Error(data.error); + _renderRedownloadStep1(overlay, track, data); + } catch (e) { + document.getElementById('redownload-body').innerHTML = `
Error: ${_esc(e.message)}
`; + } +} + +function _renderRedownloadStep1(overlay, track, data) { + const body = document.getElementById('redownload-body'); + if (!body) return; + + const sources = Object.keys(data.metadata_results); + if (sources.length === 0) { + body.innerHTML = '
No metadata sources available. Check your Spotify/iTunes/Deezer connections.
'; + return; + } + + const bestSource = data.best_match?.source || sources[0]; + let selectedMeta = null; + + // Build source tabs + results + const tabsHtml = sources.map(s => ``).join(''); + + const resultsHtml = sources.map(source => { + const results = data.metadata_results[source] || []; + if (results.length === 0) return `
No results from ${source}
`; + + const items = results.map((r, i) => { + const pct = Math.round((r.match_score || 0) * 100); + const cls = pct >= 90 ? 'high' : pct >= 70 ? 'medium' : 'low'; + const dur = r.duration_ms ? `${Math.floor(r.duration_ms / 60000)}:${String(Math.floor((r.duration_ms % 60000) / 1000)).padStart(2, '0')}` : ''; + const checked = (source === bestSource && i === 0) ? 'checked' : ''; + return ` + `; + }).join(''); + + return `
${items}
`; + }).join(''); + + body.innerHTML = ` +
${tabsHtml}
+
${resultsHtml}
+
+ + +
+ `; + + // Tab switching + body.querySelectorAll('.redownload-tab').forEach(tab => { + tab.addEventListener('click', () => { + body.querySelectorAll('.redownload-tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + body.querySelectorAll('.redownload-source-panel').forEach(p => p.style.display = 'none'); + const panel = body.querySelector(`.redownload-source-panel[data-source="${tab.dataset.source}"]`); + if (panel) panel.style.display = ''; + }); + }); + + // Next button + document.getElementById('redownload-next-btn').addEventListener('click', async () => { + const checked = body.querySelector('input[name="metadata-choice"]:checked'); + if (!checked) { showToast('Select a metadata source first', 'error'); return; } + const [source, idx] = checked.value.split('|'); + selectedMeta = data.metadata_results[source][parseInt(idx)]; + selectedMeta._source = source; + + // Update step indicator + overlay.querySelectorAll('.redownload-step').forEach(s => s.classList.remove('active')); + overlay.querySelector('.redownload-step[data-step="2"]').classList.add('active'); + + body.innerHTML = '
Searching download sources...
'; + + try { + const res = await fetch(`/api/library/track/${track.id}/redownload/search-sources`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ metadata: selectedMeta }) + }); + const srcData = await res.json(); + if (!srcData.success) throw new Error(srcData.error); + _renderRedownloadStep2(overlay, track, selectedMeta, srcData); + } catch (e) { + body.innerHTML = `
Source search failed: ${_esc(e.message)}
`; + } + }); +} + +function _renderRedownloadStep2(overlay, track, metadata, srcData) { + const body = document.getElementById('redownload-body'); + if (!body) return; + + const candidates = srcData.candidates || []; + if (candidates.length === 0) { + body.innerHTML = ` +
No download sources found for this track.
+
+ +
`; + return; + } + + const bestIdx = srcData.best_candidate_index >= 0 ? srcData.best_candidate_index : 0; + + const candidatesHtml = candidates.map((c, i) => { + const confPct = Math.round((c.confidence || 0) * 100); + const confCls = confPct >= 90 ? 'high' : confPct >= 70 ? 'medium' : 'low'; + const checked = (i === bestIdx && !c.blacklisted) ? 'checked' : ''; + const blClass = c.blacklisted ? ' blacklisted' : ''; + + return ` + `; + }).join(''); + + body.innerHTML = ` +
${candidatesHtml}
+ +
+ + +
+ `; + + document.getElementById('redownload-start-btn').addEventListener('click', async () => { + const checked = body.querySelector('input[name="source-choice"]:checked'); + if (!checked) { showToast('Select a download source', 'error'); return; } + const candidate = candidates[parseInt(checked.value)]; + const deleteOld = document.getElementById('redownload-delete-old-check')?.checked ?? true; + + // Update step indicator + overlay.querySelectorAll('.redownload-step').forEach(s => s.classList.remove('active')); + overlay.querySelector('.redownload-step[data-step="3"]').classList.add('active'); + + body.innerHTML = ` +
+
Downloading: ${_esc(candidate.display_name)}
+
from ${_esc(candidate.username)}
+
+
Starting download...
+
+ `; + + try { + const res = await fetch(`/api/library/track/${track.id}/redownload/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ metadata, candidate, delete_old_file: deleteOld }) + }); + const startData = await res.json(); + if (!startData.success) throw new Error(startData.error); + + // Poll for progress + _pollRedownloadProgress(startData.task_id, overlay); + } catch (e) { + body.innerHTML = `
Download failed: ${_esc(e.message)}
`; + } + }); +} + +function _pollRedownloadProgress(taskId, overlay) { + const bar = document.getElementById('redownload-progress-bar'); + const status = document.getElementById('redownload-progress-status'); + + const poll = setInterval(async () => { + try { + const res = await fetch(`/api/active-processes`); + const data = await res.json(); + + // Find our task in download_tasks via batch + // Check if the task is in any batch + let taskStatus = null; + const procs = data.active_processes || []; + for (const proc of procs) { + if (proc.batch_id && proc.batch_id.includes(`redownload_batch_`)) { + taskStatus = proc; + break; + } + } + + // Simpler: just check the task status directly + // Since we can't easily get individual task status from active-processes, + // we'll use a simple timer-based approach + if (status) { + const elapsed = Math.round((Date.now() - _redownloadStartTime) / 1000); + status.textContent = `Downloading... (${elapsed}s)`; + } + if (bar) bar.style.width = `${Math.min(90, (Date.now() - _redownloadStartTime) / 600)}%`; + + } catch (e) { /* ignore poll errors */ } + }, 2000); + + _redownloadStartTime = Date.now(); + + // Also poll for completion via a simpler check + const completionCheck = setInterval(async () => { + try { + // Check if the task completed by trying to see if the batch is gone + const res = await fetch('/api/active-processes'); + const data = await res.json(); + const procs = data.active_processes || []; + const ourBatch = procs.find(p => p.batch_id && p.batch_id.includes('redownload_batch_')); + + if (!ourBatch) { + // Batch is gone — either completed or failed + clearInterval(poll); + clearInterval(completionCheck); + if (bar) bar.style.width = '100%'; + if (status) status.textContent = 'Complete!'; + showToast('Track redownloaded successfully', 'success'); + setTimeout(() => { + overlay.remove(); + // Refresh enhanced view + if (artistDetailPageState.enhancedData?.artist?.id) { + loadEnhancedViewData(artistDetailPageState.enhancedData.artist.id); + } + }, 1500); + } + } catch (e) { /* ignore */ } + }, 3000); + + // Safety timeout — 5 minutes max + setTimeout(() => { + clearInterval(poll); + clearInterval(completionCheck); + if (status) status.textContent = 'Download may still be in progress. Check the dashboard.'; + }, 300000); +} +let _redownloadStartTime = 0; + async function deleteLibraryAlbum(albumId) { if (!await showConfirmDialog({ title: 'Delete Album', message: 'Delete this album and all its tracks from the library? (Files on disk are not affected)', confirmText: 'Delete', destructive: true })) return; try { diff --git a/webui/static/style.css b/webui/static/style.css index 9544afcf..5a050be0 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -52044,3 +52044,157 @@ tr.tag-diff-same { color: rgba(255, 255, 255, 0.35); line-height: 1.3; } + +/* ================================================================================== + TRACK REDOWNLOAD MODAL + ================================================================================== */ + +.redownload-modal { + background: rgba(20, 20, 28, 0.98); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 20px; + max-width: 600px; + width: 95vw; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 0 32px 80px rgba(0, 0, 0, 0.6); +} + +.redownload-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px 0; +} +.redownload-header h3 { font-size: 16px; font-weight: 700; color: #fff; margin: 0; } + +.redownload-close { + width: 30px; height: 30px; border: none; background: rgba(255,255,255,0.06); + color: rgba(255,255,255,0.4); border-radius: 50%; font-size: 18px; cursor: pointer; + display: flex; align-items: center; justify-content: center; transition: all 0.2s; +} +.redownload-close:hover { background: rgba(255,255,255,0.12); color: #fff; } + +.redownload-current { + padding: 10px 24px 0; +} +.redownload-current-title { font-size: 13px; font-weight: 600; color: rgba(255,255,255,0.8); } +.redownload-current-meta { font-size: 11px; color: rgba(255,255,255,0.3); margin-top: 2px; } + +/* Step indicator */ +.redownload-steps { + display: flex; align-items: center; gap: 8px; padding: 14px 24px; +} +.redownload-step { + font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.2); + padding: 4px 10px; border-radius: 6px; transition: all 0.2s; +} +.redownload-step.active { color: var(--accent); background: rgba(var(--accent-rgb), 0.1); } +.redownload-step-arrow { color: rgba(255,255,255,0.1); font-size: 11px; } + +.redownload-body { padding: 0 24px 20px; overflow-y: auto; flex: 1; } + +.redownload-loading, .redownload-error, .redownload-empty { + text-align: center; padding: 30px; color: rgba(255,255,255,0.25); font-size: 12px; +} +.redownload-error { color: #ef5350; } + +/* Step 1 — Metadata tabs + results */ +.redownload-tabs { display: flex; gap: 4px; margin-bottom: 12px; } +.redownload-tab { + padding: 6px 14px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.06); + background: rgba(255,255,255,0.03); color: rgba(255,255,255,0.5); font-size: 11px; + font-weight: 600; cursor: pointer; transition: all 0.2s; +} +.redownload-tab.active { background: rgba(var(--accent-rgb),0.12); color: var(--accent); border-color: rgba(var(--accent-rgb),0.2); } +.redownload-tab:hover:not(.active) { background: rgba(255,255,255,0.06); } + +.redownload-results-wrap { max-height: 300px; overflow-y: auto; } +.redownload-results-wrap::-webkit-scrollbar { width: 4px; } +.redownload-results-wrap::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 2px; } + +.redownload-result { + display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 10px; + cursor: pointer; transition: background 0.15s; margin-bottom: 2px; +} +.redownload-result:hover { background: rgba(255,255,255,0.04); } +.redownload-result input[type="radio"] { accent-color: var(--accent); flex-shrink: 0; } + +.redownload-result-art { width: 40px; height: 40px; border-radius: 6px; overflow: hidden; flex-shrink: 0; } +.redownload-result-art img { width: 100%; height: 100%; object-fit: cover; } +.redownload-art-empty { width: 100%; height: 100%; background: rgba(255,255,255,0.04); border-radius: 6px; } + +.redownload-result-info { flex: 1; min-width: 0; } +.redownload-result-title { font-size: 12px; font-weight: 600; color: rgba(255,255,255,0.85); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.redownload-result-meta { font-size: 10px; color: rgba(255,255,255,0.35); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.redownload-current-badge { font-size: 8px; background: rgba(var(--accent-rgb),0.15); color: var(--accent); padding: 1px 5px; border-radius: 3px; font-weight: 700; text-transform: uppercase; } + +.redownload-result-score { font-size: 10px; font-weight: 700; flex-shrink: 0; padding: 2px 6px; border-radius: 4px; } +.redownload-result-score.high { background: rgba(76,175,80,0.12); color: #4caf50; } +.redownload-result-score.medium { background: rgba(255,183,77,0.12); color: #ffb74d; } +.redownload-result-score.low { background: rgba(239,83,80,0.12); color: #ef5350; } + +.redownload-result-dur { font-size: 10px; color: rgba(255,255,255,0.2); flex-shrink: 0; min-width: 30px; text-align: right; } + +/* Step 2 — Download candidates */ +.redownload-candidates-wrap { max-height: 320px; overflow-y: auto; } +.redownload-candidates-wrap::-webkit-scrollbar { width: 4px; } +.redownload-candidates-wrap::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 2px; } + +.redownload-candidate { + display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 10px; + cursor: pointer; transition: background 0.15s; margin-bottom: 2px; border: 1px solid transparent; +} +.redownload-candidate:hover { background: rgba(255,255,255,0.04); } +.redownload-candidate input[type="radio"] { accent-color: var(--accent); flex-shrink: 0; } +.redownload-candidate.blacklisted { opacity: 0.35; cursor: not-allowed; } + +.redownload-candidate-info { flex: 1; min-width: 0; } +.redownload-candidate-name { font-size: 12px; font-weight: 600; color: rgba(255,255,255,0.85); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.redownload-candidate-meta { font-size: 10px; color: rgba(255,255,255,0.35); } + +.redownload-candidate-details { display: flex; align-items: center; gap: 4px; flex-shrink: 0; } +.redownload-fmt { font-size: 9px; font-weight: 700; padding: 2px 5px; border-radius: 3px; background: rgba(var(--accent-rgb),0.12); color: var(--accent); } +.redownload-bitrate { font-size: 9px; color: rgba(255,255,255,0.25); } +.redownload-size { font-size: 9px; color: rgba(255,255,255,0.2); } + +.redownload-candidate-conf { font-size: 10px; font-weight: 700; flex-shrink: 0; padding: 2px 6px; border-radius: 4px; } +.redownload-candidate-conf.high { background: rgba(76,175,80,0.12); color: #4caf50; } +.redownload-candidate-conf.medium { background: rgba(255,183,77,0.12); color: #ffb74d; } +.redownload-candidate-conf.low { background: rgba(239,83,80,0.12); color: #ef5350; } + +.redownload-blacklisted { font-size: 9px; color: #ef5350; font-weight: 600; flex-shrink: 0; } + +.redownload-delete-old { + display: flex; align-items: center; gap: 8px; padding: 12px 0 4px; + font-size: 11px; color: rgba(255,255,255,0.4); cursor: pointer; +} +.redownload-delete-old input { accent-color: var(--accent); } + +/* Step 3 — Progress */ +.redownload-progress { padding: 20px 0; text-align: center; } +.redownload-progress-title { font-size: 13px; font-weight: 600; color: rgba(255,255,255,0.8); } +.redownload-progress-from { font-size: 11px; color: rgba(255,255,255,0.3); margin: 4px 0 16px; } +.redownload-progress-bar-wrap { height: 6px; background: rgba(255,255,255,0.06); border-radius: 3px; overflow: hidden; } +.redownload-progress-bar { height: 100%; background: var(--accent); border-radius: 3px; width: 0; transition: width 0.5s ease; } +.redownload-progress-status { font-size: 11px; color: rgba(255,255,255,0.3); margin-top: 12px; } + +/* Action buttons */ +.redownload-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 14px; } +.redownload-btn { + padding: 8px 18px; border-radius: 10px; font-size: 12px; font-weight: 600; cursor: pointer; transition: all 0.2s; border: none; +} +.redownload-btn.primary { background: var(--accent); color: #000; } +.redownload-btn.primary:hover { filter: brightness(1.1); } +.redownload-btn.secondary { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.6); } +.redownload-btn.secondary:hover { background: rgba(255,255,255,0.1); } + +/* Redownload button in track table */ +.enhanced-redownload-btn { + background: none; border: none; color: rgba(255,255,255,0.2); cursor: pointer; + font-size: 16px; padding: 2px 4px; border-radius: 4px; transition: all 0.2s; +} +.enhanced-redownload-btn:hover { color: var(--accent); background: rgba(var(--accent-rgb),0.1); } +.col-redownload { width: 30px; text-align: center; }