b969b7e5af
- restream.py: eliminar -reconnect_streamed de ffmpeg para evitar rebobinados al reconectar (el proveedor re-enviaba desde keyframe anterior causando PTS backward). El loop de Python gestiona las reconexiones con conexión fresca. - restream.py: eliminar _drain_queue del loop de reconexión para que el buffer de la cola cubra el tiempo de reconexión sin pantalla negra en el player. - restream.py: añadir buffer_server_bytes/secs/capacity a stats() y método _log_stream_event() para persistir eventos de caída y recuperación en BD. - models/stream_event.py: nuevo modelo StreamEvent para registro de fallos. - database.py: registrar StreamEvent en init_db. - api/admin/logs.py: nuevos endpoints GET/DELETE /logs/events. - Dashboard.tsx: mostrar reserva de buffer del servidor (capacidad + estado). - Logs.tsx: añadir pestaña "Fallos de stream" con tabla de eventos persistidos. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
119 lines
4.8 KiB
TypeScript
119 lines
4.8 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { Plus, Trash2, RefreshCw } from 'lucide-react'
|
|
import api from '../lib/api'
|
|
|
|
interface EpgSource { id: number; name: string; url: string; is_active: boolean; last_fetched_at: string | null }
|
|
|
|
export default function EPG() {
|
|
const [sources, setSources] = useState<EpgSource[]>([])
|
|
const [showForm, setShowForm] = useState(false)
|
|
const [form, setForm] = useState({ name: '', url: '' })
|
|
const [refreshing, setRefreshing] = useState(false)
|
|
|
|
async function load() {
|
|
const { data } = await api.get('/epg/')
|
|
setSources(data)
|
|
}
|
|
|
|
useEffect(() => { load() }, [])
|
|
|
|
async function submit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
await api.post('/epg/', form)
|
|
setForm({ name: '', url: '' })
|
|
setShowForm(false)
|
|
load()
|
|
}
|
|
|
|
async function del(id: number) {
|
|
if (!confirm('¿Eliminar fuente EPG?')) return
|
|
await api.delete(`/epg/${id}`)
|
|
load()
|
|
}
|
|
|
|
async function refresh() {
|
|
setRefreshing(true)
|
|
try {
|
|
await api.post('/epg/refresh')
|
|
alert('EPG actualización iniciada en segundo plano')
|
|
} finally {
|
|
setRefreshing(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 space-y-5">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-xl font-bold">Gestión EPG</h1>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={refresh}
|
|
disabled={refreshing}
|
|
className="flex items-center gap-2 bg-dark-700 hover:bg-dark-600 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
|
>
|
|
<RefreshCw size={15} className={refreshing ? 'animate-spin' : ''} />
|
|
Actualizar EPG
|
|
</button>
|
|
<button onClick={() => setShowForm(v => !v)} className="flex items-center gap-2 bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium">
|
|
<Plus size={16} /> Añadir fuente
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-dark-800 border border-primary-500/20 rounded-xl p-4 text-sm text-gray-400">
|
|
<strong className="text-gray-200">Endpoint EPG para tus apps IPTV:</strong>
|
|
<div className="font-mono text-xs mt-1 bg-dark-700 rounded p-2 text-green-400">
|
|
http://10.10.10.234/xmltv.php?username=USUARIO&password=CONTRASEÑA
|
|
</div>
|
|
</div>
|
|
|
|
{showForm && (
|
|
<form onSubmit={submit} className="bg-dark-800 border border-dark-700 rounded-xl p-5 space-y-3">
|
|
<h3 className="font-semibold text-sm">Nueva fuente XMLTV</h3>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs text-gray-500 mb-1">Nombre</label>
|
|
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} required
|
|
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-500 mb-1">URL XMLTV (o del proveedor)</label>
|
|
<input value={form.url} onChange={e => setForm(f => ({ ...f, url: e.target.value }))} required
|
|
placeholder="http://proveedor.com/xmltv.php?username=X&password=Y"
|
|
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm" />
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button type="submit" className="bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium">Guardar</button>
|
|
<button type="button" onClick={() => setShowForm(false)} className="bg-dark-700 px-4 py-2 rounded-lg text-sm">Cancelar</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
{sources.map(s => (
|
|
<div key={s.id} className="bg-dark-800 border border-dark-700 rounded-xl p-4 flex items-center justify-between gap-4">
|
|
<div>
|
|
<div className="font-semibold">{s.name}</div>
|
|
<div className="text-xs text-gray-500 truncate max-w-xl">{s.url}</div>
|
|
{s.last_fetched_at && (
|
|
<div className="text-xs text-gray-600 mt-0.5">
|
|
Última actualización: {new Date(s.last_fetched_at).toLocaleString('es-ES')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button onClick={() => del(s.id)} className="p-2 rounded-lg bg-dark-700 hover:bg-red-900/40 text-gray-500 hover:text-red-400 transition-colors">
|
|
<Trash2 size={15} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
{sources.length === 0 && (
|
|
<div className="bg-dark-800 border border-dark-700 border-dashed rounded-xl p-8 text-center text-gray-500 text-sm">
|
|
No hay fuentes EPG configuradas
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|