youtube-summarizer/backend/tests/unit/test_ytdlp_downloader.py

361 lines
14 KiB
Python

"""
Unit tests for yt-dlp downloader
"""
import pytest
from unittest.mock import Mock, AsyncMock, patch, MagicMock
from pathlib import Path
import asyncio
from backend.models.video_download import (
DownloadMethod,
DownloadPreferences,
VideoDownloadResult,
DownloadStatus,
VideoQuality,
VideoMetadata,
TranscriptData,
DownloaderException,
VideoNotAvailableError,
NetworkError
)
from backend.services.video_downloaders.ytdlp_downloader import YtDlpDownloader
class TestYtDlpDownloader:
"""Test yt-dlp downloader functionality"""
@pytest.fixture
def mock_config(self, tmp_path):
"""Mock configuration for testing"""
return {
'output_dir': str(tmp_path),
'timeout': 60,
'cookies_path': None,
'use_proxy': False
}
@pytest.fixture
def downloader(self, mock_config):
"""Create downloader instance for testing"""
return YtDlpDownloader(config=mock_config)
def test_initialization(self, downloader, mock_config):
"""Test downloader initialization"""
assert downloader.method == DownloadMethod.YTDLP
assert downloader.output_dir == Path(mock_config['output_dir'])
assert downloader.output_dir.exists()
def test_capabilities(self, downloader):
"""Test downloader capabilities"""
assert downloader.supports_audio_only() is True
assert downloader.supports_quality_selection() is True
assert "mp4" in downloader.get_supported_formats()
assert "webm" in downloader.get_supported_formats()
assert "mp3" in downloader.get_supported_formats()
@patch('backend.services.video_downloaders.ytdlp_downloader.yt_dlp')
@pytest.mark.asyncio
async def test_successful_audio_download(self, mock_ytdlp, downloader):
"""Test successful audio-only download"""
# Setup mock yt-dlp
mock_ydl = Mock()
mock_info = {
'id': 'test123',
'title': 'Test Video',
'description': 'Test description',
'duration': 240,
'view_count': 1000000,
'upload_date': '20240101',
'uploader': 'Test Author',
'thumbnail': 'http://example.com/thumb.jpg',
'tags': ['test', 'video'],
'language': 'en',
'availability': 'public',
'age_limit': 0
}
mock_ydl.extract_info.return_value = mock_info
mock_ydl.prepare_filename.return_value = str(downloader.output_dir / "test123.mp3")
mock_ytdlp.YoutubeDL.return_value.__enter__ = Mock(return_value=mock_ydl)
mock_ytdlp.YoutubeDL.return_value.__exit__ = Mock(return_value=None)
# Create test audio file
test_audio_file = downloader.output_dir / "test123.mp3"
test_audio_file.write_text("fake audio content")
url = "https://youtube.com/watch?v=test123"
preferences = DownloadPreferences(prefer_audio_only=True)
result = await downloader.download_video(url, preferences)
assert result.status == DownloadStatus.COMPLETED
assert result.video_id == "test123"
assert result.method == DownloadMethod.YTDLP
assert result.audio_path is not None
assert result.video_path is None
assert result.metadata.title == "Test Video"
assert result.metadata.duration_seconds == 240
@patch('backend.services.video_downloaders.ytdlp_downloader.yt_dlp')
@pytest.mark.asyncio
async def test_403_error_handling(self, mock_ytdlp, downloader):
"""Test handling of 403 forbidden errors with fallback strategies"""
# First attempt fails with 403
mock_ydl_fail = Mock()
mock_ydl_fail.extract_info.side_effect = Exception("HTTP Error 403: Forbidden")
# Second attempt succeeds with different options
mock_ydl_success = Mock()
mock_info = {
'id': 'test123',
'title': 'Test Video',
'duration': 120
}
mock_ydl_success.extract_info.return_value = mock_info
mock_ydl_success.prepare_filename.return_value = str(downloader.output_dir / "test123.mp4")
# Setup context manager returns
call_count = 0
def mock_enter(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return mock_ydl_fail
else:
return mock_ydl_success
mock_ytdlp.YoutubeDL.return_value.__enter__ = mock_enter
mock_ytdlp.YoutubeDL.return_value.__exit__ = Mock(return_value=None)
# Create test file for second attempt
test_file = downloader.output_dir / "test123.mp4"
test_file.write_text("fake video content")
url = "https://youtube.com/watch?v=test123"
preferences = DownloadPreferences()
result = await downloader.download_video(url, preferences)
assert result.status == DownloadStatus.COMPLETED
assert mock_ytdlp.YoutubeDL.call_count >= 2 # Multiple attempts
@patch('backend.services.video_downloaders.ytdlp_downloader.yt_dlp')
@pytest.mark.asyncio
async def test_private_video_error(self, mock_ytdlp, downloader):
"""Test handling of private video error"""
mock_ydl = Mock()
mock_ydl.extract_info.side_effect = Exception("Private video")
mock_ytdlp.YoutubeDL.return_value.__enter__ = Mock(return_value=mock_ydl)
mock_ytdlp.YoutubeDL.return_value.__exit__ = Mock(return_value=None)
url = "https://youtube.com/watch?v=private123"
preferences = DownloadPreferences()
with pytest.raises(VideoNotAvailableError, match="Video not available"):
await downloader.download_video(url, preferences)
@patch('backend.services.video_downloaders.ytdlp_downloader.yt_dlp')
@pytest.mark.asyncio
async def test_age_restricted_video(self, mock_ytdlp, downloader):
"""Test handling of age-restricted video"""
mock_ydl = Mock()
mock_ydl.extract_info.side_effect = Exception("Sign in to confirm your age")
mock_ytdlp.YoutubeDL.return_value.__enter__ = Mock(return_value=mock_ydl)
mock_ytdlp.YoutubeDL.return_value.__exit__ = Mock(return_value=None)
url = "https://youtube.com/watch?v=restricted123"
preferences = DownloadPreferences()
with pytest.raises(VideoNotAvailableError, match="Age-restricted video"):
await downloader.download_video(url, preferences)
@patch('backend.services.video_downloaders.ytdlp_downloader.yt_dlp')
@pytest.mark.asyncio
async def test_network_error(self, mock_ytdlp, downloader):
"""Test handling of network errors"""
mock_ydl = Mock()
mock_ydl.extract_info.side_effect = Exception("Network is unreachable")
mock_ytdlp.YoutubeDL.return_value.__enter__ = Mock(return_value=mock_ydl)
mock_ytdlp.YoutubeDL.return_value.__exit__ = Mock(return_value=None)
url = "https://youtube.com/watch?v=network123"
preferences = DownloadPreferences()
with pytest.raises(NetworkError, match="Network error"):
await downloader.download_video(url, preferences)
@patch('backend.services.video_downloaders.ytdlp_downloader.yt_dlp')
@pytest.mark.asyncio
async def test_quality_selection(self, mock_ytdlp, downloader):
"""Test video quality selection"""
mock_ydl = Mock()
mock_info = {
'id': 'test123',
'title': 'Test Video',
'duration': 120
}
mock_ydl.extract_info.return_value = mock_info
mock_ydl.prepare_filename.return_value = str(downloader.output_dir / "test123.mp4")
mock_ytdlp.YoutubeDL.return_value.__enter__ = Mock(return_value=mock_ydl)
mock_ytdlp.YoutubeDL.return_value.__exit__ = Mock(return_value=None)
# Create test file
test_file = downloader.output_dir / "test123.mp4"
test_file.write_text("fake video content")
url = "https://youtube.com/watch?v=test123"
preferences = DownloadPreferences(quality=VideoQuality.HIGH_1080P)
result = await downloader.download_video(url, preferences)
assert result.status == DownloadStatus.COMPLETED
# Check that quality preference was passed to yt-dlp options
ytdlp_calls = mock_ytdlp.YoutubeDL.call_args_list
assert len(ytdlp_calls) >= 1
options = ytdlp_calls[0][0][0] # First call, first argument
assert 'format' in options
assert '1080p' in options['format'] or 'best[height<=1080]' in options['format']
@patch('backend.services.video_downloaders.ytdlp_downloader.yt_dlp')
@pytest.mark.asyncio
async def test_connection_test_success(self, mock_ytdlp, downloader):
"""Test successful connection test"""
mock_ydl = Mock()
mock_ydl.extract_info.return_value = {'title': 'Test Video'}
mock_ytdlp.YoutubeDL.return_value.__enter__ = Mock(return_value=mock_ydl)
mock_ytdlp.YoutubeDL.return_value.__exit__ = Mock(return_value=None)
result = await downloader.test_connection()
assert result is True
@patch('backend.services.video_downloaders.ytdlp_downloader.yt_dlp')
@pytest.mark.asyncio
async def test_connection_test_failure(self, mock_ytdlp, downloader):
"""Test failed connection test"""
mock_ydl = Mock()
mock_ydl.extract_info.side_effect = Exception("Connection failed")
mock_ytdlp.YoutubeDL.return_value.__enter__ = Mock(return_value=mock_ydl)
mock_ytdlp.YoutubeDL.return_value.__exit__ = Mock(return_value=None)
result = await downloader.test_connection()
assert result is False
def test_get_ytdlp_options_basic(self, downloader):
"""Test basic yt-dlp options generation"""
preferences = DownloadPreferences(prefer_audio_only=True)
options = downloader._get_ytdlp_options(preferences)
assert 'format' in options
assert 'outtmpl' in options
assert 'extractaudio' in options
assert options['extractaudio'] is True
def test_get_ytdlp_options_video(self, downloader):
"""Test video-specific options"""
preferences = DownloadPreferences(
prefer_audio_only=False,
quality=VideoQuality.MEDIUM_720P,
save_video=True
)
options = downloader._get_ytdlp_options(preferences)
assert 'format' in options
assert '720p' in options['format'] or 'height<=720' in options['format']
assert options.get('extractaudio', False) is False
def test_get_ytdlp_options_with_cookies(self, tmp_path):
"""Test options with cookies file"""
cookies_file = tmp_path / "cookies.txt"
cookies_file.write_text("# Netscape HTTP Cookie File")
config = {
'output_dir': str(tmp_path),
'cookies_path': str(cookies_file)
}
downloader = YtDlpDownloader(config=config)
preferences = DownloadPreferences()
options = downloader._get_ytdlp_options(preferences)
assert 'cookiefile' in options
assert options['cookiefile'] == str(cookies_file)
def test_get_fallback_strategies(self, downloader):
"""Test fallback strategy generation"""
strategies = downloader._get_fallback_strategies()
assert len(strategies) >= 3 # At least 3 fallback strategies
# Each strategy should be a dict with format and user-agent options
for strategy in strategies:
assert isinstance(strategy, dict)
assert 'http_headers' in strategy
assert 'User-Agent' in strategy['http_headers']
@pytest.mark.asyncio
async def test_metadata_extraction(self, downloader):
"""Test metadata extraction from yt-dlp info"""
info_dict = {
'id': 'test123',
'title': 'Test Video Title',
'description': 'This is a test video description',
'duration': 300,
'view_count': 1500000,
'upload_date': '20240101',
'uploader': 'Test Channel',
'thumbnail': 'https://example.com/thumbnail.jpg',
'tags': ['test', 'video', 'example'],
'language': 'en',
'availability': 'public',
'age_limit': 0
}
metadata = await downloader._extract_metadata(info_dict)
assert metadata.video_id == "test123"
assert metadata.title == "Test Video Title"
assert metadata.description == "This is a test video description"
assert metadata.duration_seconds == 300
assert metadata.view_count == 1500000
assert metadata.upload_date == "2024-01-01"
assert metadata.uploader == "Test Channel"
assert metadata.thumbnail_url == "https://example.com/thumbnail.jpg"
assert metadata.tags == ['test', 'video', 'example']
assert metadata.language == 'en'
assert metadata.availability == 'public'
assert metadata.age_restricted is False
@pytest.mark.asyncio
async def test_metadata_extraction_minimal(self, downloader):
"""Test metadata extraction with minimal info"""
info_dict = {
'id': 'minimal123',
'title': 'Minimal Video'
}
metadata = await downloader._extract_metadata(info_dict)
assert metadata.video_id == "minimal123"
assert metadata.title == "Minimal Video"
assert metadata.description is None
assert metadata.duration_seconds is None
assert metadata.view_count is None
assert metadata.upload_date is None
assert metadata.uploader is None
assert metadata.thumbnail_url is None
assert metadata.tags == []
def test_should_retry_error(self, downloader):
"""Test error retry logic"""
# Transient errors should be retried
assert downloader._should_retry_error("HTTP Error 429: Too Many Requests") is True
assert downloader._should_retry_error("timeout") is True
assert downloader._should_retry_error("Connection reset") is True
# Permanent errors should not be retried
assert downloader._should_retry_error("Private video") is False
assert downloader._should_retry_error("Video unavailable") is False
assert downloader._should_retry_error("Sign in to confirm your age") is False