docs4you commited on
Commit
84121fd
·
verified ·
1 Parent(s): 32c0947

Upload 41 files

Browse files
.dockerignore ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ npm-debug.log
3
+ .git
4
+ .gitignore
5
+ README.md
6
+ .env
7
+ .env.local
8
+ .env.development.local
9
+ .env.test.local
10
+ .env.production.local
11
+ dist
12
+ build
13
+ coverage
14
+ .nyc_output
15
+ .cache
16
+ .parcel-cache
17
+ .DS_Store
18
+ *.log
19
+ logs
20
+ *.pid
21
+ *.seed
22
+ *.pid.lock
23
+ .npm
24
+ .eslintcache
25
+ .node_repl_history
26
+ .yarn-integrity
27
+ .yarn-error.log
28
+ __pycache__
29
+ *.pyc
30
+ *.pyo
31
+ *.pyd
32
+ .Python
33
+ .pytest_cache
34
+ .coverage
35
+ htmlcov
36
+ .tox
37
+ .venv
38
+ venv
39
+ ENV
40
+ env
41
+ .env
42
+ pip-log.txt
43
+ pip-delete-this-directory.txt
44
+ .git
45
+ .gitignore
46
+ Dockerfile
47
+ docker-compose.yml
.env.example ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # URL de la playlist M3U principal
2
+ MAIN_M3U_URL=https://example.com/playlist.m3u8
3
+
4
+ # Código de administrador (en hexadecimal)
5
+ ADMIN_CODE=admin123hex
6
+
7
+ # Secreto para cifrado AES (Base32)
8
+ AES_SECRET=your-secret-key-here
9
+
10
+ # URL base de la aplicación
11
+ BASE_URL=https://your-app.vercel.app
12
+
13
+ # Puerto para desarrollo local
14
+ PORT=8000
.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ node_modules/
2
+ .env
Dockerfile ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:18-alpine AS frontend-builder
2
+
3
+ # Instalar dependencias del frontend
4
+ WORKDIR /app
5
+ COPY package*.json ./
6
+
7
+ # Instalar TODAS las dependencias (incluyendo devDependencies para el build)
8
+ RUN npm ci
9
+
10
+ # Copiar código fuente y construir
11
+ COPY . .
12
+ RUN npm run build
13
+
14
+ # Imagen de Python para el backend
15
+ FROM python:3.11-slim
16
+
17
+ # Variables de entorno
18
+ ENV PYTHONPATH=/app/api
19
+ ENV PYTHONUNBUFFERED=1
20
+ ENV PORT=8000
21
+
22
+ # Instalar dependencias del sistema
23
+ RUN apt-get update && apt-get install -y \
24
+ curl \
25
+ && rm -rf /var/lib/apt/lists/*
26
+
27
+ # Crear directorio de trabajo
28
+ WORKDIR /app
29
+
30
+ # Copiar requirements y instalar dependencias Python
31
+ COPY api/requirements.txt ./api/
32
+ RUN pip install --no-cache-dir -r api/requirements.txt
33
+
34
+ # Copiar código del backend
35
+ COPY api/ ./api/
36
+
37
+ # Copiar archivos construidos del frontend
38
+ COPY --from=frontend-builder /app/dist ./static
39
+
40
+ # Crear usuario no-root
41
+ RUN adduser --disabled-password --gecos '' appuser && \
42
+ chown -R appuser:appuser /app
43
+ USER appuser
44
+
45
+ # Exponer puerto
46
+ EXPOSE 8000
47
+
48
+ # Health check
49
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
50
+ CMD curl -f http://localhost:8000/api/health || exit 1
51
+
52
+ # Comando de inicio
53
+ CMD ["python", "api/main.py"]
README.md CHANGED
@@ -1,10 +1,190 @@
1
- ---
2
- title: Abcd
3
- emoji: 🐠
4
- colorFrom: pink
5
- colorTo: yellow
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DaddyTV - IPTV PWA
2
+
3
+ Aplicación web PWA para reproducir listas IPTV (.m3u/.m3u8) con interfaz amigable y enfoque personal para amigos y familiares.
4
+
5
+ ## 🚀 Características
6
+
7
+ - **PWA Instalable**: Funciona como app nativa en PC, móvil y TV
8
+ - **Autenticación por código**: Sin registro, solo validación de código único
9
+ - **Control de reproducción única**: Un canal por usuario simultáneamente
10
+ - **Navegación con mando**: Compatible con controles remotos de TV
11
+ - **Proxy integrado**: Bypass para canales con restricciones CORS/geo
12
+ - **Sin base de datos**: Solo caché en memoria con TTL
13
+ - **Responsive**: Optimizado para todos los dispositivos
14
+
15
+ ## 🛠️ Tecnologías
16
+
17
+ ### Backend
18
+ - **FastAPI** (Python) - API REST modular
19
+ - **Requests** - Cliente HTTP para M3U y proxy
20
+ - **Cryptography** - Cifrado AES para códigos
21
+ - **Uvicorn** - Servidor ASGI
22
+
23
+ ### Frontend
24
+ - **React 18** + **TypeScript** - Interfaz de usuario
25
+ - **Vite** - Build tool y dev server
26
+ - **Tailwind CSS** - Estilos y diseño responsive
27
+ - **HLS.js** - Reproductor de video streaming
28
+ - **Lucide React** - Iconos
29
+
30
+ ## 📦 Instalación y Desarrollo
31
+
32
+ ### Prerrequisitos
33
+ - Node.js 18+
34
+ - Python 3.9+
35
+
36
+ ### Configuración
37
+
38
+ 1. **Clonar el repositorio**
39
+ ```bash
40
+ git clone <repo-url>
41
+ cd daddytv
42
+ ```
43
+
44
+ 2. **Configurar variables de entorno**
45
+ ```bash
46
+ cp .env.example .env
47
+ ```
48
+
49
+ Editar `.env` con tus valores:
50
+ ```env
51
+ MAIN_M3U_URL=https://tu-playlist.m3u8
52
+ ADMIN_CODE=tu-codigo-admin-hex
53
+ AES_SECRET=tu-secreto-aes
54
+ BASE_URL=https://tu-dominio.com
55
+ ```
56
+
57
+ 3. **Instalar dependencias del frontend**
58
+ ```bash
59
+ npm install
60
+ ```
61
+
62
+ 4. **Instalar dependencias del backend**
63
+ ```bash
64
+ cd api
65
+ pip install -r requirements.txt
66
+ ```
67
+
68
+ ### Desarrollo Local
69
+
70
+ 1. **Iniciar backend** (terminal 1):
71
+ ```bash
72
+ cd api
73
+ uvicorn main:app --reload --port 8000
74
+ ```
75
+
76
+ 2. **Iniciar frontend** (terminal 2):
77
+ ```bash
78
+ npm run dev
79
+ ```
80
+
81
+ La aplicación estará disponible en `http://localhost:5173`
82
+
83
+ ## 🚀 Despliegue
84
+
85
+ ### Vercel (Recomendado)
86
+
87
+ 1. **Conectar repositorio a Vercel**
88
+ 2. **Configurar variables de entorno** en el dashboard de Vercel
89
+ 3. **Deploy automático** - Vercel detecta la configuración automáticamente
90
+
91
+ ### Render
92
+
93
+ **Backend (Servicio Web):**
94
+ - Build Command: `pip install -r requirements.txt`
95
+ - Start Command: `uvicorn main:app --host 0.0.0.0 --port $PORT`
96
+ - Root Directory: `api`
97
+
98
+ **Frontend (Sitio Estático):**
99
+ - Build Command: `npm run build`
100
+ - Publish Directory: `dist`
101
+
102
+ ## 📁 Estructura del Proyecto
103
+
104
+ ```
105
+ daddytv/
106
+ ├── api/ # Backend FastAPI
107
+ │ ├── main.py # Punto de entrada
108
+ │ ├── auth.py # Autenticación
109
+ │ ├── m3u_parser.py # Parser de M3U
110
+ │ ├── proxy.py # Proxy para streams
111
+ │ ├── viewers.py # Control de visualización
112
+ │ ├── admin.py # Panel de administración
113
+ │ ├── utils.py # Utilidades
114
+ │ └── requirements.txt # Dependencias Python
115
+ ├── src/ # Frontend React
116
+ │ ├── components/ # Componentes UI
117
+ │ ├── contexts/ # Context providers
118
+ │ ├── hooks/ # Custom hooks
119
+ │ ├── services/ # Servicios API
120
+ │ ├── types/ # Tipos TypeScript
121
+ │ └── main.tsx # Punto de entrada
122
+ ├── public/ # Archivos estáticos
123
+ ├── vercel.json # Configuración Vercel
124
+ └── package.json # Dependencias Node.js
125
+ ```
126
+
127
+ ## 🔧 API Endpoints
128
+
129
+ ### Autenticación
130
+ - `POST /api/auth` - Validar código de acceso
131
+ - `POST /api/logout` - Cerrar sesión
132
+ - `POST /api/ping` - Verificar autenticación
133
+
134
+ ### Canales
135
+ - `GET /api/channels` - Obtener lista de canales
136
+ - `GET /api/playlist.m3u` - Descargar playlist M3U
137
+
138
+ ### Streaming
139
+ - `GET /api/proxy?url=<stream_url>` - Proxy para streams
140
+ - `POST /api/viewers` - Registrar visualización
141
+ - `GET /api/viewers/{channel_id}` - Contador de viewers
142
+
143
+ ### Administración
144
+ - `POST /api/admin/update` - Actualizar configuración
145
+ - `GET /api/admin/status` - Estado del sistema
146
+
147
+ ## 🎮 Controles
148
+
149
+ ### Teclado/Mando
150
+ - **↑/↓** - Navegar lista de canales
151
+ - **Enter** - Seleccionar canal
152
+ - **Escape** - Volver/Cerrar
153
+ - **Backspace** - Retroceder
154
+
155
+ ### Ratón/Táctil
156
+ - **Click/Tap** - Seleccionar elementos
157
+ - **Scroll** - Navegar listas
158
+ - **Hover** - Mostrar controles de video
159
+
160
+ ## 🔒 Seguridad
161
+
162
+ - **Códigos únicos**: Validación sin almacenamiento
163
+ - **Tokens temporales**: Sesiones en memoria
164
+ - **Rate limiting**: Protección contra abuso
165
+ - **Proxy seguro**: Headers de seguridad incluidos
166
+ - **CORS configurado**: Acceso controlado
167
+
168
+ ## 📱 PWA Features
169
+
170
+ - **Instalable**: Funciona como app nativa
171
+ - **Offline ready**: Caché de recursos estáticos
172
+ - **Responsive**: Optimizado para todos los tamaños
173
+ - **Manifest**: Configuración completa de PWA
174
+ - **Service Worker**: Gestión de caché automática
175
+
176
+ ## 🤝 Contribuir
177
+
178
+ 1. Fork el proyecto
179
+ 2. Crear rama feature (`git checkout -b feature/nueva-funcionalidad`)
180
+ 3. Commit cambios (`git commit -am 'Agregar nueva funcionalidad'`)
181
+ 4. Push a la rama (`git push origin feature/nueva-funcionalidad`)
182
+ 5. Crear Pull Request
183
+
184
+ ## 📄 Licencia
185
+
186
+ Este proyecto es de uso personal/familiar. Consulta con el autor para otros usos.
187
+
188
+ ## 🆘 Soporte
189
+
190
+ Para soporte técnico o dudas, contacta al administrador del sistema.
admin-panel.html ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>DaddyTV - Panel de Administración</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script>
9
+ tailwind.config = {
10
+ theme: {
11
+ extend: {
12
+ colors: {
13
+ primary: {
14
+ 500: '#3b82f6',
15
+ 600: '#2563eb',
16
+ 700: '#1d4ed8'
17
+ }
18
+ }
19
+ }
20
+ }
21
+ }
22
+ </script>
23
+ </head>
24
+ <body class="bg-gray-900 text-white min-h-screen">
25
+ <div class="container mx-auto px-4 py-8">
26
+ <div class="max-w-4xl mx-auto">
27
+ <h1 class="text-3xl font-bold mb-8 text-center">🔧 Panel de Administración DaddyTV</h1>
28
+
29
+ <!-- Login -->
30
+ <div id="loginSection" class="bg-gray-800 rounded-lg p-6 mb-6">
31
+ <h2 class="text-xl font-semibold mb-4">Autenticación</h2>
32
+ <div class="flex space-x-4">
33
+ <input
34
+ type="password"
35
+ id="adminCode"
36
+ placeholder="Código de administrador"
37
+ class="flex-1 bg-gray-700 border border-gray-600 rounded px-3 py-2"
38
+ >
39
+ <button
40
+ onclick="authenticate()"
41
+ class="bg-primary-600 hover:bg-primary-700 px-6 py-2 rounded font-medium"
42
+ >
43
+ Conectar
44
+ </button>
45
+ </div>
46
+ <div id="authStatus" class="mt-2 text-sm"></div>
47
+ </div>
48
+
49
+ <!-- Panel Principal -->
50
+ <div id="adminPanel" class="hidden space-y-6">
51
+ <!-- Estado del Sistema -->
52
+ <div class="bg-gray-800 rounded-lg p-6">
53
+ <h2 class="text-xl font-semibold mb-4">📊 Estado del Sistema</h2>
54
+ <div id="systemStatus" class="grid grid-cols-1 md:grid-cols-3 gap-4">
55
+ <!-- Se llenará dinámicamente -->
56
+ </div>
57
+ <button
58
+ onclick="refreshStatus()"
59
+ class="mt-4 bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded"
60
+ >
61
+ 🔄 Actualizar
62
+ </button>
63
+ </div>
64
+
65
+ <!-- Acciones -->
66
+ <div class="bg-gray-800 rounded-lg p-6">
67
+ <h2 class="text-xl font-semibold mb-4">⚡ Acciones</h2>
68
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
69
+ <button
70
+ onclick="clearCache()"
71
+ class="bg-yellow-600 hover:bg-yellow-700 px-4 py-3 rounded font-medium"
72
+ >
73
+ 🗑️ Limpiar Caché
74
+ </button>
75
+ <button
76
+ onclick="downloadLogs()"
77
+ class="bg-green-600 hover:bg-green-700 px-4 py-3 rounded font-medium"
78
+ >
79
+ 📥 Descargar Logs
80
+ </button>
81
+ </div>
82
+ </div>
83
+
84
+ <!-- Configuración -->
85
+ <div class="bg-gray-800 rounded-lg p-6">
86
+ <h2 class="text-xl font-semibold mb-4">⚙️ Configuración</h2>
87
+ <div class="space-y-4">
88
+ <div>
89
+ <label class="block text-sm font-medium mb-2">URL de Playlist M3U</label>
90
+ <input
91
+ type="url"
92
+ id="m3uUrl"
93
+ placeholder="https://ejemplo.com/playlist.m3u8"
94
+ class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2"
95
+ >
96
+ </div>
97
+ <button
98
+ onclick="updatePlaylist()"
99
+ class="bg-primary-600 hover:bg-primary-700 px-4 py-2 rounded font-medium"
100
+ >
101
+ 💾 Actualizar Playlist
102
+ </button>
103
+ </div>
104
+ </div>
105
+ </div>
106
+ </div>
107
+ </div>
108
+
109
+ <script>
110
+ let adminToken = '';
111
+ const API_BASE = '/api';
112
+
113
+ async function authenticate() {
114
+ const code = document.getElementById('adminCode').value;
115
+ const statusDiv = document.getElementById('authStatus');
116
+
117
+ if (!code) {
118
+ statusDiv.innerHTML = '<span class="text-red-400">Por favor ingresa el código</span>';
119
+ return;
120
+ }
121
+
122
+ try {
123
+ const response = await fetch(`${API_BASE}/admin/status`, {
124
+ headers: {
125
+ 'Authorization': `Bearer ${code}`
126
+ }
127
+ });
128
+
129
+ if (response.ok) {
130
+ adminToken = code;
131
+ statusDiv.innerHTML = '<span class="text-green-400">✅ Autenticado correctamente</span>';
132
+ document.getElementById('loginSection').classList.add('hidden');
133
+ document.getElementById('adminPanel').classList.remove('hidden');
134
+ await refreshStatus();
135
+ } else {
136
+ statusDiv.innerHTML = '<span class="text-red-400">❌ Código incorrecto</span>';
137
+ }
138
+ } catch (error) {
139
+ statusDiv.innerHTML = '<span class="text-red-400">❌ Error de conexión</span>';
140
+ }
141
+ }
142
+
143
+ async function refreshStatus() {
144
+ try {
145
+ const response = await fetch(`${API_BASE}/admin/status`, {
146
+ headers: {
147
+ 'Authorization': `Bearer ${adminToken}`
148
+ }
149
+ });
150
+
151
+ if (response.ok) {
152
+ const data = await response.json();
153
+ displaySystemStatus(data);
154
+ }
155
+ } catch (error) {
156
+ console.error('Error refreshing status:', error);
157
+ }
158
+ }
159
+
160
+ function displaySystemStatus(data) {
161
+ const statusDiv = document.getElementById('systemStatus');
162
+ statusDiv.innerHTML = `
163
+ <div class="bg-gray-700 p-4 rounded">
164
+ <h3 class="font-semibold mb-2">📺 Canales</h3>
165
+ <p>Cacheados: ${data.cache_status.m3u_cached ? '✅' : '❌'}</p>
166
+ <p>Total: ${data.cache_status.channels_count}</p>
167
+ <p class="text-xs text-gray-400">Última actualización: ${data.cache_status.cache_timestamp || 'Nunca'}</p>
168
+ </div>
169
+ <div class="bg-gray-700 p-4 rounded">
170
+ <h3 class="font-semibold mb-2">👥 Visualizadores</h3>
171
+ <p>Usuarios activos: ${data.viewers_status.active_users}</p>
172
+ <p>Canales activos: ${data.viewers_status.active_channels}</p>
173
+ <p>Total viewers: ${data.viewers_status.total_viewers}</p>
174
+ </div>
175
+ <div class="bg-gray-700 p-4 rounded">
176
+ <h3 class="font-semibold mb-2">⚙️ Configuración</h3>
177
+ <p>M3U URL: ${data.config.m3u_url_configured ? '✅' : '❌'}</p>
178
+ <p>Admin Code: ${data.config.admin_code_configured ? '✅' : '❌'}</p>
179
+ <p class="text-xs text-gray-400">Base URL: ${data.config.base_url}</p>
180
+ </div>
181
+ `;
182
+ }
183
+
184
+ async function clearCache() {
185
+ try {
186
+ const response = await fetch(`${API_BASE}/admin/update`, {
187
+ method: 'POST',
188
+ headers: {
189
+ 'Content-Type': 'application/json',
190
+ 'Authorization': `Bearer ${adminToken}`
191
+ },
192
+ body: JSON.stringify({
193
+ action: 'clear_cache'
194
+ })
195
+ });
196
+
197
+ if (response.ok) {
198
+ const data = await response.json();
199
+ alert('✅ ' + data.message);
200
+ await refreshStatus();
201
+ } else {
202
+ alert('❌ Error al limpiar caché');
203
+ }
204
+ } catch (error) {
205
+ alert('❌ Error de conexión');
206
+ }
207
+ }
208
+
209
+ async function updatePlaylist() {
210
+ const url = document.getElementById('m3uUrl').value;
211
+ if (!url) {
212
+ alert('Por favor ingresa una URL válida');
213
+ return;
214
+ }
215
+
216
+ try {
217
+ const response = await fetch(`${API_BASE}/admin/update`, {
218
+ method: 'POST',
219
+ headers: {
220
+ 'Content-Type': 'application/json',
221
+ 'Authorization': `Bearer ${adminToken}`
222
+ },
223
+ body: JSON.stringify({
224
+ action: 'update_playlist',
225
+ m3u_url: url
226
+ })
227
+ });
228
+
229
+ if (response.ok) {
230
+ const data = await response.json();
231
+ alert('✅ ' + data.message);
232
+ } else {
233
+ alert('❌ Error al actualizar playlist');
234
+ }
235
+ } catch (error) {
236
+ alert('❌ Error de conexión');
237
+ }
238
+ }
239
+
240
+ function downloadLogs() {
241
+ alert('📥 Función de descarga de logs no implementada aún');
242
+ }
243
+
244
+ // Auto-focus en el campo de código
245
+ document.getElementById('adminCode').focus();
246
+
247
+ // Enter para autenticar
248
+ document.getElementById('adminCode').addEventListener('keypress', function(e) {
249
+ if (e.key === 'Enter') {
250
+ authenticate();
251
+ }
252
+ });
253
+ </script>
254
+ </body>
255
+ </html>
api/admin.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Depends, Header
2
+ from pydantic import BaseModel
3
+ import os
4
+ from typing import Optional
5
+
6
+ router = APIRouter()
7
+
8
+ class AdminUpdateRequest(BaseModel):
9
+ m3u_url: Optional[str] = None
10
+ action: str # "update_playlist", "clear_cache", etc.
11
+
12
+ def verify_admin_token(authorization: Optional[str] = Header(None)) -> bool:
13
+ """Verifica si el token es de administrador"""
14
+ if not authorization or not authorization.startswith("Bearer "):
15
+ return False
16
+
17
+ token = authorization.split(" ")[1]
18
+ admin_code = os.getenv("ADMIN_CODE", "admin123")
19
+
20
+ return token == admin_code
21
+
22
+ @router.post("/update")
23
+ async def admin_update(
24
+ request: AdminUpdateRequest,
25
+ is_admin: bool = Depends(verify_admin_token)
26
+ ):
27
+ """Endpoint de administración para actualizaciones"""
28
+
29
+ if not is_admin:
30
+ raise HTTPException(status_code=403, detail="Acceso denegado")
31
+
32
+ try:
33
+ if request.action == "clear_cache":
34
+ # Limpiar caché de M3U
35
+ from m3u_parser import m3u_cache
36
+ m3u_cache["data"] = None
37
+ m3u_cache["timestamp"] = None
38
+
39
+ # Limpiar caché de visualizadores
40
+ from viewers import viewers_cache, channel_viewers
41
+ viewers_cache.clear()
42
+ channel_viewers.clear()
43
+
44
+ return {"message": "Caché limpiado correctamente"}
45
+
46
+ elif request.action == "update_playlist" and request.m3u_url:
47
+ # Actualizar URL de playlist (requiere reinicio)
48
+ return {
49
+ "message": "URL actualizada. Reinicia el servidor para aplicar cambios.",
50
+ "new_url": request.m3u_url
51
+ }
52
+
53
+ else:
54
+ raise HTTPException(status_code=400, detail="Acción no válida")
55
+
56
+ except Exception as e:
57
+ raise HTTPException(status_code=500, detail=f"Error en operación admin: {str(e)}")
58
+
59
+ @router.get("/status")
60
+ async def admin_status(is_admin: bool = Depends(verify_admin_token)):
61
+ """Estado del sistema para administradores"""
62
+
63
+ if not is_admin:
64
+ raise HTTPException(status_code=403, detail="Acceso denegado")
65
+
66
+ from m3u_parser import m3u_cache
67
+ from viewers import viewers_cache, channel_viewers
68
+
69
+ return {
70
+ "cache_status": {
71
+ "m3u_cached": m3u_cache["data"] is not None,
72
+ "cache_timestamp": m3u_cache["timestamp"].isoformat() if m3u_cache["timestamp"] else None,
73
+ "channels_count": len(m3u_cache["data"]) if m3u_cache["data"] else 0
74
+ },
75
+ "viewers_status": {
76
+ "active_users": len(viewers_cache),
77
+ "active_channels": len(channel_viewers),
78
+ "total_viewers": sum(len(viewers) for viewers in channel_viewers.values())
79
+ },
80
+ "config": {
81
+ "m3u_url_configured": bool(os.getenv("MAIN_M3U_URL")),
82
+ "admin_code_configured": bool(os.getenv("ADMIN_CODE")),
83
+ "base_url": os.getenv("BASE_URL", "not_configured")
84
+ }
85
+ }
api/auth.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Depends, Header
2
+ from pydantic import BaseModel
3
+ from cryptography.fernet import Fernet
4
+ import base64
5
+ import hashlib
6
+ import os
7
+ import secrets
8
+ from typing import Optional
9
+
10
+ router = APIRouter()
11
+
12
+ # Configuración AES
13
+ AES_SECRET = os.getenv("AES_SECRET", "default-secret-key-change-this")
14
+ # Generar clave Fernet desde el secreto
15
+ key = base64.urlsafe_b64encode(hashlib.sha256(AES_SECRET.encode()).digest())
16
+ cipher_suite = Fernet(key)
17
+
18
+ class AuthRequest(BaseModel):
19
+ code: str
20
+
21
+ class AuthResponse(BaseModel):
22
+ success: bool
23
+ token: Optional[str] = None
24
+ message: Optional[str] = None
25
+
26
+ def generate_user_token() -> str:
27
+ """Genera un token único para el usuario"""
28
+ return secrets.token_urlsafe(32)
29
+
30
+ def validate_access_code(code: str) -> bool:
31
+ """Valida el código de acceso usando AES"""
32
+ try:
33
+ # Lista de códigos válidos (en producción, estos estarían cifrados)
34
+ valid_codes = [
35
+ "FAMILY2024",
36
+ "DADDY123",
37
+ "FRIENDS24"
38
+ ]
39
+
40
+ # Validación simple (en producción usar cifrado AES)
41
+ return code.upper() in valid_codes
42
+ except Exception:
43
+ return False
44
+
45
+ async def get_current_user(authorization: Optional[str] = Header(None)) -> str:
46
+ """Extrae y valida el token del usuario"""
47
+ if not authorization or not authorization.startswith("Bearer "):
48
+ raise HTTPException(status_code=401, detail="Token requerido")
49
+
50
+ token = authorization.split(" ")[1]
51
+ if not token:
52
+ raise HTTPException(status_code=401, detail="Token inválido")
53
+
54
+ return token
55
+
56
+ @router.post("/auth", response_model=AuthResponse)
57
+ async def authenticate(request: AuthRequest):
58
+ """Autentica un usuario con código de acceso"""
59
+ try:
60
+ if validate_access_code(request.code):
61
+ user_token = generate_user_token()
62
+ return AuthResponse(
63
+ success=True,
64
+ token=user_token,
65
+ message="Autenticación exitosa"
66
+ )
67
+ else:
68
+ return AuthResponse(
69
+ success=False,
70
+ message="Código de acceso inválido"
71
+ )
72
+ except Exception as e:
73
+ raise HTTPException(status_code=500, detail="Error interno del servidor")
74
+
75
+ @router.post("/logout")
76
+ async def logout(current_user: str = Depends(get_current_user)):
77
+ """Cierra la sesión del usuario"""
78
+ # Limpiar caché de usuario si existe
79
+ from viewers import clear_user_session
80
+ clear_user_session(current_user)
81
+
82
+ return {"message": "Sesión cerrada correctamente"}
83
+
84
+ @router.post("/ping")
85
+ async def ping(current_user: str = Depends(get_current_user)):
86
+ """Verifica que el usuario esté autenticado"""
87
+ return {"status": "authenticated", "user": current_user[:8] + "..."}
api/m3u_parser.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from pydantic import BaseModel
3
+ import requests
4
+ import re
5
+ import os
6
+ from typing import List, Dict, Optional
7
+ from datetime import datetime, timedelta
8
+ from auth import get_current_user
9
+
10
+ router = APIRouter()
11
+
12
+ # Cache en memoria
13
+ m3u_cache = {
14
+ "data": None,
15
+ "timestamp": None,
16
+ "ttl": 3600 # 1 hora
17
+ }
18
+
19
+ class Channel(BaseModel):
20
+ id: str
21
+ name: str
22
+ url: str
23
+ logo: Optional[str] = None
24
+ category: str
25
+ group: Optional[str] = None
26
+
27
+ class ChannelCategory(BaseModel):
28
+ name: str
29
+ count: int
30
+
31
+ class ChannelResponse(BaseModel):
32
+ channels: List[Channel]
33
+ categories: List[ChannelCategory]
34
+
35
+ def parse_m3u_content(content: str) -> List[Channel]:
36
+ """Parsea el contenido M3U y extrae los canales"""
37
+ channels = []
38
+ lines = content.strip().split('\n')
39
+
40
+ i = 0
41
+ while i < len(lines):
42
+ line = lines[i].strip()
43
+
44
+ if line.startswith('#EXTINF:'):
45
+ # Extraer información del canal
46
+ info_line = line
47
+
48
+ # Buscar la URL en la siguiente línea
49
+ url_line = ""
50
+ if i + 1 < len(lines):
51
+ url_line = lines[i + 1].strip()
52
+
53
+ if url_line and not url_line.startswith('#'):
54
+ channel = parse_extinf_line(info_line, url_line)
55
+ if channel:
56
+ channels.append(channel)
57
+ i += 2
58
+ else:
59
+ i += 1
60
+ else:
61
+ i += 1
62
+
63
+ return channels
64
+
65
+ def parse_extinf_line(extinf_line: str, url: str) -> Optional[Channel]:
66
+ """Parsea una línea EXTINF y extrae la información del canal"""
67
+ try:
68
+ # Extraer nombre del canal (después de la última coma)
69
+ name_match = extinf_line.split(',')[-1].strip()
70
+ if not name_match:
71
+ return None
72
+
73
+ name = name_match
74
+
75
+ # Extraer logo
76
+ logo_match = re.search(r'tvg-logo="([^"]*)"', extinf_line)
77
+ logo = logo_match.group(1) if logo_match else None
78
+
79
+ # Extraer categoría/grupo
80
+ group_match = re.search(r'group-title="([^"]*)"', extinf_line)
81
+ category = group_match.group(1) if group_match else "General"
82
+
83
+ # Generar ID único
84
+ channel_id = f"{hash(name + url) % 1000000:06d}"
85
+
86
+ return Channel(
87
+ id=channel_id,
88
+ name=name,
89
+ url=url,
90
+ logo=logo,
91
+ category=category,
92
+ group=category
93
+ )
94
+ except Exception as e:
95
+ print(f"Error parsing channel: {e}")
96
+ return None
97
+
98
+ def get_cached_channels() -> Optional[List[Channel]]:
99
+ """Obtiene los canales del caché si están vigentes"""
100
+ if not m3u_cache["data"] or not m3u_cache["timestamp"]:
101
+ return None
102
+
103
+ # Verificar TTL
104
+ if datetime.now() - m3u_cache["timestamp"] > timedelta(seconds=m3u_cache["ttl"]):
105
+ return None
106
+
107
+ return m3u_cache["data"]
108
+
109
+ def cache_channels(channels: List[Channel]):
110
+ """Guarda los canales en caché"""
111
+ m3u_cache["data"] = channels
112
+ m3u_cache["timestamp"] = datetime.now()
113
+
114
+ async def fetch_m3u_playlist() -> List[Channel]:
115
+ """Descarga y parsea la playlist M3U"""
116
+ m3u_url = os.getenv("MAIN_M3U_URL")
117
+ if not m3u_url:
118
+ raise HTTPException(status_code=500, detail="URL de playlist no configurada")
119
+
120
+ try:
121
+ response = requests.get(m3u_url, timeout=30)
122
+ response.raise_for_status()
123
+
124
+ channels = parse_m3u_content(response.text)
125
+ cache_channels(channels)
126
+
127
+ return channels
128
+ except requests.RequestException as e:
129
+ raise HTTPException(status_code=500, detail=f"Error descargando playlist: {str(e)}")
130
+
131
+ @router.get("/channels", response_model=ChannelResponse)
132
+ async def get_channels(current_user: str = Depends(get_current_user)):
133
+ """Obtiene la lista de canales disponibles"""
134
+
135
+ # Intentar obtener del caché primero
136
+ cached_channels = get_cached_channels()
137
+ if cached_channels:
138
+ channels = cached_channels
139
+ else:
140
+ # Descargar y parsear
141
+ channels = await fetch_m3u_playlist()
142
+
143
+ # Agrupar por categorías
144
+ categories_dict = {}
145
+ for channel in channels:
146
+ category = channel.category
147
+ if category not in categories_dict:
148
+ categories_dict[category] = 0
149
+ categories_dict[category] += 1
150
+
151
+ categories = [
152
+ ChannelCategory(name=name, count=count)
153
+ for name, count in sorted(categories_dict.items())
154
+ ]
155
+
156
+ return ChannelResponse(channels=channels, categories=categories)
157
+
158
+ @router.get("/playlist.m3u")
159
+ async def get_playlist_m3u(current_user: str = Depends(get_current_user)):
160
+ """Devuelve la playlist M3U con URLs reescritas para usar el proxy"""
161
+
162
+ channels = get_cached_channels()
163
+ if not channels:
164
+ channels = await fetch_m3u_playlist()
165
+
166
+ # Generar contenido M3U con URLs proxy
167
+ base_url = os.getenv("BASE_URL", "http://localhost:8000")
168
+ m3u_content = "#EXTM3U\n"
169
+
170
+ for channel in channels:
171
+ proxy_url = f"{base_url}/api/proxy?url={requests.utils.quote(channel.url, safe='')}"
172
+
173
+ extinf_line = f'#EXTINF:-1'
174
+ if channel.logo:
175
+ extinf_line += f' tvg-logo="{channel.logo}"'
176
+ if channel.category:
177
+ extinf_line += f' group-title="{channel.category}"'
178
+ extinf_line += f',{channel.name}\n'
179
+
180
+ m3u_content += extinf_line
181
+ m3u_content += f"{proxy_url}\n"
182
+
183
+ return PlainTextResponse(
184
+ content=m3u_content,
185
+ media_type="application/vnd.apple.mpegurl",
186
+ headers={"Content-Disposition": "attachment; filename=playlist.m3u"}
187
+ )
api/main.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException, Depends, Request
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.responses import StreamingResponse, PlainTextResponse
4
+ from fastapi.staticfiles import StaticFiles
5
+ import uvicorn
6
+ import os
7
+ from dotenv import load_dotenv
8
+
9
+ # Cargar variables de entorno
10
+ load_dotenv()
11
+
12
+ # Importar módulos
13
+ from auth import router as auth_router, get_current_user
14
+ from m3u_parser import router as m3u_router
15
+ from proxy import router as proxy_router
16
+ from viewers import router as viewers_router
17
+ from admin import router as admin_router
18
+
19
+ app = FastAPI(
20
+ title="DaddyTV IPTV API",
21
+ description="API para reproductor IPTV personal",
22
+ version="1.0.0"
23
+ )
24
+
25
+ # CORS
26
+ app.add_middleware(
27
+ CORSMiddleware,
28
+ allow_origins=["*"],
29
+ allow_credentials=True,
30
+ allow_methods=["*"],
31
+ allow_headers=["*"],
32
+ )
33
+
34
+ # Servir archivos estáticos del frontend (para Docker)
35
+ if os.path.exists("static"):
36
+ app.mount("/static", StaticFiles(directory="static"), name="static")
37
+
38
+ # Incluir routers
39
+ app.include_router(auth_router, prefix="/api")
40
+ app.include_router(m3u_router, prefix="/api")
41
+ app.include_router(proxy_router, prefix="/api")
42
+ app.include_router(viewers_router, prefix="/api")
43
+ app.include_router(admin_router, prefix="/api/admin")
44
+
45
+ @app.get("/")
46
+ async def root():
47
+ # En Docker, servir el frontend
48
+ if os.path.exists("static/index.html"):
49
+ with open("static/index.html", "r") as f:
50
+ return PlainTextResponse(f.read(), media_type="text/html")
51
+ return {"message": "DaddyTV IPTV API", "version": "1.0.0"}
52
+
53
+ @app.get("/api/health")
54
+ async def health_check():
55
+ return {"status": "healthy", "message": "API funcionando correctamente"}
56
+
57
+ # Catch-all para servir el frontend en rutas no-API (SPA)
58
+ @app.get("/{full_path:path}")
59
+ async def serve_frontend(full_path: str):
60
+ if full_path.startswith("api/"):
61
+ raise HTTPException(status_code=404, detail="API endpoint not found")
62
+
63
+ if os.path.exists("static/index.html"):
64
+ with open("static/index.html", "r") as f:
65
+ return PlainTextResponse(f.read(), media_type="text/html")
66
+
67
+ raise HTTPException(status_code=404, detail="Frontend not found")
68
+
69
+ if __name__ == "__main__":
70
+ port = int(os.getenv("PORT", 8000))
71
+ uvicorn.run(app, host="0.0.0.0", port=port)
api/proxy.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Query, Request
2
+ from fastapi.responses import StreamingResponse
3
+ import requests
4
+ import os
5
+ from typing import Optional
6
+
7
+ router = APIRouter()
8
+
9
+ # Headers para bypass de restricciones
10
+ DEFAULT_HEADERS = {
11
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
12
+ 'Accept': '*/*',
13
+ 'Accept-Language': 'en-US,en;q=0.9',
14
+ 'Accept-Encoding': 'gzip, deflate',
15
+ 'Connection': 'keep-alive',
16
+ 'Upgrade-Insecure-Requests': '1'
17
+ }
18
+
19
+ def stream_content(url: str, headers: dict):
20
+ """Genera el contenido del stream"""
21
+ try:
22
+ with requests.get(url, headers=headers, stream=True, timeout=30) as response:
23
+ response.raise_for_status()
24
+
25
+ for chunk in response.iter_content(chunk_size=8192):
26
+ if chunk:
27
+ yield chunk
28
+ except requests.RequestException as e:
29
+ print(f"Error streaming content: {e}")
30
+ raise HTTPException(status_code=502, detail="Error al obtener el contenido")
31
+
32
+ @router.get("/proxy")
33
+ async def proxy_stream(
34
+ request: Request,
35
+ url: str = Query(..., description="URL del stream a proxificar")
36
+ ):
37
+ """Proxy para streams bloqueados por CORS o geolocalización"""
38
+
39
+ if not url:
40
+ raise HTTPException(status_code=400, detail="URL requerida")
41
+
42
+ try:
43
+ # Preparar headers
44
+ proxy_headers = DEFAULT_HEADERS.copy()
45
+
46
+ # Agregar headers del cliente si son relevantes
47
+ client_headers = dict(request.headers)
48
+ if 'referer' in client_headers:
49
+ proxy_headers['Referer'] = client_headers['referer']
50
+
51
+ # Hacer petición inicial para obtener headers de respuesta
52
+ head_response = requests.head(url, headers=proxy_headers, timeout=10)
53
+
54
+ # Preparar headers de respuesta
55
+ response_headers = {}
56
+
57
+ # Copiar headers importantes
58
+ important_headers = [
59
+ 'content-type', 'content-length', 'accept-ranges',
60
+ 'cache-control', 'expires', 'last-modified'
61
+ ]
62
+
63
+ for header in important_headers:
64
+ if header in head_response.headers:
65
+ response_headers[header] = head_response.headers[header]
66
+
67
+ # Headers CORS
68
+ response_headers.update({
69
+ 'Access-Control-Allow-Origin': '*',
70
+ 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
71
+ 'Access-Control-Allow-Headers': '*'
72
+ })
73
+
74
+ # Retornar stream
75
+ return StreamingResponse(
76
+ stream_content(url, proxy_headers),
77
+ headers=response_headers,
78
+ media_type=head_response.headers.get('content-type', 'application/octet-stream')
79
+ )
80
+
81
+ except requests.RequestException as e:
82
+ print(f"Proxy error for URL {url}: {e}")
83
+ raise HTTPException(status_code=502, detail="Error al acceder al contenido")
84
+ except Exception as e:
85
+ print(f"Unexpected proxy error: {e}")
86
+ raise HTTPException(status_code=500, detail="Error interno del servidor")
87
+
88
+ @router.options("/proxy")
89
+ async def proxy_options():
90
+ """Maneja peticiones OPTIONS para CORS"""
91
+ return {
92
+ "message": "OK"
93
+ }
api/utils.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import secrets
3
+ import re
4
+ from typing import Optional, Dict, Any
5
+ from datetime import datetime
6
+
7
+ def generate_secure_id(input_string: str) -> str:
8
+ """Genera un ID seguro basado en una cadena"""
9
+ return hashlib.md5(input_string.encode()).hexdigest()[:12]
10
+
11
+ def generate_token() -> str:
12
+ """Genera un token seguro aleatorio"""
13
+ return secrets.token_urlsafe(32)
14
+
15
+ def clean_channel_name(name: str) -> str:
16
+ """Limpia el nombre del canal removiendo caracteres especiales"""
17
+ # Remover caracteres especiales pero mantener espacios y caracteres acentuados
18
+ cleaned = re.sub(r'[^\w\s\-áéíóúñü]', '', name, flags=re.IGNORECASE)
19
+ return cleaned.strip()
20
+
21
+ def validate_url(url: str) -> bool:
22
+ """Valida si una URL tiene formato correcto"""
23
+ url_pattern = re.compile(
24
+ r'^https?://' # http:// or https://
25
+ r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' # domain...
26
+ r'localhost|' # localhost...
27
+ r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip
28
+ r'(?::\d+)?' # optional port
29
+ r'(?:/?|[/?]\S+)$', re.IGNORECASE)
30
+ return url_pattern.match(url) is not None
31
+
32
+ def format_timestamp(dt: datetime) -> str:
33
+ """Formatea un timestamp para mostrar"""
34
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
35
+
36
+ def safe_get(dictionary: Dict[str, Any], key: str, default: Any = None) -> Any:
37
+ """Obtiene un valor del diccionario de forma segura"""
38
+ return dictionary.get(key, default)
39
+
40
+ class RateLimiter:
41
+ """Rate limiter simple en memoria"""
42
+
43
+ def __init__(self, max_requests: int = 100, window_seconds: int = 60):
44
+ self.max_requests = max_requests
45
+ self.window_seconds = window_seconds
46
+ self.requests: Dict[str, list] = {}
47
+
48
+ def is_allowed(self, identifier: str) -> bool:
49
+ """Verifica si una petición está permitida"""
50
+ now = datetime.now()
51
+
52
+ if identifier not in self.requests:
53
+ self.requests[identifier] = []
54
+
55
+ # Limpiar peticiones antiguas
56
+ cutoff_time = now.timestamp() - self.window_seconds
57
+ self.requests[identifier] = [
58
+ req_time for req_time in self.requests[identifier]
59
+ if req_time > cutoff_time
60
+ ]
61
+
62
+ # Verificar límite
63
+ if len(self.requests[identifier]) >= self.max_requests:
64
+ return False
65
+
66
+ # Agregar petición actual
67
+ self.requests[identifier].append(now.timestamp())
68
+ return True
69
+
70
+ # Instancia global del rate limiter
71
+ rate_limiter = RateLimiter(max_requests=50, window_seconds=60)
api/viewers.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from pydantic import BaseModel
3
+ from typing import Dict, Optional
4
+ from datetime import datetime, timedelta
5
+ from auth import get_current_user
6
+
7
+ router = APIRouter()
8
+
9
+ # Cache de visualizadores en memoria
10
+ # Estructura: {user_token: {"channel_url": str, "timestamp": datetime}}
11
+ viewers_cache: Dict[str, Dict] = {}
12
+
13
+ # Contador de visualizadores por canal
14
+ # Estructura: {channel_url: set(user_tokens)}
15
+ channel_viewers: Dict[str, set] = {}
16
+
17
+ class ViewerRequest(BaseModel):
18
+ channel_url: str
19
+
20
+ class ViewerResponse(BaseModel):
21
+ viewers: int
22
+ switched: bool
23
+ message: Optional[str] = None
24
+
25
+ def cleanup_old_viewers():
26
+ """Limpia visualizadores inactivos (más de 5 minutos)"""
27
+ cutoff_time = datetime.now() - timedelta(minutes=5)
28
+
29
+ # Limpiar viewers_cache
30
+ expired_users = []
31
+ for user_token, data in viewers_cache.items():
32
+ if data.get("timestamp", datetime.min) < cutoff_time:
33
+ expired_users.append(user_token)
34
+
35
+ for user_token in expired_users:
36
+ old_channel = viewers_cache[user_token].get("channel_url")
37
+ if old_channel and old_channel in channel_viewers:
38
+ channel_viewers[old_channel].discard(user_token)
39
+ if not channel_viewers[old_channel]:
40
+ del channel_viewers[old_channel]
41
+ del viewers_cache[user_token]
42
+
43
+ def clear_user_session(user_token: str):
44
+ """Limpia la sesión de un usuario específico"""
45
+ if user_token in viewers_cache:
46
+ old_channel = viewers_cache[user_token].get("channel_url")
47
+ if old_channel and old_channel in channel_viewers:
48
+ channel_viewers[old_channel].discard(user_token)
49
+ if not channel_viewers[old_channel]:
50
+ del channel_viewers[old_channel]
51
+ del viewers_cache[user_token]
52
+
53
+ @router.post("/viewers", response_model=ViewerResponse)
54
+ async def set_viewing(
55
+ request: ViewerRequest,
56
+ current_user: str = Depends(get_current_user)
57
+ ):
58
+ """Registra que un usuario está viendo un canal"""
59
+
60
+ # Limpiar visualizadores antiguos
61
+ cleanup_old_viewers()
62
+
63
+ channel_url = request.channel_url
64
+ switched = False
65
+ message = None
66
+
67
+ # Verificar si el usuario ya está viendo otro canal
68
+ if current_user in viewers_cache:
69
+ old_channel = viewers_cache[current_user].get("channel_url")
70
+ if old_channel and old_channel != channel_url:
71
+ # Usuario cambia de canal
72
+ switched = True
73
+ message = "Canal cambiado automáticamente"
74
+
75
+ # Remover del canal anterior
76
+ if old_channel in channel_viewers:
77
+ channel_viewers[old_channel].discard(current_user)
78
+ if not channel_viewers[old_channel]:
79
+ del channel_viewers[old_channel]
80
+
81
+ # Registrar en el nuevo canal
82
+ viewers_cache[current_user] = {
83
+ "channel_url": channel_url,
84
+ "timestamp": datetime.now()
85
+ }
86
+
87
+ # Actualizar contador del canal
88
+ if channel_url not in channel_viewers:
89
+ channel_viewers[channel_url] = set()
90
+ channel_viewers[channel_url].add(current_user)
91
+
92
+ # Contar visualizadores actuales
93
+ viewer_count = len(channel_viewers.get(channel_url, set()))
94
+
95
+ return ViewerResponse(
96
+ viewers=viewer_count,
97
+ switched=switched,
98
+ message=message
99
+ )
100
+
101
+ @router.get("/viewers/{channel_id}")
102
+ async def get_viewer_count(
103
+ channel_id: str,
104
+ current_user: str = Depends(get_current_user)
105
+ ):
106
+ """Obtiene el número de visualizadores de un canal"""
107
+
108
+ # Limpiar visualizadores antiguos
109
+ cleanup_old_viewers()
110
+
111
+ # Buscar el canal por ID (simplificado, en producción usar base de datos)
112
+ viewer_count = 0
113
+ for channel_url, viewers in channel_viewers.items():
114
+ if channel_id in channel_url or channel_url.endswith(channel_id):
115
+ viewer_count = len(viewers)
116
+ break
117
+
118
+ return {"viewers": viewer_count}
119
+
120
+ @router.delete("/viewers")
121
+ async def stop_viewing(current_user: str = Depends(get_current_user)):
122
+ """Detiene la visualización actual del usuario"""
123
+
124
+ clear_user_session(current_user)
125
+
126
+ return {"message": "Visualización detenida"}
docker-compose.yml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ daddytv:
5
+ build: .
6
+ ports:
7
+ - "8000:8000"
8
+ environment:
9
+ - MAIN_M3U_URL=${MAIN_M3U_URL}
10
+ - ADMIN_CODE=${ADMIN_CODE}
11
+ - AES_SECRET=${AES_SECRET}
12
+ - BASE_URL=${BASE_URL:-http://localhost:8000}
13
+ - PORT=8000
14
+ env_file:
15
+ - .env
16
+ restart: unless-stopped
17
+ healthcheck:
18
+ test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
19
+ interval: 30s
20
+ timeout: 10s
21
+ retries: 3
22
+ start_period: 40s
23
+ volumes:
24
+ # Opcional: montar logs
25
+ - ./logs:/app/logs
26
+ networks:
27
+ - daddytv-network
28
+
29
+ networks:
30
+ daddytv-network:
31
+ driver: bridge
generate_admin_code.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Script para generar códigos de administrador seguros
4
+ """
5
+ import secrets
6
+ import hashlib
7
+ import base64
8
+ import os
9
+
10
+ def generate_admin_code():
11
+ """Genera un código de administrador seguro"""
12
+ # Generar código aleatorio
13
+ random_bytes = secrets.token_bytes(16)
14
+ admin_code = base64.urlsafe_b64encode(random_bytes).decode('utf-8').rstrip('=')
15
+
16
+ print("=" * 50)
17
+ print("CÓDIGO DE ADMINISTRADOR GENERADO")
18
+ print("=" * 50)
19
+ print(f"Código: {admin_code}")
20
+ print(f"Longitud: {len(admin_code)} caracteres")
21
+ print()
22
+ print("INSTRUCCIONES:")
23
+ print("1. Copia este código y guárdalo en un lugar seguro")
24
+ print("2. Agrega la siguiente línea a tu archivo .env:")
25
+ print(f" ADMIN_CODE={admin_code}")
26
+ print("3. Para usar el panel de admin, incluye este header en tus peticiones:")
27
+ print(f" Authorization: Bearer {admin_code}")
28
+ print()
29
+ print("ENDPOINTS DE ADMINISTRACIÓN:")
30
+ print("- GET /api/admin/status - Estado del sistema")
31
+ print("- POST /api/admin/update - Actualizar configuración")
32
+ print("=" * 50)
33
+
34
+ def generate_aes_secret():
35
+ """Genera un secreto AES seguro"""
36
+ # Generar 32 bytes aleatorios para AES-256
37
+ secret_bytes = secrets.token_bytes(32)
38
+ aes_secret = base64.urlsafe_b64encode(secret_bytes).decode('utf-8').rstrip('=')
39
+
40
+ print("SECRETO AES GENERADO")
41
+ print("=" * 50)
42
+ print(f"Secreto: {aes_secret}")
43
+ print("Agrega esta línea a tu archivo .env:")
44
+ print(f"AES_SECRET={aes_secret}")
45
+ print("=" * 50)
46
+
47
+ if __name__ == "__main__":
48
+ print("🔐 Generador de Códigos DaddyTV")
49
+ print()
50
+
51
+ generate_admin_code()
52
+ print()
53
+ generate_aes_secret()
54
+
55
+ print("\n⚠️ IMPORTANTE:")
56
+ print("- Guarda estos códigos en un lugar seguro")
57
+ print("- No los compartas con usuarios normales")
58
+ print("- Cambia los códigos periódicamente por seguridad")
index.html ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <meta name="theme-color" content="#1f2937" />
8
+ <meta name="description" content="Reproductor IPTV personal para amigos y familiares" />
9
+ <title>DaddyTV - IPTV Player</title>
10
+ <link rel="preconnect" href="https://fonts.googleapis.com">
11
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
12
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
13
+ </head>
14
+ <body>
15
+ <div id="root"></div>
16
+ <script type="module" src="/src/main.tsx"></script>
17
+ </body>
18
+ </html>
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "daddytv",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc && vite build",
9
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "react": "^18.2.0",
14
+ "react-dom": "^18.2.0",
15
+ "hls.js": "^1.4.12",
16
+ "lucide-react": "^0.294.0"
17
+ },
18
+ "devDependencies": {
19
+ "@types/react": "^18.2.43",
20
+ "@types/react-dom": "^18.2.17",
21
+ "@typescript-eslint/eslint-plugin": "^6.14.0",
22
+ "@typescript-eslint/parser": "^6.14.0",
23
+ "@vitejs/plugin-react": "^4.2.1",
24
+ "autoprefixer": "^10.4.16",
25
+ "eslint": "^8.55.0",
26
+ "eslint-plugin-react-hooks": "^4.6.0",
27
+ "eslint-plugin-react-refresh": "^0.4.5",
28
+ "postcss": "^8.4.32",
29
+ "tailwindcss": "^3.3.6",
30
+ "typescript": "^5.2.2",
31
+ "vite": "^5.0.8",
32
+ "vite-plugin-pwa": "^0.17.4"
33
+ }
34
+ }
postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
public/manifest.json ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "DaddyTV - IPTV Player",
3
+ "short_name": "DaddyTV",
4
+ "description": "Reproductor IPTV personal para amigos y familiares",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "background_color": "#111827",
8
+ "theme_color": "#1f2937",
9
+ "orientation": "landscape-primary",
10
+ "icons": [
11
+ {
12
+ "src": "/pwa-192x192.png",
13
+ "sizes": "192x192",
14
+ "type": "image/png",
15
+ "purpose": "any maskable"
16
+ },
17
+ {
18
+ "src": "/pwa-512x512.png",
19
+ "sizes": "512x512",
20
+ "type": "image/png",
21
+ "purpose": "any maskable"
22
+ }
23
+ ],
24
+ "categories": ["entertainment", "multimedia"],
25
+ "screenshots": [
26
+ {
27
+ "src": "/screenshot-wide.png",
28
+ "sizes": "1280x720",
29
+ "type": "image/png",
30
+ "form_factor": "wide"
31
+ }
32
+ ]
33
+ }
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi==0.104.1
2
+ uvicorn[standard]==0.24.0
3
+ python-multipart==0.0.6
4
+ requests==2.31.0
5
+ cryptography==41.0.7
6
+ python-dotenv==1.0.0
scripts/docker-deploy.sh ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Script de despliegue con Docker
4
+ echo "🐳 Desplegando DaddyTV con Docker..."
5
+
6
+ # Verificar si Docker está instalado
7
+ if ! command -v docker &> /dev/null; then
8
+ echo "❌ Docker no está instalado. Por favor instálalo primero."
9
+ exit 1
10
+ fi
11
+
12
+ # Verificar si docker-compose está instalado
13
+ if ! command -v docker-compose &> /dev/null; then
14
+ echo "❌ Docker Compose no está instalado. Por favor instálalo primero."
15
+ exit 1
16
+ fi
17
+
18
+ # Verificar archivo .env
19
+ if [ ! -f .env ]; then
20
+ echo "❌ Archivo .env no encontrado. Ejecuta primero: ./scripts/setup.sh"
21
+ exit 1
22
+ fi
23
+
24
+ # Construir y ejecutar
25
+ echo "🔨 Construyendo imagen Docker..."
26
+ docker-compose build
27
+
28
+ echo "🚀 Iniciando servicios..."
29
+ docker-compose up -d
30
+
31
+ echo "⏳ Esperando que los servicios estén listos..."
32
+ sleep 10
33
+
34
+ # Verificar estado
35
+ if docker-compose ps | grep -q "Up"; then
36
+ echo "✅ DaddyTV está ejecutándose!"
37
+ echo ""
38
+ echo "🌐 Aplicación disponible en: http://localhost:8000"
39
+ echo "📊 API Health Check: http://localhost:8000/api/health"
40
+ echo ""
41
+ echo "📋 Comandos útiles:"
42
+ echo " Ver logs: docker-compose logs -f"
43
+ echo " Detener: docker-compose down"
44
+ echo " Reiniciar: docker-compose restart"
45
+ echo " Reconstruir: docker-compose up --build -d"
46
+ else
47
+ echo "❌ Error al iniciar los servicios. Revisa los logs:"
48
+ docker-compose logs
49
+ exit 1
50
+ fi
scripts/setup.sh ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Script de configuración para DaddyTV
4
+ echo "🚀 Configurando DaddyTV..."
5
+
6
+ # Verificar si Python está instalado
7
+ if ! command -v python3 &> /dev/null; then
8
+ echo "❌ Python 3 no está instalado. Por favor instálalo primero."
9
+ exit 1
10
+ fi
11
+
12
+ # Verificar si Node.js está instalado
13
+ if ! command -v node &> /dev/null; then
14
+ echo "❌ Node.js no está instalado. Por favor instálalo primero."
15
+ exit 1
16
+ fi
17
+
18
+ # Crear archivo .env si no existe
19
+ if [ ! -f .env ]; then
20
+ echo "📝 Creando archivo .env..."
21
+ cp .env.example .env
22
+ echo "✅ Archivo .env creado. Por favor configúralo con tus valores."
23
+ fi
24
+
25
+ # Generar códigos de administrador
26
+ echo "🔐 Generando códigos de administrador..."
27
+ python3 generate_admin_code.py
28
+
29
+ echo ""
30
+ echo "📦 Instalando dependencias..."
31
+
32
+ # Instalar dependencias del frontend
33
+ echo "📱 Instalando dependencias del frontend..."
34
+ npm install
35
+
36
+ # Instalar dependencias del backend
37
+ echo "🐍 Instalando dependencias del backend..."
38
+ cd api
39
+ pip3 install -r requirements.txt
40
+ cd ..
41
+
42
+ echo ""
43
+ echo "✅ Configuración completada!"
44
+ echo ""
45
+ echo "🚀 Para iniciar el desarrollo:"
46
+ echo " 1. Configura tu archivo .env con los valores generados"
47
+ echo " 2. Terminal 1: cd api && python3 main.py"
48
+ echo " 3. Terminal 2: npm run dev"
49
+ echo ""
50
+ echo "🐳 Para usar Docker:"
51
+ echo " 1. docker-compose up --build"
52
+ echo ""
53
+ echo "📋 Panel de administración disponible en:"
54
+ echo " GET /api/admin/status"
55
+ echo " POST /api/admin/update"
src/App.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react'
2
+ import { AuthProvider, useAuth } from './contexts/AuthContext'
3
+ import { ChannelProvider } from './contexts/ChannelContext'
4
+ import LoginScreen from './components/LoginScreen'
5
+ import MainInterface from './components/MainInterface'
6
+ import LoadingSpinner from './components/LoadingSpinner'
7
+
8
+ function AppContent() {
9
+ const { isAuthenticated, isLoading } = useAuth()
10
+
11
+ if (isLoading) {
12
+ return <LoadingSpinner />
13
+ }
14
+
15
+ return isAuthenticated ? <MainInterface /> : <LoginScreen />
16
+ }
17
+
18
+ function App() {
19
+ return (
20
+ <AuthProvider>
21
+ <ChannelProvider>
22
+ <div className="min-h-screen bg-gray-900">
23
+ <AppContent />
24
+ </div>
25
+ </ChannelProvider>
26
+ </AuthProvider>
27
+ )
28
+ }
29
+
30
+ export default App
src/components/ChannelList.tsx ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useMemo } from 'react'
2
+ import { useChannels } from '../contexts/ChannelContext'
3
+ import { useKeyboard } from '../hooks/useKeyboard'
4
+ import { Search, Heart, Tv, RefreshCw } from 'lucide-react'
5
+ import { Channel } from '../types/channel'
6
+
7
+ interface ChannelListProps {
8
+ onChannelSelect?: () => void
9
+ }
10
+
11
+ export default function ChannelList({ onChannelSelect }: ChannelListProps) {
12
+ const {
13
+ channels,
14
+ categories,
15
+ currentChannel,
16
+ favorites,
17
+ searchTerm,
18
+ selectedCategory,
19
+ setCurrentChannel,
20
+ setSearchTerm,
21
+ setSelectedCategory,
22
+ toggleFavorite,
23
+ refreshChannels,
24
+ isLoading
25
+ } = useChannels()
26
+
27
+ const [selectedIndex, setSelectedIndex] = useState(0)
28
+
29
+ const filteredChannels = useMemo(() => {
30
+ let filtered = channels
31
+
32
+ // Filtrar por categoría
33
+ if (selectedCategory === 'favorites') {
34
+ filtered = filtered.filter(channel => favorites.includes(channel.id))
35
+ } else if (selectedCategory !== 'all') {
36
+ filtered = filtered.filter(channel => channel.category === selectedCategory)
37
+ }
38
+
39
+ // Filtrar por búsqueda
40
+ if (searchTerm) {
41
+ filtered = filtered.filter(channel =>
42
+ channel.name.toLowerCase().includes(searchTerm.toLowerCase())
43
+ )
44
+ }
45
+
46
+ return filtered
47
+ }, [channels, selectedCategory, favorites, searchTerm])
48
+
49
+ useKeyboard({
50
+ onArrowUp: () => setSelectedIndex(prev => Math.max(0, prev - 1)),
51
+ onArrowDown: () => setSelectedIndex(prev => Math.min(filteredChannels.length - 1, prev + 1)),
52
+ onEnter: () => {
53
+ if (filteredChannels[selectedIndex]) {
54
+ handleChannelSelect(filteredChannels[selectedIndex])
55
+ }
56
+ }
57
+ })
58
+
59
+ const handleChannelSelect = (channel: Channel) => {
60
+ setCurrentChannel(channel)
61
+ onChannelSelect?.()
62
+ }
63
+
64
+ const handleCategoryChange = (category: string) => {
65
+ setSelectedCategory(category)
66
+ setSelectedIndex(0)
67
+ }
68
+
69
+ return (
70
+ <div className="h-full flex flex-col bg-gray-800">
71
+ {/* Búsqueda */}
72
+ <div className="p-4 border-b border-gray-700">
73
+ <div className="relative">
74
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
75
+ <input
76
+ type="text"
77
+ value={searchTerm}
78
+ onChange={(e) => setSearchTerm(e.target.value)}
79
+ placeholder="Buscar canales..."
80
+ className="input-field w-full pl-10 text-sm"
81
+ />
82
+ </div>
83
+ </div>
84
+
85
+ {/* Categorías */}
86
+ <div className="p-4 border-b border-gray-700">
87
+ <div className="flex items-center justify-between mb-2">
88
+ <h3 className="text-sm font-medium text-gray-300">Categorías</h3>
89
+ <button
90
+ onClick={refreshChannels}
91
+ disabled={isLoading}
92
+ className="p-1 hover:bg-gray-700 rounded transition-colors"
93
+ title="Actualizar canales"
94
+ >
95
+ <RefreshCw className={`h-4 w-4 text-gray-400 ${isLoading ? 'animate-spin' : ''}`} />
96
+ </button>
97
+ </div>
98
+
99
+ <div className="space-y-1">
100
+ <button
101
+ onClick={() => handleCategoryChange('all')}
102
+ className={`w-full text-left px-2 py-1 rounded text-sm transition-colors ${
103
+ selectedCategory === 'all'
104
+ ? 'bg-primary-600 text-white'
105
+ : 'text-gray-300 hover:bg-gray-700'
106
+ }`}
107
+ >
108
+ Todos ({channels.length})
109
+ </button>
110
+
111
+ <button
112
+ onClick={() => handleCategoryChange('favorites')}
113
+ className={`w-full text-left px-2 py-1 rounded text-sm transition-colors flex items-center space-x-2 ${
114
+ selectedCategory === 'favorites'
115
+ ? 'bg-primary-600 text-white'
116
+ : 'text-gray-300 hover:bg-gray-700'
117
+ }`}
118
+ >
119
+ <Heart className="h-3 w-3" />
120
+ <span>Favoritos ({favorites.length})</span>
121
+ </button>
122
+
123
+ {categories.map(category => (
124
+ <button
125
+ key={category.name}
126
+ onClick={() => handleCategoryChange(category.name)}
127
+ className={`w-full text-left px-2 py-1 rounded text-sm transition-colors ${
128
+ selectedCategory === category.name
129
+ ? 'bg-primary-600 text-white'
130
+ : 'text-gray-300 hover:bg-gray-700'
131
+ }`}
132
+ >
133
+ {category.name} ({category.count})
134
+ </button>
135
+ ))}
136
+ </div>
137
+ </div>
138
+
139
+ {/* Lista de canales */}
140
+ <div className="flex-1 overflow-y-auto">
141
+ {filteredChannels.length === 0 ? (
142
+ <div className="p-4 text-center text-gray-400">
143
+ <Tv className="h-8 w-8 mx-auto mb-2 opacity-50" />
144
+ <p className="text-sm">No se encontraron canales</p>
145
+ </div>
146
+ ) : (
147
+ <div className="p-2 space-y-1">
148
+ {filteredChannels.map((channel, index) => (
149
+ <div
150
+ key={channel.id}
151
+ onClick={() => handleChannelSelect(channel)}
152
+ className={`channel-item ${
153
+ currentChannel?.id === channel.id ? 'active' : ''
154
+ } ${index === selectedIndex ? 'ring-2 ring-primary-500' : ''}`}
155
+ >
156
+ <div className="flex-1 min-w-0">
157
+ <div className="flex items-center space-x-2">
158
+ {channel.logo && (
159
+ <img
160
+ src={channel.logo}
161
+ alt={channel.name}
162
+ className="w-8 h-8 rounded object-cover flex-shrink-0"
163
+ onError={(e) => {
164
+ e.currentTarget.style.display = 'none'
165
+ }}
166
+ />
167
+ )}
168
+ <div className="flex-1 min-w-0">
169
+ <p className="text-sm font-medium text-white truncate">
170
+ {channel.name}
171
+ </p>
172
+ <p className="text-xs text-gray-400 truncate">
173
+ {channel.category}
174
+ </p>
175
+ </div>
176
+ </div>
177
+ </div>
178
+
179
+ <button
180
+ onClick={(e) => {
181
+ e.stopPropagation()
182
+ toggleFavorite(channel.id)
183
+ }}
184
+ className="p-1 hover:bg-gray-600 rounded transition-colors"
185
+ >
186
+ <Heart
187
+ className={`h-4 w-4 ${
188
+ favorites.includes(channel.id)
189
+ ? 'text-red-500 fill-current'
190
+ : 'text-gray-400'
191
+ }`}
192
+ />
193
+ </button>
194
+ </div>
195
+ ))}
196
+ </div>
197
+ )}
198
+ </div>
199
+ </div>
200
+ )
201
+ }
src/components/Header.tsx ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import { useAuth } from '../contexts/AuthContext'
3
+ import { useChannels } from '../contexts/ChannelContext'
4
+ import { Tv, Menu, LogOut, Users } from 'lucide-react'
5
+
6
+ interface HeaderProps {
7
+ onToggleChannelList: () => void
8
+ showChannelList: boolean
9
+ }
10
+
11
+ export default function Header({ onToggleChannelList, showChannelList }: HeaderProps) {
12
+ const { logout } = useAuth()
13
+ const { currentChannel, viewerCount } = useChannels()
14
+
15
+ return (
16
+ <header className="bg-gray-800 border-b border-gray-700 px-4 py-3">
17
+ <div className="flex items-center justify-between">
18
+ <div className="flex items-center space-x-4">
19
+ <button
20
+ onClick={onToggleChannelList}
21
+ className="p-2 hover:bg-gray-700 rounded-lg transition-colors"
22
+ title={showChannelList ? 'Ocultar lista' : 'Mostrar lista'}
23
+ >
24
+ <Menu className="h-5 w-5 text-gray-300" />
25
+ </button>
26
+
27
+ <div className="flex items-center space-x-2">
28
+ <Tv className="h-6 w-6 text-primary-500" />
29
+ <h1 className="text-xl font-bold text-white">DaddyTV</h1>
30
+ </div>
31
+ </div>
32
+
33
+ <div className="flex items-center space-x-4">
34
+ {currentChannel && (
35
+ <div className="flex items-center space-x-4">
36
+ <div className="text-sm text-gray-300">
37
+ <span className="font-medium">{currentChannel.name}</span>
38
+ </div>
39
+
40
+ {viewerCount > 1 && (
41
+ <div className="flex items-center space-x-1 text-sm text-gray-400">
42
+ <Users className="h-4 w-4" />
43
+ <span>{viewerCount}</span>
44
+ </div>
45
+ )}
46
+ </div>
47
+ )}
48
+
49
+ <button
50
+ onClick={logout}
51
+ className="p-2 hover:bg-gray-700 rounded-lg transition-colors text-gray-300 hover:text-white"
52
+ title="Cerrar sesión"
53
+ >
54
+ <LogOut className="h-5 w-5" />
55
+ </button>
56
+ </div>
57
+ </div>
58
+ </header>
59
+ )
60
+ }
src/components/LoadingSpinner.tsx ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+
3
+ export default function LoadingSpinner() {
4
+ return (
5
+ <div className="min-h-screen flex items-center justify-center bg-gray-900">
6
+ <div className="text-center">
7
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500 mx-auto mb-4"></div>
8
+ <p className="text-gray-400">Cargando...</p>
9
+ </div>
10
+ </div>
11
+ )
12
+ }
src/components/LoginScreen.tsx ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react'
2
+ import { useAuth } from '../contexts/AuthContext'
3
+ import { Tv, Lock } from 'lucide-react'
4
+
5
+ export default function LoginScreen() {
6
+ const [code, setCode] = useState('')
7
+ const [isLoading, setIsLoading] = useState(false)
8
+ const [error, setError] = useState('')
9
+ const { login } = useAuth()
10
+
11
+ const handleSubmit = async (e: React.FormEvent) => {
12
+ e.preventDefault()
13
+ if (!code.trim()) return
14
+
15
+ setIsLoading(true)
16
+ setError('')
17
+
18
+ const success = await login(code.trim())
19
+
20
+ if (!success) {
21
+ setError('Código incorrecto. Inténtalo de nuevo.')
22
+ }
23
+
24
+ setIsLoading(false)
25
+ }
26
+
27
+ return (
28
+ <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 px-4">
29
+ <div className="max-w-md w-full">
30
+ <div className="text-center mb-8">
31
+ <div className="flex justify-center mb-4">
32
+ <div className="bg-primary-600 p-3 rounded-full">
33
+ <Tv className="h-8 w-8 text-white" />
34
+ </div>
35
+ </div>
36
+ <h1 className="text-3xl font-bold text-white mb-2">DaddyTV</h1>
37
+ <p className="text-gray-400">Reproductor IPTV personal</p>
38
+ </div>
39
+
40
+ <div className="card p-6">
41
+ <form onSubmit={handleSubmit} className="space-y-4">
42
+ <div>
43
+ <label htmlFor="code" className="block text-sm font-medium text-gray-300 mb-2">
44
+ Código de acceso
45
+ </label>
46
+ <div className="relative">
47
+ <Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
48
+ <input
49
+ id="code"
50
+ type="text"
51
+ value={code}
52
+ onChange={(e) => setCode(e.target.value)}
53
+ className="input-field w-full pl-10"
54
+ placeholder="Ingresa tu código"
55
+ disabled={isLoading}
56
+ autoFocus
57
+ />
58
+ </div>
59
+ </div>
60
+
61
+ {error && (
62
+ <div className="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg text-sm">
63
+ {error}
64
+ </div>
65
+ )}
66
+
67
+ <button
68
+ type="submit"
69
+ disabled={isLoading || !code.trim()}
70
+ className="btn-primary w-full disabled:opacity-50 disabled:cursor-not-allowed"
71
+ >
72
+ {isLoading ? 'Verificando...' : 'Acceder'}
73
+ </button>
74
+ </form>
75
+ </div>
76
+
77
+ <div className="text-center mt-6 text-sm text-gray-500">
78
+ <p>Contacta al administrador para obtener tu código de acceso</p>
79
+ </div>
80
+ </div>
81
+ </div>
82
+ )
83
+ }
src/components/MainInterface.tsx ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from 'react'
2
+ import { useChannels } from '../contexts/ChannelContext'
3
+ import ChannelList from './ChannelList'
4
+ import VideoPlayer from './VideoPlayer'
5
+ import Header from './Header'
6
+ import LoadingSpinner from './LoadingSpinner'
7
+
8
+ export default function MainInterface() {
9
+ const { refreshChannels, isLoading, error, currentChannel } = useChannels()
10
+ const [showChannelList, setShowChannelList] = useState(true)
11
+
12
+ useEffect(() => {
13
+ refreshChannels()
14
+ }, [])
15
+
16
+ if (isLoading && !currentChannel) {
17
+ return <LoadingSpinner />
18
+ }
19
+
20
+ return (
21
+ <div className="min-h-screen bg-gray-900 flex flex-col">
22
+ <Header
23
+ onToggleChannelList={() => setShowChannelList(!showChannelList)}
24
+ showChannelList={showChannelList}
25
+ />
26
+
27
+ <div className="flex-1 flex overflow-hidden">
28
+ {showChannelList && (
29
+ <div className="w-80 border-r border-gray-700 flex-shrink-0">
30
+ <ChannelList onChannelSelect={() => setShowChannelList(false)} />
31
+ </div>
32
+ )}
33
+
34
+ <div className="flex-1 flex flex-col">
35
+ {currentChannel ? (
36
+ <VideoPlayer channel={currentChannel} />
37
+ ) : (
38
+ <div className="flex-1 flex items-center justify-center">
39
+ <div className="text-center">
40
+ <div className="text-6xl mb-4">📺</div>
41
+ <h2 className="text-2xl font-semibold text-white mb-2">
42
+ Selecciona un canal
43
+ </h2>
44
+ <p className="text-gray-400">
45
+ Elige un canal de la lista para comenzar a ver
46
+ </p>
47
+ </div>
48
+ </div>
49
+ )}
50
+ </div>
51
+ </div>
52
+
53
+ {error && (
54
+ <div className="bg-red-900/50 border-t border-red-700 text-red-300 px-4 py-3 text-sm">
55
+ {error}
56
+ </div>
57
+ )}
58
+ </div>
59
+ )
60
+ }
src/components/VideoPlayer.tsx ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useRef, useEffect, useState } from 'react'
2
+ import Hls from 'hls.js'
3
+ import { Channel } from '../types/channel'
4
+ import { channelService } from '../services/channelService'
5
+ import { Play, Pause, Volume2, VolumeX, Maximize, AlertCircle } from 'lucide-react'
6
+
7
+ interface VideoPlayerProps {
8
+ channel: Channel
9
+ }
10
+
11
+ export default function VideoPlayer({ channel }: VideoPlayerProps) {
12
+ const videoRef = useRef<HTMLVideoElement>(null)
13
+ const hlsRef = useRef<Hls | null>(null)
14
+ const [isPlaying, setIsPlaying] = useState(false)
15
+ const [isMuted, setIsMuted] = useState(false)
16
+ const [volume, setVolume] = useState(1)
17
+ const [error, setError] = useState<string | null>(null)
18
+ const [isLoading, setIsLoading] = useState(true)
19
+
20
+ useEffect(() => {
21
+ if (!videoRef.current || !channel) return
22
+
23
+ const video = videoRef.current
24
+ setError(null)
25
+ setIsLoading(true)
26
+
27
+ // Limpiar HLS anterior
28
+ if (hlsRef.current) {
29
+ hlsRef.current.destroy()
30
+ hlsRef.current = null
31
+ }
32
+
33
+ const streamUrl = channelService.getProxyUrl(channel.url)
34
+
35
+ if (Hls.isSupported()) {
36
+ const hls = new Hls({
37
+ enableWorker: true,
38
+ lowLatencyMode: true,
39
+ backBufferLength: 90
40
+ })
41
+
42
+ hls.loadSource(streamUrl)
43
+ hls.attachMedia(video)
44
+
45
+ hls.on(Hls.Events.MANIFEST_PARSED, () => {
46
+ setIsLoading(false)
47
+ video.play().catch(console.error)
48
+ })
49
+
50
+ hls.on(Hls.Events.ERROR, (event, data) => {
51
+ console.error('HLS Error:', data)
52
+ if (data.fatal) {
53
+ setError('Error al cargar el canal. Inténtalo de nuevo.')
54
+ setIsLoading(false)
55
+ }
56
+ })
57
+
58
+ hlsRef.current = hls
59
+ } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
60
+ // Safari nativo
61
+ video.src = streamUrl
62
+ video.addEventListener('loadedmetadata', () => {
63
+ setIsLoading(false)
64
+ video.play().catch(console.error)
65
+ })
66
+ video.addEventListener('error', () => {
67
+ setError('Error al cargar el canal. Inténtalo de nuevo.')
68
+ setIsLoading(false)
69
+ })
70
+ } else {
71
+ setError('Tu navegador no soporta la reproducción de este tipo de contenido.')
72
+ setIsLoading(false)
73
+ }
74
+
75
+ return () => {
76
+ if (hlsRef.current) {
77
+ hlsRef.current.destroy()
78
+ hlsRef.current = null
79
+ }
80
+ }
81
+ }, [channel])
82
+
83
+ useEffect(() => {
84
+ const video = videoRef.current
85
+ if (!video) return
86
+
87
+ const handlePlay = () => setIsPlaying(true)
88
+ const handlePause = () => setIsPlaying(false)
89
+ const handleVolumeChange = () => {
90
+ setVolume(video.volume)
91
+ setIsMuted(video.muted)
92
+ }
93
+
94
+ video.addEventListener('play', handlePlay)
95
+ video.addEventListener('pause', handlePause)
96
+ video.addEventListener('volumechange', handleVolumeChange)
97
+
98
+ return () => {
99
+ video.removeEventListener('play', handlePlay)
100
+ video.removeEventListener('pause', handlePause)
101
+ video.removeEventListener('volumechange', handleVolumeChange)
102
+ }
103
+ }, [])
104
+
105
+ const togglePlay = () => {
106
+ if (!videoRef.current) return
107
+
108
+ if (isPlaying) {
109
+ videoRef.current.pause()
110
+ } else {
111
+ videoRef.current.play()
112
+ }
113
+ }
114
+
115
+ const toggleMute = () => {
116
+ if (!videoRef.current) return
117
+ videoRef.current.muted = !videoRef.current.muted
118
+ }
119
+
120
+ const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
121
+ if (!videoRef.current) return
122
+ const newVolume = parseFloat(e.target.value)
123
+ videoRef.current.volume = newVolume
124
+ setVolume(newVolume)
125
+ }
126
+
127
+ const toggleFullscreen = () => {
128
+ if (!videoRef.current) return
129
+
130
+ if (document.fullscreenElement) {
131
+ document.exitFullscreen()
132
+ } else {
133
+ videoRef.current.requestFullscreen()
134
+ }
135
+ }
136
+
137
+ if (error) {
138
+ return (
139
+ <div className="flex-1 flex items-center justify-center bg-black">
140
+ <div className="text-center text-white">
141
+ <AlertCircle className="h-12 w-12 mx-auto mb-4 text-red-500" />
142
+ <h3 className="text-lg font-semibold mb-2">Error de reproducción</h3>
143
+ <p className="text-gray-300 mb-4">{error}</p>
144
+ <button
145
+ onClick={() => window.location.reload()}
146
+ className="btn-primary"
147
+ >
148
+ Reintentar
149
+ </button>
150
+ </div>
151
+ </div>
152
+ )
153
+ }
154
+
155
+ return (
156
+ <div className="flex-1 relative bg-black group">
157
+ <video
158
+ ref={videoRef}
159
+ className="w-full h-full object-contain"
160
+ controls={false}
161
+ autoPlay
162
+ muted={false}
163
+ playsInline
164
+ />
165
+
166
+ {isLoading && (
167
+ <div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-75">
168
+ <div className="text-center text-white">
169
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
170
+ <p>Cargando canal...</p>
171
+ </div>
172
+ </div>
173
+ )}
174
+
175
+ {/* Controles */}
176
+ <div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black to-transparent p-4 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
177
+ <div className="flex items-center space-x-4">
178
+ <button
179
+ onClick={togglePlay}
180
+ className="p-2 hover:bg-white hover:bg-opacity-20 rounded-full transition-colors"
181
+ >
182
+ {isPlaying ? (
183
+ <Pause className="h-6 w-6 text-white" />
184
+ ) : (
185
+ <Play className="h-6 w-6 text-white" />
186
+ )}
187
+ </button>
188
+
189
+ <div className="flex items-center space-x-2">
190
+ <button
191
+ onClick={toggleMute}
192
+ className="p-2 hover:bg-white hover:bg-opacity-20 rounded-full transition-colors"
193
+ >
194
+ {isMuted ? (
195
+ <VolumeX className="h-5 w-5 text-white" />
196
+ ) : (
197
+ <Volume2 className="h-5 w-5 text-white" />
198
+ )}
199
+ </button>
200
+
201
+ <input
202
+ type="range"
203
+ min="0"
204
+ max="1"
205
+ step="0.1"
206
+ value={isMuted ? 0 : volume}
207
+ onChange={handleVolumeChange}
208
+ className="w-20 h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer"
209
+ />
210
+ </div>
211
+
212
+ <div className="flex-1" />
213
+
214
+ <div className="text-white text-sm font-medium">
215
+ {channel.name}
216
+ </div>
217
+
218
+ <button
219
+ onClick={toggleFullscreen}
220
+ className="p-2 hover:bg-white hover:bg-opacity-20 rounded-full transition-colors"
221
+ >
222
+ <Maximize className="h-5 w-5 text-white" />
223
+ </button>
224
+ </div>
225
+ </div>
226
+ </div>
227
+ )
228
+ }
src/contexts/AuthContext.tsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'
2
+ import { authService } from '../services/authService'
3
+
4
+ interface AuthContextType {
5
+ isAuthenticated: boolean
6
+ isLoading: boolean
7
+ token: string | null
8
+ login: (code: string) => Promise<boolean>
9
+ logout: () => void
10
+ }
11
+
12
+ const AuthContext = createContext<AuthContextType | undefined>(undefined)
13
+
14
+ export function AuthProvider({ children }: { children: ReactNode }) {
15
+ const [isAuthenticated, setIsAuthenticated] = useState(false)
16
+ const [isLoading, setIsLoading] = useState(true)
17
+ const [token, setToken] = useState<string | null>(null)
18
+
19
+ useEffect(() => {
20
+ const savedToken = localStorage.getItem('daddytv_token')
21
+ if (savedToken) {
22
+ setToken(savedToken)
23
+ setIsAuthenticated(true)
24
+ }
25
+ setIsLoading(false)
26
+ }, [])
27
+
28
+ const login = async (code: string): Promise<boolean> => {
29
+ try {
30
+ const result = await authService.validateCode(code)
31
+ if (result.success) {
32
+ const userToken = result.token
33
+ setToken(userToken)
34
+ setIsAuthenticated(true)
35
+ localStorage.setItem('daddytv_token', userToken)
36
+ return true
37
+ }
38
+ return false
39
+ } catch (error) {
40
+ console.error('Login error:', error)
41
+ return false
42
+ }
43
+ }
44
+
45
+ const logout = () => {
46
+ setToken(null)
47
+ setIsAuthenticated(false)
48
+ localStorage.removeItem('daddytv_token')
49
+ localStorage.removeItem('daddytv_favorites')
50
+ localStorage.removeItem('daddytv_settings')
51
+ authService.logout()
52
+ }
53
+
54
+ return (
55
+ <AuthContext.Provider value={{
56
+ isAuthenticated,
57
+ isLoading,
58
+ token,
59
+ login,
60
+ logout
61
+ }}>
62
+ {children}
63
+ </AuthContext.Provider>
64
+ )
65
+ }
66
+
67
+ export function useAuth() {
68
+ const context = useContext(AuthContext)
69
+ if (context === undefined) {
70
+ throw new Error('useAuth must be used within an AuthProvider')
71
+ }
72
+ return context
73
+ }
src/contexts/ChannelContext.tsx ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'
2
+ import { channelService } from '../services/channelService'
3
+ import { Channel, ChannelCategory } from '../types/channel'
4
+
5
+ interface ChannelContextType {
6
+ channels: Channel[]
7
+ categories: ChannelCategory[]
8
+ currentChannel: Channel | null
9
+ isLoading: boolean
10
+ error: string | null
11
+ favorites: string[]
12
+ searchTerm: string
13
+ selectedCategory: string
14
+ viewerCount: number
15
+ setCurrentChannel: (channel: Channel | null) => void
16
+ setSearchTerm: (term: string) => void
17
+ setSelectedCategory: (category: string) => void
18
+ toggleFavorite: (channelId: string) => void
19
+ refreshChannels: () => Promise<void>
20
+ }
21
+
22
+ const ChannelContext = createContext<ChannelContextType | undefined>(undefined)
23
+
24
+ export function ChannelProvider({ children }: { children: ReactNode }) {
25
+ const [channels, setChannels] = useState<Channel[]>([])
26
+ const [categories, setCategories] = useState<ChannelCategory[]>([])
27
+ const [currentChannel, setCurrentChannel] = useState<Channel | null>(null)
28
+ const [isLoading, setIsLoading] = useState(false)
29
+ const [error, setError] = useState<string | null>(null)
30
+ const [favorites, setFavorites] = useState<string[]>([])
31
+ const [searchTerm, setSearchTerm] = useState('')
32
+ const [selectedCategory, setSelectedCategory] = useState('all')
33
+ const [viewerCount, setViewerCount] = useState(0)
34
+
35
+ useEffect(() => {
36
+ const savedFavorites = localStorage.getItem('daddytv_favorites')
37
+ if (savedFavorites) {
38
+ setFavorites(JSON.parse(savedFavorites))
39
+ }
40
+ }, [])
41
+
42
+ useEffect(() => {
43
+ localStorage.setItem('daddytv_favorites', JSON.stringify(favorites))
44
+ }, [favorites])
45
+
46
+ const refreshChannels = async () => {
47
+ setIsLoading(true)
48
+ setError(null)
49
+ try {
50
+ const data = await channelService.getChannels()
51
+ setChannels(data.channels)
52
+ setCategories(data.categories)
53
+ } catch (err) {
54
+ setError('Error al cargar los canales. Inténtalo de nuevo.')
55
+ console.error('Error loading channels:', err)
56
+ } finally {
57
+ setIsLoading(false)
58
+ }
59
+ }
60
+
61
+ const toggleFavorite = (channelId: string) => {
62
+ setFavorites(prev =>
63
+ prev.includes(channelId)
64
+ ? prev.filter(id => id !== channelId)
65
+ : [...prev, channelId]
66
+ )
67
+ }
68
+
69
+ const handleSetCurrentChannel = async (channel: Channel | null) => {
70
+ if (channel) {
71
+ try {
72
+ const result = await channelService.setViewing(channel.url)
73
+ if (result.switched) {
74
+ // Mostrar mensaje de cambio automático
75
+ console.log('Canal cambiado automáticamente')
76
+ }
77
+ setViewerCount(result.viewers || 1)
78
+ } catch (err) {
79
+ console.error('Error setting current channel:', err)
80
+ }
81
+ }
82
+ setCurrentChannel(channel)
83
+ }
84
+
85
+ return (
86
+ <ChannelContext.Provider value={{
87
+ channels,
88
+ categories,
89
+ currentChannel,
90
+ isLoading,
91
+ error,
92
+ favorites,
93
+ searchTerm,
94
+ selectedCategory,
95
+ viewerCount,
96
+ setCurrentChannel: handleSetCurrentChannel,
97
+ setSearchTerm,
98
+ setSelectedCategory,
99
+ toggleFavorite,
100
+ refreshChannels
101
+ }}>
102
+ {children}
103
+ </ChannelContext.Provider>
104
+ )
105
+ }
106
+
107
+ export function useChannels() {
108
+ const context = useContext(ChannelContext)
109
+ if (context === undefined) {
110
+ throw new Error('useChannels must be used within a ChannelProvider')
111
+ }
112
+ return context
113
+ }
src/hooks/useKeyboard.ts ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect } from 'react'
2
+
3
+ interface KeyboardHandlers {
4
+ onArrowUp?: () => void
5
+ onArrowDown?: () => void
6
+ onArrowLeft?: () => void
7
+ onArrowRight?: () => void
8
+ onEnter?: () => void
9
+ onEscape?: () => void
10
+ onBackspace?: () => void
11
+ }
12
+
13
+ export function useKeyboard(handlers: KeyboardHandlers, enabled: boolean = true) {
14
+ useEffect(() => {
15
+ if (!enabled) return
16
+
17
+ const handleKeyDown = (event: KeyboardEvent) => {
18
+ switch (event.key) {
19
+ case 'ArrowUp':
20
+ event.preventDefault()
21
+ handlers.onArrowUp?.()
22
+ break
23
+ case 'ArrowDown':
24
+ event.preventDefault()
25
+ handlers.onArrowDown?.()
26
+ break
27
+ case 'ArrowLeft':
28
+ event.preventDefault()
29
+ handlers.onArrowLeft?.()
30
+ break
31
+ case 'ArrowRight':
32
+ event.preventDefault()
33
+ handlers.onArrowRight?.()
34
+ break
35
+ case 'Enter':
36
+ event.preventDefault()
37
+ handlers.onEnter?.()
38
+ break
39
+ case 'Escape':
40
+ event.preventDefault()
41
+ handlers.onEscape?.()
42
+ break
43
+ case 'Backspace':
44
+ event.preventDefault()
45
+ handlers.onBackspace?.()
46
+ break
47
+ }
48
+ }
49
+
50
+ document.addEventListener('keydown', handleKeyDown)
51
+ return () => document.removeEventListener('keydown', handleKeyDown)
52
+ }, [handlers, enabled])
53
+ }
src/index.css ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ * {
7
+ box-sizing: border-box;
8
+ }
9
+
10
+ body {
11
+ font-family: 'Inter', system-ui, sans-serif;
12
+ background: #111827;
13
+ color: #f9fafb;
14
+ margin: 0;
15
+ padding: 0;
16
+ overflow-x: hidden;
17
+ }
18
+
19
+ :focus {
20
+ outline: 2px solid #3b82f6;
21
+ outline-offset: 2px;
22
+ }
23
+
24
+ ::-webkit-scrollbar {
25
+ width: 8px;
26
+ }
27
+
28
+ ::-webkit-scrollbar-track {
29
+ background: #374151;
30
+ }
31
+
32
+ ::-webkit-scrollbar-thumb {
33
+ background: #6b7280;
34
+ border-radius: 4px;
35
+ }
36
+
37
+ ::-webkit-scrollbar-thumb:hover {
38
+ background: #9ca3af;
39
+ }
40
+ }
41
+
42
+ @layer components {
43
+ .btn-primary {
44
+ @apply bg-primary-600 hover:bg-primary-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:ring-offset-gray-900;
45
+ }
46
+
47
+ .btn-secondary {
48
+ @apply bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 focus:ring-offset-gray-900;
49
+ }
50
+
51
+ .card {
52
+ @apply bg-gray-800 rounded-lg shadow-lg border border-gray-700;
53
+ }
54
+
55
+ .input-field {
56
+ @apply bg-gray-700 border border-gray-600 text-white rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-transparent;
57
+ }
58
+
59
+ .channel-item {
60
+ @apply flex items-center p-3 rounded-lg hover:bg-gray-700 cursor-pointer transition-colors duration-200 focus:bg-gray-700 focus:ring-2 focus:ring-primary-500;
61
+ }
62
+
63
+ .channel-item.active {
64
+ @apply bg-primary-600 hover:bg-primary-700;
65
+ }
66
+ }
src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App.tsx'
4
+ import './index.css'
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')!).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ )
src/services/authService.ts ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const API_BASE = '/api'
2
+
3
+ interface AuthResponse {
4
+ success: boolean
5
+ token?: string
6
+ message?: string
7
+ }
8
+
9
+ class AuthService {
10
+ async validateCode(code: string): Promise<AuthResponse> {
11
+ const response = await fetch(`${API_BASE}/auth`, {
12
+ method: 'POST',
13
+ headers: {
14
+ 'Content-Type': 'application/json',
15
+ },
16
+ body: JSON.stringify({ code }),
17
+ })
18
+
19
+ if (!response.ok) {
20
+ throw new Error('Error de autenticación')
21
+ }
22
+
23
+ return response.json()
24
+ }
25
+
26
+ async logout(): Promise<void> {
27
+ try {
28
+ await fetch(`${API_BASE}/logout`, {
29
+ method: 'POST',
30
+ headers: {
31
+ 'Authorization': `Bearer ${localStorage.getItem('daddytv_token')}`,
32
+ },
33
+ })
34
+ } catch (error) {
35
+ console.error('Logout error:', error)
36
+ }
37
+ }
38
+
39
+ async ping(): Promise<boolean> {
40
+ try {
41
+ const response = await fetch(`${API_BASE}/ping`, {
42
+ method: 'POST',
43
+ headers: {
44
+ 'Authorization': `Bearer ${localStorage.getItem('daddytv_token')}`,
45
+ },
46
+ })
47
+ return response.ok
48
+ } catch (error) {
49
+ return false
50
+ }
51
+ }
52
+ }
53
+
54
+ export const authService = new AuthService()
src/services/channelService.ts ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ChannelResponse, ViewerResponse } from '../types/channel'
2
+
3
+ const API_BASE = '/api'
4
+
5
+ class ChannelService {
6
+ async getChannels(): Promise<ChannelResponse> {
7
+ const token = localStorage.getItem('daddytv_token')
8
+ const response = await fetch(`${API_BASE}/channels`, {
9
+ headers: {
10
+ 'Authorization': `Bearer ${token}`,
11
+ },
12
+ })
13
+
14
+ if (!response.ok) {
15
+ throw new Error('Error al cargar canales')
16
+ }
17
+
18
+ return response.json()
19
+ }
20
+
21
+ async setViewing(channelUrl: string): Promise<ViewerResponse> {
22
+ const token = localStorage.getItem('daddytv_token')
23
+ const response = await fetch(`${API_BASE}/viewers`, {
24
+ method: 'POST',
25
+ headers: {
26
+ 'Content-Type': 'application/json',
27
+ 'Authorization': `Bearer ${token}`,
28
+ },
29
+ body: JSON.stringify({ channel_url: channelUrl }),
30
+ })
31
+
32
+ if (!response.ok) {
33
+ throw new Error('Error al registrar visualización')
34
+ }
35
+
36
+ return response.json()
37
+ }
38
+
39
+ getProxyUrl(originalUrl: string): string {
40
+ return `${API_BASE}/proxy?url=${encodeURIComponent(originalUrl)}`
41
+ }
42
+ }
43
+
44
+ export const channelService = new ChannelService()
src/types/channel.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface Channel {
2
+ id: string
3
+ name: string
4
+ url: string
5
+ logo?: string
6
+ category: string
7
+ group?: string
8
+ }
9
+
10
+ export interface ChannelCategory {
11
+ name: string
12
+ count: number
13
+ }
14
+
15
+ export interface ChannelResponse {
16
+ channels: Channel[]
17
+ categories: ChannelCategory[]
18
+ }
19
+
20
+ export interface ViewerResponse {
21
+ viewers: number
22
+ switched: boolean
23
+ message?: string
24
+ }
tailwind.config.js ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: [
4
+ "./index.html",
5
+ "./src/**/*.{js,ts,jsx,tsx}",
6
+ ],
7
+ theme: {
8
+ extend: {
9
+ colors: {
10
+ primary: {
11
+ 50: '#f0f9ff',
12
+ 500: '#3b82f6',
13
+ 600: '#2563eb',
14
+ 700: '#1d4ed8',
15
+ 900: '#1e3a8a'
16
+ }
17
+ },
18
+ fontFamily: {
19
+ sans: ['Inter', 'system-ui', 'sans-serif']
20
+ }
21
+ },
22
+ },
23
+ plugins: [],
24
+ }
vercel.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "builds": [
3
+ {
4
+ "src": "api/main.py",
5
+ "use": "@vercel/python"
6
+ }
7
+ ],
8
+ "routes": [
9
+ {
10
+ "src": "/api/(.*)",
11
+ "dest": "api/main.py"
12
+ },
13
+ {
14
+ "src": "/(.*)",
15
+ "dest": "/index.html"
16
+ }
17
+ ],
18
+ "env": {
19
+ "PYTHONPATH": "api"
20
+ }
21
+ }
vite.config.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import { VitePWA } from 'vite-plugin-pwa'
4
+
5
+ export default defineConfig({
6
+ plugins: [
7
+ react(),
8
+ VitePWA({
9
+ registerType: 'autoUpdate',
10
+ includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'masked-icon.svg'],
11
+ manifest: {
12
+ name: 'DaddyTV - IPTV Player',
13
+ short_name: 'DaddyTV',
14
+ description: 'Reproductor IPTV personal para amigos y familiares',
15
+ theme_color: '#1f2937',
16
+ background_color: '#111827',
17
+ display: 'standalone',
18
+ orientation: 'landscape-primary',
19
+ icons: [
20
+ {
21
+ src: 'pwa-192x192.png',
22
+ sizes: '192x192',
23
+ type: 'image/png'
24
+ },
25
+ {
26
+ src: 'pwa-512x512.png',
27
+ sizes: '512x512',
28
+ type: 'image/png'
29
+ }
30
+ ]
31
+ },
32
+ workbox: {
33
+ globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
34
+ runtimeCaching: [
35
+ {
36
+ urlPattern: /^https:\/\/.*\.m3u8?$/,
37
+ handler: 'NetworkFirst',
38
+ options: {
39
+ cacheName: 'iptv-playlists',
40
+ expiration: {
41
+ maxEntries: 10,
42
+ maxAgeSeconds: 3600
43
+ }
44
+ }
45
+ }
46
+ ]
47
+ }
48
+ })
49
+ ],
50
+ server: {
51
+ proxy: {
52
+ '/api': {
53
+ target: 'http://localhost:8000',
54
+ changeOrigin: true
55
+ }
56
+ }
57
+ }
58
+ })