streaming fix

This commit is contained in:
Alireza Vaezi
2025-08-05 15:55:48 -04:00
parent 07e8e52503
commit 14ac229098
4 changed files with 693 additions and 125 deletions

View File

@@ -14,95 +14,55 @@ from fastapi.responses import StreamingResponse
from ..application.video_service import VideoService
from ..application.streaming_service import StreamingService
from ..domain.models import StreamRange, VideoFile
from .schemas import (
VideoInfoResponse, VideoListResponse, VideoListRequest,
StreamingInfoResponse, ThumbnailRequest, VideoMetadataResponse
)
from .schemas import VideoInfoResponse, VideoListResponse, VideoListRequest, StreamingInfoResponse, ThumbnailRequest, VideoMetadataResponse
class VideoController:
"""Controller for video management operations"""
def __init__(self, video_service: VideoService):
self.video_service = video_service
self.logger = logging.getLogger(__name__)
async def get_video_info(self, file_id: str) -> VideoInfoResponse:
"""Get video information"""
video_file = await self.video_service.get_video_by_id(file_id)
if not video_file:
raise HTTPException(status_code=404, detail=f"Video {file_id} not found")
return self._convert_to_response(video_file)
async def list_videos(self, request: VideoListRequest) -> VideoListResponse:
"""List videos with optional filters"""
if request.camera_name:
videos = await self.video_service.get_videos_by_camera(
camera_name=request.camera_name,
start_date=request.start_date,
end_date=request.end_date,
limit=request.limit,
include_metadata=request.include_metadata
)
videos = await self.video_service.get_videos_by_camera(camera_name=request.camera_name, start_date=request.start_date, end_date=request.end_date, limit=request.limit, include_metadata=request.include_metadata)
else:
videos = await self.video_service.get_all_videos(
start_date=request.start_date,
end_date=request.end_date,
limit=request.limit,
include_metadata=request.include_metadata
)
videos = await self.video_service.get_all_videos(start_date=request.start_date, end_date=request.end_date, limit=request.limit, include_metadata=request.include_metadata)
video_responses = [self._convert_to_response(video) for video in videos]
return VideoListResponse(
videos=video_responses,
total_count=len(video_responses)
)
async def get_video_thumbnail(
self,
file_id: str,
thumbnail_request: ThumbnailRequest
) -> Response:
return VideoListResponse(videos=video_responses, total_count=len(video_responses))
async def get_video_thumbnail(self, file_id: str, thumbnail_request: ThumbnailRequest) -> Response:
"""Get video thumbnail"""
thumbnail_data = await self.video_service.get_video_thumbnail(
file_id=file_id,
timestamp_seconds=thumbnail_request.timestamp_seconds,
size=(thumbnail_request.width, thumbnail_request.height)
)
thumbnail_data = await self.video_service.get_video_thumbnail(file_id=file_id, timestamp_seconds=thumbnail_request.timestamp_seconds, size=(thumbnail_request.width, thumbnail_request.height))
if not thumbnail_data:
raise HTTPException(status_code=404, detail=f"Could not generate thumbnail for {file_id}")
return Response(
content=thumbnail_data,
media_type="image/jpeg",
headers={
"Cache-Control": "public, max-age=3600", # Cache for 1 hour
"Content-Length": str(len(thumbnail_data))
}
)
return Response(content=thumbnail_data, media_type="image/jpeg", headers={"Cache-Control": "public, max-age=3600", "Content-Length": str(len(thumbnail_data))}) # Cache for 1 hour
async def validate_video(self, file_id: str) -> dict:
"""Validate video file"""
is_valid = await self.video_service.validate_video(file_id)
return {"file_id": file_id, "is_valid": is_valid}
def _convert_to_response(self, video_file: VideoFile) -> VideoInfoResponse:
"""Convert domain model to response model"""
metadata_response = None
if video_file.metadata:
metadata_response = VideoMetadataResponse(
duration_seconds=video_file.metadata.duration_seconds,
width=video_file.metadata.width,
height=video_file.metadata.height,
fps=video_file.metadata.fps,
codec=video_file.metadata.codec,
bitrate=video_file.metadata.bitrate,
aspect_ratio=video_file.metadata.aspect_ratio
)
metadata_response = VideoMetadataResponse(duration_seconds=video_file.metadata.duration_seconds, width=video_file.metadata.width, height=video_file.metadata.height, fps=video_file.metadata.fps, codec=video_file.metadata.codec, bitrate=video_file.metadata.bitrate, aspect_ratio=video_file.metadata.aspect_ratio)
return VideoInfoResponse(
file_id=video_file.file_id,
camera_name=video_file.camera_name,
@@ -116,92 +76,109 @@ class VideoController:
machine_trigger=video_file.machine_trigger,
metadata=metadata_response,
is_streamable=video_file.is_streamable,
needs_conversion=video_file.needs_conversion()
needs_conversion=video_file.needs_conversion(),
)
class StreamingController:
"""Controller for video streaming operations"""
def __init__(self, streaming_service: StreamingService, video_service: VideoService):
self.streaming_service = streaming_service
self.video_service = video_service
self.logger = logging.getLogger(__name__)
async def get_streaming_info(self, file_id: str) -> StreamingInfoResponse:
"""Get streaming information for a video"""
video_file = await self.streaming_service.get_video_info(file_id)
if not video_file:
raise HTTPException(status_code=404, detail=f"Video {file_id} not found")
chunk_size = await self.streaming_service.get_optimal_chunk_size(video_file.file_size_bytes)
content_type = self._get_content_type(video_file)
return StreamingInfoResponse(
file_id=file_id,
file_size_bytes=video_file.file_size_bytes,
content_type=content_type,
supports_range_requests=True,
chunk_size_bytes=chunk_size
)
return StreamingInfoResponse(file_id=file_id, file_size_bytes=video_file.file_size_bytes, content_type=content_type, supports_range_requests=True, chunk_size_bytes=chunk_size)
async def stream_video(self, file_id: str, request: Request) -> Response:
"""Stream video with range request support"""
# Prepare video for streaming (convert if needed)
video_file = await self.video_service.prepare_for_streaming(file_id)
if not video_file:
raise HTTPException(status_code=404, detail=f"Video {file_id} not found or not streamable")
# Parse range header
range_header = request.headers.get("range")
range_request = None
if range_header:
try:
range_request = StreamRange.from_header(range_header, video_file.file_size_bytes)
except ValueError as e:
raise HTTPException(status_code=416, detail=f"Invalid range request: {e}")
# Get video data
data, _, actual_range = await self.streaming_service.stream_video_range(file_id, range_request)
if data is None:
raise HTTPException(status_code=500, detail="Failed to read video data")
# Determine response type and headers
content_type = self._get_content_type(video_file)
headers = {
"Accept-Ranges": "bytes",
"Content-Length": str(len(data)),
"Cache-Control": "public, max-age=3600"
}
# Use partial content if range was requested
if actual_range and self.streaming_service.should_use_partial_content(actual_range, video_file.file_size_bytes):
headers["Content-Range"] = self.streaming_service.calculate_content_range_header(
actual_range, video_file.file_size_bytes
)
status_code = 206 # Partial Content
headers = {"Accept-Ranges": "bytes", "Cache-Control": "public, max-age=3600"}
# Handle range requests for progressive streaming
if range_request:
# Validate range
actual_range = self.streaming_service._validate_range(range_request, video_file.file_size_bytes)
if not actual_range:
raise HTTPException(status_code=416, detail="Range not satisfiable")
headers["Content-Range"] = self.streaming_service.calculate_content_range_header(actual_range, video_file.file_size_bytes)
headers["Content-Length"] = str(actual_range.end - actual_range.start + 1)
# Create streaming generator for range
async def generate_range():
try:
import aiofiles
async with aiofiles.open(video_file.file_path, "rb") as f:
await f.seek(actual_range.start)
remaining = actual_range.end - actual_range.start + 1
chunk_size = min(8192, remaining) # 8KB chunks
while remaining > 0:
chunk_size = min(chunk_size, remaining)
chunk = await f.read(chunk_size)
if not chunk:
break
remaining -= len(chunk)
yield chunk
except Exception as e:
self.logger.error(f"Error streaming range for {file_id}: {e}")
raise
return StreamingResponse(generate_range(), status_code=206, headers=headers, media_type=content_type)
else:
status_code = 200 # OK
return Response(
content=data,
status_code=status_code,
headers=headers,
media_type=content_type
)
# Stream entire file
headers["Content-Length"] = str(video_file.file_size_bytes)
async def generate_full():
try:
import aiofiles
async with aiofiles.open(video_file.file_path, "rb") as f:
chunk_size = 8192 # 8KB chunks
while True:
chunk = await f.read(chunk_size)
if not chunk:
break
yield chunk
except Exception as e:
self.logger.error(f"Error streaming full file for {file_id}: {e}")
raise
return StreamingResponse(generate_full(), status_code=200, headers=headers, media_type=content_type)
async def invalidate_cache(self, file_id: str) -> dict:
"""Invalidate streaming cache for a video"""
success = await self.streaming_service.invalidate_cache(file_id)
return {"file_id": file_id, "cache_invalidated": success}
def _get_content_type(self, video_file: VideoFile) -> str:
"""Get MIME content type for video file"""
format_to_mime = {
"avi": "video/x-msvideo",
"mp4": "video/mp4",
"webm": "video/webm"
}
format_to_mime = {"avi": "video/x-msvideo", "mp4": "video/mp4", "webm": "video/webm"}
return format_to_mime.get(video_file.format.value, "application/octet-stream")