Files
video-service/app/api/routes.py

459 lines
14 KiB
Python
Executable File

"""
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
},
)