From 7e41c48dc14c196f11e363f02d23ac29798cd376 Mon Sep 17 00:00:00 2001 From: "Carlos (Almeira)" Date: Sat, 20 Jun 2026 09:24:33 -0500 Subject: [PATCH] feat(streaming): Native Restreamer Dashboard integration --- almeira-live | 1 + src/app/actions/restreamer.ts | 209 ++++++++++++ .../streaming/StreamingDashboard.tsx | 304 ++++++++++++++++++ src/app/dashboard/streaming/page.tsx | 45 +-- 4 files changed, 538 insertions(+), 21 deletions(-) create mode 160000 almeira-live create mode 100644 src/app/actions/restreamer.ts create mode 100644 src/app/dashboard/streaming/StreamingDashboard.tsx diff --git a/almeira-live b/almeira-live new file mode 160000 index 0000000..37dfe05 --- /dev/null +++ b/almeira-live @@ -0,0 +1 @@ +Subproject commit 37dfe05a26b5c11da18b6e04480bf20719d8c42d diff --git a/src/app/actions/restreamer.ts b/src/app/actions/restreamer.ts new file mode 100644 index 0000000..fc09638 --- /dev/null +++ b/src/app/actions/restreamer.ts @@ -0,0 +1,209 @@ +'use server' + +/** + * Server Actions para interactuar con la API REST de Datarhei Restreamer. + * Restreamer expone una API Swagger en /api/swagger. + * Auth: POST /api/login → JWT token → Bearer en headers. + */ + +const RESTREAMER_BASE_URL = process.env.RESTREAMER_URL || 'https://stream.almeira.com.co'; +const RESTREAMER_USER = process.env.RESTREAMER_USER || 'admin'; +const RESTREAMER_PASS = process.env.RESTREAMER_PASS || ''; + +// ── Auth Token (se renueva en cada llamada del servidor) ────────────────────── +async function getRestreamToken(): Promise { + const res = await fetch(`${RESTREAMER_BASE_URL}/api/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: RESTREAMER_USER, password: RESTREAMER_PASS }), + cache: 'no-store', + }); + if (!res.ok) throw new Error(`Restreamer auth failed: ${res.status}`); + const data = await res.json(); + return data.access_token as string; +} + +async function restreamFetch(path: string, opts: RequestInit = {}) { + const token = await getRestreamToken(); + const res = await fetch(`${RESTREAMER_BASE_URL}${path}`, { + ...opts, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + ...(opts.headers || {}), + }, + cache: 'no-store', + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Restreamer API error ${res.status}: ${text}`); + } + return res.json(); +} + +// ── Tipos ───────────────────────────────────────────────────────────────────── + +export type RestreamOutput = { + id: string; + name: string; + address: string; // RTMP URL completa con stream key + state: 'running' | 'stopped' | 'failed' | 'idle'; + platform: 'youtube' | 'facebook' | 'twitch' | 'tiktok' | 'custom'; +}; + +export type RestreamServerInfo = { + version: string; + uptime_seconds: number; + is_connected: boolean; + active_ingest: boolean; + viewer_count: number; +}; + +// ── Información del servidor ─────────────────────────────────────────────────── +export async function getRestreamServerInfo(): Promise<{ success: boolean; data?: RestreamServerInfo; error?: string }> { + try { + const data = await restreamFetch('/api/v3/metrics/process/restreamer-ui:ingest:*'); + return { + success: true, + data: { + version: data?.version ?? 'desconocida', + uptime_seconds: data?.uptime ?? 0, + is_connected: true, + active_ingest: data?.state === 'running', + viewer_count: data?.viewers ?? 0, + } + }; + } catch (error: any) { + // Si falla el endpoint de metrics, devolvemos status básico + try { + await restreamFetch('/api/v3/config/active'); + return { success: true, data: { version: '-', uptime_seconds: 0, is_connected: true, active_ingest: false, viewer_count: 0 } }; + } catch { + return { success: false, error: error.message }; + } + } +} + +// ── Listar outputs (procesos de egress) ─────────────────────────────────────── +export async function listRestreamOutputs(): Promise<{ success: boolean; data: RestreamOutput[]; error?: string }> { + try { + // Los outputs en Restreamer son procesos con ID que contienen 'egress' + const processes: any[] = await restreamFetch('/api/v3/process'); + const outputs: RestreamOutput[] = processes + .filter((p: any) => p.id?.includes('egress') || p.reference?.includes('egress')) + .map((p: any) => { + const addr: string = p.config?.output?.[0]?.address ?? ''; + return { + id: p.id, + name: p.metadata?.['restreamer-ui']?.name ?? p.id, + address: addr, + state: p.state?.exec ?? 'idle', + platform: detectPlatform(addr), + }; + }); + return { success: true, data: outputs }; + } catch (error: any) { + return { success: false, data: [], error: error.message }; + } +} + +// ── Crear output ────────────────────────────────────────────────────────────── +export async function createRestreamOutput(params: { + name: string; + rtmpUrl: string; + streamKey: string; + platform: string; +}): Promise<{ success: boolean; id?: string; error?: string }> { + try { + const address = `${params.rtmpUrl.replace(/\/$/, '')}/${params.streamKey}`; + const id = `restreamer-ui:egress:rtmp:${Date.now()}`; + + await restreamFetch('/api/v3/process', { + method: 'POST', + body: JSON.stringify({ + id, + reference: 'restreamer-ui:ingest:*', + config: { + id, + reconnect: true, + reconnect_delay_seconds: 15, + autostart: false, + input: [{ id: 'input_0', address: '{memfs}/{reference}.m3u8', options: ['-re'] }], + output: [{ id: 'output_0', address, options: ['-codec', 'copy', '-f', 'flv'] }], + }, + metadata: { + 'restreamer-ui': { + name: params.name, + platform: params.platform, + } + } + }), + }); + return { success: true, id }; + } catch (error: any) { + return { success: false, error: error.message }; + } +} + +// ── Iniciar output ──────────────────────────────────────────────────────────── +export async function startRestreamOutput(id: string): Promise<{ success: boolean; error?: string }> { + try { + await restreamFetch(`/api/v3/process/${encodeURIComponent(id)}/command`, { + method: 'PUT', + body: JSON.stringify({ command: 'start' }), + }); + return { success: true }; + } catch (error: any) { + return { success: false, error: error.message }; + } +} + +// ── Detener output ──────────────────────────────────────────────────────────── +export async function stopRestreamOutput(id: string): Promise<{ success: boolean; error?: string }> { + try { + await restreamFetch(`/api/v3/process/${encodeURIComponent(id)}/command`, { + method: 'PUT', + body: JSON.stringify({ command: 'stop' }), + }); + return { success: true }; + } catch (error: any) { + return { success: false, error: error.message }; + } +} + +// ── Eliminar output ─────────────────────────────────────────────────────────── +export async function deleteRestreamOutput(id: string): Promise<{ success: boolean; error?: string }> { + try { + await restreamFetch(`/api/v3/process/${encodeURIComponent(id)}`, { method: 'DELETE' }); + return { success: true }; + } catch (error: any) { + return { success: false, error: error.message }; + } +} + +// ── Estadísticas del ingest principal ──────────────────────────────────────── +export async function getIngestStats(): Promise<{ success: boolean; data?: { bitrate: number; fps: number; state: string }; error?: string }> { + try { + const processes: any[] = await restreamFetch('/api/v3/process'); + const ingest = processes.find((p: any) => p.id?.includes('ingest')); + return { + success: true, + data: { + state: ingest?.state?.exec ?? 'stopped', + bitrate: ingest?.state?.progress?.speed ?? 0, + fps: ingest?.state?.progress?.fps ?? 0, + } + }; + } catch (error: any) { + return { success: false, error: error.message }; + } +} + +// ── Helper: detecta plataforma por URL ─────────────────────────────────────── +function detectPlatform(url: string): RestreamOutput['platform'] { + if (url.includes('youtube.com') || url.includes('a.rtmp.youtube')) return 'youtube'; + if (url.includes('facebook.com') || url.includes('live-api')) return 'facebook'; + if (url.includes('twitch.tv') || url.includes('live.twitch')) return 'twitch'; + if (url.includes('tiktok')) return 'tiktok'; + return 'custom'; +} diff --git a/src/app/dashboard/streaming/StreamingDashboard.tsx b/src/app/dashboard/streaming/StreamingDashboard.tsx new file mode 100644 index 0000000..b50d167 --- /dev/null +++ b/src/app/dashboard/streaming/StreamingDashboard.tsx @@ -0,0 +1,304 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { + Activity, RadioTower, Users, Clock, + Play, Square, Trash2, Plus, + Youtube, Facebook, Twitch, Globe, RefreshCw +} from 'lucide-react'; +import { + getRestreamServerInfo, listRestreamOutputs, + createRestreamOutput, startRestreamOutput, stopRestreamOutput, deleteRestreamOutput, + RestreamServerInfo, RestreamOutput +} from '@/app/actions/restreamer'; + +export default function StreamingDashboard() { + const [info, setInfo] = useState(null); + const [outputs, setOutputs] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + const [isAdding, setIsAdding] = useState(false); + const [newOutput, setNewOutput] = useState({ name: '', platform: 'youtube', rtmpUrl: '', streamKey: '' }); + + const fetchData = async () => { + setRefreshing(true); + try { + const [infoRes, outRes] = await Promise.all([ + getRestreamServerInfo(), + listRestreamOutputs() + ]); + if (infoRes.success && infoRes.data) setInfo(infoRes.data); + if (outRes.success) setOutputs(outRes.data); + } catch (error) { + console.error("Error fetching restreamer data", error); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + useEffect(() => { + fetchData(); + const interval = setInterval(fetchData, 10000); // Poll cada 10s + return () => clearInterval(interval); + }, []); + + const handleCreateOutput = async (e: React.FormEvent) => { + e.preventDefault(); + const res = await createRestreamOutput(newOutput); + if (res.success) { + setIsAdding(false); + setNewOutput({ name: '', platform: 'youtube', rtmpUrl: '', streamKey: '' }); + fetchData(); + } else { + alert("Error al crear la retransmisión: " + res.error); + } + }; + + const handleAction = async (action: 'start' | 'stop' | 'delete', id: string) => { + let res; + if (action === 'start') res = await startRestreamOutput(id); + else if (action === 'stop') res = await stopRestreamOutput(id); + else if (action === 'delete') { + if (confirm('¿Estás seguro de eliminar este destino?')) { + res = await deleteRestreamOutput(id); + } else return; + } + + if (res && res.success) { + fetchData(); + } else { + alert(`Error al ejecutar acción: ${res?.error}`); + } + }; + + const formatUptime = (seconds: number) => { + if (!seconds) return '0s'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return `${h}h ${m}m`; + }; + + const PlatformIcon = ({ platform }: { platform: string }) => { + switch(platform) { + case 'youtube': return ; + case 'facebook': return ; + case 'twitch': return ; + default: return ; + } + }; + + if (loading) return
Cargando datos del servidor...
; + + return ( +
+ {/* Status Cards */} +
+
+
+ +
+
+

Estado API

+

{info?.is_connected ? 'Conectado' : 'Desconectado'}

+
+
+ +
+
+ +
+
+

Señal Principal

+

{info?.active_ingest ? 'EN VIVO' : 'Apagada'}

+
+
+ +
+
+ +
+
+

Espectadores

+

{info?.viewer_count || 0}

+
+
+ +
+
+ +
+
+

Uptime

+

{formatUptime(info?.uptime_seconds || 0)}

+
+
+
+ + {/* Ingest Info & Keys */} +
+

Parámetros de Transmisión (Para App Móvil y OBS)

+
+
+ +
+ rtmp://streaming.almeira.com.co/live +
+
+
+ +
+ main_stream (o cualquier clave única) +
+
+
+

+ Utiliza estos parámetros en tu aplicación Almeira Live o en OBS Studio para enviar video al servidor Restreamer. +

+
+ + {/* Restream Outputs List */} +
+
+

Retransmisiones (Multistream)

+
+ + +
+
+ + {isAdding && ( +
+

Nuevo Destino de Retransmisión

+
+
+ + +
+
+ + setNewOutput({...newOutput, name: e.target.value})} + className="w-full bg-black border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-cyan-500 focus:outline-none" + /> +
+
+ + setNewOutput({...newOutput, rtmpUrl: e.target.value})} + className="w-full bg-black border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-cyan-500 focus:outline-none" + /> +
+
+ + setNewOutput({...newOutput, streamKey: e.target.value})} + className="w-full bg-black border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-cyan-500 focus:outline-none" + /> +
+
+
+ + +
+
+ )} + + {outputs.length === 0 ? ( +
+ +

No hay destinos configurados.

+

Añade plataformas como YouTube o Facebook para retransmitir tu señal.

+
+ ) : ( +
+ + + + + + + + + + + {outputs.map((out) => ( + + + + + + + ))} + +
DestinoURLEstadoAcciones
+
+
+ +
+
+

{out.name}

+

{out.platform}

+
+
+
+

{out.address.replace(/\/[^\/]+$/, '/****')}

+
+ {out.state === 'running' ? ( + + Transmitiendo + + ) : out.state === 'failed' ? ( + + Error + + ) : ( + + Detenido + + )} + +
+ {out.state !== 'running' ? ( + + ) : ( + + )} + +
+
+
+ )} +
+
+ ); +} diff --git a/src/app/dashboard/streaming/page.tsx b/src/app/dashboard/streaming/page.tsx index d0a73a5..3d0042d 100644 --- a/src/app/dashboard/streaming/page.tsx +++ b/src/app/dashboard/streaming/page.tsx @@ -1,4 +1,6 @@ -import { RadioTower, ExternalLink } from 'lucide-react'; +import { RadioTower, Settings } from 'lucide-react'; +import StreamingDashboard from './StreamingDashboard'; +import Link from 'next/link'; export const metadata = { title: 'Gestor StreamingTV | Almeira CMS', @@ -16,30 +18,31 @@ export default function StreamingAdminPage() { Gestor StreamingTV

- Panel administrativo integrado del servidor Datarhei Restreamer. Desde aquí puedes controlar la captura, el estado y las transmisiones de tu canal de TV. + Panel administrativo integrado del servidor Datarhei Restreamer. Controla la captura, el estado y las retransmisiones (multistream) de tu canal de TV.

- - - Abrir en Pestaña Nueva - +
+ + + Config Global + + + + Restreamer UI + +
-
-
- - -
+ {/* Nuevo Dashboard Nativo */} + ); }