459 lines
14 KiB
Python
Executable File
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
|
|
},
|
|
)
|