diff --git a/nuxt4/app/components/streams/StreamViewer.vue b/nuxt4/app/components/streams/StreamViewer.vue index 805e028..72b316f 100644 --- a/nuxt4/app/components/streams/StreamViewer.vue +++ b/nuxt4/app/components/streams/StreamViewer.vue @@ -64,10 +64,52 @@ :items="streamTypeOptions" value-key="value" class="w-full" + :disabled="viewMode === 'event'" /> + +
+
+ + En Vivo + + + Eventos ({{ events.length }}) + +
+ + +
+ +

+ No hay eventos recientes para esta camara +

+
+
+
- +
@@ -245,7 +312,13 @@ const { const { isCreating: isCreatingEvent, + isLoadingEvents, error: eventError, + events, + fetchEvents, + getEventClipUrl, + getEventSnapshotUrl, + formatEventTime, createEvent, createQuickEvent, clearError: clearEventError @@ -256,6 +329,10 @@ const toast = useToast() // Modal state const showEventModal = ref(false) +// View mode: 'live' or 'event' +const viewMode = ref<'live' | 'event'>('live') +const selectedEventId = ref(null) + // Estado de conexión a streams const isConnecting = ref(false) const isStreamSessionActive = ref(false) @@ -338,6 +415,30 @@ const currentTypeDescription = computed(() => { return type?.description || '' }) +// Opciones para el dropdown de eventos +const eventOptions = computed(() => { + return events.value.map(e => ({ + value: e.id, + label: `${formatEventTime(e.start_time)} - ${e.label}${e.sub_label ? ` (${e.sub_label})` : ''}` + })) +}) + +// URL del evento seleccionado +const selectedEventUrl = computed(() => { + if (!selectedEventId.value) return null + return getEventClipUrl(selectedEventId.value) +}) + +// Cargar eventos cuando cambia la cámara +watch(() => selectedStream.value, async (newStream) => { + if (newStream) { + await fetchEvents(newStream, 10) + // Reset event selection when camera changes + selectedEventId.value = null + viewMode.value = 'live' + } +}) + // Cargar streams al montar el componente onMounted(() => { fetchStreams() diff --git a/nuxt4/app/composables/useFrigateEvents.ts b/nuxt4/app/composables/useFrigateEvents.ts index ff01dbe..940abb8 100644 --- a/nuxt4/app/composables/useFrigateEvents.ts +++ b/nuxt4/app/composables/useFrigateEvents.ts @@ -1,7 +1,7 @@ /** * Composable para gestionar eventos de Frigate * Usa proxy backend para evitar problemas de CORS/cookies entre subdominios - * API Proxy: /api/frigate/event + * API Proxy: /api/frigate/event, /api/frigate/events */ export interface FrigateEventParams { @@ -24,10 +24,81 @@ export interface FrigateEventResponse { message?: string } +export interface FrigateEvent { + id: string + label: string + sub_label?: string + camera: string + start_time: number + end_time?: number + has_clip: boolean + has_snapshot: boolean + zones: string[] +} + export const useFrigateEvents = () => { + const FRIGATE_PUBLIC_URL = 'https://camaras.nucleoriofrio.com' + const isCreating = useState('frigate_creating', () => false) + const isLoadingEvents = useState('frigate_loading_events', () => false) const error = useState('frigate_error', () => null) const lastEventId = useState('frigate_last_event', () => null) + const events = useState('frigate_events', () => []) + + /** + * Obtiene los eventos recientes de una camara + */ + const fetchEvents = async (camera?: string, limit: number = 10): Promise => { + isLoadingEvents.value = true + error.value = null + + try { + const params = new URLSearchParams() + if (camera) { + params.set('camera', camera) + } + params.set('limit', limit.toString()) + + const response = await $fetch(`/api/frigate/events?${params.toString()}`) + + events.value = response + return response + } catch (err: unknown) { + const errorMessage = (err as Error)?.message || 'Error al obtener eventos' + error.value = errorMessage + console.error('[Frigate] Error fetching events:', err) + return [] + } finally { + isLoadingEvents.value = false + } + } + + /** + * Genera la URL del clip de un evento + */ + const getEventClipUrl = (eventId: string): string => { + return `${FRIGATE_PUBLIC_URL}/api/events/${eventId}/clip.mp4` + } + + /** + * Genera la URL del snapshot de un evento + */ + const getEventSnapshotUrl = (eventId: string): string => { + return `${FRIGATE_PUBLIC_URL}/api/events/${eventId}/snapshot.jpg` + } + + /** + * Formatea la fecha de un evento + */ + const formatEventTime = (timestamp: number): string => { + const date = new Date(timestamp * 1000) + return date.toLocaleString('es-ES', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) + } /** * Crea un evento en Frigate para una camara especifica via proxy @@ -40,7 +111,6 @@ export const useFrigateEvents = () => { error.value = null try { - // Usar proxy backend para evitar CORS/cookies issues const response = await $fetch('/api/frigate/event', { method: 'POST', body: { @@ -83,9 +153,18 @@ export const useFrigateEvents = () => { } return { + // Estado isCreating: readonly(isCreating), + isLoadingEvents: readonly(isLoadingEvents), error: readonly(error), lastEventId: readonly(lastEventId), + events: readonly(events), + + // Métodos + fetchEvents, + getEventClipUrl, + getEventSnapshotUrl, + formatEventTime, createEvent, createQuickEvent, clearError diff --git a/nuxt4/server/api/frigate/event.post.ts b/nuxt4/server/api/frigate/event.post.ts index f7db109..6f0df38 100644 --- a/nuxt4/server/api/frigate/event.post.ts +++ b/nuxt4/server/api/frigate/event.post.ts @@ -39,9 +39,7 @@ export default defineEventHandler(async (event) => { const cameraName = body.camera.replace(/_main$|_sub$/, '') // Preparar payload para Frigate - const frigatePayload: Record = { - label: body.label - } + const frigatePayload: Record = {} if (body.sub_label) { frigatePayload.sub_label = body.sub_label @@ -59,8 +57,9 @@ export default defineEventHandler(async (event) => { const FRIGATE_URL = process.env.FRIGATE_URL || 'http://192.168.87.29:5000' try { + // API: POST /api/events/:camera_name/:label/create const response = await fetch( - `${FRIGATE_URL}/api/events/${cameraName}/create`, + `${FRIGATE_URL}/api/events/${cameraName}/${body.label}/create`, { method: 'POST', headers: { diff --git a/nuxt4/server/api/frigate/events.get.ts b/nuxt4/server/api/frigate/events.get.ts new file mode 100644 index 0000000..49cc590 --- /dev/null +++ b/nuxt4/server/api/frigate/events.get.ts @@ -0,0 +1,68 @@ +/** + * Proxy endpoint para obtener eventos de Frigate + * GET /api/frigate/events?camera=X&limit=10 + */ + +export default defineEventHandler(async (event) => { + // Verificar autenticación via headers de Authentik + const headers = getRequestHeaders(event) + const username = headers['x-authentik-username'] + + if (!username) { + throw createError({ + statusCode: 401, + message: 'No autenticado' + }) + } + + // Leer query params + const query = getQuery(event) + const camera = query.camera as string | undefined + const limit = query.limit ? parseInt(query.limit as string) : 10 + + // URL interna de Frigate + const FRIGATE_URL = process.env.FRIGATE_URL || 'http://192.168.87.29:5000' + + // Construir URL con filtros + const params = new URLSearchParams() + if (camera) { + // Extraer nombre base de la cámara (sin _main o _sub) + params.set('camera', camera.replace(/_main$|_sub$/, '')) + } + params.set('limit', limit.toString()) + params.set('has_clip', 'true') // Solo eventos con clip + + try { + const response = await fetch( + `${FRIGATE_URL}/api/events?${params.toString()}`, + { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + } + ) + + if (!response.ok) { + throw createError({ + statusCode: response.status, + message: `Error al obtener eventos: ${response.statusText}` + }) + } + + const events = await response.json() + + return events + } catch (error: unknown) { + console.error('[Frigate Events] Error:', error) + + if ((error as any).statusCode) { + throw error + } + + throw createError({ + statusCode: 500, + message: (error as Error)?.message || 'Error al obtener eventos' + }) + } +})