361 lines
14 KiB
Python
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 |