""" Servicio para gestión de video y cámaras. Integración con MediaMTX para streaming de video. """ from datetime import datetime, timezone from typing import List, Optional import httpx from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.core.security import decrypt_sensitive_data, encrypt_sensitive_data from app.models.camara import Camara from app.models.grabacion import Grabacion from app.models.evento_video import EventoVideo from app.schemas.video import CamaraStreamURL class VideoService: """Servicio para gestión de video y streaming.""" def __init__(self, db: AsyncSession): """ Inicializa el servicio. Args: db: Sesión de base de datos async. """ self.db = db self.mediamtx_api = f"http://{settings.MEDIAMTX_HOST}:{settings.MEDIAMTX_API_PORT}" async def obtener_urls_stream( self, camara_id: int, ) -> Optional[CamaraStreamURL]: """ Obtiene las URLs de streaming de una cámara. Args: camara_id: ID de la cámara. Returns: URLs de streaming disponibles. """ result = await self.db.execute( select(Camara).where(Camara.id == camara_id) ) camara = result.scalar_one_or_none() if not camara or not camara.activa: return None # Construir URLs según el path de MediaMTX path = camara.mediamtx_path or f"cam{camara.id}" rtsp_url = None hls_url = None webrtc_url = None if camara.url_stream: # Usar URL directa de la cámara rtsp_url = camara.url_stream_completa else: # Usar MediaMTX como proxy rtsp_url = f"rtsp://{settings.MEDIAMTX_HOST}:{settings.MEDIAMTX_RTSP_PORT}/{path}" # URLs de MediaMTX para diferentes protocolos hls_url = f"http://{settings.MEDIAMTX_HOST}:8888/{path}/index.m3u8" webrtc_url = f"http://{settings.MEDIAMTX_HOST}:{settings.MEDIAMTX_WEBRTC_PORT}/{path}" return CamaraStreamURL( camara_id=camara.id, camara_nombre=camara.nombre, rtsp_url=rtsp_url, hls_url=hls_url, webrtc_url=webrtc_url, estado=camara.estado, ) async def verificar_estado_camaras(self) -> List[dict]: """ Verifica el estado de todas las cámaras activas. Returns: Lista con estado de cada cámara. """ result = await self.db.execute( select(Camara).where(Camara.activa == True) ) camaras = result.scalars().all() estados = [] for camara in camaras: estado = await self._verificar_stream(camara) estados.append({ "camara_id": camara.id, "nombre": camara.nombre, "vehiculo_id": camara.vehiculo_id, "estado_anterior": camara.estado, "estado_actual": estado, "cambio": camara.estado != estado, }) # Actualizar estado si cambió if camara.estado != estado: camara.estado = estado if estado == "conectada": camara.ultima_conexion = datetime.now(timezone.utc) await self.db.commit() return estados async def _verificar_stream( self, camara: Camara, ) -> str: """ Verifica si un stream está activo. Args: camara: Cámara a verificar. Returns: Estado del stream. """ if not camara.url_stream and not camara.mediamtx_path: return "desconectada" path = camara.mediamtx_path or f"cam{camara.id}" try: async with httpx.AsyncClient() as client: # Verificar en MediaMTX API response = await client.get( f"{self.mediamtx_api}/v3/paths/get/{path}", timeout=5.0, ) if response.status_code == 200: data = response.json() if data.get("ready"): return "conectada" return "desconectada" return "desconectada" except httpx.HTTPError: return "error" async def iniciar_grabacion( self, camara_id: int, tipo: str = "manual", ) -> Optional[Grabacion]: """ Inicia una grabación de una cámara. Args: camara_id: ID de la cámara. tipo: Tipo de grabación. Returns: Registro de grabación creado. """ result = await self.db.execute( select(Camara).where(Camara.id == camara_id) ) camara = result.scalar_one_or_none() if not camara or camara.estado != "conectada": return None # Generar nombre de archivo timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") archivo_nombre = f"cam{camara_id}_{timestamp}.mp4" archivo_url = f"{settings.UPLOAD_DIR}/videos/{camara.vehiculo_id}/{archivo_nombre}" grabacion = Grabacion( camara_id=camara_id, vehiculo_id=camara.vehiculo_id, inicio_tiempo=datetime.now(timezone.utc), archivo_url=archivo_url, archivo_nombre=archivo_nombre, tipo=tipo, estado="grabando", ) self.db.add(grabacion) # Actualizar estado de cámara camara.estado = "grabando" await self.db.commit() await self.db.refresh(grabacion) # Enviar comando a MediaMTX para iniciar grabación await self._iniciar_grabacion_mediamtx(camara, archivo_url) return grabacion async def detener_grabacion( self, grabacion_id: int, ) -> Optional[Grabacion]: """ Detiene una grabación en curso. Args: grabacion_id: ID de la grabación. Returns: Grabación actualizada. """ result = await self.db.execute( select(Grabacion).where(Grabacion.id == grabacion_id) ) grabacion = result.scalar_one_or_none() if not grabacion or grabacion.estado != "grabando": return None # Detener grabación en MediaMTX result_cam = await self.db.execute( select(Camara).where(Camara.id == grabacion.camara_id) ) camara = result_cam.scalar_one_or_none() if camara: await self._detener_grabacion_mediamtx(camara) camara.estado = "conectada" # Actualizar registro grabacion.fin_tiempo = datetime.now(timezone.utc) grabacion.duracion_segundos = int( (grabacion.fin_tiempo - grabacion.inicio_tiempo).total_seconds() ) grabacion.estado = "procesando" # Se procesará para generar thumbnail, etc. await self.db.commit() await self.db.refresh(grabacion) return grabacion async def _iniciar_grabacion_mediamtx( self, camara: Camara, archivo_url: str, ) -> bool: """Envía comando a MediaMTX para iniciar grabación.""" # MediaMTX usa configuración para grabación automática # o se puede usar ffmpeg para grabar el stream # Esta es una implementación simplificada try: # En una implementación real, se usaría la API de MediaMTX # o se ejecutaría ffmpeg como proceso return True except Exception: return False async def _detener_grabacion_mediamtx( self, camara: Camara, ) -> bool: """Envía comando a MediaMTX para detener grabación.""" try: return True except Exception: return False async def registrar_evento_video( self, camara_id: int, tipo: str, severidad: str, lat: float = None, lng: float = None, velocidad: float = None, descripcion: str = None, confianza: float = None, snapshot_url: str = None, ) -> EventoVideo: """ Registra un evento de video detectado. Args: camara_id: ID de la cámara. tipo: Tipo de evento. severidad: Severidad del evento. lat, lng: Coordenadas. velocidad: Velocidad al momento del evento. descripcion: Descripción del evento. confianza: Confianza de la detección (0-100). snapshot_url: URL de la imagen del evento. Returns: Evento creado. """ result = await self.db.execute( select(Camara).where(Camara.id == camara_id) ) camara = result.scalar_one_or_none() if not camara: raise ValueError(f"Cámara {camara_id} no encontrada") evento = EventoVideo( camara_id=camara_id, vehiculo_id=camara.vehiculo_id, tipo=tipo, severidad=severidad, tiempo=datetime.now(timezone.utc), lat=lat, lng=lng, velocidad=velocidad, descripcion=descripcion, confianza=confianza, snapshot_url=snapshot_url, ) self.db.add(evento) await self.db.commit() await self.db.refresh(evento) # Iniciar grabación de evento si está configurado if camara.grabacion_evento: await self.iniciar_grabacion(camara_id, tipo="evento") return evento async def obtener_grabaciones( self, vehiculo_id: int = None, camara_id: int = None, desde: datetime = None, hasta: datetime = None, tipo: str = None, limite: int = 50, ) -> List[Grabacion]: """ Obtiene grabaciones filtradas. Args: vehiculo_id: Filtrar por vehículo. camara_id: Filtrar por cámara. desde: Fecha inicio. hasta: Fecha fin. tipo: Tipo de grabación. limite: Límite de resultados. Returns: Lista de grabaciones. """ query = ( select(Grabacion) .where(Grabacion.estado != "eliminado") .order_by(Grabacion.inicio_tiempo.desc()) .limit(limite) ) if vehiculo_id: query = query.where(Grabacion.vehiculo_id == vehiculo_id) if camara_id: query = query.where(Grabacion.camara_id == camara_id) if desde: query = query.where(Grabacion.inicio_tiempo >= desde) if hasta: query = query.where(Grabacion.inicio_tiempo <= hasta) if tipo: query = query.where(Grabacion.tipo == tipo) result = await self.db.execute(query) return result.scalars().all() async def configurar_camara_mediamtx( self, camara: Camara, ) -> bool: """ Configura una cámara en MediaMTX. Args: camara: Cámara a configurar. Returns: True si se configuró correctamente. """ if not camara.url_stream: return False path = camara.mediamtx_path or f"cam{camara.id}" # Construir configuración para MediaMTX config = { "name": path, "source": camara.url_stream_completa, "sourceOnDemand": True, "record": camara.grabacion_continua, } try: async with httpx.AsyncClient() as client: response = await client.post( f"{self.mediamtx_api}/v3/config/paths/add/{path}", json=config, timeout=10.0, ) return response.status_code in [200, 201] except httpx.HTTPError: return False