""" 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