feat(streaming): Native Restreamer Dashboard integration

This commit is contained in:
Carlos (Almeira) 2026-06-20 09:24:33 -05:00
parent 9680d8788f
commit 7e41c48dc1
4 changed files with 538 additions and 21 deletions

1
almeira-live Submodule

@ -0,0 +1 @@
Subproject commit 37dfe05a26b5c11da18b6e04480bf20719d8c42d

View File

@ -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<string> {
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';
}

View File

@ -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<RestreamServerInfo | null>(null);
const [outputs, setOutputs] = useState<RestreamOutput[]>([]);
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 <Youtube size={18} className="text-red-500" />;
case 'facebook': return <Facebook size={18} className="text-blue-500" />;
case 'twitch': return <Twitch size={18} className="text-purple-500" />;
default: return <Globe size={18} className="text-cyan-500" />;
}
};
if (loading) return <div className="text-white text-center py-20 flex flex-col items-center gap-4"><RefreshCw className="animate-spin text-cyan-400" size={32} /> Cargando datos del servidor...</div>;
return (
<div className="space-y-8">
{/* Status Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-[#1e2023] border border-slate-800 rounded-xl p-5 shadow-lg flex items-center gap-4">
<div className={`p-3 rounded-lg ${info?.is_connected ? 'bg-green-500/10 text-green-500' : 'bg-red-500/10 text-red-500'}`}>
<Activity size={24} />
</div>
<div>
<p className="text-slate-400 text-sm font-medium uppercase tracking-wider">Estado API</p>
<p className="text-white font-bold text-xl">{info?.is_connected ? 'Conectado' : 'Desconectado'}</p>
</div>
</div>
<div className="bg-[#1e2023] border border-slate-800 rounded-xl p-5 shadow-lg flex items-center gap-4">
<div className={`p-3 rounded-lg ${info?.active_ingest ? 'bg-cyan-500/10 text-cyan-400 animate-pulse' : 'bg-slate-700/50 text-slate-500'}`}>
<RadioTower size={24} />
</div>
<div>
<p className="text-slate-400 text-sm font-medium uppercase tracking-wider">Señal Principal</p>
<p className="text-white font-bold text-xl">{info?.active_ingest ? 'EN VIVO' : 'Apagada'}</p>
</div>
</div>
<div className="bg-[#1e2023] border border-slate-800 rounded-xl p-5 shadow-lg flex items-center gap-4">
<div className="p-3 bg-blue-500/10 text-blue-400 rounded-lg">
<Users size={24} />
</div>
<div>
<p className="text-slate-400 text-sm font-medium uppercase tracking-wider">Espectadores</p>
<p className="text-white font-bold text-xl">{info?.viewer_count || 0}</p>
</div>
</div>
<div className="bg-[#1e2023] border border-slate-800 rounded-xl p-5 shadow-lg flex items-center gap-4">
<div className="p-3 bg-purple-500/10 text-purple-400 rounded-lg">
<Clock size={24} />
</div>
<div>
<p className="text-slate-400 text-sm font-medium uppercase tracking-wider">Uptime</p>
<p className="text-white font-bold text-xl">{formatUptime(info?.uptime_seconds || 0)}</p>
</div>
</div>
</div>
{/* Ingest Info & Keys */}
<div className="bg-slate-900 border border-cyan-500/30 rounded-xl p-6 shadow-[0_0_20px_rgba(6,182,212,0.1)]">
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2"><RadioTower size={20} className="text-cyan-400" /> Parámetros de Transmisión (Para App Móvil y OBS)</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="text-sm text-slate-400 block mb-1">Servidor RTMP</label>
<div className="bg-black/50 border border-slate-700 rounded-lg p-3 text-cyan-300 font-mono text-sm break-all">
rtmp://streaming.almeira.com.co/live
</div>
</div>
<div>
<label className="text-sm text-slate-400 block mb-1">Stream Key (Recomendada)</label>
<div className="bg-black/50 border border-slate-700 rounded-lg p-3 text-white font-mono text-sm">
main_stream <span className="text-slate-500 text-xs ml-2">(o cualquier clave única)</span>
</div>
</div>
</div>
<p className="text-slate-500 text-sm mt-4">
Utiliza estos parámetros en tu aplicación Almeira Live o en OBS Studio para enviar video al servidor Restreamer.
</p>
</div>
{/* Restream Outputs List */}
<div className="bg-[#1e2023] border border-slate-800 rounded-xl p-6 shadow-lg">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-black font-outfit uppercase tracking-tighter text-white">Retransmisiones (Multistream)</h3>
<div className="flex gap-2">
<button onClick={() => fetchData()} className="p-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg transition-colors border border-slate-700">
<RefreshCw size={18} className={refreshing ? 'animate-spin' : ''} />
</button>
<button onClick={() => setIsAdding(!isAdding)} className="flex items-center gap-2 px-4 py-2 bg-cyan-600 hover:bg-cyan-500 text-white rounded-lg text-sm font-bold uppercase transition-colors">
<Plus size={16} /> Agregar Destino
</button>
</div>
</div>
{isAdding && (
<form onSubmit={handleCreateOutput} className="bg-slate-900 border border-slate-700 rounded-lg p-5 mb-6 animate-fade-in">
<h4 className="text-white font-bold mb-4">Nuevo Destino de Retransmisión</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="text-xs text-slate-400 uppercase tracking-wider block mb-1">Plataforma</label>
<select
value={newOutput.platform}
onChange={(e) => setNewOutput({...newOutput, platform: 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"
>
<option value="youtube">YouTube</option>
<option value="facebook">Facebook</option>
<option value="twitch">Twitch</option>
<option value="custom">Custom RTMP</option>
</select>
</div>
<div>
<label className="text-xs text-slate-400 uppercase tracking-wider block mb-1">Nombre Descriptivo</label>
<input
type="text" required
placeholder="Ej: Canal Secundario YouTube"
value={newOutput.name}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs text-slate-400 uppercase tracking-wider block mb-1">URL RTMP del destino</label>
<input
type="text" required
placeholder="rtmp://a.rtmp.youtube.com/live2"
value={newOutput.rtmpUrl}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs text-slate-400 uppercase tracking-wider block mb-1">Stream Key</label>
<input
type="password" required
placeholder="****-****-****-****"
value={newOutput.streamKey}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setIsAdding(false)} className="px-4 py-2 bg-slate-800 text-slate-300 rounded-lg hover:bg-slate-700 text-sm font-medium">Cancelar</button>
<button type="submit" className="px-4 py-2 bg-cyan-600 text-white rounded-lg hover:bg-cyan-500 text-sm font-bold uppercase">Guardar y Añadir</button>
</div>
</form>
)}
{outputs.length === 0 ? (
<div className="text-center py-10 bg-black/20 rounded-lg border border-dashed border-slate-700">
<Globe size={32} className="mx-auto text-slate-600 mb-3" />
<p className="text-slate-400">No hay destinos configurados.</p>
<p className="text-slate-500 text-sm mt-1">Añade plataformas como YouTube o Facebook para retransmitir tu señal.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-slate-800 text-slate-500 text-xs uppercase tracking-wider">
<th className="pb-3 font-medium">Destino</th>
<th className="pb-3 font-medium">URL</th>
<th className="pb-3 font-medium">Estado</th>
<th className="pb-3 font-medium text-right">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{outputs.map((out) => (
<tr key={out.id} className="hover:bg-slate-800/30 transition-colors">
<td className="py-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-black rounded-lg border border-slate-800">
<PlatformIcon platform={out.platform} />
</div>
<div>
<p className="text-white font-medium">{out.name}</p>
<p className="text-slate-500 text-xs capitalize">{out.platform}</p>
</div>
</div>
</td>
<td className="py-4">
<p className="text-slate-400 font-mono text-xs truncate max-w-[200px]" title={out.address}>{out.address.replace(/\/[^\/]+$/, '/****')}</p>
</td>
<td className="py-4">
{out.state === 'running' ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-green-500/10 text-green-400 border border-green-500/20">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></span> Transmitiendo
</span>
) : out.state === 'failed' ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-red-500/10 text-red-400 border border-red-500/20">
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span> Error
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-slate-500/10 text-slate-400 border border-slate-500/20">
<span className="w-1.5 h-1.5 rounded-full bg-slate-500"></span> Detenido
</span>
)}
</td>
<td className="py-4">
<div className="flex items-center justify-end gap-2">
{out.state !== 'running' ? (
<button onClick={() => handleAction('start', out.id)} className="p-2 bg-green-500/10 hover:bg-green-500/20 text-green-400 rounded-lg transition-colors" title="Iniciar Transmisión">
<Play size={16} className="fill-current" />
</button>
) : (
<button onClick={() => handleAction('stop', out.id)} className="p-2 bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 rounded-lg transition-colors" title="Detener Transmisión">
<Square size={16} className="fill-current" />
</button>
)}
<button onClick={() => handleAction('delete', out.id)} className="p-2 bg-red-500/10 hover:bg-red-500/20 text-red-400 rounded-lg transition-colors" title="Eliminar Destino">
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@ -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
</h1>
<p className="text-slate-400 mt-1 max-w-2xl">
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.
</p>
</div>
<div className="flex gap-3">
<Link
href="/dashboard/settings"
className="flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm font-bold uppercase tracking-wider transition-colors border border-slate-700"
>
<Settings size={16} />
Config Global
</Link>
<a
href="https://stream.almeira.com.co/ui/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-cyan-400 rounded-lg text-sm font-bold uppercase tracking-wider transition-colors border border-slate-700 hover:border-cyan-500/50"
>
<ExternalLink size={16} />
Abrir en Pestaña Nueva
<RadioTower size={16} />
Restreamer UI
</a>
</div>
<div className="w-full bg-[#1e2023] rounded-2xl overflow-hidden shadow-[0_0_40px_rgba(0,0,0,0.5)] border border-slate-800 relative z-10" style={{ height: 'calc(100vh - 200px)', minHeight: '600px' }}>
<div className="absolute inset-x-0 top-0 h-10 bg-gradient-to-b from-black/50 to-transparent pointer-events-none z-20"></div>
<iframe
src="https://stream.almeira.com.co/ui/"
className="w-full h-full border-none"
title="Datarhei Restreamer Panel"
allow="camera; microphone; fullscreen; display-capture"
></iframe>
</div>
{/* Nuevo Dashboard Nativo */}
<StreamingDashboard />
</div>
);
}