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
This commit is contained in:
parent
b44bb34b44
commit
54b7a0f0e8
4 changed files with 339 additions and 3 deletions
|
|
@ -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).
|
||||
|
|
|
|||
132
web_server.py
132
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/<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, '"')})">
|
||||
<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, '"')})">
|
||||
<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…
Reference in a new issue