commit baa1675738d4611c6f1cb9f524e93e011d5c9d0f Author: Joseph Kisler Date: Wed Dec 17 10:07:44 2025 +0100 Initial commit - Video Service for Coolify diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d127ff3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,55 @@ +# Git +.git +.gitignore + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +.env +.venv +venv/ +ENV/ +.eggs +*.egg-info/ +.pytest_cache/ +.coverage +htmlcov/ +.mypy_cache/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Docker +Dockerfile* +docker-compose*.yml +.docker/ + +# Documentation +*.md +docs/ +LICENSE + +# Tests +tests/ +test_*.py +*_test.py +conftest.py + +# CI/CD +.github/ +.gitlab-ci.yml +.travis.yml +Jenkinsfile + +# Misc +*.log +*.tmp +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000..51102cd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,75 @@ +# Video Service - Bulletproof Production Dockerfile +# Security-hardened, multi-stage build + +# ============================================================================= +# Stage 1: Build dependencies +# ============================================================================= +FROM python:3.13-slim-bookworm AS builder + +WORKDIR /build + +# Install build dependencies in separate layer for caching +COPY requirements.txt . +RUN pip install --no-cache-dir --user --no-warn-script-location -r requirements.txt + +# ============================================================================= +# Stage 2: Production image +# ============================================================================= +FROM python:3.13-slim-bookworm + +# OCI Labels (https://github.com/opencontainers/image-spec/blob/main/annotations.md) +LABEL org.opencontainers.image.title="Video Service" +LABEL org.opencontainers.image.description="Video transcoding service for Kurs-Booking" +LABEL org.opencontainers.image.vendor="webideas24" +LABEL org.opencontainers.image.version="1.0.0" +LABEL org.opencontainers.image.source="https://git.islandpferde-melanieworbs.de/webideas24/video-service" + +# Security: Install tini for proper signal handling and zombie reaping +# Install runtime dependencies in single layer +RUN apt-get update && apt-get install -y --no-install-recommends \ + tini \ + ffmpeg \ + curl \ + ca-certificates \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \ + && rm -rf /root/.cache + +WORKDIR /app + +# Copy Python packages from builder (before creating user to set ownership) +COPY --from=builder /root/.local /home/videouser/.local + +# Security: Create non-root user with no shell, no home directory files +RUN groupadd -r -g 1000 videouser && \ + useradd -r -u 1000 -g videouser -s /usr/sbin/nologin -d /home/videouser videouser && \ + mkdir -p /app/storage/uploads /app/storage/converted && \ + chown -R videouser:videouser /app /home/videouser + +# Copy application with correct ownership +COPY --chown=videouser:videouser app/ ./app/ + +# Switch to non-root user +USER videouser + +# Environment +ENV PATH=/home/videouser/.local/bin:$PATH \ + PYTHONPATH=/app \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# Security: Document exposed port +EXPOSE 8000 + +# Proper signal handling +STOPSIGNAL SIGTERM + +# Health check with reasonable timeouts +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD curl -fsS http://localhost:8000/health || exit 1 + +# Use tini as init system, run uvicorn +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"] diff --git a/README.md b/README.md new file mode 100755 index 0000000..ff09177 --- /dev/null +++ b/README.md @@ -0,0 +1,230 @@ +# Video-Service fuer Kurs-Booking Plugin + +Python-basierter Microservice fuer Video-Hosting und HLS-Streaming. + +## Technologie-Stack + +| Komponente | Version | Beschreibung | +|------------|---------|--------------| +| Python | 3.13 | Support bis Oktober 2029 | +| FastAPI | 0.115 | REST API Framework | +| Redis | 7.4 | Task Queue (BSD-Lizenz) | +| Celery | 5.4 | Async Task Processing | +| FFmpeg | 6.1 | Video-Konvertierung | + +## Quick Start + +```bash +# Container starten +cd video-service +docker compose up -d + +# Logs anzeigen +docker compose logs -f video-api + +# Health Check +curl http://localhost:8500/api/v1/health +``` + +## Architektur + +``` +video-service/ ++-- app/ +| +-- main.py # FastAPI App +| +-- config.py # Pydantic Settings +| +-- celery_app.py # Celery Config +| +-- api/ +| | +-- routes.py # API Endpoints +| | +-- auth.py # JWT + API-Key +| +-- services/ +| | +-- storage.py # File Management +| | +-- converter.py # FFmpeg Wrapper +| +-- tasks/ +| | +-- video_tasks.py # Celery Tasks +| +-- models/ +| +-- schemas.py # Pydantic Models ++-- storage/ +| +-- uploads/ # Hochgeladene Videos +| +-- converted/ # HLS Output ++-- tests/ + +-- test_api.py +``` + +## API Endpoints + +| Endpoint | Methode | Auth | Beschreibung | +|----------|---------|------|--------------| +| `/health` | GET | - | Health Check | +| `/api/v1/health` | GET | - | Detaillierter Health Check | +| `/api/v1/videos/upload` | POST | API-Key | Video hochladen | +| `/api/v1/videos/{id}/status` | GET | API-Key | Status abfragen | +| `/api/v1/videos` | GET | API-Key | Alle Videos listen | +| `/api/v1/videos/{id}` | DELETE | API-Key | Video loeschen | +| `/api/v1/videos/{id}/token` | POST | API-Key | Stream-Token generieren | +| `/api/v1/stream/{id}/master.m3u8` | GET | JWT | HLS Master Playlist | +| `/api/v1/stream/{id}/{quality}/playlist.m3u8` | GET | JWT | Quality Playlist | +| `/api/v1/stream/{id}/{quality}/{segment}` | GET | JWT | Video Segment | + +## Authentifizierung + +### API-Key (WordPress -> Video-Service) + +``` +X-API-Key: your-api-key +``` + +### JWT Token (Client -> Streaming) + +``` +/api/v1/stream/{video_id}/master.m3u8?token=eyJ... +``` + +Token-Inhalt: +```json +{ + "video_id": "abc123", + "buchung_id": 789, + "ip": "192.168.1.100", + "exp": 1701442800 +} +``` + +## Konfiguration + +### Environment Variables + +| Variable | Default | Beschreibung | +|----------|---------|--------------| +| `API_KEY` | - | API-Key fuer WordPress | +| `JWT_SECRET` | - | Secret fuer Stream-Tokens | +| `JWT_EXPIRY_HOURS` | 1 | Token-Gueltigkeit | +| `REDIS_URL` | redis://redis:6379/0 | Redis-Verbindung | +| `STORAGE_PATH` | /app/storage | Speicherpfad | +| `WORDPRESS_WEBHOOK_URL` | - | Webhook-URL | +| `ALLOWED_ORIGINS` | - | CORS Origins | +| `VIDEO_DOMAIN` | - | Produktions-Domain | +| `MAX_UPLOAD_SIZE_MB` | 2048 | Max. Upload (2GB) | +| `VIDEO_QUALITIES` | 360p,720p,1080p | HLS Qualitaeten | +| `HLS_SEGMENT_DURATION` | 6 | Segment-Laenge | + +## Video-Konvertierung + +### HLS Output-Struktur + +``` +videos/{video_id}/ ++-- metadata.json ++-- thumbnail.jpg ++-- hls/ + +-- master.m3u8 + +-- 360p/ + | +-- playlist.m3u8 + | +-- segment_000.ts + | +-- segment_001.ts + +-- 720p/ + | +-- playlist.m3u8 + | +-- segment_000.ts + +-- 1080p/ + +-- playlist.m3u8 + +-- segment_000.ts +``` + +### Quality Presets + +| Quality | Resolution | Video Bitrate | Audio Bitrate | +|---------|------------|---------------|---------------| +| 360p | -2:360 | 800k | 96k | +| 720p | -2:720 | 2500k | 128k | +| 1080p | -2:1080 | 5000k | 192k | + +## Webhook Events + +Der Service sendet Webhooks an WordPress: + +### `video.progress` +```json +{ + "event": "video.progress", + "video_id": "abc123", + "kurs_id": 42, + "status": "processing", + "progress": 45 +} +``` + +### `video.ready` +```json +{ + "event": "video.ready", + "video_id": "abc123", + "kurs_id": 42, + "status": "ready", + "metadata": { + "duration_seconds": 3600, + "width": 1920, + "height": 1080 + }, + "thumbnail_url": "https://..." +} +``` + +### `video.error` +```json +{ + "event": "video.error", + "video_id": "abc123", + "kurs_id": 42, + "status": "error", + "error_message": "FFmpeg conversion failed" +} +``` + +## WordPress Integration + +### Einstellungen + +In WordPress unter **Veranstaltungen > Einstellungen > Video-Service**: + +1. Video-Service URL eintragen (z.B. `http://localhost:8500`) +2. API-Key eintragen (muss mit VIDEO_API_KEY uebereinstimmen) +3. Verbindung testen + +### Shortcode + +``` +[kurs_video buchung_id="123"] +[kurs_video buchung_id="123" video_id="5"] +``` + +## Entwicklung + +```bash +# Lokale Tests +cd video-service +python -m pytest tests/ -v + +# API Dokumentation +# http://localhost:8500/docs (nur im Debug-Modus) +``` + +## Deployment auf Hetzner + +1. Storage Box einrichten (BX11) +2. CX22 Server mit Docker +3. DNS: videos.islandpferde-melanieworbs.de -> Server-IP +4. SSL-Zertifikat via Coolify/Traefik +5. Environment Variables in Coolify setzen + +## Sicherheit + +- API-Key zwischen WordPress und Service +- JWT fuer Streaming mit IP-Binding +- Rate-Limiting (10 req/s) +- Kein direkter Zugriff auf Storage +- HTTPS in Produktion + +--- + +**Version:** 1.0.0 +**Stand:** 02. Dezember 2025 diff --git a/app/__init__.py b/app/__init__.py new file mode 100755 index 0000000..010de29 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,2 @@ +# Video-Service for Kurs-Booking Plugin +__version__ = "1.0.0" diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100755 index 0000000..38f9039 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +# API Module diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100755 index 0000000..bb3b412 --- /dev/null +++ b/app/api/auth.py @@ -0,0 +1,127 @@ +""" +Authentication Module +- API Key for WordPress ↔ Video-Service communication +- JWT for streaming token validation +""" + +from datetime import UTC, datetime, timedelta +from typing import Annotated + +import jwt +from fastapi import Depends, Header, HTTPException, Request, status +from pydantic import BaseModel + +from app.config import Settings, get_settings + + +class TokenPayload(BaseModel): + """JWT token payload for video streaming.""" + + video_id: str + buchung_id: int + ip: str + exp: datetime + + +class StreamToken(BaseModel): + """Response model for stream token.""" + + token: str + expires_at: datetime + stream_url: str + + +def verify_api_key( + x_api_key: Annotated[str | None, Header()] = None, + settings: Settings = Depends(get_settings), +) -> bool: + """Verify API key from WordPress.""" + if not x_api_key: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing API key", + ) + if x_api_key != settings.api_key: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid API key", + ) + return True + + +def create_stream_token( + video_id: str, + buchung_id: int, + client_ip: str, + settings: Settings = Depends(get_settings), +) -> StreamToken: + """Create a JWT token for video streaming with IP binding.""" + expiry = datetime.now(UTC) + timedelta(hours=settings.jwt_expiry_hours) + + payload = { + "video_id": video_id, + "buchung_id": buchung_id, + "ip": client_ip, + "exp": expiry, + "iat": datetime.now(UTC), + } + + token = jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) + + # Build stream URL using configured base_url (handles production/development) + stream_url = ( + f"{settings.base_url}/api/v1/stream/{video_id}/master.m3u8?token={token}" + ) + + return StreamToken( + token=token, + expires_at=expiry, + stream_url=stream_url, + ) + + +def verify_stream_token( + token: str, + video_id: str, + request: Request, + settings: Settings = Depends(get_settings), +) -> TokenPayload: + """Verify JWT token for streaming, including IP binding.""" + try: + payload = jwt.decode( + token, + settings.jwt_secret, + algorithms=[settings.jwt_algorithm], + ) + except jwt.ExpiredSignatureError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has expired", + ) from e + except jwt.InvalidTokenError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Invalid token: {e}", + ) from e + + # Verify video_id matches + if payload.get("video_id") != video_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Token not valid for this video", + ) + + # Verify IP binding (optional in development) + client_ip = request.client.host if request.client else "unknown" + if settings.environment == "production" and payload.get("ip") != client_ip: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="IP address mismatch", + ) + + return TokenPayload( + video_id=payload["video_id"], + buchung_id=payload["buchung_id"], + ip=payload["ip"], + exp=datetime.fromtimestamp(payload["exp"], tz=UTC), + ) diff --git a/app/api/routes.py b/app/api/routes.py new file mode 100755 index 0000000..61b63d7 --- /dev/null +++ b/app/api/routes.py @@ -0,0 +1,458 @@ +""" +API Routes for Video-Service +""" + +import logging +import re +from datetime import UTC +from pathlib import Path + +import redis +from fastapi import ( + APIRouter, + Depends, + File, + Form, + HTTPException, + Query, + Request, + UploadFile, + status, +) +from fastapi.responses import FileResponse, Response + +from app.api.auth import create_stream_token, verify_api_key, verify_stream_token +from app.config import Settings, get_settings +from app.models.schemas import ( + HealthResponse, + StreamTokenRequest, + VideoListResponse, + VideoMetadata, + VideoQuality, + VideoStatus, + VideoStatusResponse, +) +from app.services.converter import ConverterService +from app.services.storage import StorageService +from app.tasks.video_tasks import process_video + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# Health Check (no auth required) +@router.get("/health", response_model=HealthResponse) +async def health_check(settings: Settings = Depends(get_settings)): + """Health check endpoint.""" + storage = StorageService(settings) + converter = ConverterService(settings) + + # Check Redis connection + redis_connected = False + try: + r = redis.from_url(settings.redis_url) + r.ping() + redis_connected = True + except Exception: + pass + + from app import __version__ + + return HealthResponse( + status="healthy" if redis_connected and storage.is_writable() else "degraded", + version=__version__, + environment=settings.environment, + redis_connected=redis_connected, + storage_writable=storage.is_writable(), + ffmpeg_available=converter.is_ffmpeg_available(), + ) + + +# Video Upload +@router.post("/videos/upload", response_model=VideoStatusResponse) +async def upload_video( + file: UploadFile = File(...), + kurs_id: int = Form(...), + title: str = Form(...), + description: str | None = Form(None), + _: bool = Depends(verify_api_key), + settings: Settings = Depends(get_settings), +): + """ + Upload a video file for processing. + Requires API key authentication. + """ + # Validate file type + allowed_types = [ + "video/mp4", + "video/quicktime", + "video/x-msvideo", + "video/x-matroska", + ] + if file.content_type not in allowed_types: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid file type: {file.content_type}. Allowed: MP4, MOV, AVI, MKV", + ) + + # Check file size (read first chunk to estimate) + storage = StorageService(settings) + video_id = storage.generate_video_id() + + # Determine safe filename + original_ext = Path(file.filename or "video.mp4").suffix.lower() + if original_ext not in [".mp4", ".mov", ".avi", ".mkv"]: + original_ext = ".mp4" + safe_filename = f"source{original_ext}" + + # Save uploaded file + upload_path = storage.get_upload_path(video_id, safe_filename) + + try: + # Stream file to disk + file_size = 0 + with upload_path.open("wb") as f: + while chunk := await file.read(1024 * 1024): # 1MB chunks + file_size += len(chunk) + if file_size > settings.max_upload_size_bytes: + f.close() + upload_path.unlink() + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"File too large. Max: {settings.max_upload_size_mb}MB", + ) + f.write(chunk) + + logger.info(f"Uploaded {file_size} bytes for video {video_id}") + + # Save initial metadata + from datetime import datetime + + storage.save_metadata( + video_id=video_id, + kurs_id=kurs_id, + title=title, + status=VideoStatus.PENDING, + ) + + # Queue processing task + process_video.delay(video_id, safe_filename) + + # Return status + return VideoStatusResponse( + video_id=video_id, + kurs_id=kurs_id, + title=title, + status=VideoStatus.PENDING, + progress=0, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"Upload failed: {e}") + if upload_path.exists(): + upload_path.unlink() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Upload failed: {e!s}", + ) from e + + +# Video Status +@router.get("/videos/{video_id}/status", response_model=VideoStatusResponse) +async def get_video_status( + video_id: str, + _: bool = Depends(verify_api_key), + settings: Settings = Depends(get_settings), +): + """Get video processing status.""" + storage = StorageService(settings) + meta = storage.load_metadata(video_id) + + if not meta: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Video not found", + ) + + from datetime import datetime + + # Parse metadata + video_metadata = None + if meta.get("metadata"): + video_metadata = VideoMetadata(**meta["metadata"]) + + # Build thumbnail URL using configured base_url + thumbnail_url = None + thumbnail_path = storage.get_thumbnail_path(video_id) + if thumbnail_path.exists(): + thumbnail_url = f"{settings.base_url}/api/v1/videos/{video_id}/thumbnail" + + # Determine available qualities + hls_path = storage.get_hls_path(video_id) + available_qualities = [] + if hls_path.exists(): + for quality in ["360p", "720p", "1080p"]: + if (hls_path / quality).exists(): + available_qualities.append(VideoQuality(quality)) + + return VideoStatusResponse( + video_id=video_id, + kurs_id=meta.get("kurs_id", 0), + title=meta.get("title", ""), + status=VideoStatus(meta.get("status", "pending")), + progress=meta.get("progress", 0), + metadata=video_metadata, + thumbnail_url=thumbnail_url, + available_qualities=available_qualities, + created_at=datetime.fromisoformat( + meta.get("created_at", datetime.now().isoformat()) + ), + updated_at=datetime.fromisoformat( + meta.get("updated_at", datetime.now().isoformat()) + ), + error_message=meta.get("error_message"), + ) + + +# List Videos +@router.get("/videos", response_model=VideoListResponse) +async def list_videos( + kurs_id: int | None = Query(None), + status_filter: VideoStatus | None = Query(None, alias="status"), + _: bool = Depends(verify_api_key), + settings: Settings = Depends(get_settings), +): + """List all videos, optionally filtered by kurs_id or status.""" + storage = StorageService(settings) + videos = [] + + for vid in storage.get_video_ids(): + meta = storage.load_metadata(vid) + if not meta: + continue + + # Apply filters + if kurs_id and meta.get("kurs_id") != kurs_id: + continue + if status_filter and meta.get("status") != status_filter.value: + continue + + from datetime import datetime + + video_metadata = None + if meta.get("metadata"): + video_metadata = VideoMetadata(**meta["metadata"]) + + videos.append( + VideoStatusResponse( + video_id=vid, + kurs_id=meta.get("kurs_id", 0), + title=meta.get("title", ""), + status=VideoStatus(meta.get("status", "pending")), + progress=meta.get("progress", 0), + metadata=video_metadata, + created_at=datetime.fromisoformat( + meta.get("created_at", datetime.now().isoformat()) + ), + updated_at=datetime.fromisoformat( + meta.get("updated_at", datetime.now().isoformat()) + ), + error_message=meta.get("error_message"), + ) + ) + + return VideoListResponse(videos=videos, total=len(videos)) + + +# Delete Video +@router.delete("/videos/{video_id}") +async def delete_video( + video_id: str, + _: bool = Depends(verify_api_key), + settings: Settings = Depends(get_settings), +): + """Delete a video and all associated files.""" + storage = StorageService(settings) + + if not storage.delete_video(video_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Video not found", + ) + + return {"status": "deleted", "video_id": video_id} + + +# Thumbnail +@router.get("/videos/{video_id}/thumbnail") +async def get_thumbnail( + video_id: str, + settings: Settings = Depends(get_settings), +): + """Get video thumbnail. No auth required for thumbnails.""" + storage = StorageService(settings) + thumbnail_path = storage.get_thumbnail_path(video_id) + + if not thumbnail_path.exists(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Thumbnail not found", + ) + + return FileResponse( + thumbnail_path, + media_type="image/jpeg", + headers={"Cache-Control": "public, max-age=86400"}, + ) + + +# Stream Token +@router.post("/videos/{video_id}/token") +async def get_stream_token( + video_id: str, + request: Request, + data: StreamTokenRequest, + _: bool = Depends(verify_api_key), + settings: Settings = Depends(get_settings), +): + """ + Get a JWT token for video streaming. + Token is IP-bound and expires after configured time. + """ + storage = StorageService(settings) + meta = storage.load_metadata(video_id) + + if not meta: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Video not found", + ) + + if meta.get("status") != VideoStatus.READY.value: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Video not ready. Status: {meta.get('status')}", + ) + + client_ip = request.client.host if request.client else "unknown" + + return create_stream_token( + video_id=video_id, + buchung_id=data.buchung_id, + client_ip=client_ip, + settings=settings, + ) + + +# HLS Streaming +@router.get("/stream/{video_id}/master.m3u8") +async def stream_master_playlist( + video_id: str, + token: str = Query(...), + request: Request = None, + settings: Settings = Depends(get_settings), +): + """Serve master HLS playlist. Requires valid JWT token.""" + # Verify token + verify_stream_token(token, video_id, request, settings) + + storage = StorageService(settings) + master_path = storage.get_hls_path(video_id) / "master.m3u8" + + if not master_path.exists(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Playlist not found", + ) + + # Modify playlist to include token in quality playlist URLs + content = master_path.read_text() + # Add token to each playlist URL + modified_content = re.sub( + r"(\d+p/playlist\.m3u8)", + rf"\1?token={token}", + content, + ) + + return Response( + content=modified_content, + media_type="application/vnd.apple.mpegurl", + headers={"Cache-Control": "no-cache"}, + ) + + +@router.get("/stream/{video_id}/{quality}/playlist.m3u8") +async def stream_quality_playlist( + video_id: str, + quality: str, + token: str = Query(...), + request: Request = None, + settings: Settings = Depends(get_settings), +): + """Serve quality-specific HLS playlist.""" + verify_stream_token(token, video_id, request, settings) + + storage = StorageService(settings) + playlist_path = storage.get_hls_path(video_id) / quality / "playlist.m3u8" + + if not playlist_path.exists(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Playlist not found", + ) + + # Modify segment URLs to include token + content = playlist_path.read_text() + modified_content = re.sub( + r"(segment_\d+\.ts)", + rf"\1?token={token}", + content, + ) + + return Response( + content=modified_content, + media_type="application/vnd.apple.mpegurl", + headers={"Cache-Control": "no-cache"}, + ) + + +@router.api_route("/stream/{video_id}/{quality}/{segment}", methods=["GET", "HEAD"]) +async def stream_segment( + video_id: str, + quality: str, + segment: str, + token: str = Query(...), + request: Request = None, + settings: Settings = Depends(get_settings), +): + """Serve HLS video segment. Supports HEAD for content-length checks.""" + verify_stream_token(token, video_id, request, settings) + + # Validate segment filename (prevent path traversal) + if not re.match(r"^segment_\d{3}\.ts$", segment): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid segment name", + ) + + storage = StorageService(settings) + segment_path = storage.get_hls_path(video_id) / quality / segment + + if not segment_path.exists(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Segment not found", + ) + + return FileResponse( + segment_path, + media_type="video/mp2t", + headers={ + "Cache-Control": "public, max-age=31536000", # 1 year - segments don't change + }, + ) diff --git a/app/celery_app.py b/app/celery_app.py new file mode 100755 index 0000000..43bc167 --- /dev/null +++ b/app/celery_app.py @@ -0,0 +1,34 @@ +""" +Celery Application Configuration +""" + +from celery import Celery + +from app.config import get_settings + +settings = get_settings() + +celery_app = Celery( + "video_tasks", + broker=settings.redis_url, + backend=settings.redis_url, + include=["app.tasks.video_tasks"], +) + +# Celery Configuration +celery_app.conf.update( + task_serializer="json", + accept_content=["json"], + result_serializer="json", + timezone="Europe/Vienna", + enable_utc=True, + # Task settings + task_track_started=True, + task_time_limit=3600, # 1 hour hard limit + task_soft_time_limit=3300, # 55 min soft limit + # Worker settings + worker_prefetch_multiplier=1, # One task at a time for video processing + worker_concurrency=2, + # Result settings + result_expires=86400, # 24 hours +) diff --git a/app/config.py b/app/config.py new file mode 100755 index 0000000..6988f14 --- /dev/null +++ b/app/config.py @@ -0,0 +1,86 @@ +""" +Video-Service Configuration +Using pydantic-settings for type-safe environment configuration +""" + +from functools import lru_cache + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + # Environment + environment: str = "development" + debug: bool = False + + # API Security + api_key: str = "change-me-in-production" + jwt_secret: str = "change-me-in-production" + jwt_algorithm: str = "HS256" + jwt_expiry_hours: int = 1 + + # Redis + redis_url: str = "redis://localhost:6379/0" + + # Storage + storage_path: str = "/app/storage" + + # WordPress Integration + wordpress_webhook_url: str = "" + wordpress_api_key: str = "" + + # CORS + allowed_origins: str = "http://localhost:8300" + + # Upload Limits + max_upload_size_mb: int = 2048 # 2GB default + + # FFmpeg Settings + ffmpeg_threads: int = 2 + hls_segment_duration: int = 6 + video_qualities: str = "360p,720p,1080p" + + # Production Domain + video_domain: str = "videos.islandpferde-melanieworbs.de" + + # Public URL for development (external access from browser) + # In production, https://{video_domain} is used instead + video_public_url: str = "" + + @property + def base_url(self) -> str: + """Get the base URL for video streaming (accessible from browser).""" + if self.environment == "production": + return f"https://{self.video_domain}" + elif self.video_public_url: + return self.video_public_url.rstrip("/") + else: + return "http://localhost:8500" + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + case_sensitive = False + + @property + def allowed_origins_list(self) -> list[str]: + """Parse comma-separated origins into list.""" + return [origin.strip() for origin in self.allowed_origins.split(",")] + + @property + def video_qualities_list(self) -> list[str]: + """Parse comma-separated qualities into list.""" + return [q.strip() for q in self.video_qualities.split(",")] + + @property + def max_upload_size_bytes(self) -> int: + """Convert MB to bytes.""" + return self.max_upload_size_mb * 1024 * 1024 + + +@lru_cache +def get_settings() -> Settings: + """Cached settings instance.""" + return Settings() diff --git a/app/main.py b/app/main.py new file mode 100755 index 0000000..ee3c97c --- /dev/null +++ b/app/main.py @@ -0,0 +1,81 @@ +""" +Video-Service for Kurs-Booking Plugin +FastAPI Application Entry Point +""" + +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app import __version__ +from app.api.routes import router +from app.config import get_settings + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan handler.""" + settings = get_settings() + logger.info(f"Starting Video-Service v{__version__}") + logger.info(f"Environment: {settings.environment}") + logger.info(f"Storage path: {settings.storage_path}") + logger.info(f"Redis URL: {settings.redis_url}") + + yield + + logger.info("Shutting down Video-Service") + + +# Create FastAPI app +settings = get_settings() + +app = FastAPI( + title="Kurs-Booking Video-Service", + description="Video hosting and streaming service for Kurs-Booking WordPress plugin", + version=__version__, + docs_url="/docs" if settings.debug else None, + redoc_url="/redoc" if settings.debug else None, + openapi_url="/openapi.json" if settings.debug else None, + lifespan=lifespan, +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=settings.allowed_origins_list, + allow_credentials=True, + allow_methods=["GET", "POST", "DELETE"], + allow_headers=["*"], + expose_headers=["Content-Disposition"], +) + +# Include API routes +app.include_router(router, prefix="/api/v1") + + +# Root endpoint +@app.get("/") +async def root(): + """Root endpoint with service info.""" + return { + "service": "Kurs-Booking Video-Service", + "version": __version__, + "docs": "/docs" if settings.debug else "disabled", + "health": "/api/v1/health", + } + + +# Health check at root level (for Docker health checks) +@app.get("/health") +async def health(): + """Simple health check for Docker.""" + return {"status": "ok"} diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100755 index 0000000..72bee71 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1 @@ +# Models Module diff --git a/app/models/schemas.py b/app/models/schemas.py new file mode 100755 index 0000000..c02eb0f --- /dev/null +++ b/app/models/schemas.py @@ -0,0 +1,109 @@ +""" +Pydantic schemas for Video-Service API +""" + +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, Field + + +class VideoStatus(str, Enum): + """Video processing status.""" + + PENDING = "pending" + UPLOADING = "uploading" + PROCESSING = "processing" + READY = "ready" + ERROR = "error" + + +class VideoQuality(str, Enum): + """Available video qualities.""" + + Q360P = "360p" + Q720P = "720p" + Q1080P = "1080p" + + +class VideoMetadata(BaseModel): + """Video metadata extracted from file.""" + + duration_seconds: float = 0 + width: int = 0 + height: int = 0 + fps: float = 0 + codec: str = "" + bitrate: int = 0 + file_size_bytes: int = 0 + + +class VideoUploadRequest(BaseModel): + """Request to initiate video upload.""" + + kurs_id: int = Field(..., description="WordPress Kurs Post ID") + title: str = Field(..., min_length=1, max_length=255) + description: str | None = None + + +class VideoUploadResponse(BaseModel): + """Response after initiating upload.""" + + video_id: str + upload_url: str + expires_at: datetime + + +class VideoStatusResponse(BaseModel): + """Video status response.""" + + video_id: str + kurs_id: int + title: str + status: VideoStatus + progress: int = Field(0, ge=0, le=100) + metadata: VideoMetadata | None = None + thumbnail_url: str | None = None + available_qualities: list[VideoQuality] = [] + created_at: datetime + updated_at: datetime + error_message: str | None = None + + +class VideoListResponse(BaseModel): + """List of videos response.""" + + videos: list[VideoStatusResponse] + total: int + + +class StreamTokenRequest(BaseModel): + """Request for stream token.""" + + video_id: str + buchung_id: int + + +class WebhookPayload(BaseModel): + """Payload sent to WordPress webhook.""" + + event: str # video.ready, video.error, video.progress + video_id: str + kurs_id: int + status: VideoStatus + progress: int = 0 + metadata: VideoMetadata | None = None + thumbnail_url: str | None = None + error_message: str | None = None + timestamp: datetime + + +class HealthResponse(BaseModel): + """Health check response.""" + + status: str + version: str + environment: str + redis_connected: bool + storage_writable: bool + ffmpeg_available: bool diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100755 index 0000000..4642aab --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +# Services Module diff --git a/app/services/converter.py b/app/services/converter.py new file mode 100755 index 0000000..4024746 --- /dev/null +++ b/app/services/converter.py @@ -0,0 +1,347 @@ +""" +Video Converter Service +FFmpeg wrapper for HLS conversion with multiple qualities +""" + +import json +import subprocess +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +from app.config import Settings, get_settings +from app.models.schemas import VideoMetadata + + +@dataclass +class QualityPreset: + """Video quality preset for HLS.""" + + name: str + height: int + bitrate: str # e.g., "1000k" + audio_bitrate: str # e.g., "128k" + + +QUALITY_PRESETS: dict[str, QualityPreset] = { + "360p": QualityPreset(name="360p", height=360, bitrate="800k", audio_bitrate="96k"), + "720p": QualityPreset( + name="720p", height=720, bitrate="2500k", audio_bitrate="128k" + ), + "1080p": QualityPreset( + name="1080p", height=1080, bitrate="5000k", audio_bitrate="192k" + ), +} + + +class ConverterService: + """FFmpeg video converter for HLS streaming.""" + + def __init__(self, settings: Settings | None = None): + self.settings = settings or get_settings() + + def is_ffmpeg_available(self) -> bool: + """Check if FFmpeg is installed and accessible.""" + try: + result = subprocess.run( + ["ffmpeg", "-version"], + check=False, + capture_output=True, + text=True, + timeout=10, + ) + return result.returncode == 0 + except (subprocess.SubprocessError, FileNotFoundError): + return False + + def get_ffmpeg_version(self) -> str | None: + """Get FFmpeg version string.""" + try: + result = subprocess.run( + ["ffmpeg", "-version"], + check=False, + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + # First line contains version + return result.stdout.split("\n")[0] + return None + except (subprocess.SubprocessError, FileNotFoundError): + return None + + def extract_metadata(self, input_path: Path) -> VideoMetadata: + """Extract video metadata using ffprobe.""" + cmd = [ + "ffprobe", + "-v", + "quiet", + "-print_format", + "json", + "-show_format", + "-show_streams", + str(input_path), + ] + + result = subprocess.run( + cmd, check=False, capture_output=True, text=True, timeout=60 + ) + + if result.returncode != 0: + raise RuntimeError(f"ffprobe failed: {result.stderr}") + + data = json.loads(result.stdout) + + # Find video stream + video_stream = None + for stream in data.get("streams", []): + if stream.get("codec_type") == "video": + video_stream = stream + break + + if not video_stream: + raise RuntimeError("No video stream found") + + format_info = data.get("format", {}) + + # Parse frame rate (e.g., "30/1" or "29.97") + fps_str = video_stream.get("r_frame_rate", "0/1") + if "/" in fps_str: + num, den = fps_str.split("/") + fps = float(num) / float(den) if float(den) > 0 else 0 + else: + fps = float(fps_str) + + return VideoMetadata( + duration_seconds=float(format_info.get("duration", 0)), + width=int(video_stream.get("width", 0)), + height=int(video_stream.get("height", 0)), + fps=round(fps, 2), + codec=video_stream.get("codec_name", ""), + bitrate=int(format_info.get("bit_rate", 0)), + file_size_bytes=int(format_info.get("size", 0)), + ) + + def has_audio_stream(self, input_path: Path) -> bool: + """Check if video file has an audio stream.""" + cmd = [ + "ffprobe", + "-v", + "quiet", + "-print_format", + "json", + "-show_streams", + "-select_streams", + "a", + str(input_path), + ] + result = subprocess.run( + cmd, check=False, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + return False + data = json.loads(result.stdout) + return len(data.get("streams", [])) > 0 + + def generate_thumbnail( + self, + input_path: Path, + output_path: Path, + time_offset: float = 5.0, + ) -> bool: + """Generate thumbnail at specified time offset.""" + cmd = [ + "ffmpeg", + "-y", # Overwrite + "-ss", + str(time_offset), + "-i", + str(input_path), + "-vframes", + "1", + "-vf", + "scale=640:-1", # 640px width, maintain aspect + "-q:v", + "2", # High quality JPEG + str(output_path), + ] + + result = subprocess.run( + cmd, check=False, capture_output=True, text=True, timeout=60 + ) + return result.returncode == 0 + + def convert_to_hls( + self, + input_path: Path, + output_dir: Path, + qualities: list[str] | None = None, + progress_callback: Callable[[int], None] | None = None, + ) -> dict: + """ + Convert video to HLS with multiple quality levels. + + Returns dict with: + - master_playlist: path to master.m3u8 + - qualities: list of generated quality levels + - success: bool + - error: optional error message + """ + if qualities is None: + qualities = self.settings.video_qualities_list + + output_dir.mkdir(parents=True, exist_ok=True) + hls_dir = output_dir / "hls" + hls_dir.mkdir(exist_ok=True) + + # Get source video metadata to determine which qualities to generate + try: + metadata = self.extract_metadata(input_path) + source_height = metadata.height + except Exception: + source_height = 1080 # Assume HD if can't detect + + # Filter qualities based on source resolution + valid_qualities = [] + for q in qualities: + preset = QUALITY_PRESETS.get(q) + if preset and preset.height <= source_height: + valid_qualities.append(q) + + if not valid_qualities: + # At minimum, include lowest quality + valid_qualities = ["360p"] + + # Check if video has audio stream + has_audio = self.has_audio_stream(input_path) + + # Build FFmpeg command for multi-quality HLS + cmd = [ + "ffmpeg", + "-y", + "-i", + str(input_path), + "-threads", + str(self.settings.ffmpeg_threads), + ] + + # Add output for each quality + stream_maps = [] + for i, quality_name in enumerate(valid_qualities): + preset = QUALITY_PRESETS[quality_name] + quality_dir = hls_dir / quality_name + quality_dir.mkdir(exist_ok=True) + + # Video mapping and encoding + cmd.extend( + [ + "-map", + "0:v:0", + f"-c:v:{i}", + "libx264", + f"-b:v:{i}", + preset.bitrate, + f"-vf:v:{i}", + f"scale=-2:{preset.height}", + ] + ) + + # Audio mapping and encoding (only if audio stream exists) + if has_audio: + cmd.extend( + [ + "-map", + "0:a:0", + f"-c:a:{i}", + "aac", + f"-b:a:{i}", + preset.audio_bitrate, + ] + ) + stream_maps.append(f"v:{i},a:{i}") + else: + stream_maps.append(f"v:{i}") + + # HLS settings + segment_duration = self.settings.hls_segment_duration + + # Use var_stream_map for multiple quality outputs + var_stream_map = " ".join(stream_maps) + + cmd.extend( + [ + "-f", + "hls", + "-hls_time", + str(segment_duration), + "-hls_playlist_type", + "vod", + "-hls_flags", + "independent_segments", + "-hls_segment_filename", + str(hls_dir / "%v" / "segment_%03d.ts"), + "-master_pl_name", + "master.m3u8", + "-var_stream_map", + var_stream_map, + str(hls_dir / "%v" / "playlist.m3u8"), + ] + ) + + # Execute conversion + try: + result = subprocess.run( + cmd, + check=False, + capture_output=True, + text=True, + timeout=3600, # 1 hour timeout + ) + + if result.returncode != 0: + return { + "success": False, + "error": result.stderr[-500:] if result.stderr else "Unknown error", + "qualities": [], + } + + # Rename quality directories to proper names + for i, quality_name in enumerate(valid_qualities): + src_dir = hls_dir / str(i) + dst_dir = hls_dir / quality_name + if src_dir.exists() and src_dir != dst_dir: + if dst_dir.exists(): + import shutil + + shutil.rmtree(dst_dir) + src_dir.rename(dst_dir) + + # Fix master playlist to use correct quality names + master_path = hls_dir / "master.m3u8" + if master_path.exists(): + content = master_path.read_text() + for i, quality_name in enumerate(valid_qualities): + content = content.replace( + f"{i}/playlist.m3u8", f"{quality_name}/playlist.m3u8" + ) + master_path.write_text(content) + + return { + "success": True, + "master_playlist": str(master_path), + "qualities": valid_qualities, + } + + except subprocess.TimeoutExpired: + return { + "success": False, + "error": "Conversion timed out after 1 hour", + "qualities": [], + } + except Exception as e: + return { + "success": False, + "error": str(e), + "qualities": [], + } diff --git a/app/services/storage.py b/app/services/storage.py new file mode 100755 index 0000000..538c377 --- /dev/null +++ b/app/services/storage.py @@ -0,0 +1,168 @@ +""" +Storage Service +Handles file storage for uploaded and converted videos +""" + +import json +import shutil +import uuid +from datetime import UTC, datetime +from pathlib import Path + +from app.config import Settings, get_settings +from app.models.schemas import VideoMetadata, VideoStatus + + +class StorageService: + """Manage video file storage.""" + + def __init__(self, settings: Settings | None = None): + self.settings = settings or get_settings() + self.base_path = Path(self.settings.storage_path) + self.uploads_path = self.base_path / "uploads" + self.converted_path = self.base_path / "converted" + + # Ensure directories exist + self.uploads_path.mkdir(parents=True, exist_ok=True) + self.converted_path.mkdir(parents=True, exist_ok=True) + + def generate_video_id(self) -> str: + """Generate unique video ID.""" + return str(uuid.uuid4())[:12] + + def get_upload_path(self, video_id: str, filename: str) -> Path: + """Get path for uploaded video file.""" + video_dir = self.uploads_path / video_id + video_dir.mkdir(parents=True, exist_ok=True) + return video_dir / filename + + def get_converted_path(self, video_id: str) -> Path: + """Get path for converted video directory.""" + video_dir = self.converted_path / video_id + video_dir.mkdir(parents=True, exist_ok=True) + return video_dir + + def get_hls_path(self, video_id: str) -> Path: + """Get path for HLS output.""" + return self.get_converted_path(video_id) / "hls" + + def get_thumbnail_path(self, video_id: str) -> Path: + """Get path for thumbnail.""" + return self.get_converted_path(video_id) / "thumbnail.jpg" + + def get_metadata_path(self, video_id: str) -> Path: + """Get path for metadata JSON.""" + return self.get_converted_path(video_id) / "metadata.json" + + def save_metadata( + self, + video_id: str, + kurs_id: int, + title: str, + status: VideoStatus, + metadata: VideoMetadata | None = None, + error_message: str | None = None, + ) -> dict: + """Save video metadata to JSON file.""" + meta_path = self.get_metadata_path(video_id) + + data = { + "video_id": video_id, + "kurs_id": kurs_id, + "title": title, + "status": status.value, + "created_at": datetime.now(UTC).isoformat(), + "updated_at": datetime.now(UTC).isoformat(), + "error_message": error_message, + } + + if metadata: + data["metadata"] = metadata.model_dump() + + # Load existing data to preserve created_at + if meta_path.exists(): + with meta_path.open() as f: + existing = json.load(f) + data["created_at"] = existing.get("created_at", data["created_at"]) + + with meta_path.open("w") as f: + json.dump(data, f, indent=2, default=str) + + return data + + def load_metadata(self, video_id: str) -> dict | None: + """Load video metadata from JSON file.""" + meta_path = self.get_metadata_path(video_id) + if not meta_path.exists(): + return None + + with meta_path.open() as f: + return json.load(f) + + def update_status( + self, + video_id: str, + status: VideoStatus, + progress: int = 0, + error_message: str | None = None, + ) -> dict | None: + """Update video status in metadata.""" + meta = self.load_metadata(video_id) + if not meta: + return None + + meta["status"] = status.value + meta["progress"] = progress + meta["updated_at"] = datetime.now(UTC).isoformat() + if error_message: + meta["error_message"] = error_message + + meta_path = self.get_metadata_path(video_id) + with meta_path.open("w") as f: + json.dump(meta, f, indent=2, default=str) + + return meta + + def delete_video(self, video_id: str) -> bool: + """Delete all files for a video.""" + upload_dir = self.uploads_path / video_id + converted_dir = self.converted_path / video_id + + deleted = False + if upload_dir.exists(): + shutil.rmtree(upload_dir) + deleted = True + if converted_dir.exists(): + shutil.rmtree(converted_dir) + deleted = True + + return deleted + + def get_video_ids(self) -> list[str]: + """Get all video IDs.""" + ids = set() + if self.uploads_path.exists(): + ids.update(d.name for d in self.uploads_path.iterdir() if d.is_dir()) + if self.converted_path.exists(): + ids.update(d.name for d in self.converted_path.iterdir() if d.is_dir()) + return sorted(ids) + + def is_writable(self) -> bool: + """Check if storage is writable.""" + test_file = self.base_path / ".write_test" + try: + test_file.write_text("test") + test_file.unlink() + return True + except (OSError, PermissionError): + return False + + def get_disk_usage(self) -> dict: + """Get disk usage statistics.""" + total, used, free = shutil.disk_usage(self.base_path) + return { + "total_gb": round(total / (1024**3), 2), + "used_gb": round(used / (1024**3), 2), + "free_gb": round(free / (1024**3), 2), + "percent_used": round((used / total) * 100, 1), + } diff --git a/app/tasks/__init__.py b/app/tasks/__init__.py new file mode 100755 index 0000000..908e794 --- /dev/null +++ b/app/tasks/__init__.py @@ -0,0 +1 @@ +# Tasks Module diff --git a/app/tasks/video_tasks.py b/app/tasks/video_tasks.py new file mode 100755 index 0000000..21c5fd9 --- /dev/null +++ b/app/tasks/video_tasks.py @@ -0,0 +1,219 @@ +""" +Celery Tasks for Video Processing +""" + +import logging +from datetime import UTC, datetime + +import httpx + +from app.celery_app import celery_app +from app.config import get_settings +from app.models.schemas import VideoMetadata, VideoStatus, WebhookPayload +from app.services.converter import ConverterService +from app.services.storage import StorageService + +logger = logging.getLogger(__name__) + + +def send_webhook(payload: WebhookPayload) -> bool: + """Send webhook notification to WordPress.""" + settings = get_settings() + + if not settings.wordpress_webhook_url: + logger.warning("WordPress webhook URL not configured") + return False + + headers = {"Content-Type": "application/json"} + if settings.wordpress_api_key: + headers["X-API-Key"] = settings.wordpress_api_key + + try: + with httpx.Client(timeout=30.0) as client: + response = client.post( + settings.wordpress_webhook_url, + json=payload.model_dump(mode="json"), + headers=headers, + ) + response.raise_for_status() + logger.info(f"Webhook sent successfully: {payload.event}") + return True + except httpx.HTTPError as e: + logger.error(f"Webhook failed: {e}") + return False + + +@celery_app.task(bind=True, max_retries=3) +def process_video(self, video_id: str, input_filename: str) -> dict: + """ + Process uploaded video: + 1. Extract metadata + 2. Generate thumbnail + 3. Convert to HLS + 4. Send webhook on completion + """ + storage = StorageService() + converter = ConverterService() + + input_path = storage.get_upload_path(video_id, input_filename) + output_dir = storage.get_converted_path(video_id) + + try: + # Update status to processing + meta = storage.load_metadata(video_id) + if not meta: + raise RuntimeError(f"Metadata not found for video {video_id}") + + storage.update_status(video_id, VideoStatus.PROCESSING, progress=5) + + # Send progress webhook + send_webhook( + WebhookPayload( + event="video.progress", + video_id=video_id, + kurs_id=meta["kurs_id"], + status=VideoStatus.PROCESSING, + progress=5, + timestamp=datetime.now(UTC), + ) + ) + + # Step 1: Extract metadata (10%) + logger.info(f"Extracting metadata for {video_id}") + try: + video_metadata = converter.extract_metadata(input_path) + storage.update_status(video_id, VideoStatus.PROCESSING, progress=10) + except Exception as e: + logger.error(f"Metadata extraction failed: {e}") + video_metadata = VideoMetadata() + + # Step 2: Generate thumbnail (20%) + logger.info(f"Generating thumbnail for {video_id}") + thumbnail_path = storage.get_thumbnail_path(video_id) + thumbnail_success = converter.generate_thumbnail( + input_path, + thumbnail_path, + time_offset=min(5.0, video_metadata.duration_seconds / 2), + ) + storage.update_status(video_id, VideoStatus.PROCESSING, progress=20) + + if thumbnail_success: + logger.info(f"Thumbnail generated: {thumbnail_path}") + else: + logger.warning(f"Thumbnail generation failed for {video_id}") + + # Step 3: Convert to HLS (20% - 90%) + logger.info(f"Starting HLS conversion for {video_id}") + + # Progress callback for conversion + def update_progress(progress: int): + # Map 0-100 to 20-90 + mapped_progress = 20 + int(progress * 0.7) + storage.update_status( + video_id, VideoStatus.PROCESSING, progress=mapped_progress + ) + + result = converter.convert_to_hls( + input_path=input_path, + output_dir=output_dir, + progress_callback=update_progress, + ) + + if not result["success"]: + raise RuntimeError(f"HLS conversion failed: {result.get('error')}") + + # Step 4: Finalize (100%) + logger.info(f"Conversion complete for {video_id}") + + # Save final metadata + storage.save_metadata( + video_id=video_id, + kurs_id=meta["kurs_id"], + title=meta["title"], + status=VideoStatus.READY, + metadata=video_metadata, + ) + storage.update_status(video_id, VideoStatus.READY, progress=100) + + # Build thumbnail URL + settings = get_settings() + if settings.environment == "production": + base_url = f"https://{settings.video_domain}" + else: + base_url = "http://localhost:8500" + thumbnail_url = ( + f"{base_url}/api/v1/videos/{video_id}/thumbnail" + if thumbnail_success + else None + ) + + # Send completion webhook + send_webhook( + WebhookPayload( + event="video.ready", + video_id=video_id, + kurs_id=meta["kurs_id"], + status=VideoStatus.READY, + progress=100, + metadata=video_metadata, + thumbnail_url=thumbnail_url, + timestamp=datetime.now(UTC), + ) + ) + + # Optionally delete source file to save space + # input_path.unlink() + + return { + "success": True, + "video_id": video_id, + "qualities": result["qualities"], + "duration": video_metadata.duration_seconds, + } + + except Exception as e: + logger.exception(f"Video processing failed for {video_id}") + + # Update status to error + storage.update_status(video_id, VideoStatus.ERROR, error_message=str(e)) + + # Load metadata for webhook + meta = storage.load_metadata(video_id) or {} + + # Send error webhook + send_webhook( + WebhookPayload( + event="video.error", + video_id=video_id, + kurs_id=meta.get("kurs_id", 0), + status=VideoStatus.ERROR, + error_message=str(e), + timestamp=datetime.now(UTC), + ) + ) + + # Retry with exponential backoff + raise self.retry(exc=e, countdown=60 * (2**self.request.retries)) from e + + +@celery_app.task +def cleanup_old_uploads(days: int = 7) -> dict: + """Clean up old upload files that were never processed.""" + from datetime import timedelta + + storage = StorageService() + deleted_count = 0 + cutoff = datetime.now(UTC) - timedelta(days=days) + + for video_id in storage.get_video_ids(): + meta = storage.load_metadata(video_id) + if meta: + # Check if stuck in pending/uploading + if meta.get("status") in ["pending", "uploading"]: + created = datetime.fromisoformat(meta.get("created_at", "")) + if created.replace(tzinfo=UTC) < cutoff: + storage.delete_video(video_id) + deleted_count += 1 + logger.info(f"Deleted stale video: {video_id}") + + return {"deleted_count": deleted_count} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100755 index 0000000..1bc73ca --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,123 @@ +# Video-Service Docker Stack +# Für Kurs-Booking Plugin - Video-Streaming mit HLS +# +# Versionen (Stand Dezember 2025): +# - Python 3.13 (Support bis Oktober 2029) - https://endoflife.date/python +# - Redis 7.4.x (BSD-Lizenz, Support bis Nov 2026) - https://endoflife.date/redis +# - FFmpeg 6.1.x (Debian Bookworm default, stabil) + +services: + # FastAPI Video-API + video-api: + build: + context: . + dockerfile: Dockerfile + container_name: kurs_video_api + restart: unless-stopped + ports: + - "8501:8000" + environment: + - ENVIRONMENT=development + - DEBUG=true + - API_KEY=${VIDEO_API_KEY:-change-me-in-production} + - JWT_SECRET=${VIDEO_JWT_SECRET:-change-me-in-production} + - JWT_ALGORITHM=HS256 + - JWT_EXPIRY_HOURS=1 + - REDIS_URL=redis://redis:6379/0 + - STORAGE_PATH=/app/storage + - WORDPRESS_WEBHOOK_URL=${WORDPRESS_WEBHOOK_URL:-http://host.docker.internal:8300/wp-json/kurs-booking/v1/video-webhook} + - WORDPRESS_API_KEY=${WORDPRESS_API_KEY:-} + - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost:8300,http://192.168.100.93:8300} + - MAX_UPLOAD_SIZE_MB=2048 + - VIDEO_PUBLIC_URL=${VIDEO_PUBLIC_URL:-http://localhost:8500} + - VIDEO_DOMAIN=${VIDEO_DOMAIN:-videos.islandpferde-melanieworbs.de} + volumes: + - ./storage:/app/storage + - ./app:/app/app:ro + depends_on: + redis: + condition: service_healthy + networks: + - video-network + - mec-network + - staging-network + - refactoring-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + # Celery Worker für Video-Konvertierung + video-worker: + build: + context: . + dockerfile: Dockerfile + container_name: kurs_video_worker + restart: unless-stopped + command: celery -A app.celery_app worker --loglevel=info --concurrency=2 + environment: + - ENVIRONMENT=development + - REDIS_URL=redis://redis:6379/0 + - STORAGE_PATH=/app/storage + - WORDPRESS_WEBHOOK_URL=${WORDPRESS_WEBHOOK_URL:-http://host.docker.internal:8300/wp-json/kurs-booking/v1/video-webhook} + - WORDPRESS_API_KEY=${WORDPRESS_API_KEY:-} + # FFmpeg Einstellungen + - FFMPEG_THREADS=2 + - HLS_SEGMENT_DURATION=6 + - VIDEO_QUALITIES=360p,720p,1080p + volumes: + - ./storage:/app/storage + - ./app:/app/app:ro + depends_on: + redis: + condition: service_healthy + networks: + - video-network + - staging-network + - refactoring-network + # Worker braucht mehr Ressourcen für FFmpeg + deploy: + resources: + limits: + memory: 2G + reservations: + memory: 512M + + # Redis 7.4 - Letzte BSD-lizenzierte Version + # https://endoflife.date/redis - Support bis November 2026 + redis: + image: redis:7.4-alpine + container_name: kurs_video_redis + restart: unless-stopped + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + volumes: + - redis-data:/data + networks: + - video-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + +networks: + video-network: + driver: bridge + # Verbindung zum bestehenden MEC-Netzwerk für WordPress-Kommunikation + mec-network: + external: true + name: proj_wordpress_webnus_mec-network + # Verbindung zum Staging-Netzwerk + staging-network: + external: true + name: mec_staging_network + # Verbindung zum Refactoring-Netzwerk (Port 8200) + refactoring-network: + external: true + name: mec_refactoring_network + +volumes: + redis-data: diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..529ba10 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,31 @@ +# FastAPI + ASGI +fastapi==0.115.5 +uvicorn[standard]==0.32.1 +python-multipart==0.0.17 + +# Celery + Redis +celery[redis]==5.4.0 +redis==5.2.1 + +# JWT + Security +pyjwt==2.10.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 + +# FFmpeg + Video +ffmpeg-python==0.2.0 + +# Pydantic + Config +pydantic==2.10.2 +pydantic-settings==2.6.1 + +# HTTP Client (for WordPress webhook) +httpx==0.28.1 + +# Testing +pytest==8.3.3 +pytest-asyncio==0.24.0 +httpx==0.28.1 + +# Utilities +python-dotenv==1.0.1 diff --git a/storage/converted/388bc5d6-6bd/metadata.json b/storage/converted/388bc5d6-6bd/metadata.json new file mode 100755 index 0000000..abc7a59 --- /dev/null +++ b/storage/converted/388bc5d6-6bd/metadata.json @@ -0,0 +1,9 @@ +{ + "video_id": "388bc5d6-6bd", + "kurs_id": 4447, + "title": "Test Video ohne Audio", + "status": "pending", + "created_at": "2025-12-03T19:50:28.381009+00:00", + "updated_at": "2025-12-03T19:50:28.381041+00:00", + "error_message": null +} \ No newline at end of file diff --git a/storage/converted/72172373-56d/hls/360p/playlist.m3u8 b/storage/converted/72172373-56d/hls/360p/playlist.m3u8 new file mode 100755 index 0000000..6a37c95 --- /dev/null +++ b/storage/converted/72172373-56d/hls/360p/playlist.m3u8 @@ -0,0 +1,11 @@ +#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-TARGETDURATION:8 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-PLAYLIST-TYPE:VOD +#EXT-X-INDEPENDENT-SEGMENTS +#EXTINF:8.333333, +segment_000.ts +#EXTINF:1.666667, +segment_001.ts +#EXT-X-ENDLIST diff --git a/storage/converted/72172373-56d/hls/360p/segment_000.ts b/storage/converted/72172373-56d/hls/360p/segment_000.ts new file mode 100755 index 0000000..05dfee2 Binary files /dev/null and b/storage/converted/72172373-56d/hls/360p/segment_000.ts differ diff --git a/storage/converted/72172373-56d/hls/360p/segment_001.ts b/storage/converted/72172373-56d/hls/360p/segment_001.ts new file mode 100755 index 0000000..bea7df4 Binary files /dev/null and b/storage/converted/72172373-56d/hls/360p/segment_001.ts differ diff --git a/storage/converted/72172373-56d/hls/720p/playlist.m3u8 b/storage/converted/72172373-56d/hls/720p/playlist.m3u8 new file mode 100755 index 0000000..6a37c95 --- /dev/null +++ b/storage/converted/72172373-56d/hls/720p/playlist.m3u8 @@ -0,0 +1,11 @@ +#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-TARGETDURATION:8 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-PLAYLIST-TYPE:VOD +#EXT-X-INDEPENDENT-SEGMENTS +#EXTINF:8.333333, +segment_000.ts +#EXTINF:1.666667, +segment_001.ts +#EXT-X-ENDLIST diff --git a/storage/converted/72172373-56d/hls/720p/segment_000.ts b/storage/converted/72172373-56d/hls/720p/segment_000.ts new file mode 100755 index 0000000..5fe4adc Binary files /dev/null and b/storage/converted/72172373-56d/hls/720p/segment_000.ts differ diff --git a/storage/converted/72172373-56d/hls/720p/segment_001.ts b/storage/converted/72172373-56d/hls/720p/segment_001.ts new file mode 100755 index 0000000..d2fc974 Binary files /dev/null and b/storage/converted/72172373-56d/hls/720p/segment_001.ts differ diff --git a/storage/converted/72172373-56d/hls/master.m3u8 b/storage/converted/72172373-56d/hls/master.m3u8 new file mode 100755 index 0000000..183f0f7 --- /dev/null +++ b/storage/converted/72172373-56d/hls/master.m3u8 @@ -0,0 +1,8 @@ +#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-STREAM-INF:BANDWIDTH=880000,RESOLUTION=1280x720,CODECS="avc1.64001f" +360p/playlist.m3u8 + +#EXT-X-STREAM-INF:BANDWIDTH=2750000,RESOLUTION=1280x720,CODECS="avc1.64001f" +720p/playlist.m3u8 + diff --git a/storage/converted/72172373-56d/metadata.json b/storage/converted/72172373-56d/metadata.json new file mode 100755 index 0000000..e257f60 --- /dev/null +++ b/storage/converted/72172373-56d/metadata.json @@ -0,0 +1,19 @@ +{ + "video_id": "72172373-56d", + "kurs_id": 4447, + "title": "Webhook Test Video", + "status": "ready", + "created_at": "2025-12-03T20:00:26.432116+00:00", + "updated_at": "2025-12-03T20:14:19.101801+00:00", + "error_message": null, + "metadata": { + "duration_seconds": 10.0, + "width": 1280, + "height": 720, + "fps": 30.0, + "codec": "h264", + "bitrate": 775360, + "file_size_bytes": 969201 + }, + "progress": 100 +} \ No newline at end of file diff --git a/storage/converted/72172373-56d/thumbnail.jpg b/storage/converted/72172373-56d/thumbnail.jpg new file mode 100755 index 0000000..7dadbf2 Binary files /dev/null and b/storage/converted/72172373-56d/thumbnail.jpg differ diff --git a/storage/converted/8fa619af-967/hls/360p/playlist.m3u8 b/storage/converted/8fa619af-967/hls/360p/playlist.m3u8 new file mode 100755 index 0000000..6a37c95 --- /dev/null +++ b/storage/converted/8fa619af-967/hls/360p/playlist.m3u8 @@ -0,0 +1,11 @@ +#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-TARGETDURATION:8 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-PLAYLIST-TYPE:VOD +#EXT-X-INDEPENDENT-SEGMENTS +#EXTINF:8.333333, +segment_000.ts +#EXTINF:1.666667, +segment_001.ts +#EXT-X-ENDLIST diff --git a/storage/converted/8fa619af-967/hls/360p/segment_000.ts b/storage/converted/8fa619af-967/hls/360p/segment_000.ts new file mode 100755 index 0000000..b9afd59 Binary files /dev/null and b/storage/converted/8fa619af-967/hls/360p/segment_000.ts differ diff --git a/storage/converted/8fa619af-967/hls/360p/segment_001.ts b/storage/converted/8fa619af-967/hls/360p/segment_001.ts new file mode 100755 index 0000000..8d33516 Binary files /dev/null and b/storage/converted/8fa619af-967/hls/360p/segment_001.ts differ diff --git a/storage/converted/8fa619af-967/hls/720p/playlist.m3u8 b/storage/converted/8fa619af-967/hls/720p/playlist.m3u8 new file mode 100755 index 0000000..6a37c95 --- /dev/null +++ b/storage/converted/8fa619af-967/hls/720p/playlist.m3u8 @@ -0,0 +1,11 @@ +#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-TARGETDURATION:8 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-PLAYLIST-TYPE:VOD +#EXT-X-INDEPENDENT-SEGMENTS +#EXTINF:8.333333, +segment_000.ts +#EXTINF:1.666667, +segment_001.ts +#EXT-X-ENDLIST diff --git a/storage/converted/8fa619af-967/hls/720p/segment_000.ts b/storage/converted/8fa619af-967/hls/720p/segment_000.ts new file mode 100755 index 0000000..4aa6606 Binary files /dev/null and b/storage/converted/8fa619af-967/hls/720p/segment_000.ts differ diff --git a/storage/converted/8fa619af-967/hls/720p/segment_001.ts b/storage/converted/8fa619af-967/hls/720p/segment_001.ts new file mode 100755 index 0000000..a8789c4 Binary files /dev/null and b/storage/converted/8fa619af-967/hls/720p/segment_001.ts differ diff --git a/storage/converted/8fa619af-967/hls/master.m3u8 b/storage/converted/8fa619af-967/hls/master.m3u8 new file mode 100755 index 0000000..183f0f7 --- /dev/null +++ b/storage/converted/8fa619af-967/hls/master.m3u8 @@ -0,0 +1,8 @@ +#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-STREAM-INF:BANDWIDTH=880000,RESOLUTION=1280x720,CODECS="avc1.64001f" +360p/playlist.m3u8 + +#EXT-X-STREAM-INF:BANDWIDTH=2750000,RESOLUTION=1280x720,CODECS="avc1.64001f" +720p/playlist.m3u8 + diff --git a/storage/converted/8fa619af-967/metadata.json b/storage/converted/8fa619af-967/metadata.json new file mode 100755 index 0000000..3455a56 --- /dev/null +++ b/storage/converted/8fa619af-967/metadata.json @@ -0,0 +1,19 @@ +{ + "video_id": "8fa619af-967", + "kurs_id": 4447, + "title": "Big Buck Bunny - Test Video", + "status": "ready", + "created_at": "2025-12-03T19:37:50.595831+00:00", + "updated_at": "2025-12-03T19:48:13.054821+00:00", + "error_message": null, + "metadata": { + "duration_seconds": 10.0, + "width": 1280, + "height": 720, + "fps": 30.0, + "codec": "h264", + "bitrate": 3999503, + "file_size_bytes": 4999379 + }, + "progress": 100 +} \ No newline at end of file diff --git a/storage/converted/8fa619af-967/thumbnail.jpg b/storage/converted/8fa619af-967/thumbnail.jpg new file mode 100755 index 0000000..417e0c6 Binary files /dev/null and b/storage/converted/8fa619af-967/thumbnail.jpg differ diff --git a/storage/converted/ea658f63-b47/metadata.json b/storage/converted/ea658f63-b47/metadata.json new file mode 100755 index 0000000..73e2bbc --- /dev/null +++ b/storage/converted/ea658f63-b47/metadata.json @@ -0,0 +1,9 @@ +{ + "video_id": "ea658f63-b47", + "kurs_id": 4447, + "title": "Test Video v2", + "status": "pending", + "created_at": "2025-12-03T19:51:27.945537+00:00", + "updated_at": "2025-12-03T19:51:27.945566+00:00", + "error_message": null +} \ No newline at end of file diff --git a/storage/uploads/388bc5d6-6bd/source.mp4 b/storage/uploads/388bc5d6-6bd/source.mp4 new file mode 100755 index 0000000..38a4460 Binary files /dev/null and b/storage/uploads/388bc5d6-6bd/source.mp4 differ diff --git a/storage/uploads/72172373-56d/source.mp4 b/storage/uploads/72172373-56d/source.mp4 new file mode 100755 index 0000000..38a4460 Binary files /dev/null and b/storage/uploads/72172373-56d/source.mp4 differ diff --git a/storage/uploads/8fa619af-967/source.mp4 b/storage/uploads/8fa619af-967/source.mp4 new file mode 100755 index 0000000..d76ec38 Binary files /dev/null and b/storage/uploads/8fa619af-967/source.mp4 differ diff --git a/storage/uploads/ea658f63-b47/source.mp4 b/storage/uploads/ea658f63-b47/source.mp4 new file mode 100755 index 0000000..38a4460 Binary files /dev/null and b/storage/uploads/ea658f63-b47/source.mp4 differ diff --git a/test-video-720p.mp4 b/test-video-720p.mp4 new file mode 100755 index 0000000..d76ec38 Binary files /dev/null and b/test-video-720p.mp4 differ diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100755 index 0000000..1b5d9b8 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100755 index 0000000..fec437e --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,120 @@ +""" +API Tests for Video-Service +""" + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +@pytest.fixture +def client(): + """Test client fixture.""" + return TestClient(app) + + +@pytest.fixture +def api_key(): + """API key for testing.""" + return "change-me-in-production" + + +class TestHealthEndpoints: + """Tests for health check endpoints.""" + + def test_root_health(self, client): + """Test root health endpoint.""" + response = client.get("/health") + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + def test_api_health(self, client): + """Test API health endpoint.""" + response = client.get("/api/v1/health") + assert response.status_code == 200 + data = response.json() + assert "status" in data + assert "version" in data + assert "ffmpeg_available" in data + + +class TestAuthentication: + """Tests for authentication.""" + + def test_missing_api_key(self, client): + """Test request without API key.""" + response = client.get("/api/v1/videos") + assert response.status_code == 401 + assert "Missing API key" in response.json()["detail"] + + def test_invalid_api_key(self, client): + """Test request with invalid API key.""" + response = client.get( + "/api/v1/videos", + headers={"X-API-Key": "invalid-key"}, + ) + assert response.status_code == 401 + assert "Invalid API key" in response.json()["detail"] + + def test_valid_api_key(self, client, api_key): + """Test request with valid API key.""" + response = client.get( + "/api/v1/videos", + headers={"X-API-Key": api_key}, + ) + assert response.status_code == 200 + + +class TestVideoEndpoints: + """Tests for video endpoints.""" + + def test_list_videos_empty(self, client, api_key): + """Test listing videos when empty.""" + response = client.get( + "/api/v1/videos", + headers={"X-API-Key": api_key}, + ) + assert response.status_code == 200 + data = response.json() + assert "videos" in data + assert "total" in data + + def test_get_nonexistent_video(self, client, api_key): + """Test getting non-existent video.""" + response = client.get( + "/api/v1/videos/nonexistent/status", + headers={"X-API-Key": api_key}, + ) + assert response.status_code == 404 + + def test_delete_nonexistent_video(self, client, api_key): + """Test deleting non-existent video.""" + response = client.delete( + "/api/v1/videos/nonexistent", + headers={"X-API-Key": api_key}, + ) + assert response.status_code == 404 + + +class TestStreamEndpoints: + """Tests for streaming endpoints.""" + + def test_stream_without_token(self, client): + """Test streaming without token.""" + response = client.get("/api/v1/stream/test/master.m3u8") + assert response.status_code == 422 # Missing required parameter + + def test_stream_with_invalid_token(self, client): + """Test streaming with invalid token.""" + response = client.get("/api/v1/stream/test/master.m3u8?token=invalid") + assert response.status_code == 401 + + +class TestThumbnailEndpoint: + """Tests for thumbnail endpoint.""" + + def test_thumbnail_not_found(self, client): + """Test thumbnail for non-existent video.""" + response = client.get("/api/v1/videos/nonexistent/thumbnail") + assert response.status_code == 404