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'"
/>
+
+
-
+
+
+
+
+
+
+
+
+
+
+
Selecciona un evento para reproducir
+
+
+
+
@@ -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'
+ })
+ }
+})