1248 lines
50 KiB
Python
1248 lines
50 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
YouTube Download Test Client - Proof of Concept
|
||
Mirrors the Soulseek download flow but uses yt-dlp/YouTube instead.
|
||
|
||
This is a standalone test file to evaluate YouTube as a fallback download source.
|
||
Does NOT modify any production code.
|
||
|
||
Requirements:
|
||
pip install yt-dlp
|
||
|
||
Usage:
|
||
python tools/test_youtube_download.py
|
||
"""
|
||
|
||
import sys
|
||
import os
|
||
import json
|
||
import re
|
||
from typing import List, Optional, Dict, Any, Tuple
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from datetime import datetime
|
||
|
||
# Fix Windows console encoding for emojis
|
||
if sys.platform == 'win32':
|
||
import io
|
||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
|
||
|
||
# Try to import yt-dlp
|
||
try:
|
||
import yt_dlp
|
||
except ImportError:
|
||
print("❌ yt-dlp not installed. Install with: pip install yt-dlp")
|
||
sys.exit(1)
|
||
|
||
# Add parent directory to path to import from core
|
||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
|
||
from utils.logging_config import get_logger
|
||
from core.matching_engine import MusicMatchingEngine
|
||
|
||
logger = get_logger("youtube_test")
|
||
|
||
|
||
@dataclass
|
||
class SpotifyTrack:
|
||
"""Mock Spotify track (matches your actual SpotifyTrack)"""
|
||
id: str
|
||
name: str
|
||
artists: List[str]
|
||
album: str
|
||
duration_ms: int
|
||
popularity: int = 0
|
||
|
||
|
||
@dataclass
|
||
class YouTubeSearchResult:
|
||
"""YouTube search result - mirrors TrackResult structure"""
|
||
video_id: str
|
||
title: str
|
||
channel: str # Artist/uploader
|
||
duration: int # seconds
|
||
url: str
|
||
thumbnail: str
|
||
view_count: int
|
||
upload_date: str
|
||
|
||
# Parsed metadata (attempted extraction from title)
|
||
parsed_artist: Optional[str] = None
|
||
parsed_title: Optional[str] = None
|
||
parsed_album: Optional[str] = None
|
||
|
||
# Quality info (extracted from available formats)
|
||
available_quality: str = "unknown" # e.g., "256kbps AAC"
|
||
best_audio_format: Optional[Dict] = None
|
||
|
||
# Confidence score (0.0 - 1.0)
|
||
confidence: float = 0.0
|
||
match_reason: str = ""
|
||
|
||
def __post_init__(self):
|
||
"""Parse metadata from title"""
|
||
self._parse_title_metadata()
|
||
|
||
def _parse_title_metadata(self):
|
||
"""Extract artist and title from YouTube video title"""
|
||
# Common patterns: "Artist - Title", "Artist: Title", "Title by Artist"
|
||
patterns = [
|
||
r'^(.+?)\s*[-–—]\s*(.+)$', # Artist - Title
|
||
r'^(.+?)\s*:\s*(.+)$', # Artist: Title
|
||
r'^(.+?)\s+by\s+(.+)$', # Title by Artist (reversed)
|
||
]
|
||
|
||
for pattern in patterns:
|
||
match = re.match(pattern, self.title, re.IGNORECASE)
|
||
if match:
|
||
if 'by' in pattern: # Reversed format
|
||
self.parsed_title = match.group(1).strip()
|
||
self.parsed_artist = match.group(2).strip()
|
||
else:
|
||
self.parsed_artist = match.group(1).strip()
|
||
self.parsed_title = match.group(2).strip()
|
||
break
|
||
|
||
# Fallback: use entire title
|
||
if not self.parsed_title:
|
||
self.parsed_title = self.title
|
||
self.parsed_artist = self.channel
|
||
|
||
|
||
class YouTubeClient:
|
||
"""
|
||
YouTube download client using yt-dlp.
|
||
Mirrors SoulseekClient API structure for easy comparison.
|
||
"""
|
||
|
||
def __init__(self, download_path: str = "./downloads/youtube"):
|
||
self.download_path = Path(download_path)
|
||
self.download_path.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Initialize production matching engine for parity with Soulseek
|
||
self.matching_engine = MusicMatchingEngine()
|
||
logger.info("✅ Initialized production MusicMatchingEngine")
|
||
|
||
# Check for ffmpeg (REQUIRED for MP3 conversion)
|
||
if not self._check_ffmpeg():
|
||
print("\n" + "="*80)
|
||
print("❌ ERROR: ffmpeg is required but not found in PATH")
|
||
print("="*80)
|
||
print("\nInstall ffmpeg:")
|
||
print(" Windows: scoop install ffmpeg")
|
||
print(" Linux: sudo apt install ffmpeg")
|
||
print(" Mac: brew install ffmpeg")
|
||
print("\nAfter installing, restart your terminal and try again.")
|
||
print("="*80 + "\n")
|
||
raise RuntimeError("ffmpeg is required for MP3 conversion but was not found in PATH")
|
||
|
||
# yt-dlp options for search
|
||
self.search_opts = {
|
||
'quiet': True,
|
||
'no_warnings': True,
|
||
'extract_flat': True, # Don't download during search
|
||
'skip_download': True,
|
||
# Anti-bot measures
|
||
'extractor_args': {
|
||
'youtube': {
|
||
'player_client': ['android', 'web'],
|
||
'skip': ['hls', 'dash']
|
||
}
|
||
},
|
||
}
|
||
|
||
# yt-dlp options for download - MP3 conversion with ffmpeg
|
||
self.download_opts = {
|
||
'format': 'bestaudio/best', # Best audio quality
|
||
'outtmpl': str(self.download_path / '%(artist)s - %(title)s.%(ext)s'),
|
||
'quiet': False,
|
||
'no_warnings': False,
|
||
'progress_hooks': [self._download_progress_hook],
|
||
# Anti-bot measures for YouTube
|
||
'extractor_args': {
|
||
'youtube': {
|
||
'player_client': ['android', 'web'], # Use mobile client to avoid restrictions
|
||
'skip': ['hls', 'dash']
|
||
}
|
||
},
|
||
# Better user agent
|
||
'http_headers': {
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||
'Accept-Language': 'en-us,en;q=0.5',
|
||
'Sec-Fetch-Mode': 'navigate',
|
||
},
|
||
# FFmpeg audio extraction to MP3
|
||
'postprocessors': [{
|
||
'key': 'FFmpegExtractAudio',
|
||
'preferredcodec': 'mp3',
|
||
'preferredquality': '320',
|
||
}],
|
||
# Don't download YouTube thumbnail - we'll get album art from Spotify
|
||
'writethumbnail': False,
|
||
}
|
||
|
||
self.current_download_progress = {}
|
||
|
||
def _check_ffmpeg(self) -> bool:
|
||
"""Check if ffmpeg is available (system PATH or auto-download to tools folder)"""
|
||
import shutil
|
||
import platform
|
||
import urllib.request
|
||
import zipfile
|
||
import tarfile
|
||
|
||
# Check if ffmpeg is in system PATH
|
||
if shutil.which('ffmpeg'):
|
||
logger.info("✅ Found ffmpeg in system PATH")
|
||
return True
|
||
|
||
# Auto-download ffmpeg to tools folder if not found
|
||
tools_dir = Path(__file__).parent
|
||
system = platform.system().lower()
|
||
|
||
if system == 'windows':
|
||
ffmpeg_path = tools_dir / 'ffmpeg.exe'
|
||
ffprobe_path = tools_dir / 'ffprobe.exe'
|
||
else:
|
||
ffmpeg_path = tools_dir / 'ffmpeg'
|
||
ffprobe_path = tools_dir / 'ffprobe'
|
||
|
||
# If we already have both locally, use them
|
||
if ffmpeg_path.exists() and ffprobe_path.exists():
|
||
logger.info(f"✅ Found ffmpeg and ffprobe in tools folder")
|
||
# Add to PATH so yt-dlp can find them
|
||
import os
|
||
tools_dir_str = str(tools_dir.absolute())
|
||
os.environ['PATH'] = tools_dir_str + os.pathsep + os.environ.get('PATH', '')
|
||
return True
|
||
|
||
# Auto-download ffmpeg binary
|
||
logger.info(f"⬇️ ffmpeg not found - downloading for {system}...")
|
||
|
||
try:
|
||
if system == 'windows':
|
||
# Download Windows ffmpeg (static build)
|
||
url = 'https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip'
|
||
zip_path = tools_dir / 'ffmpeg.zip'
|
||
|
||
logger.info(f" Downloading from GitHub (this may take a minute)...")
|
||
urllib.request.urlretrieve(url, zip_path)
|
||
|
||
logger.info(f" Extracting ffmpeg.exe and ffprobe.exe...")
|
||
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||
# Extract ffmpeg.exe and ffprobe.exe from the bin folder
|
||
for file in zip_ref.namelist():
|
||
if file.endswith('bin/ffmpeg.exe'):
|
||
with zip_ref.open(file) as source, open(tools_dir / 'ffmpeg.exe', 'wb') as target:
|
||
target.write(source.read())
|
||
elif file.endswith('bin/ffprobe.exe'):
|
||
with zip_ref.open(file) as source, open(tools_dir / 'ffprobe.exe', 'wb') as target:
|
||
target.write(source.read())
|
||
|
||
zip_path.unlink() # Clean up zip
|
||
|
||
elif system == 'linux':
|
||
# Download Linux ffmpeg (static build)
|
||
url = 'https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz'
|
||
tar_path = tools_dir / 'ffmpeg.tar.xz'
|
||
|
||
logger.info(f" Downloading from GitHub (this may take a minute)...")
|
||
urllib.request.urlretrieve(url, tar_path)
|
||
|
||
logger.info(f" Extracting ffmpeg and ffprobe...")
|
||
with tarfile.open(tar_path, 'r:xz') as tar_ref:
|
||
for member in tar_ref.getmembers():
|
||
if member.name.endswith('bin/ffmpeg'):
|
||
with tar_ref.extractfile(member) as source, open(tools_dir / 'ffmpeg', 'wb') as target:
|
||
target.write(source.read())
|
||
(tools_dir / 'ffmpeg').chmod(0o755) # Make executable
|
||
elif member.name.endswith('bin/ffprobe'):
|
||
with tar_ref.extractfile(member) as source, open(tools_dir / 'ffprobe', 'wb') as target:
|
||
target.write(source.read())
|
||
(tools_dir / 'ffprobe').chmod(0o755) # Make executable
|
||
|
||
tar_path.unlink() # Clean up tar
|
||
|
||
elif system == 'darwin':
|
||
# Download Mac ffmpeg and ffprobe (static builds)
|
||
logger.info(f" Downloading ffmpeg from evermeet.cx...")
|
||
ffmpeg_url = 'https://evermeet.cx/ffmpeg/getrelease/zip'
|
||
ffmpeg_zip = tools_dir / 'ffmpeg.zip'
|
||
urllib.request.urlretrieve(ffmpeg_url, ffmpeg_zip)
|
||
|
||
logger.info(f" Downloading ffprobe from evermeet.cx...")
|
||
ffprobe_url = 'https://evermeet.cx/ffmpeg/getrelease/ffprobe/zip'
|
||
ffprobe_zip = tools_dir / 'ffprobe.zip'
|
||
urllib.request.urlretrieve(ffprobe_url, ffprobe_zip)
|
||
|
||
logger.info(f" Extracting ffmpeg and ffprobe...")
|
||
with zipfile.ZipFile(ffmpeg_zip, 'r') as zip_ref:
|
||
zip_ref.extract('ffmpeg', tools_dir)
|
||
with zipfile.ZipFile(ffprobe_zip, 'r') as zip_ref:
|
||
zip_ref.extract('ffprobe', tools_dir)
|
||
|
||
(tools_dir / 'ffmpeg').chmod(0o755) # Make executable
|
||
(tools_dir / 'ffprobe').chmod(0o755) # Make executable
|
||
|
||
ffmpeg_zip.unlink() # Clean up zip
|
||
ffprobe_zip.unlink() # Clean up zip
|
||
|
||
else:
|
||
logger.error(f"❌ Unsupported platform: {system}")
|
||
return False
|
||
|
||
logger.info(f"✅ Downloaded ffmpeg to: {ffmpeg_path}")
|
||
|
||
# Add to PATH
|
||
import os
|
||
tools_dir_str = str(tools_dir.absolute())
|
||
os.environ['PATH'] = tools_dir_str + os.pathsep + os.environ.get('PATH', '')
|
||
|
||
return True
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Failed to download ffmpeg: {e}")
|
||
logger.error(f" Please install manually:")
|
||
logger.error(f" Windows: scoop install ffmpeg")
|
||
logger.error(f" Linux: sudo apt install ffmpeg")
|
||
logger.error(f" Mac: brew install ffmpeg")
|
||
return False
|
||
|
||
def _download_progress_hook(self, d):
|
||
"""Track download progress (like slskd monitoring)"""
|
||
if d['status'] == 'downloading':
|
||
total = d.get('total_bytes') or d.get('total_bytes_estimate', 0)
|
||
downloaded = d.get('downloaded_bytes', 0)
|
||
speed = d.get('speed') or 0
|
||
eta = d.get('eta') or 0
|
||
|
||
if total > 0:
|
||
progress = (downloaded / total) * 100
|
||
self.current_download_progress = {
|
||
'status': 'downloading',
|
||
'progress': progress,
|
||
'downloaded': downloaded,
|
||
'total': total,
|
||
'speed': speed,
|
||
'eta': eta
|
||
}
|
||
|
||
# Format speed safely
|
||
speed_kb = speed / 1024 if speed else 0
|
||
logger.info(f"📥 Progress: {progress:.1f}% | Speed: {speed_kb:.1f} KB/s | ETA: {eta}s")
|
||
|
||
elif d['status'] == 'finished':
|
||
self.current_download_progress = {
|
||
'status': 'finished',
|
||
'progress': 100.0,
|
||
'filename': d.get('filename', '')
|
||
}
|
||
logger.info(f"✅ Download finished: {d.get('filename', '')}")
|
||
|
||
elif d['status'] == 'error':
|
||
self.current_download_progress = {
|
||
'status': 'error',
|
||
'error': str(d.get('error', 'Unknown error'))
|
||
}
|
||
logger.error(f"❌ Download error: {d.get('error', '')}")
|
||
|
||
def search(self, query: str, max_results: int = 10) -> List[YouTubeSearchResult]:
|
||
"""
|
||
Search YouTube for tracks (mirrors soulseek_client.search).
|
||
|
||
Args:
|
||
query: Search query (e.g., "Artist - Track Name")
|
||
max_results: Maximum number of results to return
|
||
|
||
Returns:
|
||
List of YouTubeSearchResult objects
|
||
"""
|
||
logger.info(f"🔍 Searching YouTube for: '{query}'")
|
||
|
||
try:
|
||
# Use YouTube Music for better music search results
|
||
search_url = f"ytsearch{max_results}:{query}"
|
||
|
||
with yt_dlp.YoutubeDL(self.search_opts) as ydl:
|
||
search_results = ydl.extract_info(search_url, download=False)
|
||
|
||
if not search_results or 'entries' not in search_results:
|
||
logger.warning(f"No results found for: {query}")
|
||
return []
|
||
|
||
results = []
|
||
for entry in search_results['entries']:
|
||
if not entry:
|
||
continue
|
||
|
||
# Get detailed info for quality assessment
|
||
video_url = f"https://www.youtube.com/watch?v={entry['id']}"
|
||
try:
|
||
with yt_dlp.YoutubeDL({'quiet': True}) as detail_ydl:
|
||
video_info = detail_ydl.extract_info(video_url, download=False)
|
||
|
||
# Find best audio format
|
||
best_audio = self._get_best_audio_format(video_info.get('formats', []))
|
||
quality_str = self._format_quality_string(best_audio)
|
||
|
||
result = YouTubeSearchResult(
|
||
video_id=entry['id'],
|
||
title=entry.get('title', 'Unknown'),
|
||
channel=entry.get('channel', entry.get('uploader', 'Unknown')),
|
||
duration=entry.get('duration', 0),
|
||
url=video_url,
|
||
thumbnail=entry.get('thumbnail', ''),
|
||
view_count=entry.get('view_count', 0),
|
||
upload_date=entry.get('upload_date', ''),
|
||
available_quality=quality_str,
|
||
best_audio_format=best_audio
|
||
)
|
||
|
||
results.append(result)
|
||
|
||
except Exception as e:
|
||
logger.warning(f"Could not get detailed info for {entry['id']}: {e}")
|
||
continue
|
||
|
||
logger.info(f"✅ Found {len(results)} YouTube results")
|
||
return results
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ YouTube search error: {e}")
|
||
return []
|
||
|
||
def _get_best_audio_format(self, formats: List[Dict]) -> Optional[Dict]:
|
||
"""Extract best audio format from available formats"""
|
||
audio_formats = [f for f in formats if f.get('acodec') != 'none' and f.get('vcodec') == 'none']
|
||
|
||
if not audio_formats:
|
||
# Fallback: get best format with audio
|
||
audio_formats = [f for f in formats if f.get('acodec') != 'none']
|
||
|
||
if not audio_formats:
|
||
return None
|
||
|
||
# Sort by audio bitrate (tbr = total bitrate, abr = audio bitrate)
|
||
audio_formats.sort(key=lambda f: f.get('abr', f.get('tbr', 0)), reverse=True)
|
||
return audio_formats[0]
|
||
|
||
def _format_quality_string(self, audio_format: Optional[Dict]) -> str:
|
||
"""Format quality info string"""
|
||
if not audio_format:
|
||
return "unknown"
|
||
|
||
abr = audio_format.get('abr', audio_format.get('tbr', 0))
|
||
acodec = audio_format.get('acodec', 'unknown')
|
||
|
||
if abr:
|
||
return f"{int(abr)}kbps {acodec.upper()}"
|
||
return acodec.upper()
|
||
|
||
def calculate_match_confidence(self, spotify_track: SpotifyTrack, yt_result: YouTubeSearchResult) -> Tuple[float, str]:
|
||
"""
|
||
Calculate match confidence using PRODUCTION matching engine for parity with Soulseek.
|
||
|
||
Returns:
|
||
(confidence_score, match_reason) tuple
|
||
"""
|
||
# Use production matching engine's normalization and similarity scoring
|
||
spotify_artist = spotify_track.artists[0] if spotify_track.artists else ""
|
||
yt_artist = yt_result.parsed_artist or yt_result.channel
|
||
|
||
# Normalize using production engine
|
||
spotify_artist_clean = self.matching_engine.clean_artist(spotify_artist)
|
||
yt_artist_clean = self.matching_engine.clean_artist(yt_artist)
|
||
|
||
spotify_title_clean = self.matching_engine.clean_title(spotify_track.name)
|
||
yt_title_clean = self.matching_engine.clean_title(yt_result.parsed_title)
|
||
|
||
# Use production similarity_score (includes version detection, remaster penalties, etc.)
|
||
artist_similarity = self.matching_engine.similarity_score(spotify_artist_clean, yt_artist_clean)
|
||
title_similarity = self.matching_engine.similarity_score(spotify_title_clean, yt_title_clean)
|
||
|
||
# Duration matching using production engine
|
||
spotify_duration_ms = spotify_track.duration_ms
|
||
yt_duration_ms = int(yt_result.duration * 1000) # Convert seconds to ms
|
||
duration_similarity = self.matching_engine.duration_similarity(spotify_duration_ms, yt_duration_ms)
|
||
|
||
# Quality penalty (YouTube-specific)
|
||
quality_score = self._quality_score(yt_result.available_quality)
|
||
|
||
# Weighted confidence calculation (similar to production Soulseek matching)
|
||
# Production uses: title * 0.5 + artist * 0.3 + duration * 0.2
|
||
# Adjusted for YouTube: title * 0.4 + artist * 0.3 + duration * 0.2 + quality * 0.1
|
||
confidence = (
|
||
title_similarity * 0.40 +
|
||
artist_similarity * 0.30 +
|
||
duration_similarity * 0.20 +
|
||
quality_score * 0.10
|
||
)
|
||
|
||
# Determine match reason
|
||
if confidence >= 0.8:
|
||
reason = "excellent_match"
|
||
elif confidence >= 0.65:
|
||
reason = "good_match"
|
||
elif confidence >= 0.58: # Match production threshold
|
||
reason = "acceptable_match"
|
||
else:
|
||
reason = "poor_match"
|
||
|
||
# Bonus for official channels/verified
|
||
if 'vevo' in yt_artist.lower() or 'official' in yt_result.channel.lower():
|
||
confidence = min(1.0, confidence + 0.05)
|
||
reason += "_official"
|
||
|
||
logger.debug(f"Match confidence: {confidence:.2f} | Artist: {artist_similarity:.2f} | Title: {title_similarity:.2f} | Duration: {duration_similarity:.2f} | Quality: {quality_score:.2f}")
|
||
|
||
return confidence, reason
|
||
|
||
def _quality_score(self, quality_str: str) -> float:
|
||
"""Score quality string (mirrors quality_score logic)"""
|
||
quality_lower = quality_str.lower()
|
||
|
||
# Extract bitrate
|
||
bitrate_match = re.search(r'(\d+)kbps', quality_lower)
|
||
if bitrate_match:
|
||
bitrate = int(bitrate_match.group(1))
|
||
|
||
# Scoring based on bitrate
|
||
if bitrate >= 256:
|
||
return 1.0
|
||
elif bitrate >= 192:
|
||
return 0.8
|
||
elif bitrate >= 128:
|
||
return 0.6
|
||
else:
|
||
return 0.4
|
||
|
||
# Codec-based scoring if no bitrate
|
||
if 'opus' in quality_lower:
|
||
return 0.9
|
||
elif 'aac' in quality_lower:
|
||
return 0.7
|
||
elif 'mp3' in quality_lower:
|
||
return 0.7
|
||
|
||
return 0.5 # Unknown quality
|
||
|
||
def find_best_matches(self, spotify_track: SpotifyTrack, yt_results: List[YouTubeSearchResult],
|
||
min_confidence: float = 0.58) -> List[YouTubeSearchResult]:
|
||
"""
|
||
Find best YouTube matches for Spotify track (mirrors find_best_slskd_matches).
|
||
Uses production threshold of 0.58 for parity with Soulseek matching.
|
||
|
||
Args:
|
||
spotify_track: Spotify track to match
|
||
yt_results: YouTube search results
|
||
min_confidence: Minimum confidence threshold (default: 0.58, same as production)
|
||
|
||
Returns:
|
||
Sorted list of matches above confidence threshold
|
||
"""
|
||
matches = []
|
||
|
||
for yt_result in yt_results:
|
||
confidence, reason = self.calculate_match_confidence(spotify_track, yt_result)
|
||
yt_result.confidence = confidence
|
||
yt_result.match_reason = reason
|
||
|
||
if confidence >= min_confidence:
|
||
matches.append(yt_result)
|
||
|
||
# Sort by confidence (best first)
|
||
matches.sort(key=lambda r: r.confidence, reverse=True)
|
||
|
||
logger.info(f"✅ Found {len(matches)} matches above {min_confidence} confidence")
|
||
return matches
|
||
|
||
def download(self, yt_result: YouTubeSearchResult, spotify_track: Optional[SpotifyTrack] = None) -> Optional[str]:
|
||
"""
|
||
Download YouTube video as audio with proper metadata tagging (mirrors soulseek download).
|
||
|
||
Args:
|
||
yt_result: YouTube result to download
|
||
spotify_track: Optional Spotify track for metadata embedding
|
||
|
||
Returns:
|
||
Path to downloaded file, or None if failed
|
||
"""
|
||
logger.info(f"📥 Starting download: {yt_result.title}")
|
||
logger.info(f" Quality: {yt_result.available_quality}")
|
||
logger.info(f" URL: {yt_result.url}")
|
||
|
||
try:
|
||
# Build download options
|
||
download_opts = self.download_opts.copy()
|
||
|
||
# Get Spotify album details for proper folder structure and track numbering
|
||
track_number = 1
|
||
disc_number = 1
|
||
release_year = str(datetime.now().year)
|
||
album_artist = None
|
||
artist_genres = []
|
||
|
||
if spotify_track and spotify_track.id and not spotify_track.id.startswith('test'):
|
||
# Fetch full Spotify details to get track number, disc number, release date, genres
|
||
try:
|
||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||
from core.spotify_client import SpotifyClient
|
||
|
||
spotify_client = SpotifyClient()
|
||
if spotify_client.is_authenticated():
|
||
track_details = spotify_client.get_track_details(spotify_track.id)
|
||
if track_details:
|
||
track_number = track_details.get('track_number', 1)
|
||
disc_number = track_details.get('disc_number', 1)
|
||
|
||
# Use album artist if available, otherwise use track artist
|
||
album_data = track_details.get('album', {})
|
||
if album_data.get('artists'):
|
||
album_artist = album_data['artists'][0]
|
||
|
||
# Get actual release year from Spotify
|
||
release_date = album_data.get('release_date', '')
|
||
if release_date:
|
||
release_year = release_date.split('-')[0] # Extract year from YYYY-MM-DD
|
||
|
||
# Get artist genres (for metadata parity with Soulseek flow)
|
||
try:
|
||
primary_artist = track_details.get('primary_artist')
|
||
if primary_artist:
|
||
artist_info = spotify_client.get_artist(primary_artist)
|
||
if artist_info and hasattr(artist_info, 'genres'):
|
||
artist_genres = artist_info.genres
|
||
except:
|
||
pass
|
||
|
||
logger.info(f" 📀 Spotify track #{track_number} on album: {spotify_track.album} ({release_year})")
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ Could not fetch Spotify track details: {e}")
|
||
|
||
# If we have Spotify metadata, use production file organization
|
||
if spotify_track:
|
||
artist = spotify_track.artists[0] if spotify_track.artists else yt_result.parsed_artist
|
||
title = spotify_track.name
|
||
album = spotify_track.album
|
||
|
||
# Use album artist if found, otherwise use track artist
|
||
if not album_artist:
|
||
album_artist = artist
|
||
|
||
# Create folder structure: $albumartist/$albumartist - $album/
|
||
album_folder = self.download_path / album_artist / f"{album_artist} - {album}"
|
||
album_folder.mkdir(parents=True, exist_ok=True)
|
||
|
||
# File naming: $track - $title (production format)
|
||
final_filename = f"{track_number:02d} - {title}"
|
||
|
||
# Sanitize filename (remove invalid characters)
|
||
final_filename = re.sub(r'[<>:"/\\|?*]', '', final_filename)
|
||
|
||
# Override output template with production folder structure
|
||
download_opts['outtmpl'] = str(album_folder / f'{final_filename}.%(ext)s')
|
||
|
||
logger.info(f" 📁 Album folder: {album_artist}/{album_artist} - {album}/")
|
||
logger.info(f" 📝 Filename: {final_filename}.mp3")
|
||
|
||
# Add metadata postprocessor with Spotify info
|
||
download_opts['postprocessor_args'] = {
|
||
'ffmpeg': [
|
||
'-metadata', f'artist={artist}',
|
||
'-metadata', f'title={title}',
|
||
'-metadata', f'album={album}',
|
||
'-metadata', f'album_artist={album_artist}',
|
||
'-metadata', f'track={track_number}/{spotify_track.total_tracks if hasattr(spotify_track, "total_tracks") else track_number}',
|
||
'-metadata', f'disc={disc_number}',
|
||
'-metadata', f'date={release_year}',
|
||
'-metadata', 'comment=Downloaded via SoulSync (YouTube fallback)',
|
||
]
|
||
}
|
||
|
||
# Perform download
|
||
with yt_dlp.YoutubeDL(download_opts) as ydl:
|
||
info = ydl.extract_info(yt_result.url, download=True)
|
||
|
||
# Get final filename (will be MP3 after ffmpeg conversion)
|
||
filename = Path(ydl.prepare_filename(info)).with_suffix('.mp3')
|
||
|
||
if filename.exists():
|
||
logger.info(f"✅ Download successful: {filename}")
|
||
|
||
# Post-download: Enhance metadata with mutagen
|
||
album_art_url = self._enhance_metadata(str(filename), spotify_track, yt_result, track_number, disc_number, release_year, artist_genres)
|
||
|
||
# Save cover.jpg to album folder (like production)
|
||
if album_art_url and spotify_track:
|
||
self._save_cover_art(filename.parent, album_art_url)
|
||
|
||
# Create .lrc lyrics file (like production)
|
||
if spotify_track:
|
||
self._create_lyrics_file(str(filename), spotify_track)
|
||
|
||
return str(filename)
|
||
else:
|
||
logger.error(f"❌ Download completed but file not found: {filename}")
|
||
return None
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Download failed: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return None
|
||
|
||
def _enhance_metadata(self, filepath: str, spotify_track: Optional[SpotifyTrack], yt_result: YouTubeSearchResult, track_number: int = 1, disc_number: int = 1, release_year: str = None, artist_genres: list = None):
|
||
"""
|
||
Enhance MP3 metadata using mutagen + Spotify album art (mirrors main app's metadata enhancement).
|
||
Uses full Spotify metadata including disc number, actual release year, and genre tags.
|
||
"""
|
||
try:
|
||
from mutagen.mp3 import MP3
|
||
from mutagen.id3 import ID3, TIT2, TPE1, TALB, TDRC, COMM, APIC, TRCK, TPE2, TPOS, TCON
|
||
from mutagen.id3 import ID3NoHeaderError
|
||
import requests
|
||
|
||
logger.info(f"🏷️ Enhancing metadata for: {Path(filepath).name}")
|
||
|
||
# Load MP3 file
|
||
audio = MP3(filepath)
|
||
|
||
# Clear ALL existing tags and start fresh
|
||
if audio.tags is not None:
|
||
# Delete ALL existing frames
|
||
audio.tags.clear()
|
||
logger.info(f" 🧹 Cleared all existing tag frames")
|
||
else:
|
||
# No tags exist, add them
|
||
audio.add_tags()
|
||
logger.info(f" ➕ Added new tag structure")
|
||
|
||
if spotify_track:
|
||
# Use Spotify metadata
|
||
artist = spotify_track.artists[0] if spotify_track.artists else "Unknown Artist"
|
||
title = spotify_track.name
|
||
album = spotify_track.album
|
||
year = release_year or str(datetime.now().year)
|
||
|
||
# Get album artist from Spotify
|
||
album_artist = artist
|
||
try:
|
||
if spotify_track.id and not spotify_track.id.startswith('test'):
|
||
from core.spotify_client import SpotifyClient
|
||
spotify_client = SpotifyClient()
|
||
if spotify_client.is_authenticated():
|
||
track_details = spotify_client.get_track_details(spotify_track.id)
|
||
if track_details:
|
||
album_data = track_details.get('album', {})
|
||
if album_data.get('artists'):
|
||
album_artist = album_data['artists'][0]
|
||
except:
|
||
pass
|
||
|
||
logger.info(f" 📝 Setting metadata tags...")
|
||
|
||
# Set ID3 tags (using setall to ensure they're set)
|
||
audio.tags.setall('TIT2', [TIT2(encoding=3, text=title)])
|
||
audio.tags.setall('TPE1', [TPE1(encoding=3, text=artist)])
|
||
audio.tags.setall('TPE2', [TPE2(encoding=3, text=album_artist)]) # Album artist
|
||
audio.tags.setall('TALB', [TALB(encoding=3, text=album)])
|
||
audio.tags.setall('TRCK', [TRCK(encoding=3, text=str(track_number))]) # Track number
|
||
audio.tags.setall('TPOS', [TPOS(encoding=3, text=str(disc_number))]) # Disc number
|
||
audio.tags.setall('TDRC', [TDRC(encoding=3, text=year)])
|
||
|
||
# Genre (from Spotify artist data - matches production flow)
|
||
if artist_genres:
|
||
if len(artist_genres) == 1:
|
||
genre = artist_genres[0]
|
||
else:
|
||
# Combine up to 3 genres (matches production logic)
|
||
genre = ', '.join(artist_genres[:3])
|
||
audio.tags.setall('TCON', [TCON(encoding=3, text=genre)])
|
||
logger.info(f" ✓ Genre: {genre}")
|
||
|
||
audio.tags.setall('COMM', [COMM(encoding=3, lang='eng', desc='',
|
||
text=f'Downloaded via SoulSync (YouTube)\nSource: {yt_result.url}\nConfidence: {yt_result.confidence:.2f}')])
|
||
|
||
logger.info(f" ✓ Artist: {artist}")
|
||
logger.info(f" ✓ Album Artist: {album_artist}")
|
||
logger.info(f" ✓ Title: {title}")
|
||
logger.info(f" ✓ Album: {album}")
|
||
logger.info(f" ✓ Track #: {track_number}")
|
||
logger.info(f" ✓ Disc #: {disc_number}")
|
||
logger.info(f" ✓ Year: {year}")
|
||
|
||
# Fetch and embed album art from Spotify (via search)
|
||
logger.info(f" 🎨 Fetching album art from Spotify...")
|
||
album_art_url = self._get_spotify_album_art(spotify_track)
|
||
|
||
if album_art_url:
|
||
try:
|
||
# Download album art
|
||
response = requests.get(album_art_url, timeout=10)
|
||
response.raise_for_status()
|
||
|
||
# Determine image type
|
||
if 'jpeg' in response.headers.get('Content-Type', ''):
|
||
mime_type = 'image/jpeg'
|
||
elif 'png' in response.headers.get('Content-Type', ''):
|
||
mime_type = 'image/png'
|
||
else:
|
||
mime_type = 'image/jpeg' # Default
|
||
|
||
# Embed album art
|
||
audio.tags.add(APIC(
|
||
encoding=3,
|
||
mime=mime_type,
|
||
type=3, # Cover (front)
|
||
desc='Cover',
|
||
data=response.content
|
||
))
|
||
|
||
logger.info(f" ✓ Album art embedded ({len(response.content) // 1024} KB)")
|
||
except Exception as art_error:
|
||
logger.warning(f" ⚠️ Could not embed album art: {art_error}")
|
||
else:
|
||
logger.warning(f" ⚠️ No album art found on Spotify")
|
||
|
||
# Save all tags
|
||
audio.save()
|
||
logger.info(f"✅ Metadata enhanced successfully")
|
||
|
||
# Return album art URL for cover.jpg creation
|
||
return album_art_url
|
||
|
||
except ImportError:
|
||
logger.warning("⚠️ mutagen not installed - skipping enhanced metadata tagging")
|
||
logger.warning(" Install with: pip install mutagen")
|
||
return None
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Could not enhance metadata: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return None
|
||
|
||
def _get_spotify_album_art(self, spotify_track: SpotifyTrack) -> Optional[str]:
|
||
"""
|
||
Fetch album art URL from Spotify using your existing Spotify client.
|
||
Returns URL to highest quality album art.
|
||
"""
|
||
try:
|
||
# Import your existing Spotify client
|
||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||
from core.spotify_client import SpotifyClient
|
||
|
||
logger.info(f" 🔍 Getting Spotify client...")
|
||
|
||
# Get authenticated Spotify client
|
||
spotify_client = SpotifyClient()
|
||
|
||
if not spotify_client:
|
||
logger.warning(f" ⚠️ Spotify client not available")
|
||
return None
|
||
|
||
if not spotify_client.is_authenticated():
|
||
logger.warning(f" ⚠️ Spotify client not authenticated")
|
||
return None
|
||
|
||
logger.info(f" ✓ Spotify client authenticated")
|
||
|
||
# Use the track ID if available (real Spotify IDs)
|
||
if spotify_track.id and not spotify_track.id.startswith('test'):
|
||
logger.info(f" 🔍 Fetching track info for ID: {spotify_track.id}")
|
||
try:
|
||
# Get track info from Spotify API
|
||
track_info = spotify_client.sp.track(spotify_track.id)
|
||
|
||
if track_info:
|
||
logger.info(f" ✓ Got track info from Spotify")
|
||
|
||
if 'album' in track_info:
|
||
album_images = track_info['album'].get('images', [])
|
||
logger.info(f" 📸 Found {len(album_images)} album images")
|
||
|
||
if album_images:
|
||
# Get highest quality image (first in list)
|
||
album_art_url = album_images[0]['url']
|
||
logger.info(f" ✓ Album art URL: {album_art_url[:50]}...")
|
||
return album_art_url
|
||
else:
|
||
logger.warning(f" ⚠️ No album data in track info")
|
||
else:
|
||
logger.warning(f" ⚠️ Track info is empty")
|
||
|
||
except Exception as e:
|
||
logger.warning(f" ❌ Error fetching via track ID: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
# Fallback: Search for the track
|
||
query = f"track:{spotify_track.name} artist:{spotify_track.artists[0]}"
|
||
logger.info(f" 🔍 Searching Spotify: {query}")
|
||
|
||
try:
|
||
search_results = spotify_client.sp.search(q=query, type='track', limit=1)
|
||
|
||
if search_results and 'tracks' in search_results:
|
||
tracks = search_results['tracks'].get('items', [])
|
||
logger.info(f" 📋 Search returned {len(tracks)} tracks")
|
||
|
||
if tracks:
|
||
album_images = tracks[0].get('album', {}).get('images', [])
|
||
if album_images:
|
||
# Get highest quality image (first in list)
|
||
album_art_url = album_images[0]['url']
|
||
logger.info(f" ✓ Found via search: {album_art_url[:50]}...")
|
||
return album_art_url
|
||
except Exception as search_error:
|
||
logger.warning(f" ❌ Search error: {search_error}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
logger.warning(f" ⚠️ No album art found on Spotify")
|
||
return None
|
||
|
||
except ImportError as e:
|
||
logger.warning(f" ❌ Could not import Spotify client: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return None
|
||
except Exception as e:
|
||
logger.warning(f" ❌ Error fetching album art: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return None
|
||
|
||
def _save_cover_art(self, album_folder: Path, album_art_url: str):
|
||
"""
|
||
Save cover.jpg to album folder (mirrors production _download_cover_art).
|
||
"""
|
||
try:
|
||
import requests
|
||
|
||
cover_path = album_folder / "cover.jpg"
|
||
|
||
# Skip if already exists
|
||
if cover_path.exists():
|
||
logger.info(f" 📷 cover.jpg already exists")
|
||
return
|
||
|
||
logger.info(f" 📷 Downloading cover.jpg to album folder...")
|
||
|
||
# Download album art
|
||
response = requests.get(album_art_url, timeout=10)
|
||
response.raise_for_status()
|
||
|
||
# Save to file
|
||
cover_path.write_bytes(response.content)
|
||
|
||
logger.info(f" ✅ Saved cover.jpg ({len(response.content) // 1024} KB)")
|
||
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ Could not save cover.jpg: {e}")
|
||
|
||
def _create_lyrics_file(self, audio_file_path: str, spotify_track: SpotifyTrack):
|
||
"""
|
||
Create .lrc lyrics file using LRClib API (mirrors production lyrics flow).
|
||
"""
|
||
try:
|
||
# Import lyrics client
|
||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||
from core.lyrics_client import lyrics_client
|
||
|
||
if not lyrics_client.api:
|
||
logger.debug(f" 🎵 LRClib API not available - skipping lyrics")
|
||
return
|
||
|
||
logger.info(f" 🎵 Fetching lyrics from LRClib...")
|
||
|
||
# Get track metadata
|
||
artist_name = spotify_track.artists[0] if spotify_track.artists else "Unknown Artist"
|
||
track_name = spotify_track.name
|
||
album_name = spotify_track.album
|
||
duration_seconds = int(spotify_track.duration_ms / 1000) if spotify_track.duration_ms else None
|
||
|
||
# Create LRC file
|
||
success = lyrics_client.create_lrc_file(
|
||
audio_file_path=audio_file_path,
|
||
track_name=track_name,
|
||
artist_name=artist_name,
|
||
album_name=album_name,
|
||
duration_seconds=duration_seconds
|
||
)
|
||
|
||
if success:
|
||
logger.info(f" ✅ Created .lrc lyrics file")
|
||
else:
|
||
logger.info(f" 🎵 No lyrics found on LRClib")
|
||
|
||
except ImportError:
|
||
logger.debug(f" ⚠️ lyrics_client not available - skipping lyrics")
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ Could not create lyrics file: {e}")
|
||
|
||
def search_and_download_best(self, spotify_track: SpotifyTrack, min_confidence: float = 0.58) -> Optional[str]:
|
||
"""
|
||
Complete flow: search, find best match, download (mirrors soulseek flow).
|
||
Uses production threshold of 0.58 for parity with Soulseek matching.
|
||
|
||
Args:
|
||
spotify_track: Spotify track to download
|
||
min_confidence: Minimum confidence threshold (default: 0.58, same as production)
|
||
|
||
Returns:
|
||
Path to downloaded file, or None if failed
|
||
"""
|
||
logger.info(f"🎯 Starting YouTube download flow for: {spotify_track.name} by {spotify_track.artists[0]}")
|
||
|
||
# Generate search query
|
||
query = f"{spotify_track.artists[0]} {spotify_track.name}"
|
||
|
||
# Search YouTube
|
||
results = self.search(query, max_results=10)
|
||
|
||
if not results:
|
||
logger.error(f"❌ No YouTube results found for query: {query}")
|
||
return None
|
||
|
||
# Find best matches
|
||
matches = self.find_best_matches(spotify_track, results, min_confidence=min_confidence)
|
||
|
||
if not matches:
|
||
logger.error(f"❌ No matches above {min_confidence} confidence threshold")
|
||
return None
|
||
|
||
# Try downloading best match
|
||
best_match = matches[0]
|
||
logger.info(f"🎯 Best match: {best_match.title} (confidence: {best_match.confidence:.2f})")
|
||
|
||
downloaded_file = self.download(best_match, spotify_track)
|
||
|
||
return downloaded_file
|
||
|
||
|
||
# ============================================================================
|
||
# TEST RUNNER
|
||
# ============================================================================
|
||
|
||
def test_youtube_download():
|
||
"""Test the YouTube download flow with a curated playlist"""
|
||
|
||
print("=" * 80)
|
||
print("🎵 YouTube Download Test - Curated Playlist (5 Tracks)")
|
||
print("=" * 80)
|
||
print()
|
||
|
||
# Initialize YouTube client
|
||
yt_client = YouTubeClient(download_path="./downloads/youtube_test")
|
||
|
||
# Curated playlist - 5 diverse tracks from 5 different artists
|
||
# REAL Spotify track IDs for proper album art fetching
|
||
test_playlist = [
|
||
SpotifyTrack(
|
||
id="5CQ30WqJwcep0pYcV4AMNc", # Real Spotify ID for Stairway to Heaven
|
||
name="Stairway to Heaven",
|
||
artists=["Led Zeppelin"],
|
||
album="Led Zeppelin IV (Remaster)",
|
||
duration_ms=482830, # ~8:03
|
||
popularity=82
|
||
),
|
||
SpotifyTrack(
|
||
id="39LLxExYz6ewLAcYrzQQyP", # Real Spotify ID for Levitating
|
||
name="Levitating",
|
||
artists=["Dua Lipa"],
|
||
album="Future Nostalgia",
|
||
duration_ms=203064, # ~3:23
|
||
popularity=88
|
||
),
|
||
SpotifyTrack(
|
||
id="7KXjTSCq5nL1LoYtL7XAwS", # Real Spotify ID for HUMBLE.
|
||
name="HUMBLE.",
|
||
artists=["Kendrick Lamar"],
|
||
album="DAMN.",
|
||
duration_ms=177000, # ~2:57
|
||
popularity=89
|
||
),
|
||
SpotifyTrack(
|
||
id="0DiWol3AO6WpXZgp0goxAV", # Real Spotify ID for One More Time
|
||
name="One More Time",
|
||
artists=["Daft Punk"],
|
||
album="Discovery",
|
||
duration_ms=320357, # ~5:20
|
||
popularity=85
|
||
),
|
||
SpotifyTrack(
|
||
id="63OQupATfueTdZMWTxW03A", # Real Spotify ID for Karma Police
|
||
name="Karma Police",
|
||
artists=["Radiohead"],
|
||
album="OK Computer",
|
||
duration_ms=263893, # ~4:24
|
||
popularity=80
|
||
),
|
||
]
|
||
|
||
print("🎧 Curated Playlist - Testing Diverse Genres:")
|
||
print()
|
||
for i, track in enumerate(test_playlist, 1):
|
||
print(f" {i}. {track.artists[0]:20s} - {track.name}")
|
||
print()
|
||
print("This playlist tests:")
|
||
print(" • Classic Rock (Led Zeppelin)")
|
||
print(" • Modern Pop (Dua Lipa)")
|
||
print(" • Hip Hop (Kendrick Lamar)")
|
||
print(" • Electronic (Daft Punk)")
|
||
print(" • Alternative (Radiohead)")
|
||
print()
|
||
print("─" * 80)
|
||
print()
|
||
|
||
# Auto-download all tracks
|
||
results = []
|
||
total_start_time = datetime.now()
|
||
|
||
for idx, track in enumerate(test_playlist, 1):
|
||
print(f"\n{'='*80}")
|
||
print(f"Track {idx}/{len(test_playlist)}: {track.name} by {track.artists[0]}")
|
||
print(f"{'='*80}\n")
|
||
|
||
track_start_time = datetime.now()
|
||
|
||
try:
|
||
# Use the complete search and download flow (production threshold: 0.58)
|
||
downloaded_file = yt_client.search_and_download_best(
|
||
spotify_track=track,
|
||
min_confidence=0.58
|
||
)
|
||
|
||
track_end_time = datetime.now()
|
||
track_duration = (track_end_time - track_start_time).total_seconds()
|
||
|
||
if downloaded_file:
|
||
# Get file size
|
||
file_size = Path(downloaded_file).stat().st_size / (1024 * 1024) # MB
|
||
|
||
results.append({
|
||
'track': track.name,
|
||
'artist': track.artists[0],
|
||
'album': track.album,
|
||
'file': downloaded_file,
|
||
'file_size_mb': file_size,
|
||
'duration_seconds': track_duration,
|
||
'success': True,
|
||
'error': None
|
||
})
|
||
|
||
print(f"\n✅ SUCCESS - Downloaded in {track_duration:.1f}s")
|
||
print(f" File: {Path(downloaded_file).name}")
|
||
print(f" Size: {file_size:.2f} MB")
|
||
else:
|
||
results.append({
|
||
'track': track.name,
|
||
'artist': track.artists[0],
|
||
'album': track.album,
|
||
'file': None,
|
||
'file_size_mb': 0,
|
||
'duration_seconds': track_duration,
|
||
'success': False,
|
||
'error': 'Download failed or no matches found'
|
||
})
|
||
|
||
print(f"\n❌ FAILED - No suitable match found")
|
||
|
||
except Exception as e:
|
||
track_end_time = datetime.now()
|
||
track_duration = (track_end_time - track_start_time).total_seconds()
|
||
|
||
print(f"\n❌ ERROR: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
results.append({
|
||
'track': track.name,
|
||
'artist': track.artists[0],
|
||
'album': track.album,
|
||
'file': None,
|
||
'file_size_mb': 0,
|
||
'duration_seconds': track_duration,
|
||
'success': False,
|
||
'error': str(e)
|
||
})
|
||
|
||
total_end_time = datetime.now()
|
||
total_duration = (total_end_time - total_start_time).total_seconds()
|
||
|
||
# ========================================================================
|
||
# Final Summary Report
|
||
# ========================================================================
|
||
|
||
print("\n\n" + "=" * 80)
|
||
print("📊 FINAL SUMMARY REPORT")
|
||
print("=" * 80)
|
||
print()
|
||
|
||
successful = [r for r in results if r['success']]
|
||
failed = [r for r in results if not r['success']]
|
||
|
||
print(f"⏱️ Total Time: {total_duration:.1f}s ({total_duration/60:.1f} minutes)")
|
||
print(f"✅ Success Rate: {len(successful)}/{len(results)} ({len(successful)/len(results)*100:.1f}%)")
|
||
print()
|
||
|
||
if successful:
|
||
total_size = sum(r['file_size_mb'] for r in successful)
|
||
avg_time = sum(r['duration_seconds'] for r in successful) / len(successful)
|
||
|
||
print("─" * 80)
|
||
print("✅ SUCCESSFUL DOWNLOADS:")
|
||
print("─" * 80)
|
||
for i, result in enumerate(successful, 1):
|
||
print(f"\n{i}. {result['artist']} - {result['track']}")
|
||
print(f" Album: {result['album']}")
|
||
print(f" File: {Path(result['file']).name}")
|
||
print(f" Size: {result['file_size_mb']:.2f} MB")
|
||
print(f" Time: {result['duration_seconds']:.1f}s")
|
||
|
||
print()
|
||
print(f"📦 Total Downloaded: {total_size:.2f} MB")
|
||
print(f"⚡ Average Download Time: {avg_time:.1f}s per track")
|
||
print()
|
||
|
||
if failed:
|
||
print("─" * 80)
|
||
print("❌ FAILED DOWNLOADS:")
|
||
print("─" * 80)
|
||
for i, result in enumerate(failed, 1):
|
||
print(f"\n{i}. {result['artist']} - {result['track']}")
|
||
print(f" Reason: {result['error']}")
|
||
print()
|
||
|
||
# File list for easy access
|
||
if successful:
|
||
print("─" * 80)
|
||
print("📁 DOWNLOAD LOCATION:")
|
||
print("─" * 80)
|
||
print(f"\n{yt_client.download_path.absolute()}\n")
|
||
print("Files:")
|
||
for result in successful:
|
||
print(f" • {Path(result['file']).name}")
|
||
print()
|
||
|
||
print("=" * 80)
|
||
print("🎉 Test Complete!")
|
||
print("=" * 80)
|
||
print()
|
||
|
||
# Quality check reminder
|
||
if successful:
|
||
print("📝 Next Steps:")
|
||
print(" 1. Listen to downloaded files to verify quality")
|
||
print(" 2. Check metadata tags in your music player")
|
||
print(" 3. Compare with Soulseek downloads if available")
|
||
print(" 4. Review confidence scores vs actual match quality")
|
||
print()
|
||
|
||
return results
|
||
|
||
|
||
if __name__ == "__main__":
|
||
test_youtube_download()
|