Add 'api/' from commit '14ac229098e65aa643f84e8e17e0c5f1aaf8d639'

git-subtree-dir: api
git-subtree-mainline: 4743f19aef
git-subtree-split: 14ac229098
This commit is contained in:
Alireza Vaezi
2025-08-07 20:57:34 -04:00
146 changed files with 31249 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
"""
Video Domain Layer.
Contains pure business logic and domain models for video operations.
No external dependencies - only Python standard library and domain concepts.
"""
from .models import VideoFile, VideoMetadata, StreamRange
from .interfaces import VideoRepository, VideoConverter, MetadataExtractor
__all__ = [
"VideoFile",
"VideoMetadata",
"StreamRange",
"VideoRepository",
"VideoConverter",
"MetadataExtractor",
]

View File

@@ -0,0 +1,157 @@
"""
Video Domain Interfaces.
Abstract interfaces that define contracts for video operations.
These interfaces allow dependency inversion - domain logic doesn't depend on infrastructure.
"""
from abc import ABC, abstractmethod
from typing import List, Optional, BinaryIO
from datetime import datetime
from pathlib import Path
from .models import VideoFile, VideoMetadata, StreamRange, VideoFormat
class VideoRepository(ABC):
"""Abstract repository for video file access"""
@abstractmethod
async def get_by_id(self, file_id: str) -> Optional[VideoFile]:
"""Get video file by ID"""
pass
@abstractmethod
async def get_by_camera(
self,
camera_name: str,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
limit: Optional[int] = None
) -> List[VideoFile]:
"""Get video files for a camera with optional filters"""
pass
@abstractmethod
async def get_all(
self,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
limit: Optional[int] = None
) -> List[VideoFile]:
"""Get all video files with optional filters"""
pass
@abstractmethod
async def exists(self, file_id: str) -> bool:
"""Check if video file exists"""
pass
@abstractmethod
async def get_file_stream(self, video_file: VideoFile) -> BinaryIO:
"""Get file stream for reading video data"""
pass
@abstractmethod
async def get_file_range(
self,
video_file: VideoFile,
range_request: StreamRange
) -> bytes:
"""Get specific byte range from video file"""
pass
class VideoConverter(ABC):
"""Abstract video format converter"""
@abstractmethod
async def convert(
self,
source_path: Path,
target_path: Path,
target_format: VideoFormat,
quality: Optional[str] = None
) -> bool:
"""Convert video to target format"""
pass
@abstractmethod
async def is_conversion_needed(
self,
source_format: VideoFormat,
target_format: VideoFormat
) -> bool:
"""Check if conversion is needed"""
pass
@abstractmethod
async def get_converted_path(
self,
original_path: Path,
target_format: VideoFormat
) -> Path:
"""Get path for converted file"""
pass
@abstractmethod
async def cleanup_converted_files(self, max_age_hours: int = 24) -> int:
"""Clean up old converted files"""
pass
class MetadataExtractor(ABC):
"""Abstract video metadata extractor"""
@abstractmethod
async def extract(self, file_path: Path) -> Optional[VideoMetadata]:
"""Extract metadata from video file"""
pass
@abstractmethod
async def extract_thumbnail(
self,
file_path: Path,
timestamp_seconds: float = 1.0,
size: tuple = (320, 240)
) -> Optional[bytes]:
"""Extract thumbnail image from video"""
pass
@abstractmethod
async def is_valid_video(self, file_path: Path) -> bool:
"""Check if file is a valid video"""
pass
class StreamingCache(ABC):
"""Abstract cache for streaming optimization"""
@abstractmethod
async def get_cached_range(
self,
file_id: str,
range_request: StreamRange
) -> Optional[bytes]:
"""Get cached byte range"""
pass
@abstractmethod
async def cache_range(
self,
file_id: str,
range_request: StreamRange,
data: bytes
) -> None:
"""Cache byte range data"""
pass
@abstractmethod
async def invalidate_file(self, file_id: str) -> None:
"""Invalidate all cached data for a file"""
pass
@abstractmethod
async def cleanup_cache(self, max_size_mb: int = 100) -> int:
"""Clean up cache to stay under size limit"""
pass

