Chore: rename api->camera-management-api and web->management-dashboard-web-app; update compose, ignore, README references

This commit is contained in:
Alireza Vaezi
2025-08-07 22:07:25 -04:00
parent 28dab3a366
commit fc2da16728
281 changed files with 19 additions and 19 deletions

View File

@@ -0,0 +1,18 @@
"""
Video Presentation Layer.
Contains HTTP controllers, request/response models, and API route definitions.
"""
from .controllers import VideoController, StreamingController
from .schemas import VideoInfoResponse, VideoListResponse, StreamingInfoResponse
from .routes import create_video_routes
__all__ = [
"VideoController",
"StreamingController",
"VideoInfoResponse",
"VideoListResponse",
"StreamingInfoResponse",
"create_video_routes",
]

View File

@@ -0,0 +1,184 @@
"""
Video HTTP Controllers.
Handle HTTP requests and responses for video operations.
"""
import logging
from typing import Optional
from datetime import datetime
from fastapi import HTTPException, Request, Response
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
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)
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)
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:
"""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))
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", "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)
return VideoInfoResponse(
file_id=video_file.file_id,
camera_name=video_file.camera_name,
filename=video_file.filename,
file_size_bytes=video_file.file_size_bytes,
format=video_file.format.value,
status=video_file.status.value,
created_at=video_file.created_at,
start_time=video_file.start_time,
end_time=video_file.end_time,
machine_trigger=video_file.machine_trigger,
metadata=metadata_response,
is_streamable=video_file.is_streamable,
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)
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}")
# Determine response type and headers
content_type = self._get_content_type(video_file)
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:
# 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"}
return format_to_mime.get(video_file.format.value, "application/octet-stream")

View File

@@ -0,0 +1,167 @@
"""
Video API Routes.
FastAPI route definitions for video streaming and management.
"""
from typing import Optional
from datetime import datetime
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import Response
from .controllers import VideoController, StreamingController
from .schemas import (
VideoInfoResponse, VideoListResponse, VideoListRequest,
StreamingInfoResponse, ThumbnailRequest
)
def create_video_routes(
video_controller: VideoController,
streaming_controller: StreamingController
) -> APIRouter:
"""Create video API routes with dependency injection"""
router = APIRouter(prefix="/videos", tags=["videos"])
@router.get("/", response_model=VideoListResponse)
async def list_videos(
camera_name: Optional[str] = Query(None, description="Filter by camera name"),
start_date: Optional[datetime] = Query(None, description="Filter by start date"),
end_date: Optional[datetime] = Query(None, description="Filter by end date"),
limit: Optional[int] = Query(50, description="Maximum number of results"),
include_metadata: bool = Query(False, description="Include video metadata")
):
"""
List videos with optional filters.
- **camera_name**: Filter videos by camera name
- **start_date**: Filter videos created after this date
- **end_date**: Filter videos created before this date
- **limit**: Maximum number of videos to return
- **include_metadata**: Whether to include video metadata (duration, resolution, etc.)
"""
request = VideoListRequest(
camera_name=camera_name,
start_date=start_date,
end_date=end_date,
limit=limit,
include_metadata=include_metadata
)
return await video_controller.list_videos(request)
@router.get("/{file_id}", response_model=VideoInfoResponse)
async def get_video_info(file_id: str):
"""
Get detailed information about a specific video.
- **file_id**: Unique identifier for the video file
"""
return await video_controller.get_video_info(file_id)
@router.get("/{file_id}/stream")
async def stream_video(file_id: str, request: Request):
"""
Stream video with HTTP range request support.
Supports:
- **Range requests**: For seeking and progressive download
- **Partial content**: 206 responses for range requests
- **Format conversion**: Automatic conversion to web-compatible formats
- **Caching**: Intelligent caching for better performance
Usage in HTML5:
```html
<video controls>
<source src="/videos/{file_id}/stream" type="video/mp4">
</video>
```
"""
return await streaming_controller.stream_video(file_id, request)
@router.get("/{file_id}/info", response_model=StreamingInfoResponse)
async def get_streaming_info(file_id: str):
"""
Get streaming information for a video.
Returns technical details needed for optimal streaming:
- File size and content type
- Range request support
- Recommended chunk size
"""
return await streaming_controller.get_streaming_info(file_id)
@router.get("/{file_id}/thumbnail")
async def get_video_thumbnail(
file_id: str,
timestamp: float = Query(1.0, description="Timestamp in seconds to extract thumbnail from"),
width: int = Query(320, description="Thumbnail width in pixels"),
height: int = Query(240, description="Thumbnail height in pixels")
):
"""
Generate and return a thumbnail image from the video.
- **file_id**: Video file identifier
- **timestamp**: Time position in seconds to extract thumbnail from
- **width**: Thumbnail width in pixels
- **height**: Thumbnail height in pixels
Returns JPEG image data.
"""
thumbnail_request = ThumbnailRequest(
timestamp_seconds=timestamp,
width=width,
height=height
)
return await video_controller.get_video_thumbnail(file_id, thumbnail_request)
@router.post("/{file_id}/validate")
async def validate_video(file_id: str):
"""
Validate that a video file is accessible and playable.
- **file_id**: Video file identifier
Returns validation status and any issues found.
"""
return await video_controller.validate_video(file_id)
@router.post("/{file_id}/cache/invalidate")
async def invalidate_video_cache(file_id: str):
"""
Invalidate cached data for a video file.
Useful when a video file has been updated or replaced.
- **file_id**: Video file identifier
"""
return await streaming_controller.invalidate_cache(file_id)
return router
def create_admin_video_routes(streaming_controller: StreamingController) -> APIRouter:
"""Create admin routes for video management"""
router = APIRouter(prefix="/admin/videos", tags=["admin", "videos"])
@router.post("/cache/cleanup")
async def cleanup_video_cache(
max_size_mb: int = Query(100, description="Maximum cache size in MB")
):
"""
Clean up video streaming cache.
Removes old cached data to keep cache size under the specified limit.
- **max_size_mb**: Maximum cache size to maintain
"""
entries_removed = await streaming_controller.streaming_service.cleanup_cache(max_size_mb)
return {
"cache_cleaned": True,
"entries_removed": entries_removed,
"max_size_mb": max_size_mb
}
return router

View File

@@ -0,0 +1,138 @@
"""
Video API Request/Response Schemas.
Pydantic models for API serialization and validation.
"""
from typing import List, Optional, Tuple
from datetime import datetime
from pydantic import BaseModel, Field
class VideoMetadataResponse(BaseModel):
"""Video metadata response model"""
duration_seconds: float = Field(..., description="Video duration in seconds")
width: int = Field(..., description="Video width in pixels")
height: int = Field(..., description="Video height in pixels")
fps: float = Field(..., description="Video frame rate")
codec: str = Field(..., description="Video codec")
bitrate: Optional[int] = Field(None, description="Video bitrate in bps")
aspect_ratio: float = Field(..., description="Video aspect ratio")
class Config:
schema_extra = {
"example": {
"duration_seconds": 120.5,
"width": 1920,
"height": 1080,
"fps": 30.0,
"codec": "XVID",
"bitrate": 5000000,
"aspect_ratio": 1.777
}
}
class VideoInfoResponse(BaseModel):
"""Video file information response"""
file_id: str = Field(..., description="Unique file identifier")
camera_name: str = Field(..., description="Camera that recorded the video")
filename: str = Field(..., description="Original filename")
file_size_bytes: int = Field(..., description="File size in bytes")
format: str = Field(..., description="Video format (avi, mp4, webm)")
status: str = Field(..., description="Video status")
created_at: datetime = Field(..., description="Creation timestamp")
start_time: Optional[datetime] = Field(None, description="Recording start time")
end_time: Optional[datetime] = Field(None, description="Recording end time")
machine_trigger: Optional[str] = Field(None, description="Machine that triggered recording")
metadata: Optional[VideoMetadataResponse] = Field(None, description="Video metadata")
is_streamable: bool = Field(..., description="Whether video can be streamed")
needs_conversion: bool = Field(..., description="Whether video needs format conversion")
class Config:
schema_extra = {
"example": {
"file_id": "camera1_recording_20250804_143022.avi",
"camera_name": "camera1",
"filename": "camera1_recording_20250804_143022.avi",
"file_size_bytes": 52428800,
"format": "avi",
"status": "completed",
"created_at": "2025-08-04T14:30:22",
"start_time": "2025-08-04T14:30:22",
"end_time": "2025-08-04T14:32:22",
"machine_trigger": "vibratory_conveyor",
"is_streamable": True,
"needs_conversion": True
}
}
class VideoListResponse(BaseModel):
"""Video list response"""
videos: List[VideoInfoResponse] = Field(..., description="List of videos")
total_count: int = Field(..., description="Total number of videos")
class Config:
schema_extra = {
"example": {
"videos": [],
"total_count": 0
}
}
class StreamingInfoResponse(BaseModel):
"""Streaming information response"""
file_id: str = Field(..., description="Video file ID")
file_size_bytes: int = Field(..., description="Total file size")
content_type: str = Field(..., description="MIME content type")
supports_range_requests: bool = Field(..., description="Whether range requests are supported")
chunk_size_bytes: int = Field(..., description="Recommended chunk size for streaming")
class Config:
schema_extra = {
"example": {
"file_id": "camera1_recording_20250804_143022.avi",
"file_size_bytes": 52428800,
"content_type": "video/x-msvideo",
"supports_range_requests": True,
"chunk_size_bytes": 262144
}
}
class VideoListRequest(BaseModel):
"""Video list request parameters"""
camera_name: Optional[str] = Field(None, description="Filter by camera name")
start_date: Optional[datetime] = Field(None, description="Filter by start date")
end_date: Optional[datetime] = Field(None, description="Filter by end date")
limit: Optional[int] = Field(50, description="Maximum number of results")
include_metadata: bool = Field(False, description="Include video metadata")
class Config:
schema_extra = {
"example": {
"camera_name": "camera1",
"start_date": "2025-08-04T00:00:00",
"end_date": "2025-08-04T23:59:59",
"limit": 50,
"include_metadata": True
}
}
class ThumbnailRequest(BaseModel):
"""Thumbnail generation request"""
timestamp_seconds: float = Field(1.0, description="Timestamp to extract thumbnail from")
width: int = Field(320, description="Thumbnail width")
height: int = Field(240, description="Thumbnail height")
class Config:
schema_extra = {
"example": {
"timestamp_seconds": 5.0,
"width": 320,
"height": 240
}
}