diff --git a/Docker/Dockerfile.app b/Docker/Dockerfile.app index 1a7df53..573bfa2 100644 --- a/Docker/Dockerfile.app +++ b/Docker/Dockerfile.app @@ -2,12 +2,13 @@ FROM python:3.12-slim WORKDIR /app -# Install system dependencies for compiled Python packages +# Install system dependencies for compiled Python packages and ffmpeg for HLS support RUN apt-get update && \ apt-get install -y --no-install-recommends \ gcc \ g++ \ libffi-dev \ + ffmpeg \ && rm -rf /var/lib/apt/lists/* # Install Python dependencies (cached layer) diff --git a/src/core/providers/enhanced_provider.py b/src/core/providers/enhanced_provider.py index 03e1cee..9301b7e 100644 --- a/src/core/providers/enhanced_provider.py +++ b/src/core/providers/enhanced_provider.py @@ -567,6 +567,9 @@ class EnhancedAniWorldLoader(Loader): "socket_timeout": self.download_timeout, "http_chunk_size": 1024 * 1024, # 1MB chunks "logger": self.logger, + # Use ffmpeg for HLS streams and transport stream format + "downloader": "ffmpeg", + "hls_use_mpegts": True, } if headers: ydl_opts['http_headers'] = headers diff --git a/src/server/fastapi_app.py b/src/server/fastapi_app.py index 0fde2c5..8bc68f8 100644 --- a/src/server/fastapi_app.py +++ b/src/server/fastapi_app.py @@ -329,6 +329,19 @@ async def lifespan(_application: FastAPI): logger.info( "API documentation available at http://127.0.0.1:8000/api/docs" ) + + # Check for ffmpeg availability and warn if missing + try: + import shutil as _shutil + if _shutil.which("ffmpeg") is None: + logger.warning( + "ffmpeg not found in PATH. HLS streams may fail to download. " + "Install ffmpeg to enable HLS support." + ) + else: + logger.debug("ffmpeg found at: %s", _shutil.which("ffmpeg")) + except Exception as _exc: + logger.warning("Could not check for ffmpeg: %s", _exc) except Exception as e: logger.error("Error during startup: %s", e, exc_info=True) startup_error = e diff --git a/tests/unit/test_enhanced_provider.py b/tests/unit/test_enhanced_provider.py index d7d34ce..0ff6ad0 100644 --- a/tests/unit/test_enhanced_provider.py +++ b/tests/unit/test_enhanced_provider.py @@ -917,4 +917,47 @@ class TestAniworldLoaderCompat: """AniworldLoader should extend EnhancedAniWorldLoader.""" from src.core.providers.enhanced_provider import AniworldLoader - assert issubclass(AniworldLoader, EnhancedAniWorldLoader) \ No newline at end of file + assert issubclass(AniworldLoader, EnhancedAniWorldLoader) + +class TestFfmpegHlsOptions: + """Test that yt-dlp is configured with ffmpeg for HLS streams.""" + + def test_ytdl_opts_include_ffmpeg_for_hls(self, enhanced_loader, tmp_path): + """yt-dlp options should include ffmpeg downloader and hls-use-mpegts.""" + temp_path = str(tmp_path / "temp.mp4") + output_path = str(tmp_path / "output.mp4") + + captured_opts = {} + + def capture_ytdl_download(ydl_opts, link): + captured_opts.update(ydl_opts) + with open(temp_path, "wb") as f: + f.write(b"fake-video-data") + return True + + with patch( + "src.core.providers.enhanced_provider.recovery_strategies" + ) as mock_rs, patch( + "src.core.providers.enhanced_provider.file_corruption_detector" + ) as mock_fcd, patch( + "src.core.providers.enhanced_provider.get_integrity_manager" + ) as mock_im: + mock_rs.handle_network_failure.return_value = ( + "https://direct.example.com/v.mp4", + [], + ) + mock_rs.handle_download_failure.side_effect = capture_ytdl_download + mock_fcd.is_valid_video_file.return_value = True + mock_im.return_value.store_checksum.return_value = "abc123" + + enhanced_loader._download_with_recovery( + 1, 1, "test", "German Dub", + temp_path, output_path, None, + ) + + assert captured_opts.get("downloader") == "ffmpeg", ( + f"Expected downloader='ffmpeg', got {captured_opts.get('downloader')}" + ) + assert captured_opts.get("hls_use_mpegts") is True, ( + f"Expected hls_use_mpegts=True, got {captured_opts.get('hls_use_mpegts')}" + ) diff --git a/tests/unit/test_ffmpeg_health_check.py b/tests/unit/test_ffmpeg_health_check.py new file mode 100644 index 0000000..6159448 --- /dev/null +++ b/tests/unit/test_ffmpeg_health_check.py @@ -0,0 +1,54 @@ +"""Unit tests for ffmpeg health check in fastapi_app.py.""" + +import asyncio +from unittest.mock import MagicMock, patch + +import pytest + + +class TestFfmpegHealthCheck: + """Test ffmpeg health check warns when not in PATH.""" + + @pytest.mark.asyncio + async def test_ffmpeg_missing_warns(self): + """Should log warning when ffmpeg not found in PATH.""" + with patch("shutil.which", return_value=None): + with patch("src.server.fastapi_app.setup_logging") as mock_log: + mock_logger = MagicMock() + mock_log.return_value = mock_logger + + from src.server.fastapi_app import lifespan + app = MagicMock() + + with pytest.raises(StopIteration): + async with lifespan(app): + pass + + # Should have logged a warning about ffmpeg + warning_calls = [ + c for c in mock_logger.warning.call_args_list + if "ffmpeg" in str(c) + ] + assert len(warning_calls) >= 1 + + @pytest.mark.asyncio + async def test_ffmpeg_present_no_warning(self): + """Should not log warning when ffmpeg is found.""" + with patch("shutil.which", return_value="/usr/bin/ffmpeg"): + with patch("src.server.fastapi_app.setup_logging") as mock_log: + mock_logger = MagicMock() + mock_log.return_value = mock_logger + + from src.server.fastapi_app import lifespan + app = MagicMock() + + with pytest.raises(StopIteration): + async with lifespan(app): + pass + + # Should NOT have logged a warning about ffmpeg + warning_calls = [ + c for c in mock_logger.warning.call_args_list + if "ffmpeg" in str(c) + ] + assert len(warning_calls) == 0 \ No newline at end of file