docs: complete technical documentation (architecture, DB schema, ops)
Full reference covering: - Architecture and design decisions - PostgreSQL schema (all tables) - Stream engine internals (ProviderPool, BroadcastGroup, ffmpeg flags) - Xtream Codes API endpoints - Admin dashboard features and WebSocket protocol - EPG and Jellyfin integration - Nginx + systemd configs - Environment variables and Python dependencies - Step-by-step reinstall guide - Common operations (update, logs, Redis/Postgres inspection) - Lessons learned (ffmpeg flags to avoid, health thresholds, zapping logic) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,957 @@
|
||||
# KiraStream — Documentación Técnica Completa
|
||||
|
||||
Proxy IPTV multiusuario con fan-out de streams, API Xtream Codes, EPG, dashboard en tiempo real e integración Jellyfin.
|
||||
|
||||
---
|
||||
|
||||
## Índice
|
||||
|
||||
1. [Arquitectura general](#1-arquitectura-general)
|
||||
2. [Stack tecnológico](#2-stack-tecnológico)
|
||||
3. [Esquema de base de datos](#3-esquema-de-base-de-datos)
|
||||
4. [Motor de streams](#4-motor-de-streams)
|
||||
5. [API Xtream Codes](#5-api-xtream-codes)
|
||||
6. [Panel de administración](#6-panel-de-administración)
|
||||
7. [EPG](#7-epg)
|
||||
8. [Integración Jellyfin](#8-integración-jellyfin)
|
||||
9. [Infraestructura y despliegue](#9-infraestructura-y-despliegue)
|
||||
10. [Variables de entorno](#10-variables-de-entorno)
|
||||
11. [Reinstalación desde cero](#11-reinstalación-desde-cero)
|
||||
12. [Operaciones habituales](#12-operaciones-habituales)
|
||||
13. [Decisiones de diseño y lecciones aprendidas](#13-decisiones-de-diseño-y-lecciones-aprendidas)
|
||||
|
||||
---
|
||||
|
||||
## 1. Arquitectura general
|
||||
|
||||
```
|
||||
[Apps IPTV — TiviMate, Smarters, etc.]
|
||||
│ Xtream Codes API
|
||||
▼
|
||||
┌───────────────────────────────────────────┐
|
||||
│ Nginx (puerto 80) │
|
||||
│ /panel/* → static build React │
|
||||
│ /* → proxy FastAPI :8000 │
|
||||
└───────────────────┬───────────────────────┘
|
||||
│
|
||||
┌───────────────────▼───────────────────────┐
|
||||
│ KiraStream (FastAPI :8000) │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌────────────────┐ │
|
||||
│ │ API Xtream │ │ API Admin │ │
|
||||
│ │ /player_api.php │ │ /api/admin/* │ │
|
||||
│ │ /{u}/{p}/{id} │ │ WebSocket /ws │ │
|
||||
│ └────────┬────────┘ └───────┬────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌────────▼───────────────────▼────────┐ │
|
||||
│ │ Stream Engine │ │
|
||||
│ │ ProviderPool BroadcastGroup │ │
|
||||
│ │ (slots Redis) (fan-out asyncio) │ │
|
||||
│ │ ffmpeg copy-mode │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────────────────┐ │
|
||||
│ │ Redis │ │ PostgreSQL 17 │ │
|
||||
│ │ (estado) │ │ (config + catálogos) │ │
|
||||
│ └──────────┘ └──────────────────────┘ │
|
||||
└───────────────────────────────────────────┘
|
||||
│ Xtream Codes (consume)
|
||||
▼
|
||||
[Proveedores IPTV — cuenta1 … cuentaN]
|
||||
```
|
||||
|
||||
### Principios clave
|
||||
|
||||
- **Fan-out**: N usuarios viendo el mismo canal → 1 solo slot del proveedor. El motor descarga una vez y distribuye a todos los clientes.
|
||||
- **Sin transcodificación**: los streams se reenvían bit a bit (copia remux con ffmpeg `-c copy`).
|
||||
- **Multi-cuenta**: varias cuentas del mismo proveedor para multiplicar conexiones simultáneas.
|
||||
- **Catálogos por usuario**: cada usuario ve solo las categorías que tiene asignadas.
|
||||
- **Estado efímero en Redis, configuración en PostgreSQL**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Stack tecnológico
|
||||
|
||||
| Capa | Tecnología | Versión |
|
||||
|------|-----------|---------|
|
||||
| Backend | Python + FastAPI + uvicorn | Python 3.13, FastAPI 0.115 |
|
||||
| ORM | SQLAlchemy async + Alembic | 2.0 |
|
||||
| Base de datos | PostgreSQL | 17 |
|
||||
| Cache / estado | Redis | 8 |
|
||||
| Proceso de stream | ffmpeg | sistema |
|
||||
| EPG | XMLTV fetch + merge por tvg-id | — |
|
||||
| Frontend | React 18 + Vite + TailwindCSS | React 18, Vite 6 |
|
||||
| Proxy inverso | Nginx | sistema |
|
||||
| Servicio | systemd | — |
|
||||
|
||||
---
|
||||
|
||||
## 3. Esquema de base de datos
|
||||
|
||||
### Tablas principales
|
||||
|
||||
```sql
|
||||
-- Cuentas del proveedor IPTV (una por "lista")
|
||||
provider_accounts (
|
||||
id SERIAL PK,
|
||||
name TEXT,
|
||||
base_url TEXT, -- p.ej. http://proveedor.com:8080
|
||||
username TEXT,
|
||||
password TEXT,
|
||||
max_connections INT DEFAULT 1,
|
||||
is_active BOOL DEFAULT true,
|
||||
last_sync_at TIMESTAMPTZ,
|
||||
auto_sync_hours INT DEFAULT 0, -- 0 = desactivado
|
||||
last_sync_categories JSONB -- {live:[1,2], movie:[3], series:[]}
|
||||
)
|
||||
|
||||
-- URLs alternativas por cuenta (failover multi-dominio)
|
||||
provider_urls (
|
||||
id SERIAL PK,
|
||||
provider_account_id INT FK → provider_accounts(id) CASCADE,
|
||||
url TEXT, -- URL base alternativa
|
||||
priority INT DEFAULT 0, -- menor = mayor prioridad
|
||||
is_active BOOL DEFAULT true,
|
||||
status TEXT DEFAULT 'unknown', -- ok / error / timeout / unknown
|
||||
response_ms INT,
|
||||
last_checked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ
|
||||
)
|
||||
|
||||
-- Categorías (importadas desde el proveedor)
|
||||
categories (
|
||||
id SERIAL PK,
|
||||
name TEXT,
|
||||
type TEXT, -- live / movie / series
|
||||
provider_category_id TEXT,
|
||||
provider_account_id INT FK → provider_accounts(id)
|
||||
)
|
||||
|
||||
-- Canales deduplicados
|
||||
channels (
|
||||
id SERIAL PK,
|
||||
name TEXT,
|
||||
tvg_id TEXT,
|
||||
tvg_logo TEXT,
|
||||
stream_id_at_provider TEXT,
|
||||
category_id INT FK → categories(id),
|
||||
type TEXT, -- live / movie / series
|
||||
is_active BOOL DEFAULT true
|
||||
)
|
||||
|
||||
-- Mapa canal ↔ cuenta del proveedor (un canal puede estar en varias cuentas)
|
||||
channel_provider_map (
|
||||
id SERIAL PK,
|
||||
channel_id INT FK → channels(id),
|
||||
provider_account_id INT FK → provider_accounts(id),
|
||||
stream_url TEXT
|
||||
)
|
||||
|
||||
-- Usuarios finales (Xtream Codes compatible)
|
||||
users (
|
||||
id SERIAL PK,
|
||||
username TEXT UNIQUE,
|
||||
password_hash TEXT,
|
||||
max_connections INT DEFAULT 1,
|
||||
expiry_date TIMESTAMPTZ,
|
||||
is_active BOOL DEFAULT true,
|
||||
is_priority BOOL DEFAULT false, -- puede desalojar streams no-priority
|
||||
preferred_provider_ids JSONB, -- [1,3] — proveedores preferidos
|
||||
created_at TIMESTAMPTZ
|
||||
)
|
||||
|
||||
-- Entradas de catálogo por usuario
|
||||
user_catalog_entries (
|
||||
id SERIAL PK,
|
||||
user_id INT FK → users(id),
|
||||
category_id INT FK → categories(id),
|
||||
type TEXT
|
||||
)
|
||||
|
||||
-- Categorías personalizadas (no vienen del proveedor)
|
||||
custom_categories (
|
||||
id SERIAL PK,
|
||||
name TEXT,
|
||||
type TEXT,
|
||||
display_order INT DEFAULT 0
|
||||
)
|
||||
|
||||
custom_category_channels (
|
||||
id SERIAL PK,
|
||||
custom_category_id INT FK,
|
||||
channel_id INT FK,
|
||||
display_order INT DEFAULT 0
|
||||
)
|
||||
|
||||
-- Fuentes EPG
|
||||
epg_sources (
|
||||
id SERIAL PK,
|
||||
name TEXT,
|
||||
url TEXT,
|
||||
last_fetched_at TIMESTAMPTZ,
|
||||
is_active BOOL DEFAULT true
|
||||
)
|
||||
|
||||
-- Configuración global
|
||||
app_settings (
|
||||
key TEXT PK,
|
||||
value TEXT
|
||||
)
|
||||
|
||||
-- Configuración Jellyfin
|
||||
jellyfin_configs (
|
||||
id SERIAL PK,
|
||||
name TEXT,
|
||||
base_url TEXT,
|
||||
api_key TEXT,
|
||||
category_name TEXT,
|
||||
provider_account_id INT FK,
|
||||
is_active BOOL DEFAULT true,
|
||||
last_sync_at TIMESTAMPTZ
|
||||
)
|
||||
|
||||
-- Log de actividad
|
||||
stream_logs (
|
||||
id SERIAL PK,
|
||||
user_id INT,
|
||||
username TEXT,
|
||||
channel_id INT,
|
||||
channel_name TEXT,
|
||||
provider_account_id INT,
|
||||
started_at TIMESTAMPTZ,
|
||||
ended_at TIMESTAMPTZ,
|
||||
bytes_transferred BIGINT
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Motor de streams
|
||||
|
||||
El motor tiene dos capas: **ProviderPool** (gestión de slots) y **BroadcastGroup** (fan-out y proxy).
|
||||
|
||||
### 4.1 ProviderPool (`backend/core/pool.py`)
|
||||
|
||||
Singleton que gestiona todos los BroadcastGroup activos y la asignación de slots de proveedor.
|
||||
|
||||
**Estado en Redis:**
|
||||
```
|
||||
ks:stream:{channel_id} → hash {provider_account_id, stream_url, clients, started_at}
|
||||
ks:slot:{account_id} → channel_id (clave = slot ocupado)
|
||||
ks:user_conns:{user_id} → set de client_ids activos
|
||||
ks:monitoring → pub/sub channel para WebSocket
|
||||
```
|
||||
|
||||
**Flujo `acquire(channel_id, user_id, ...)`:**
|
||||
1. Comprueba límite de conexiones del usuario (Redis + in-memory)
|
||||
2. Si el canal ya está activo → attach al BroadcastGroup existente
|
||||
3. Si no → busca slot libre entre las cuentas del proveedor (ordenadas por preferencia del usuario)
|
||||
4. Si no hay slot libre y el usuario es `is_priority` → evicta el stream con menos clientes sin usuarios priority
|
||||
5. Reserva slot en Redis, crea BroadcastGroup, arranca stream
|
||||
6. Devuelve `ClientHandle` con una `asyncio.Queue` personal del cliente
|
||||
|
||||
**Flujo `release(channel_id, client_id)`:**
|
||||
1. Elimina el cliente del grupo
|
||||
2. Si quedan 0 clientes → para el grupo, libera slot Redis
|
||||
3. Si quedan clientes → actualiza contador
|
||||
|
||||
**Zapping detection:** si un usuario ya tiene una conexión de menos de 10s, la desaloja silenciosamente en lugar de rechazar la nueva. Esto permite cambiar de canal rápido desde una TV sin ver el "stream bloqueado".
|
||||
|
||||
**Multi-dominio failover (`_build_stream_urls`):**
|
||||
Para cada slot asignado, consulta `provider_urls` ordenadas por prioridad y construye una lista de URLs alternativas sustituyendo el dominio base manteniendo el path del stream. El `BroadcastGroup` recibe todas y hace failover automático.
|
||||
|
||||
### 4.2 BroadcastGroup (`backend/core/restream.py`)
|
||||
|
||||
Descarga un stream del proveedor y lo distribuye a N clientes mediante colas asyncio.
|
||||
|
||||
**Arquitectura:**
|
||||
```
|
||||
ffmpeg (proceso externo)
|
||||
stdout → _pump_once → _dispatch → Queue[cliente1]
|
||||
→ Queue[cliente2]
|
||||
→ Queue[clienteN]
|
||||
stderr → _log_ffmpeg_stderr (logging + contador av_desync)
|
||||
|
||||
ClientHandle.read() → itera la Queue y yield chunks al StreamingResponse HTTP
|
||||
```
|
||||
|
||||
**Comando ffmpeg (copy-mode):**
|
||||
```bash
|
||||
ffmpeg -hide_banner -loglevel warning \
|
||||
-reconnect 1 \
|
||||
-reconnect_streamed 1 \
|
||||
-reconnect_delay_max 2 \
|
||||
-timeout 10000000 \
|
||||
-i {stream_url} \
|
||||
-c copy \
|
||||
-f mpegts pipe:1
|
||||
```
|
||||
|
||||
- `-reconnect_streamed 1`: al expirar el token 302 del proveedor, ffmpeg reenvía el GET original y obtiene un token nuevo. No se desconectan los clientes.
|
||||
- `-timeout 10000000`: stall detection de 10 segundos a nivel socket.
|
||||
- `-c copy`: remux sin transcodificación. Calidad bit a bit idéntica al proveedor.
|
||||
|
||||
**Por qué ffmpeg en lugar de aiohttp directo:**
|
||||
El proveedor usa redirecciones 302 con tokens de sesión de corta duración (típicamente 60-120s para HD, ~28s para algunos 4K). Con aiohttp puro, al expirar el token hay que reconectar a nivel Python, lo que producía desync A/V visible porque el nuevo stream reinicia sus PTS/DTS. ffmpeg con `-reconnect_streamed` renegocia el token internamente y normaliza los PTS/DTS en el remux, haciendo la transición imperceptible para el cliente.
|
||||
|
||||
**Flags que NO usar:**
|
||||
- `-use_wallclock_as_timestamps 1`: reemplaza cada PTS/DTS con el reloj del servidor. Genera una advertencia de stderr por cada paquete con jitter de red, resultando en cientos de warnings por minuto que atascan ffmpeg y producen freezes visibles. **No usar.**
|
||||
- `-fflags +discardcorrupt`: descarta paquetes corruptos en el punto de reconexión. El problema es que ese paquete puede ser el IDR keyframe que HEVC necesita para arrancar. Sin él, el decoder espera el siguiente IDR (hasta 2s) y el player salta al live edge → salto hacia atrás visible. **No usar.**
|
||||
- `-avoid_negative_ts make_zero`: útil para VoD pero en live con reconexiones produce acumulación de offsets incorrectos que desincroniza A/V. **No usar para live.**
|
||||
|
||||
**Failover multi-URL:**
|
||||
`_pump_with_retry` itera las URLs del proveedor en orden. Si una falla, prueba la siguiente inmediatamente (sin espera). Solo hace backoff (máx 5s) cuando ha fallado el ciclo completo de todas las URLs.
|
||||
|
||||
**Monitor de salud (`_health_check_loop`):**
|
||||
Tarea asyncio que corre cada 5s y comprueba la antigüedad del último dato recibido:
|
||||
- `< 12s` → `av_health = "ok"`
|
||||
- `12-25s` → `av_health = "warning"`
|
||||
- `> 25s` → `av_health = "error"` + mata ffmpeg vía SIGTERM para forzar reconexión inmediata (cuenta como "corrección")
|
||||
|
||||
**Buffer (`_buffer_pct`):**
|
||||
Porcentaje del queue del cliente más lento. 0% = cliente consume en tiempo real (ideal). 100% = cliente bloqueado o muy lento (riesgo de drops).
|
||||
|
||||
**Eviction de clientes lentos:**
|
||||
Si un cliente lleva más de 30s con la queue llena (sin consumir), es desalojado automáticamente.
|
||||
|
||||
### 4.3 HealthChecker (`backend/core/health_checker.py`)
|
||||
|
||||
Tarea de fondo que cada 5 minutos comprueba todos los dominios activos de cada proveedor haciendo una petición a `player_api.php?action=user_info`. Actualiza `status`, `response_ms` y `last_checked_at` en la tabla `provider_urls`.
|
||||
|
||||
### 4.4 VodTracker (`backend/core/vod_tracker.py`)
|
||||
|
||||
Gestiona sesiones VOD/series (sin fan-out, cada cliente es independiente). Hace proxy directo del stream del proveedor sin pasar por ProviderPool, ya que VOD no tiene límite de slots compartidos.
|
||||
|
||||
---
|
||||
|
||||
## 5. API Xtream Codes
|
||||
|
||||
Ruta base: `/` — compatible con TiviMate, IPTV Smarters, IPTV Pro y cualquier app que soporte Xtream Codes.
|
||||
|
||||
### Autenticación de usuario
|
||||
|
||||
Todos los endpoints de la API pública llevan `?username=X&password=Y` o `/{username}/{password}/`.
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Endpoint | Función |
|
||||
|----------|---------|
|
||||
| `GET /player_api.php?action=user_info` | Info del usuario + límites de conexión |
|
||||
| `GET /player_api.php?action=get_live_categories` | Categorías live del catálogo del usuario |
|
||||
| `GET /player_api.php?action=get_live_streams` | Canales live del usuario |
|
||||
| `GET /player_api.php?action=get_vod_categories` | Categorías VOD |
|
||||
| `GET /player_api.php?action=get_vod_streams` | Películas |
|
||||
| `GET /player_api.php?action=get_vod_info&vod_id=X` | Detalle película |
|
||||
| `GET /player_api.php?action=get_series_categories` | Categorías series |
|
||||
| `GET /player_api.php?action=get_series` | Series |
|
||||
| `GET /player_api.php?action=get_series_info&series_id=X` | Detalle serie + episodios |
|
||||
| `GET /get.php?type=m3u_plus` | Playlist M3U filtrada por catálogo del usuario |
|
||||
| `GET /xmltv.php` | EPG XMLTV filtrado por canales del usuario |
|
||||
| `GET /live/{u}/{p}/{stream_id}` | Stream live (proxy con fan-out) |
|
||||
| `GET /live/{u}/{p}/{stream_id}.ts` | Ídem con extensión |
|
||||
| `GET /movie/{u}/{p}/{stream_id}.{ext}` | Stream VOD |
|
||||
| `GET /series/{u}/{p}/{stream_id}.{ext}` | Episodio de serie |
|
||||
|
||||
### Lógica de stream live
|
||||
|
||||
```
|
||||
GET /live/usuario/password/840238.ts
|
||||
→ autenticar usuario
|
||||
→ resolver canal (stream_id → channel_id)
|
||||
→ pool.acquire(channel_id, user_id, ...)
|
||||
→ StreamingResponse(handle.read()) ← HTTP chunked al cliente
|
||||
→ [on disconnect] pool.release(channel_id, client_id, user_id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Panel de administración
|
||||
|
||||
URL: `http://{IP}/panel/`
|
||||
Credenciales por defecto: `admin` / definido en config.py
|
||||
|
||||
### Páginas
|
||||
|
||||
#### Dashboard (`/`)
|
||||
- **6 StatCards**: streams activos, usuarios conectados, slots ocupados, bajada total del proveedor, CPU total ffmpeg, salud A/V global
|
||||
- **Tabla de streams**: canal, proveedor, codec/resolución detectada, estado de conexión, salud A/V (ok/aviso/error), barra de buffer, usuarios con botón de corte, bitrate ↓/↑, tiempo activo, datos transferidos
|
||||
- **Estado de slots**: qué proveedor tiene qué canal
|
||||
- **WebSocket**: actualización en tiempo real (cada 2s + eventos Redis)
|
||||
|
||||
#### Proveedores (`/providers`)
|
||||
- CRUD de cuentas del proveedor (nombre, URL base, usuario, contraseña, max_connections)
|
||||
- Sync manual: importa categorías y canales desde la API del proveedor
|
||||
- Auto-sync configurable (cada N horas)
|
||||
- Multi-dominio: añadir URLs alternativas por proveedor con indicador de salud (ok/error/timeout/unknown + ms)
|
||||
- Botón "Verificar todos" para forzar health check inmediato
|
||||
|
||||
#### Usuarios (`/users`)
|
||||
- CRUD de usuarios Xtream Codes
|
||||
- Campos: usuario, contraseña, max_connections, expiry_date, is_priority, proveedores preferidos
|
||||
- Ver conexiones activas
|
||||
|
||||
#### Catálogos (`/catalogs`)
|
||||
- Asignar categorías del proveedor a cada usuario
|
||||
- Filtro por tipo (live/movie/series)
|
||||
|
||||
#### Categorías personalizadas (`/custom-categories`)
|
||||
- Crear categorías propias con canales seleccionados manualmente
|
||||
- Control de orden de visualización
|
||||
|
||||
#### EPG (`/epg`)
|
||||
- Añadir fuentes XMLTV (URL)
|
||||
- Estado de última importación
|
||||
|
||||
#### Jellyfin (`/jellyfin`)
|
||||
- Configurar instancias Jellyfin
|
||||
- Sync de biblioteca local como categorías KiraStream
|
||||
|
||||
#### Logs (`/logs`)
|
||||
- Historial de streams: usuario, canal, proveedor, duración, bytes
|
||||
|
||||
#### Configuración (`/settings`)
|
||||
- Variables de configuración globales editables desde el panel
|
||||
|
||||
### WebSocket de monitoring
|
||||
|
||||
```
|
||||
WS /api/admin/monitoring/ws
|
||||
→ Server emite en dos situaciones:
|
||||
1. Cada 2s (broadcast general)
|
||||
2. En cada cambio de estado (vía Redis pub/sub)
|
||||
|
||||
Payload:
|
||||
{
|
||||
"type": "state" | "update",
|
||||
"data": [
|
||||
{
|
||||
"stream_type": "live",
|
||||
"channel_id": 310412,
|
||||
"channel_name": "ES: LA 1 UHD",
|
||||
"provider_name": "Lista1",
|
||||
"client_count": 2,
|
||||
"clients": [{"client_id": "...", "username": "joaquin"}],
|
||||
"started_at": 1779000000.0,
|
||||
"bytes_pumped": 125000000,
|
||||
"bps_down": 3200000,
|
||||
"bps_up": 6400000,
|
||||
"status": "ok",
|
||||
"reconnect_count": 3,
|
||||
"last_data_ago": 0.3,
|
||||
"stream_info": {"video_codec": "hevc", "width": 3840, "height": 2160, ...},
|
||||
"ffmpeg_pid": 3251,
|
||||
"ffmpeg_cpu_pct": 0.38,
|
||||
"av_health": "ok",
|
||||
"av_desync_count": 12,
|
||||
"corrections": 0,
|
||||
"buffer_pct": 0.0,
|
||||
"active_url_domain": "proveedor.com",
|
||||
"url_count": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. EPG
|
||||
|
||||
- **Fuentes**: URLs XMLTV configuradas en el panel
|
||||
- **Merge**: por `tvg-id`. Si varios canales tienen el mismo tvg-id, se unifica la EPG
|
||||
- **Refresco**: tarea de fondo cada N horas (configurable, por defecto 12h)
|
||||
- **Endpoint cliente**: `GET /xmltv.php?username=X&password=Y` — devuelve EPG filtrada por los canales del catálogo del usuario
|
||||
|
||||
---
|
||||
|
||||
## 8. Integración Jellyfin
|
||||
|
||||
- Configura una o varias instancias Jellyfin con su URL base y API key
|
||||
- Al hacer sync, importa la biblioteca local de Jellyfin como una categoría adicional en KiraStream
|
||||
- Los streams de Jellyfin se sirven como VOD a través de KiraStream (proxy transparente)
|
||||
- Los usuarios ven el contenido de Jellyfin mezclado con el IPTV dentro de la misma app cliente
|
||||
|
||||
---
|
||||
|
||||
## 9. Infraestructura y despliegue
|
||||
|
||||
### Entorno
|
||||
|
||||
- **Contenedor**: Proxmox LXC, PCT 117, hostname `kiraIPTV`, IP `10.10.10.234`
|
||||
- **OS**: Debian 13 Trixie
|
||||
- **Recursos**: 4 cores, 8 GB RAM, 50 GB ZFS local
|
||||
|
||||
### Estructura en disco
|
||||
|
||||
```
|
||||
/opt/kirastream/
|
||||
├── backend/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # Punto de entrada FastAPI
|
||||
│ ├── config.py # Settings (pydantic-settings)
|
||||
│ ├── database.py # Engine PostgreSQL + init_db()
|
||||
│ ├── api/
|
||||
│ │ ├── auth.py # JWT admin
|
||||
│ │ ├── xtream.py # API Xtream Codes pública
|
||||
│ │ └── admin/
|
||||
│ │ ├── providers.py # CRUD proveedores + sync + multi-URL
|
||||
│ │ ├── users.py # CRUD usuarios
|
||||
│ │ ├── catalogs.py # Catálogos por usuario
|
||||
│ │ ├── custom_categories.py
|
||||
│ │ ├── epg_admin.py # Fuentes EPG
|
||||
│ │ ├── monitoring.py # REST + WebSocket monitoring
|
||||
│ │ ├── logs.py # Historial de streams
|
||||
│ │ ├── settings.py # Config global
|
||||
│ │ └── jellyfin_admin.py
|
||||
│ ├── core/
|
||||
│ │ ├── pool.py # ProviderPool (slots + Redis)
|
||||
│ │ ├── restream.py # BroadcastGroup (fan-out + ffmpeg)
|
||||
│ │ ├── xtream_client.py # Cliente HTTP para API del proveedor
|
||||
│ │ ├── epg_manager.py # Fetch + merge XMLTV
|
||||
│ │ ├── catalog.py # Resolver catálogo por usuario
|
||||
│ │ ├── health_checker.py # Health check de dominios del proveedor
|
||||
│ │ ├── vod_tracker.py # Sesiones VOD/series
|
||||
│ │ ├── probe.py # ffprobe para detectar codec/resolución
|
||||
│ │ └── jellyfin_client.py
|
||||
│ ├── models/
|
||||
│ │ ├── provider.py # ProviderAccount, ProviderUrl
|
||||
│ │ ├── channel.py # Channel, Category, ChannelProviderMap
|
||||
│ │ ├── user.py # User, UserCatalogEntry
|
||||
│ │ ├── epg.py # EpgSource
|
||||
│ │ ├── custom_category.py
|
||||
│ │ ├── log.py # StreamLog
|
||||
│ │ ├── notification.py
|
||||
│ │ └── jellyfin.py
|
||||
│ └── static/ # Frontend compilado (generado por npm run build)
|
||||
│ ├── index.html
|
||||
│ └── assets/
|
||||
│ ├── index-*.js
|
||||
│ └── index-*.css
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── App.tsx
|
||||
│ │ ├── main.tsx
|
||||
│ │ ├── lib/api.ts # Axios con base /api/admin
|
||||
│ │ ├── components/
|
||||
│ │ │ └── Layout.tsx
|
||||
│ │ └── pages/
|
||||
│ │ ├── Dashboard.tsx # Monitoring tiempo real
|
||||
│ │ ├── Providers.tsx # Multi-URL, health badges
|
||||
│ │ ├── Users.tsx
|
||||
│ │ ├── Catalogs.tsx
|
||||
│ │ ├── CustomCategories.tsx
|
||||
│ │ ├── EPG.tsx
|
||||
│ │ ├── Jellyfin.tsx
|
||||
│ │ ├── Logs.tsx
|
||||
│ │ ├── Settings.tsx
|
||||
│ │ └── Login.tsx
|
||||
│ ├── package.json
|
||||
│ ├── vite.config.ts
|
||||
│ └── tailwind.config.js
|
||||
├── nginx/
|
||||
│ └── kirastream.conf
|
||||
├── logs/
|
||||
│ └── backend.log
|
||||
├── venv/ # Virtualenv Python
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Nginx (`/etc/nginx/sites-enabled/kirastream`)
|
||||
|
||||
```nginx
|
||||
upstream kirastream_backend {
|
||||
server 127.0.0.1:8000;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_connect_timeout 10s;
|
||||
|
||||
# Panel de administración
|
||||
location /panel {
|
||||
alias /opt/kirastream/backend/static;
|
||||
try_files $uri $uri/ /panel/index.html;
|
||||
}
|
||||
|
||||
# API y Xtream Codes
|
||||
location / {
|
||||
proxy_pass http://kirastream_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
add_header X-Accel-Buffering no;
|
||||
}
|
||||
|
||||
# WebSocket monitoring (timeout extra largo)
|
||||
location /api/admin/monitoring/ws {
|
||||
proxy_pass http://kirastream_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
|
||||
# Assets del frontend (cache inmutable)
|
||||
location /panel/assets/ {
|
||||
alias /opt/kirastream/backend/static/assets/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types application/json application/javascript text/css text/plain;
|
||||
access_log /var/log/nginx/kirastream_access.log;
|
||||
error_log /var/log/nginx/kirastream_error.log;
|
||||
}
|
||||
```
|
||||
|
||||
### Systemd (`/etc/systemd/system/kirastream.service`)
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=KiraStream IPTV Server
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/kirastream
|
||||
|
||||
ExecStartPre=/bin/bash -c 'pg_ctlcluster 17 main start 2>/dev/null; true'
|
||||
ExecStartPre=/bin/bash -c 'redis-cli ping 2>/dev/null || (redis-server --port 6379 --bind 127.0.0.1 --daemonize yes --dir /var/lib/redis/ && sleep 1); true'
|
||||
ExecStartPre=/bin/bash -c 'pkill -9 uvicorn 2>/dev/null; sleep 1; true'
|
||||
|
||||
ExecStart=/opt/kirastream/venv/bin/uvicorn backend.main:app \
|
||||
--host 0.0.0.0 --port 8000 --workers 1 --log-level info
|
||||
|
||||
StandardOutput=append:/opt/kirastream/logs/backend.log
|
||||
StandardError=append:/opt/kirastream/logs/backend.log
|
||||
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
KillMode=control-group
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Variables de entorno
|
||||
|
||||
Fichero: `/opt/kirastream/.env` (si no existe, se usan los defaults de `config.py`)
|
||||
|
||||
```env
|
||||
# Base de datos
|
||||
DATABASE_URL=postgresql+asyncpg://kirastream:PASSWORD@localhost/kirastream
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# JWT — generar con: openssl rand -hex 32
|
||||
SECRET_KEY=CAMBIAR_ESTO
|
||||
|
||||
# Panel admin
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=CAMBIAR_ESTO
|
||||
|
||||
# Stream (opcionales)
|
||||
STREAM_CHUNK_SIZE=65536 # bytes por chunk (64 KB)
|
||||
STREAM_QUEUE_MAXSIZE=512 # chunks en queue por cliente
|
||||
STREAM_CONNECT_TIMEOUT=10 # segundos
|
||||
STREAM_READ_TIMEOUT=30 # segundos
|
||||
|
||||
# EPG
|
||||
EPG_REFRESH_HOURS=12
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=INFO # DEBUG / INFO / WARNING / ERROR
|
||||
```
|
||||
|
||||
### Dependencias Python (`pip freeze` actual)
|
||||
|
||||
```
|
||||
aiofiles==24.1.0
|
||||
aiohttp==3.12.4
|
||||
alembic==1.16.1
|
||||
asyncpg==0.30.0
|
||||
bcrypt==4.3.0
|
||||
fastapi==0.115.12
|
||||
hiredis==3.3.1
|
||||
httpx==0.28.1
|
||||
lxml==5.4.0
|
||||
passlib==1.7.4
|
||||
pydantic==2.13.4
|
||||
pydantic-settings==2.9.1
|
||||
PyJWT==2.9.0
|
||||
python-jose==3.3.0
|
||||
python-multipart==0.0.20
|
||||
redis==5.3.0
|
||||
SQLAlchemy==2.0.41
|
||||
structlog==25.3.0
|
||||
tenacity==9.1.2
|
||||
uvicorn==0.34.3
|
||||
uvloop==0.22.1
|
||||
websockets==15.0.1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Reinstalación desde cero
|
||||
|
||||
### Paso 1 — Preparar el sistema
|
||||
|
||||
```bash
|
||||
# En el host Proxmox, acceder al contenedor
|
||||
pct exec 117 -- bash
|
||||
|
||||
# Instalar dependencias del sistema
|
||||
apt update && apt install -y \
|
||||
postgresql-17 \
|
||||
redis-server \
|
||||
nginx \
|
||||
ffmpeg \
|
||||
python3.13 python3.13-venv \
|
||||
nodejs npm \
|
||||
git curl
|
||||
|
||||
# Verificar ffmpeg
|
||||
ffmpeg -version | head -1
|
||||
```
|
||||
|
||||
### Paso 2 — Clonar repositorio
|
||||
|
||||
```bash
|
||||
git clone https://git.kiracloud.es/joaquin/KiraTV.git /opt/kirastream
|
||||
cd /opt/kirastream
|
||||
```
|
||||
|
||||
### Paso 3 — Base de datos PostgreSQL
|
||||
|
||||
```bash
|
||||
# Crear usuario y base de datos
|
||||
sudo -u postgres psql <<EOF
|
||||
CREATE USER kirastream WITH PASSWORD 'ELEGIR_PASSWORD';
|
||||
CREATE DATABASE kirastream OWNER kirastream;
|
||||
GRANT ALL PRIVILEGES ON DATABASE kirastream TO kirastream;
|
||||
EOF
|
||||
```
|
||||
|
||||
### Paso 4 — Entorno Python
|
||||
|
||||
```bash
|
||||
cd /opt/kirastream
|
||||
python3.13 -m venv venv
|
||||
venv/bin/pip install --upgrade pip
|
||||
|
||||
# Instalar dependencias (usar el requirements.txt del repo o la lista del apartado 10)
|
||||
venv/bin/pip install \
|
||||
fastapi uvicorn[standard] uvloop \
|
||||
sqlalchemy[asyncio] asyncpg alembic \
|
||||
pydantic-settings \
|
||||
redis hiredis \
|
||||
aiohttp aiofiles httpx \
|
||||
passlib[bcrypt] python-jose[cryptography] python-multipart PyJWT \
|
||||
lxml structlog tenacity
|
||||
```
|
||||
|
||||
### Paso 5 — Variables de entorno
|
||||
|
||||
```bash
|
||||
cat > /opt/kirastream/.env <<EOF
|
||||
DATABASE_URL=postgresql+asyncpg://kirastream:ELEGIR_PASSWORD@localhost/kirastream
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
SECRET_KEY=$(openssl rand -hex 32)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=ELEGIR_PASSWORD
|
||||
EOF
|
||||
```
|
||||
|
||||
### Paso 6 — Inicializar base de datos
|
||||
|
||||
```bash
|
||||
cd /opt/kirastream
|
||||
# La función init_db() en database.py crea todas las tablas automáticamente
|
||||
# al primer arranque. También puede ejecutarse manualmente:
|
||||
venv/bin/python -c "
|
||||
import asyncio
|
||||
from backend.database import init_db
|
||||
asyncio.run(init_db())
|
||||
"
|
||||
```
|
||||
|
||||
### Paso 7 — Compilar frontend
|
||||
|
||||
```bash
|
||||
cd /opt/kirastream/frontend
|
||||
npm install
|
||||
npm run build
|
||||
# Los archivos compilados van a /opt/kirastream/backend/static/
|
||||
```
|
||||
|
||||
### Paso 8 — Nginx
|
||||
|
||||
```bash
|
||||
# Copiar configuración
|
||||
cp /opt/kirastream/nginx/kirastream.conf /etc/nginx/sites-available/kirastream
|
||||
ln -sf /etc/nginx/sites-available/kirastream /etc/nginx/sites-enabled/kirastream
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
|
||||
nginx -t && systemctl reload nginx
|
||||
```
|
||||
|
||||
### Paso 9 — Systemd
|
||||
|
||||
```bash
|
||||
cp /opt/kirastream/kirastream.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable kirastream
|
||||
systemctl start kirastream
|
||||
|
||||
# Verificar
|
||||
systemctl status kirastream
|
||||
tail -f /opt/kirastream/logs/backend.log
|
||||
```
|
||||
|
||||
### Paso 10 — Verificación
|
||||
|
||||
```bash
|
||||
# API health
|
||||
curl http://localhost:8000/health
|
||||
|
||||
# Panel admin
|
||||
# Abrir en navegador: http://10.10.10.234/panel/
|
||||
|
||||
# Logs en vivo
|
||||
tail -f /opt/kirastream/logs/backend.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Operaciones habituales
|
||||
|
||||
### Actualizar desde git
|
||||
|
||||
```bash
|
||||
# En el host Proxmox
|
||||
cd /tmp/kirastream
|
||||
git pull origin main
|
||||
|
||||
# Push de archivos modificados al contenedor
|
||||
pct push 117 backend/core/restream.py /opt/kirastream/backend/core/restream.py
|
||||
pct push 117 backend/core/pool.py /opt/kirastream/backend/core/pool.py
|
||||
# etc.
|
||||
|
||||
# Si hay cambios en el frontend
|
||||
pct exec 117 -- bash -c "cd /opt/kirastream/frontend && npm run build"
|
||||
|
||||
# Reiniciar backend
|
||||
pct exec 117 -- systemctl restart kirastream
|
||||
```
|
||||
|
||||
### Ver logs
|
||||
|
||||
```bash
|
||||
# Logs en vivo
|
||||
pct exec 117 -- tail -f /opt/kirastream/logs/backend.log
|
||||
|
||||
# Últimas 100 líneas
|
||||
pct exec 117 -- tail -100 /opt/kirastream/logs/backend.log
|
||||
|
||||
# Solo errores
|
||||
pct exec 117 -- grep ERROR /opt/kirastream/logs/backend.log | tail -20
|
||||
|
||||
# Logs de ffmpeg de un canal concreto
|
||||
pct exec 117 -- grep "ch310412" /opt/kirastream/logs/backend.log | tail -30
|
||||
```
|
||||
|
||||
### Redis — inspección de estado
|
||||
|
||||
```bash
|
||||
pct exec 117 -- redis-cli
|
||||
|
||||
# Streams activos
|
||||
KEYS ks:stream:*
|
||||
HGETALL ks:stream:310412
|
||||
|
||||
# Slots ocupados
|
||||
KEYS ks:slot:*
|
||||
|
||||
# Conexiones de un usuario
|
||||
SMEMBERS ks:user_conns:2
|
||||
```
|
||||
|
||||
### PostgreSQL — consultas útiles
|
||||
|
||||
```bash
|
||||
pct exec 117 -- sudo -u postgres psql kirastream
|
||||
|
||||
-- Usuarios activos
|
||||
SELECT username, max_connections, expiry_date FROM users WHERE is_active;
|
||||
|
||||
-- Canales por categoría
|
||||
SELECT c.name, count(*) FROM channels ch
|
||||
JOIN categories c ON ch.category_id = c.id GROUP BY c.name ORDER BY count DESC;
|
||||
|
||||
-- Salud de dominios
|
||||
SELECT pa.name, pu.url, pu.status, pu.response_ms
|
||||
FROM provider_urls pu JOIN provider_accounts pa ON pa.id = pu.provider_account_id
|
||||
ORDER BY pa.name, pu.priority;
|
||||
```
|
||||
|
||||
### Reinicio limpio
|
||||
|
||||
```bash
|
||||
# Para todos los streams activos y reinicia
|
||||
pct exec 117 -- systemctl restart kirastream
|
||||
|
||||
# Si Redis tiene estado corrupto
|
||||
pct exec 117 -- redis-cli FLUSHDB
|
||||
pct exec 117 -- systemctl restart kirastream
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Decisiones de diseño y lecciones aprendidas
|
||||
|
||||
### Por qué una sola instancia uvicorn (workers=1)
|
||||
|
||||
El `ProviderPool` y los `BroadcastGroup` son objetos en memoria con `asyncio.Lock`. Si hubiera múltiples workers, cada proceso tendría su propio pool y se perdería la coordinación de fan-out (dos workers podrían abrir dos conexiones al proveedor para el mismo canal). El estado de coordinación está en Redis para soportar múltiples procesos en el futuro, pero la implementación actual usa un solo worker con asyncio concurrente, que es suficiente para cientos de streams simultáneos dado que la operación es mayoritariamente I/O-bound.
|
||||
|
||||
### Por qué ffmpeg en lugar de aiohttp directo
|
||||
|
||||
Los proveedores Xtream usan redirecciones 302 con tokens de sesión de corta duración (60-120s para HD, ~28s para algunos streams 4K). Con aiohttp puro, al expirar el token hay que reconectar a nivel Python interrumpiendo el stream. ffmpeg con `-reconnect_streamed 1` renegocia el token internamente sin interrumpir el pipe de stdout, haciendo la transición invisible para el cliente.
|
||||
|
||||
### Tokens 302 y freezes en 4K
|
||||
|
||||
Algunos canales 4K tienen tokens que expiran cada ~28 segundos. ffmpeg los renegocia en <1 segundo típicamente. El player IPTV debe tener suficiente buffer para cubrir esa brecha (TiviMate por defecto tiene 3-8s). Si el freeze persiste, revisar la velocidad de red entre KiraStream y el proveedor.
|
||||
|
||||
### Flags ffmpeg que NO funcionan para live IPTV
|
||||
|
||||
| Flag | Problema |
|
||||
|------|---------|
|
||||
| `-use_wallclock_as_timestamps 1` | Genera una advertencia de stderr por cada paquete con jitter de red (cientos por minuto), atascando ffmpeg y produciendo freezes |
|
||||
| `-fflags +discardcorrupt` | Descarta el IDR keyframe del punto de reconexión; el decoder HEVC espera hasta el siguiente IDR (hasta 2s) → salto visible al live edge |
|
||||
| `-avoid_negative_ts make_zero` | Acumula offsets incorrectos en reconexiones con EAC3, dejando A/V desincronizado ~350ms |
|
||||
|
||||
### Umbrales del monitor de salud
|
||||
|
||||
Los proveedores renuevan tokens y la reconexión de ffmpeg tarda 1-4 segundos. Con umbrales demasiado bajos (warning >5s) el dashboard marca "aviso" en cada reconexión normal. Los umbrales correctos son:
|
||||
- `warning`: sin datos >12s
|
||||
- `error` + auto-corrección: sin datos >25s
|
||||
|
||||
### Zapping vs multi-dispositivo
|
||||
|
||||
La lógica de `_check_user_connections` distingue entre cambiar de canal rápido ("zapping": conexión existente <10s de antigüedad → evict silencioso) y uso multi-dispositivo genuino (conexión >10s → rechaza con error para mostrar "stream bloqueado" en el nuevo dispositivo).
|
||||
|
||||
### Fan-out y límites de memoria
|
||||
|
||||
Con `STREAM_QUEUE_MAXSIZE=512` y `STREAM_CHUNK_SIZE=65536` bytes, cada cliente tiene un buffer de servidor de hasta 32 MB. Para 25 Mbps (4K) eso es ~10 segundos de buffer máximo. En operación normal el buffer está casi vacío (el cliente consume al ritmo del proveedor). Si `buffer_pct` supera 70% de forma sostenida, el cliente tiene problemas de red o el dispositivo es lento.
|
||||
Reference in New Issue
Block a user