Add ffmpeg for HLS stream download support

- Add ffmpeg to Dockerfile.app for container HLS support
- Configure yt-dlp with --downloader ffmpeg --hls-use-mpegts
- Add startup health check warns if ffmpeg missing
- Update DEVELOPMENT.md with ffmpeg prerequisites and troubleshooting
- Add tests for ffmpeg HLS options and health check
This commit is contained in:
2026-05-23 21:18:39 +02:00
parent db65e28854
commit e74b602f60
5 changed files with 116 additions and 2 deletions

View File

@@ -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)

View File

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

View File

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

View File

@@ -917,4 +917,47 @@ class TestAniworldLoaderCompat:
"""AniworldLoader should extend EnhancedAniWorldLoader."""
from src.core.providers.enhanced_provider import AniworldLoader
assert issubclass(AniworldLoader, EnhancedAniWorldLoader)
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')}"
)

View File

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