View File

@@ -0,0 +1,162 @@
"""
Video Domain Models.
Pure business entities and value objects for video operations.
These models contain no external dependencies and represent core business concepts.
"""
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Optional, Tuple
from enum import Enum
class VideoFormat(Enum):
"""Supported video formats"""
AVI = "avi"
MP4 = "mp4"
WEBM = "webm"
class VideoStatus(Enum):
"""Video file status"""
RECORDING = "recording"
COMPLETED = "completed"
PROCESSING = "processing"
ERROR = "error"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class VideoMetadata:
"""Video metadata value object"""
duration_seconds: float
width: int
height: int
fps: float
codec: str
bitrate: Optional[int] = None
@property
def resolution(self) -> Tuple[int, int]:
"""Get video resolution as tuple"""
return (self.width, self.height)
@property
def aspect_ratio(self) -> float:
"""Calculate aspect ratio"""
return self.width / self.height if self.height > 0 else 0.0
@dataclass(frozen=True)
class StreamRange:
"""HTTP range request value object"""
start: int
end: Optional[int] = None
def __post_init__(self):
if self.start < 0:
raise ValueError("Start byte cannot be negative")
if self.end is not None and self.end < self.start:
raise ValueError("End byte cannot be less than start byte")
@property
def size(self) -> Optional[int]:
"""Get range size in bytes"""
if self.end is not None:
return self.end - self.start + 1
return None
@classmethod
def from_header(cls, range_header: str, file_size: int) -> 'StreamRange':
"""Parse HTTP Range header"""
if not range_header.startswith('bytes='):
raise ValueError("Invalid range header format")
range_spec = range_header[6:] # Remove 'bytes='
if '-' not in range_spec:
raise ValueError("Invalid range specification")
start_str, end_str = range_spec.split('-', 1)
if start_str:
start = int(start_str)
else:
# Suffix range (e.g., "-500" means last 500 bytes)
if not end_str:
raise ValueError("Invalid range specification")
suffix_length = int(end_str)
start = max(0, file_size - suffix_length)
end = file_size - 1
return cls(start=start, end=end)
if end_str:
end = min(int(end_str), file_size - 1)
else:
end = file_size - 1
return cls(start=start, end=end)
@dataclass
class VideoFile:
"""Video file entity"""
file_id: str
camera_name: str
filename: str
file_path: Path
file_size_bytes: int
created_at: datetime
status: VideoStatus
format: VideoFormat
metadata: Optional[VideoMetadata] = None
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None
machine_trigger: Optional[str] = None
error_message: Optional[str] = None
def __post_init__(self):
"""Validate video file data"""
if not self.file_id:
raise ValueError("File ID cannot be empty")
if not self.camera_name:
raise ValueError("Camera name cannot be empty")
if self.file_size_bytes < 0:
raise ValueError("File size cannot be negative")
@property
def duration_seconds(self) -> Optional[float]:
"""Get video duration from metadata"""
return self.metadata.duration_seconds if self.metadata else None
@property
def is_streamable(self) -> bool:
"""Check if video can be streamed"""
return (
self.status in [VideoStatus.COMPLETED, VideoStatus.RECORDING] and
self.file_path.exists() and
self.file_size_bytes > 0
)
@property
def web_compatible_format(self) -> VideoFormat:
"""Get web-compatible format for this video"""
# AVI files should be converted to MP4 for web compatibility
if self.format == VideoFormat.AVI:
return VideoFormat.MP4
return self.format
def needs_conversion(self) -> bool:
"""Check if video needs format conversion for web streaming"""
return self.format != self.web_compatible_format
def get_converted_filename(self) -> str:
"""Get filename for converted version"""
if not self.needs_conversion():
return self.filename
# Replace extension with web-compatible format
stem = Path(self.filename).stem
return f"{stem}.{self.web_compatible_format.value}"