f965ccafa8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
497 lines
19 KiB
Markdown
497 lines
19 KiB
Markdown
# KiraStream
|
||
|
||
Servidor de gestión de líneas IPTV compatible con el protocolo **Xtream Codes**. Permite administrar proveedores, usuarios, catálogos y monitorización en tiempo real desde un panel web, sirviendo los streams a cualquier reproductor IPTV estándar.
|
||
|
||
---
|
||
|
||
## Índice
|
||
|
||
- [Características](#características)
|
||
- [Arquitectura](#arquitectura)
|
||
- [Requisitos](#requisitos)
|
||
- [Instalación](#instalación)
|
||
- [Configuración](#configuración)
|
||
- [Estructura del proyecto](#estructura-del-proyecto)
|
||
- [API](#api)
|
||
- [Panel de administración](#panel-de-administración)
|
||
- [Gestión del servicio](#gestión-del-servicio)
|
||
- [Registro y monitorización](#registro-y-monitorización)
|
||
- [Notas técnicas de estabilidad](#notas-técnicas-de-estabilidad)
|
||
|
||
---
|
||
|
||
## Características
|
||
|
||
- **Proxy de streams en tiempo real** — reencaminamiento de canales en directo mediante ffmpeg en modo copy (sin recodificación). Fan-out a múltiples clientes sobre una única conexión upstream.
|
||
- **API compatible con Xtream Codes** — funciona con IPTV Smarters, TiviMate, Perfect Player, VLC, Kodi y cualquier reproductor que soporte el protocolo Xtream.
|
||
- **Gestión de proveedores** — múltiples cuentas, múltiples dominios/CDN por cuenta con failover automático, sincronización de catálogos y health check cada 5 minutos.
|
||
- **Gestión de usuarios** — usuarios independientes con límite de conexiones simultáneas, prioridad, fecha de expiración y catálogos personalizados.
|
||
- **EPG integrado** — fusión de múltiples fuentes XMLTV, filtrado por canal y servicio en `/xmltv.php`.
|
||
- **VOD y Series** — proxy transparente para películas y series con soporte de `Range` (seek).
|
||
- **Integración Jellyfin** — biblioteca local de películas y series desde un servidor Jellyfin.
|
||
- **Monitorización en tiempo real** — WebSocket con métricas de streams, clientes, CPU de ffmpeg, salud A/V, buffer y reconexiones.
|
||
- **Registro de fallos** — eventos de caída y recuperación de streams persistidos en base de datos y visibles en el panel.
|
||
- **Reconexión transparente** — al caer un proveedor el buffer de cola del servidor absorbe el tiempo de reconexión; los clientes no ven pantalla negra.
|
||
|
||
---
|
||
|
||
## Arquitectura
|
||
|
||
```
|
||
Cliente IPTV (TV/móvil/PC)
|
||
│ HTTP TS stream
|
||
▼
|
||
Nginx :80
|
||
│ proxy_pass
|
||
▼
|
||
FastAPI / Uvicorn :8000
|
||
│
|
||
├── ProviderPool (pool.py)
|
||
│ └── BroadcastGroup × canal activo
|
||
│ └── ffmpeg (copy mode) ← proveedor IPTV
|
||
│
|
||
├── VodTracker (vod_tracker.py) ← películas / series
|
||
├── EPGManager (epg_manager.py) ← XMLTV fusion
|
||
└── HealthChecker ← ping proveedores c/5min
|
||
|
||
PostgreSQL 17 ←→ SQLAlchemy async (asyncpg)
|
||
Redis ←→ pub/sub monitorización + slots de proveedor
|
||
```
|
||
|
||
### Flujo de un canal en directo
|
||
|
||
1. El reproductor solicita `GET /{user}/{pass}/{stream_id}`.
|
||
2. `ProviderPool.acquire()` busca o crea un `BroadcastGroup` para ese canal.
|
||
3. El grupo lanza un proceso ffmpeg que lee del proveedor y escribe MPEG-TS en stdout.
|
||
4. Cada chunk (64 KB) se despacha a las colas asyncio de todos los clientes conectados.
|
||
5. El `StreamingResponse` de FastAPI lee de la cola y lo envía al reproductor via HTTP chunked.
|
||
6. Cuando el proveedor cierra la sesión (~2 min), ffmpeg muere limpiamente y el loop de Python lo reinicia con una conexión fresca. El buffer de cola cubre el tiempo de reconexión.
|
||
|
||
---
|
||
|
||
## Requisitos
|
||
|
||
| Componente | Versión mínima |
|
||
|---|---|
|
||
| Python | 3.11+ (probado en 3.13) |
|
||
| PostgreSQL | 14+ (probado en 17) |
|
||
| Redis | 6+ |
|
||
| Nginx | 1.18+ |
|
||
| ffmpeg | 5+ (probado en 7.1) |
|
||
| Node.js | 18+ (solo para compilar el frontend) |
|
||
|
||
---
|
||
|
||
## Instalación
|
||
|
||
### 1. Dependencias del sistema
|
||
|
||
```bash
|
||
apt install -y python3 python3-venv python3-pip \
|
||
postgresql redis-server nginx ffmpeg \
|
||
nodejs npm
|
||
```
|
||
|
||
### 2. Clonar el repositorio
|
||
|
||
```bash
|
||
git clone https://git.kiracloud.es/joaquin/KiraTV.git /opt/kirastream
|
||
cd /opt/kirastream
|
||
```
|
||
|
||
### 3. Entorno Python
|
||
|
||
```bash
|
||
python3 -m venv venv
|
||
source venv/bin/activate
|
||
pip install fastapi uvicorn[standard] sqlalchemy[asyncio] asyncpg \
|
||
aiohttp redis pydantic-settings alembic python-jose \
|
||
passlib python-multipart
|
||
```
|
||
|
||
### 4. Base de datos
|
||
|
||
```bash
|
||
# Crear usuario y base de datos en PostgreSQL
|
||
su - postgres -c "createuser kirastream"
|
||
su - postgres -c "createdb -O kirastream kirastream"
|
||
su - postgres -c "psql -c \"ALTER USER kirastream PASSWORD 'kirastream_secret_2024';\""
|
||
```
|
||
|
||
Las tablas se crean automáticamente al arrancar la aplicación (`Base.metadata.create_all`).
|
||
|
||
### 5. Variables de entorno
|
||
|
||
Crear `/opt/kirastream/.env`:
|
||
|
||
```env
|
||
DATABASE_URL=postgresql+asyncpg://kirastream:kirastream_secret_2024@localhost/kirastream
|
||
REDIS_URL=redis://localhost:6379/0
|
||
SECRET_KEY=cambia-esto-por-una-clave-segura
|
||
ADMIN_USERNAME=admin
|
||
ADMIN_PASSWORD=cambia-esto
|
||
LOG_LEVEL=INFO
|
||
```
|
||
|
||
### 6. Compilar el frontend
|
||
|
||
```bash
|
||
cd /opt/kirastream/frontend
|
||
npm install
|
||
npm run build
|
||
# Los archivos estáticos se generan en backend/static/
|
||
```
|
||
|
||
### 7. Nginx
|
||
|
||
Copiar la configuración al directorio de Nginx:
|
||
|
||
```bash
|
||
cp /etc/nginx/sites-available/default /etc/nginx/sites-available/default.bak
|
||
```
|
||
|
||
Contenido de `/etc/nginx/sites-enabled/default`:
|
||
|
||
```nginx
|
||
upstream kirastream_backend {
|
||
server 127.0.0.1:8000;
|
||
keepalive 32;
|
||
}
|
||
|
||
server {
|
||
listen 80 default_server;
|
||
server_name _;
|
||
|
||
client_max_body_size 20m;
|
||
proxy_read_timeout 3600s;
|
||
proxy_send_timeout 3600s;
|
||
proxy_connect_timeout 10s;
|
||
|
||
location /panel/ {
|
||
alias /opt/kirastream/backend/static/;
|
||
try_files $uri $uri/ /panel/index.html;
|
||
add_header Cache-Control no-cache;
|
||
}
|
||
|
||
location = / { return 302 /panel/; }
|
||
|
||
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 X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_set_header Upgrade $http_upgrade;
|
||
proxy_set_header Connection upgrade;
|
||
proxy_buffering off;
|
||
proxy_cache off;
|
||
add_header X-Accel-Buffering no always;
|
||
}
|
||
|
||
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 application/x-mpegurl;
|
||
}
|
||
```
|
||
|
||
```bash
|
||
nginx -t && systemctl reload nginx
|
||
```
|
||
|
||
### 8. Servicio systemd
|
||
|
||
Crear `/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 || (LANG=C LC_ALL=C 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
|
||
```
|
||
|
||
```bash
|
||
mkdir -p /opt/kirastream/logs
|
||
systemctl daemon-reload
|
||
systemctl enable --now kirastream
|
||
```
|
||
|
||
---
|
||
|
||
## Configuración
|
||
|
||
Todas las variables se leen de `/opt/kirastream/.env` (o variables de entorno del sistema):
|
||
|
||
| Variable | Por defecto | Descripción |
|
||
|---|---|---|
|
||
| `DATABASE_URL` | `postgresql+asyncpg://kirastream:...@localhost/kirastream` | URL de conexión a PostgreSQL |
|
||
| `REDIS_URL` | `redis://localhost:6379/0` | URL de Redis |
|
||
| `SECRET_KEY` | *(cambiar)* | Clave para firmar tokens JWT |
|
||
| `ALGORITHM` | `HS256` | Algoritmo JWT |
|
||
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `10080` (7 días) | TTL de tokens de admin |
|
||
| `ADMIN_USERNAME` | `admin` | Usuario del panel de administración |
|
||
| `ADMIN_PASSWORD` | `kiraadmin` | Contraseña del panel (**cambiar en producción**) |
|
||
| `STREAM_CHUNK_SIZE` | `65536` | Tamaño de chunk en bytes (64 KB) |
|
||
| `STREAM_QUEUE_MAXSIZE` | `512` | Chunks máximos en cola por cliente (≈ 32 MB) |
|
||
| `STREAM_CONNECT_TIMEOUT` | `10` | Timeout de conexión ffmpeg (segundos) |
|
||
| `STREAM_READ_TIMEOUT` | `30` | Timeout de lectura (segundos) |
|
||
| `EPG_REFRESH_HOURS` | `12` | Intervalo de actualización de EPG |
|
||
| `M3U_CACHE_MINUTES` | `30` | Caché de playlist M3U |
|
||
| `LOG_LEVEL` | `INFO` | Nivel de log (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
|
||
|
||
---
|
||
|
||
## Estructura del proyecto
|
||
|
||
```
|
||
/opt/kirastream/
|
||
├── backend/
|
||
│ ├── main.py # Punto de entrada FastAPI, lifespan, routers
|
||
│ ├── config.py # Settings (pydantic-settings + .env)
|
||
│ ├── database.py # Engine async, Base, init_db()
|
||
│ ├── api/
|
||
│ │ ├── auth.py # Login JWT para el panel admin
|
||
│ │ ├── xtream.py # API pública compatible Xtream Codes
|
||
│ │ └── admin/
|
||
│ │ ├── monitoring.py # WebSocket tiempo real + endpoints slots/streams
|
||
│ │ ├── providers.py # CRUD proveedores, sync catálogos, health check
|
||
│ │ ├── users.py # CRUD usuarios
|
||
│ │ ├── catalogs.py # Catálogos y categorías
|
||
│ │ ├── custom_categories.py # Categorías personalizadas
|
||
│ │ ├── logs.py # Registro de conexiones y fallos de stream
|
||
│ │ ├── epg_admin.py # Gestión EPG
|
||
│ │ ├── jellyfin_admin.py # Integración Jellyfin
|
||
│ │ └── settings.py # Configuración del servidor
|
||
│ ├── core/
|
||
│ │ ├── restream.py # BroadcastGroup: ffmpeg pump + fan-out a clientes
|
||
│ │ ├── pool.py # ProviderPool: slots, adquisición y liberación
|
||
│ │ ├── health_checker.py # Ping periódico a URLs de proveedor
|
||
│ │ ├── epg_manager.py # Descarga y fusión de fuentes XMLTV
|
||
│ │ ├── catalog.py # Consultas de catálogo por usuario
|
||
│ │ ├── probe.py # ffprobe para info de stream (codec, resolución)
|
||
│ │ ├── vod_tracker.py # Seguimiento de sesiones VOD/series
|
||
│ │ ├── xtream_client.py # Cliente HTTP para API Xtream de proveedores
|
||
│ │ └── jellyfin_client.py # Cliente HTTP para Jellyfin
|
||
│ ├── models/
|
||
│ │ ├── provider.py # ProviderAccount, ProviderUrl
|
||
│ │ ├── channel.py # Channel (live/movie/series)
|
||
│ │ ├── user.py # User
|
||
│ │ ├── log.py # ConnectionLog
|
||
│ │ ├── stream_event.py # StreamEvent (fallos y recuperaciones)
|
||
│ │ ├── epg.py # EPGSource
|
||
│ │ ├── custom_category.py # CustomCategory, CustomCategoryItem
|
||
│ │ ├── notification.py # Notification
|
||
│ │ └── jellyfin.py # JellyfinConfig, JellyfinItem
|
||
│ └── static/ # Frontend compilado (servido por Nginx)
|
||
├── frontend/
|
||
│ ├── src/
|
||
│ │ ├── pages/
|
||
│ │ │ ├── Dashboard.tsx # Monitorización en tiempo real
|
||
│ │ │ ├── Logs.tsx # Registro de conexiones y fallos
|
||
│ │ │ ├── Providers.tsx # Gestión de proveedores
|
||
│ │ │ ├── Users.tsx # Gestión de usuarios
|
||
│ │ │ ├── Catalogs.tsx # Catálogos
|
||
│ │ │ ├── CustomCategories.tsx
|
||
│ │ │ ├── EPG.tsx
|
||
│ │ │ ├── Jellyfin.tsx
|
||
│ │ │ └── Settings.tsx
|
||
│ │ ├── components/
|
||
│ │ │ └── Layout.tsx # Navegación lateral
|
||
│ │ └── lib/
|
||
│ │ └── api.ts # Cliente axios con autenticación JWT
|
||
│ ├── package.json
|
||
│ └── vite.config.ts
|
||
├── nginx/ # Configuración de Nginx
|
||
├── scripts/
|
||
│ └── install.sh # Script de arranque manual
|
||
├── logs/ # Logs de la aplicación (no versionados)
|
||
├── .env # Variables de entorno (no versionado)
|
||
└── .gitignore
|
||
```
|
||
|
||
---
|
||
|
||
## API
|
||
|
||
### Autenticación del panel
|
||
|
||
```
|
||
POST /api/admin/auth/token
|
||
Content-Type: application/x-www-form-urlencoded
|
||
|
||
username=admin&password=...
|
||
```
|
||
|
||
Devuelve un token JWT que se incluye en las siguientes peticiones como `Authorization: Bearer <token>`.
|
||
|
||
### API Xtream Codes (pública)
|
||
|
||
| Endpoint | Descripción |
|
||
|---|---|
|
||
| `GET /player_api.php?username=&password=&action=` | API principal Xtream |
|
||
| `GET /get.php?username=&password=&type=m3u_plus` | Playlist M3U |
|
||
| `GET /xmltv.php?username=&password=` | EPG en formato XMLTV |
|
||
| `GET /{user}/{pass}/{stream_id}` | Stream en directo |
|
||
| `GET /live/{user}/{pass}/{stream_id}` | Stream en directo (ruta alternativa) |
|
||
| `GET /movie/{user}/{pass}/{stream_id}.mkv` | VOD |
|
||
| `GET /series/{user}/{pass}/{episode_id}.mkv` | Episodio de serie |
|
||
|
||
### API de administración
|
||
|
||
| Endpoint | Descripción |
|
||
|---|---|
|
||
| `GET /api/admin/monitoring/streams` | Lista streams activos |
|
||
| `GET /api/admin/monitoring/slots` | Estado de slots de proveedor |
|
||
| `WS /api/admin/monitoring/ws` | Actualizaciones en tiempo real |
|
||
| `POST /api/admin/monitoring/kill` | Cortar conexión de un cliente |
|
||
| `GET /api/admin/logs` | Registro de conexiones de usuarios |
|
||
| `GET /api/admin/logs/stats` | Estadísticas agregadas |
|
||
| `GET /api/admin/logs/events` | Registro de fallos y recuperaciones de stream |
|
||
| `DELETE /api/admin/logs` | Limpiar registro de conexiones |
|
||
| `DELETE /api/admin/logs/events` | Limpiar registro de fallos |
|
||
| `GET /api/admin/providers` | Lista de proveedores |
|
||
| `POST /api/admin/providers` | Añadir proveedor |
|
||
| `GET /api/admin/users` | Lista de usuarios |
|
||
| `POST /api/admin/users` | Crear usuario |
|
||
| `GET /api/docs` | Documentación interactiva (Swagger UI) |
|
||
|
||
---
|
||
|
||
## Panel de administración
|
||
|
||
Accesible en `http://<servidor>/panel/`
|
||
|
||
### Dashboard
|
||
|
||
Monitorización en tiempo real vía WebSocket. Muestra por cada stream activo:
|
||
|
||
- Canal, proveedor y tipo (Directo / Película / Serie)
|
||
- Resolución, FPS, códecs de vídeo y audio, bitrate
|
||
- Estado de conexión y número de reconexiones
|
||
- Salud A/V y correcciones automáticas
|
||
- **Cola cliente** — porcentaje de cola del cliente más lento (0 % = consumo sin retraso; 100 % = cliente bloqueado)
|
||
- **Reserva** — capacidad del buffer del servidor (32 MB). Muestra `ok` en reposo (normal) y los segundos de cobertura si hay datos en cola
|
||
- Usuarios conectados con opción de cortar conexión individualmente
|
||
- Métricas globales: streams activos, slots ocupados, ancho de banda total, CPU de ffmpeg
|
||
|
||
### Registro
|
||
|
||
Dos pestañas:
|
||
|
||
**Conexiones de usuarios** — historial de todas las sesiones con usuario, IP, canal, tipo, duración y datos transferidos. Filtros por usuario, canal, tipo y rango de fechas.
|
||
|
||
**Fallos de stream** — registro persistente de eventos del sistema: caídas de proveedor y recuperaciones automáticas. Cada evento incluye canal, proveedor, dominio que falló, mensaje de error y número de intento. Se genera automáticamente; no requiere intervención manual.
|
||
|
||
---
|
||
|
||
## Gestión del servicio
|
||
|
||
```bash
|
||
# Estado
|
||
systemctl status kirastream
|
||
|
||
# Reiniciar (necesario tras cambios en el backend)
|
||
systemctl restart kirastream
|
||
|
||
# Ver logs en tiempo real
|
||
tail -f /opt/kirastream/logs/backend.log
|
||
|
||
# Actualizar código y redeployar
|
||
cd /opt/kirastream
|
||
git pull
|
||
cd frontend && npm run build && cd ..
|
||
systemctl restart kirastream
|
||
```
|
||
|
||
---
|
||
|
||
## Registro y monitorización
|
||
|
||
### Logs de la aplicación
|
||
|
||
`/opt/kirastream/logs/backend.log` — log estructurado con nivel, módulo y mensaje. Rotación manual recomendada con `logrotate`.
|
||
|
||
### Eventos de stream
|
||
|
||
Los fallos y recuperaciones de streams se guardan en la tabla `stream_events` de PostgreSQL y son visibles en el panel en **Registro → Fallos de stream**.
|
||
|
||
Tipos de evento:
|
||
|
||
| Tipo | Descripción |
|
||
|---|---|
|
||
| `caida` | El stream falló por primera vez (ffmpeg no pudo conectar o el proveedor cerró la sesión) |
|
||
| `recuperado` | Tras varios intentos fallidos, el stream estuvo estable más de 30 segundos |
|
||
|
||
### Health check de proveedores
|
||
|
||
El checker hace ping a cada URL de proveedor cada 5 minutos llamando a `/player_api.php?action=user_info` y actualiza el estado (`ok` / `error` / `timeout`) y la latencia en milisegundos. El resultado es visible en la sección Proveedores del panel.
|
||
|
||
---
|
||
|
||
## Notas técnicas de estabilidad
|
||
|
||
### Reconexión sin pantalla negra
|
||
|
||
Los proveedores IPTV cierran la sesión cada ~2 minutos como política de gestión de sesiones. Cuando ocurre:
|
||
|
||
1. ffmpeg detecta el cierre de la conexión TCP y termina.
|
||
2. El loop de Python (`_pump_with_retry`) lanza inmediatamente un nuevo proceso ffmpeg con una conexión fresca al proveedor.
|
||
3. **La cola del cliente NO se vacía durante la reconexión.** Los datos en cola (hasta 32 MB) siguen alimentando al reproductor mientras el nuevo ffmpeg arranca.
|
||
4. El reproductor no experimenta pantalla negra si la reconexión es más rápida que el buffer local del reproductor.
|
||
|
||
> **Por qué no se usa `-reconnect_streamed` en ffmpeg:** ese flag hace que ffmpeg reconecte internamente sin morir. El proveedor re-envía desde el último keyframe (2-5 segundos atrás), lo que produce timestamps PTS hacia atrás que los reproductores interpretan como un rebobinado visible. La reconexión a nivel Python, en cambio, produce siempre una conexión limpia desde el punto de emisión actual.
|
||
|
||
### Buffer del servidor
|
||
|
||
La cola de cada `BroadcastGroup` tiene capacidad para 512 chunks de 64 KB = **32 MB por stream**. En operación normal la cola está casi vacía (el reproductor consume los datos a la misma velocidad que llegan); eso es correcto y no indica ausencia de protección. La protección real está en que la cola no se vacía activamente durante reconexiones.
|
||
|
||
### Fan-out eficiente
|
||
|
||
Un único proceso ffmpeg sirve a todos los clientes del mismo canal simultáneamente. Si 10 usuarios ven el mismo canal, solo hay una conexión upstream con el proveedor y un proceso ffmpeg. Los chunks se copian a la cola de cada cliente en el mismo dispatch loop.
|
||
|
||
### Evicción de clientes lentos
|
||
|
||
Si la cola de un cliente está llena durante más de 30 segundos continuados (red muy lenta o reproductor bloqueado), el cliente es desconectado automáticamente para no perjudicar a otros clientes del mismo stream.
|
||
|
||
---
|
||
|
||
## Stack tecnológico
|
||
|
||
| Capa | Tecnología |
|
||
|---|---|
|
||
| Backend | Python 3.13, FastAPI 0.115, Uvicorn 0.34 |
|
||
| ORM | SQLAlchemy 2.x async + asyncpg 0.30 |
|
||
| Base de datos | PostgreSQL 17 |
|
||
| Cache / pub-sub | Redis 6+ (redis-py 5.3) |
|
||
| Proxy HTTP | aiohttp 3.12 |
|
||
| Procesado de stream | ffmpeg 7.1 (copy mode, sin recodificación) |
|
||
| Validación | Pydantic 2.13 |
|
||
| Frontend | React 18, TypeScript, Vite 6, Tailwind CSS |
|
||
| Servidor web | Nginx (proxy inverso + archivos estáticos) |
|
||
| Proceso | systemd |
|