Initial commit - Video Service for Coolify
This commit is contained in:
1
app/api/__init__.py
Executable file
1
app/api/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
# API Module
|
||||
127
app/api/auth.py
Executable file
127
app/api/auth.py
Executable file
@@ -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),
|
||||
)
|
||||
458
app/api/routes.py
Executable file
458
app/api/routes.py
Executable file
@@ -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
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user