From 54b7a0f0e8831c28fa50921893f0f9a16f2a3a44 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:14:20 -0700 Subject: [PATCH] Add music video download with progress and metadata matching Click any video card in Music Videos tab to download. Flow: 1. Search primary metadata source for clean artist/title 2. Fall back to YouTube title parsing if no match 3. Download video via yt-dlp (best quality MP4) 4. Save to configured Music Videos folder as Artist/Title-video.mp4 UI shows circular progress ring on the thumbnail during download, green checkmark on completion, red X on error (clickable to retry). Cards are non-interactive while downloading. Backend: /api/music-video/download and /api/music-video/status endpoints YouTube client: download_music_video() method keeps video format --- core/youtube_client.py | 59 ++++++++++++++++++ web_server.py | 132 +++++++++++++++++++++++++++++++++++++++++ webui/static/script.js | 85 +++++++++++++++++++++++++- webui/static/style.css | 66 +++++++++++++++++++++ 4 files changed, 339 insertions(+), 3 deletions(-) diff --git a/core/youtube_client.py b/core/youtube_client.py index 5119dd18..0f0e6fe0 100644 --- a/core/youtube_client.py +++ b/core/youtube_client.py @@ -1070,6 +1070,65 @@ class YouTubeClient: traceback.print_exc() return None + def download_music_video(self, video_url: str, output_path: str, + progress_callback=None) -> Optional[str]: + """Download a YouTube video as a music video file (keeps video, not audio-only). + + Args: + video_url: YouTube video URL + output_path: Full path for the output file (without extension โ€” yt-dlp adds it) + progress_callback: Optional callback(percent: float) for progress updates + + Returns: + Final file path if successful, None otherwise + """ + try: + from config.settings import config_manager + + def _progress_hook(d): + if progress_callback and d.get('status') == 'downloading': + total = d.get('total_bytes') or d.get('total_bytes_estimate') or 0 + downloaded = d.get('downloaded_bytes', 0) + if total > 0: + progress_callback(downloaded / total * 100) + + download_opts = { + 'quiet': True, + 'no_warnings': True, + 'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', + 'merge_output_format': 'mp4', + 'outtmpl': output_path + '.%(ext)s', + 'noplaylist': True, + 'progress_hooks': [_progress_hook], + 'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + } + + cookies_browser = config_manager.get('youtube.cookies_browser', '') + if cookies_browser: + download_opts['cookiesfrombrowser'] = (cookies_browser,) + + with yt_dlp.YoutubeDL(download_opts) as ydl: + info = ydl.extract_info(video_url, download=True) + final_path = Path(ydl.prepare_filename(info)) + # yt-dlp may have merged to mp4 + mp4_path = final_path.with_suffix('.mp4') + if mp4_path.exists(): + return str(mp4_path) + if final_path.exists(): + return str(final_path) + # Check for any file matching the stem + for f in final_path.parent.glob(f"{final_path.stem}.*"): + if f.suffix in ('.mp4', '.mkv', '.webm'): + return str(f) + logger.error(f"Music video download completed but file not found: {final_path}") + return None + + except Exception as e: + logger.error(f"Music video download failed: {e}") + import traceback + traceback.print_exc() + return None + async def get_all_downloads(self) -> List[DownloadStatus]: """ Get all active downloads (matches Soulseek interface). diff --git a/web_server.py b/web_server.py index 6d357de5..7165ee3d 100644 --- a/web_server.py +++ b/web_server.py @@ -8346,6 +8346,138 @@ def stream_enhanced_search_track(): logger.error(f"โŒ Error streaming enhanced search track: {e}", exc_info=True) return jsonify({"error": str(e)}), 500 +# ============================================================================= +# MUSIC VIDEO DOWNLOADS +# ============================================================================= + +_music_video_downloads = {} # {video_id: {status, progress, path, error}} + +@app.route('/api/music-video/download', methods=['POST']) +def download_music_video(): + """Download a YouTube video as a music video file to the configured music videos folder.""" + data = request.get_json() + if not data: + return jsonify({"error": "No data"}), 400 + + video_id = data.get('video_id', '') + video_url = data.get('url', '') + raw_title = data.get('title', '') + raw_channel = data.get('channel', '') + + if not video_id or not video_url: + return jsonify({"error": "Missing video_id or url"}), 400 + + # Check if already downloading + if video_id in _music_video_downloads and _music_video_downloads[video_id].get('status') == 'downloading': + return jsonify({"error": "Already downloading"}), 409 + + # Get music videos path + music_videos_path = config_manager.get('library.music_videos_path', './MusicVideos') + music_videos_path = docker_resolve_path(music_videos_path) + os.makedirs(music_videos_path, exist_ok=True) + + # Initialize download state + _music_video_downloads[video_id] = {'status': 'searching', 'progress': 0, 'path': None, 'error': None} + + def _do_download(): + try: + # Step 1: Try to match against primary metadata source for clean artist/title + _music_video_downloads[video_id]['status'] = 'matching' + artist_name = raw_channel + track_title = raw_title + + # Strip common YouTube suffixes for cleaner search + import re as _re + clean_search = _re.sub(r'\s*[\(\[](official\s*(music\s*)?video|official\s*lyric\s*video|official\s*audio|official\s*hd|hd|4k|remastered|lyric\s*video|visualizer|audio)[\)\]]', '', raw_title, flags=_re.IGNORECASE).strip() + clean_search = _re.sub(r'\s*-\s*$', '', clean_search).strip() + + try: + fallback_client = _get_metadata_fallback_client() + results = fallback_client.search_tracks(clean_search, limit=5) + if results: + from difflib import SequenceMatcher + best = None + best_score = 0 + for r in results: + name_sim = SequenceMatcher(None, clean_search.lower(), r.name.lower()).ratio() + if r.artists: + artist_sim = SequenceMatcher(None, raw_channel.lower(), r.artists[0].lower()).ratio() + name_sim = (name_sim * 0.6) + (artist_sim * 0.4) + if name_sim > best_score: + best_score = name_sim + best = r + if best and best_score >= 0.5: + artist_name = best.artists[0] if best.artists else raw_channel + track_title = best.name + print(f"๐ŸŽฌ [Music Video] Matched to: {artist_name} - {track_title} (confidence: {best_score:.2f})") + else: + # Parse artist from video title: "Artist - Title" pattern + if ' - ' in raw_title: + parts = raw_title.split(' - ', 1) + artist_name = parts[0].strip() + track_title = _re.sub(r'\s*[\(\[].*?[\)\]]', '', parts[1]).strip() + print(f"๐ŸŽฌ [Music Video] No metadata match, using parsed: {artist_name} - {track_title}") + except Exception as e: + print(f"โš ๏ธ [Music Video] Metadata lookup failed: {e}") + if ' - ' in raw_title: + parts = raw_title.split(' - ', 1) + artist_name = parts[0].strip() + track_title = _re.sub(r'\s*[\(\[].*?[\)\]]', '', parts[1]).strip() + + # Sanitize for filesystem + def _sanitize(s): + return _re.sub(r'[<>:"/\\|?*]', '_', s).strip().rstrip('.') + + artist_folder = _sanitize(artist_name) + video_filename = f"{_sanitize(track_title)}-video" + + # Build output path: MusicVideos/Artist/Title-video + artist_dir = os.path.join(music_videos_path, artist_folder) + os.makedirs(artist_dir, exist_ok=True) + output_path = os.path.join(artist_dir, video_filename) + + # Step 2: Download + _music_video_downloads[video_id]['status'] = 'downloading' + _music_video_downloads[video_id]['artist'] = artist_name + _music_video_downloads[video_id]['title'] = track_title + + def _progress(pct): + _music_video_downloads[video_id]['progress'] = round(pct, 1) + + final_path = soulseek_client.youtube.download_music_video(video_url, output_path, progress_callback=_progress) + + if final_path and os.path.exists(final_path): + _music_video_downloads[video_id]['status'] = 'completed' + _music_video_downloads[video_id]['progress'] = 100 + _music_video_downloads[video_id]['path'] = final_path + print(f"โœ… [Music Video] Downloaded: {artist_name} - {track_title} โ†’ {final_path}") + add_activity_item("๐ŸŽฌ", "Music Video Downloaded", f"{artist_name} - {track_title}", "Now") + else: + _music_video_downloads[video_id]['status'] = 'error' + _music_video_downloads[video_id]['error'] = 'Download failed โ€” file not found' + print(f"โŒ [Music Video] Download failed for: {artist_name} - {track_title}") + + except Exception as e: + _music_video_downloads[video_id]['status'] = 'error' + _music_video_downloads[video_id]['error'] = str(e) + print(f"โŒ [Music Video] Error: {e}") + + # Run in background thread + import threading + threading.Thread(target=_do_download, daemon=True, name=f'music-video-{video_id}').start() + + return jsonify({"success": True, "video_id": video_id}) + + +@app.route('/api/music-video/status/', methods=['GET']) +def get_music_video_status(video_id): + """Get download status for a music video.""" + status = _music_video_downloads.get(video_id) + if not status: + return jsonify({"status": "unknown"}) + return jsonify(status) + + @app.route('/api/download', methods=['POST']) def start_download(): """Simple download route""" diff --git a/webui/static/script.js b/webui/static/script.js index 1e5147e6..94704f03 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -8917,10 +8917,18 @@ function initializeSearchModeToggle() { const duration = v.duration ? `${Math.floor(v.duration / 60)}:${String(v.duration % 60).padStart(2, '0')}` : ''; const views = v.view_count ? _formatViewCount(v.view_count) : ''; return ` -
+
โ–ถ
+ + + ${duration ? `${duration}` : ''}
@@ -8932,6 +8940,74 @@ function initializeSearchModeToggle() { }).join(''); } + window._downloadMusicVideo = async function(cardEl, video) { + if (cardEl.classList.contains('downloading') || cardEl.classList.contains('completed')) return; + cardEl.classList.add('downloading'); + cardEl.onclick = null; // Disable click + + const playBtn = cardEl.querySelector('.enh-video-play'); + const progressRing = cardEl.querySelector('.enh-video-progress-ring'); + const progressBar = cardEl.querySelector('.enh-video-progress-bar'); + const doneIcon = cardEl.querySelector('.enh-video-done'); + const errorIcon = cardEl.querySelector('.enh-video-error'); + + if (playBtn) playBtn.classList.add('hidden'); + if (progressRing) progressRing.classList.remove('hidden'); + + try { + const res = await fetch('/api/music-video/download', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + video_id: video.video_id, + url: video.url, + title: video.title, + channel: video.channel, + }), + }); + if (!res.ok) throw new Error('Download request failed'); + + // Poll for progress + const circumference = 97.4; // 2 * PI * 15.5 + const pollInterval = setInterval(async () => { + try { + const statusRes = await fetch(`/api/music-video/status/${video.video_id}`); + const status = await statusRes.json(); + + if (progressBar && status.progress > 0) { + const offset = circumference - (status.progress / 100) * circumference; + progressBar.style.strokeDashoffset = offset; + } + + if (status.status === 'completed') { + clearInterval(pollInterval); + cardEl.classList.remove('downloading'); + cardEl.classList.add('completed'); + if (progressRing) progressRing.classList.add('hidden'); + if (doneIcon) doneIcon.classList.remove('hidden'); + } else if (status.status === 'error') { + clearInterval(pollInterval); + cardEl.classList.remove('downloading'); + cardEl.classList.add('errored'); + if (progressRing) progressRing.classList.add('hidden'); + if (errorIcon) errorIcon.classList.remove('hidden'); + // Re-enable click for retry + cardEl.onclick = () => window._downloadMusicVideo(cardEl, video); + } + } catch (e) { + // Polling error โ€” keep trying + } + }, 500); + + } catch (e) { + cardEl.classList.remove('downloading'); + if (progressRing) progressRing.classList.add('hidden'); + if (playBtn) playBtn.classList.remove('hidden'); + if (errorIcon) errorIcon.classList.remove('hidden'); + cardEl.onclick = () => window._downloadMusicVideo(cardEl, video); + } + }; + function _formatViewCount(count) { if (count >= 1000000000) return `${(count / 1000000000).toFixed(1)}B`; if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`; @@ -17691,8 +17767,11 @@ function _gsRender(data) { h += videos.map(v => { const dur = v.duration ? `${Math.floor(v.duration / 60)}:${String(v.duration % 60).padStart(2, '0')}` : ''; const views = v.view_count >= 1000000 ? `${(v.view_count/1000000).toFixed(1)}M` : v.view_count >= 1000 ? `${(v.view_count/1000).toFixed(1)}K` : (v.view_count || ''); - return `
-
โ–ถ
${dur ? `${dur}` : ''}
+ return `
+
โ–ถ
+ + + ${dur ? `${dur}` : ''}
${_escToast(v.title)}
${_escToast(v.channel)}${views ? ` ยท ${views} views` : ''}
`; }).join(''); diff --git a/webui/static/style.css b/webui/static/style.css index 6524f808..e385538d 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -32982,6 +32982,72 @@ body.helper-mode-active #dashboard-activity-feed:hover { color: rgba(255, 255, 255, 0.45); } +/* Video download states */ +.enh-video-card.downloading { + pointer-events: none; + opacity: 0.8; +} + +.enh-video-card.completed .enh-video-thumb::after { + content: ''; + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.4); +} + +.enh-video-card.errored .enh-video-thumb::after { + content: ''; + position: absolute; + inset: 0; + background: rgba(180, 0, 0, 0.3); +} + +.enh-video-progress-ring { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 44px; + height: 44px; + z-index: 2; +} + +.enh-video-progress-ring svg { + width: 100%; + height: 100%; +} + +.enh-video-progress-bar { + transition: stroke-dashoffset 0.3s ease; +} + +.enh-video-done, .enh-video-error { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 44px; + height: 44px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + font-weight: 700; + z-index: 2; +} + +.enh-video-done { + background: rgba(29, 185, 84, 0.85); + color: #fff; +} + +.enh-video-error { + background: rgba(220, 50, 50, 0.85); + color: #fff; + cursor: pointer; +} + .enh-empty-state { text-align: center; padding: 40px 20px;