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
pull/273/head
Broque Thomas 1 month ago
parent b44bb34b44
commit 54b7a0f0e8

@ -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).

@ -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/<video_id>', 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"""

@ -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 `
<div class="enh-video-card" data-video-id="${v.video_id}" onclick="window.open('${v.url}', '_blank')">
<div class="enh-video-card" data-video-id="${v.video_id}" onclick="_downloadMusicVideo(this, ${JSON.stringify(v).replace(/"/g, '&quot;')})">
<div class="enh-video-thumb">
<img src="${v.thumbnail}" alt="" loading="lazy" onerror="this.style.display='none'">
<div class="enh-video-play"></div>
<div class="enh-video-progress-ring hidden">
<svg viewBox="0 0 36 36">
<circle class="enh-video-progress-bg" cx="18" cy="18" r="15.5" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="3"/>
<circle class="enh-video-progress-bar" cx="18" cy="18" r="15.5" fill="none" stroke="rgb(var(--accent-rgb))" stroke-width="3" stroke-dasharray="97.4" stroke-dashoffset="97.4" stroke-linecap="round" transform="rotate(-90 18 18)"/>
</svg>
</div>
<div class="enh-video-done hidden"></div>
<div class="enh-video-error hidden"></div>
${duration ? `<span class="enh-video-duration">${duration}</span>` : ''}
</div>
<div class="enh-video-info">
@ -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 `<div class="enh-video-card" onclick="window.open('${v.url}', '_blank')">
<div class="enh-video-thumb"><img src="${v.thumbnail}" alt="" loading="lazy" onerror="this.style.display='none'"><div class="enh-video-play"></div>${dur ? `<span class="enh-video-duration">${dur}</span>` : ''}</div>
return `<div class="enh-video-card" data-video-id="${v.video_id}" onclick="_downloadMusicVideo(this, ${_escToast(JSON.stringify(v)).replace(/"/g, '&quot;')})">
<div class="enh-video-thumb"><img src="${v.thumbnail}" alt="" loading="lazy" onerror="this.style.display='none'"><div class="enh-video-play"></div>
<div class="enh-video-progress-ring hidden"><svg viewBox="0 0 36 36"><circle class="enh-video-progress-bg" cx="18" cy="18" r="15.5" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="3"/><circle class="enh-video-progress-bar" cx="18" cy="18" r="15.5" fill="none" stroke="rgb(var(--accent-rgb))" stroke-width="3" stroke-dasharray="97.4" stroke-dashoffset="97.4" stroke-linecap="round" transform="rotate(-90 18 18)"/></svg></div>
<div class="enh-video-done hidden"></div><div class="enh-video-error hidden"></div>
${dur ? `<span class="enh-video-duration">${dur}</span>` : ''}</div>
<div class="enh-video-info"><div class="enh-video-title">${_escToast(v.title)}</div><div class="enh-video-channel">${_escToast(v.channel)}${views ? ` · ${views} views` : ''}</div></div>
</div>`;
}).join('');

@ -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;

Loading…
Cancel
Save