Upload 41 files
Browse files- .dockerignore +47 -0
- .env.example +14 -0
- .gitignore +2 -0
- Dockerfile +53 -0
- README.md +190 -10
- admin-panel.html +255 -0
- api/admin.py +85 -0
- api/auth.py +87 -0
- api/m3u_parser.py +187 -0
- api/main.py +71 -0
- api/proxy.py +93 -0
- api/utils.py +71 -0
- api/viewers.py +126 -0
- docker-compose.yml +31 -0
- generate_admin_code.py +58 -0
- index.html +18 -0
- package-lock.json +0 -0
- package.json +34 -0
- postcss.config.js +6 -0
- public/manifest.json +33 -0
- requirements.txt +6 -0
- scripts/docker-deploy.sh +50 -0
- scripts/setup.sh +55 -0
- src/App.tsx +30 -0
- src/components/ChannelList.tsx +201 -0
- src/components/Header.tsx +60 -0
- src/components/LoadingSpinner.tsx +12 -0
- src/components/LoginScreen.tsx +83 -0
- src/components/MainInterface.tsx +60 -0
- src/components/VideoPlayer.tsx +228 -0
- src/contexts/AuthContext.tsx +73 -0
- src/contexts/ChannelContext.tsx +113 -0
- src/hooks/useKeyboard.ts +53 -0
- src/index.css +66 -0
- src/main.tsx +10 -0
- src/services/authService.ts +54 -0
- src/services/channelService.ts +44 -0
- src/types/channel.ts +24 -0
- tailwind.config.js +24 -0
- vercel.json +21 -0
- vite.config.ts +58 -0
.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 |
-
|
3 |
-
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
})
|