Initial commit: KiraTV IPTV proxy server
Full-stack IPTV management system with Xtream Codes API compatibility, multi-account provider fan-out, Jellyfin local library integration, real-time dashboard, EPG support, and A/V-sync-safe stream recovery.
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Copy this file to /opt/kirastream/.env and fill in your values
|
||||
|
||||
DATABASE_URL=postgresql+asyncpg://kirastream:CHANGE_ME@localhost/kirastream
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# JWT — generate with: openssl rand -hex 32
|
||||
SECRET_KEY=CHANGE_ME
|
||||
|
||||
# Admin panel credentials
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=CHANGE_ME
|
||||
|
||||
# Stream settings (optional, defaults shown)
|
||||
# STREAM_CHUNK_SIZE=65536
|
||||
# STREAM_QUEUE_MAXSIZE=100
|
||||
# STREAM_CONNECT_TIMEOUT=10
|
||||
# STREAM_READ_TIMEOUT=30
|
||||
|
||||
# EPG / M3U cache (optional)
|
||||
# EPG_REFRESH_HOURS=12
|
||||
# M3U_CACHE_MINUTES=30
|
||||
|
||||
# Logging
|
||||
# LOG_LEVEL=INFO
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
*.egg
|
||||
.eggs/
|
||||
*.so
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
pip-wheel-metadata/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
htmlcov/
|
||||
.coverage
|
||||
coverage.xml
|
||||
*.cover
|
||||
|
||||
# Alembic
|
||||
backend/migrations/versions/
|
||||
|
||||
# Environment / secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.env
|
||||
|
||||
# Frontend
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Backups generated during dev
|
||||
*.bak
|
||||
*.orig
|
||||
@@ -0,0 +1,44 @@
|
||||
# KiraTV
|
||||
|
||||
Sistema de proxy y gestión IPTV con soporte multiusuario y fan-out de streams.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Backend**: Python 3.13 + FastAPI + asyncio
|
||||
- **Base de datos**: PostgreSQL 17 + SQLAlchemy 2 + Alembic
|
||||
- **Cache / estado**: Redis 8
|
||||
- **Frontend**: React 18 + Vite + TailwindCSS
|
||||
- **Proxy inverso**: Nginx
|
||||
|
||||
## Características
|
||||
|
||||
- Compatible con API Xtream Codes (TiviMate, Smarters, IPTV Pro…)
|
||||
- Fan-out de streams: N usuarios ven el mismo canal → 1 solo slot del proveedor
|
||||
- Soporte multi-cuenta del mismo proveedor con balanceo de slots
|
||||
- Catálogos personalizados por usuario (live, VOD, series)
|
||||
- Integración Jellyfin: biblioteca local como categorías KiraTV
|
||||
- EPG XMLTV con merge por tvg-id
|
||||
- Dashboard en tiempo real (WebSocket): qué usuario ve qué canal y qué slot usa
|
||||
- Reconexión automática con detección de stall y A/V sync garantizado
|
||||
|
||||
## Instalación
|
||||
|
||||
```bash
|
||||
cp .env.example /opt/kirastream/.env
|
||||
# Editar .env con tus valores reales
|
||||
|
||||
cd /opt/kirastream/frontend
|
||||
npm install && npm run build
|
||||
|
||||
cd /opt/kirastream
|
||||
python3 -m venv venv
|
||||
venv/bin/pip install -r backend/requirements.txt
|
||||
venv/bin/python -m alembic upgrade head
|
||||
```
|
||||
|
||||
## Configuración
|
||||
|
||||
Ver `.env.example` para todas las variables disponibles.
|
||||
|
||||
La configuración de Nginx está en `nginx/kirastream.conf`.
|
||||
El servicio systemd está en `kirastream.service`.
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Admin API: category/catalog management."""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ...database import get_db
|
||||
from ...models.channel import Category, Channel, ChannelProviderMap, ContentType
|
||||
from ..auth import get_current_admin
|
||||
|
||||
router = APIRouter(prefix="/catalogs", tags=["catalogs"])
|
||||
|
||||
|
||||
class CategoryOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
type: ContentType
|
||||
provider_category_id: str | None
|
||||
provider_account_id: int | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GroupedCategoryOut(BaseModel):
|
||||
name: str
|
||||
type: ContentType
|
||||
category_ids: list[int]
|
||||
provider_count: int # distinct providers that can serve channels in this category
|
||||
|
||||
|
||||
@router.get("/", response_model=list[CategoryOut])
|
||||
async def list_categories(
|
||||
content_type: ContentType | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(get_current_admin),
|
||||
):
|
||||
q = select(Category).order_by(Category.type, Category.name)
|
||||
if content_type:
|
||||
q = q.where(Category.type == content_type)
|
||||
result = await db.execute(q)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/grouped", response_model=list[GroupedCategoryOut])
|
||||
async def list_categories_grouped(
|
||||
content_type: ContentType | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(get_current_admin),
|
||||
):
|
||||
"""Returns categories merged by name+type. Includes how many providers can serve each."""
|
||||
q = select(Category).order_by(Category.type, Category.name)
|
||||
if content_type:
|
||||
q = q.where(Category.type == content_type)
|
||||
cats_result = await db.execute(q)
|
||||
all_cats = cats_result.scalars().all()
|
||||
|
||||
# Group by (name_normalized, type)
|
||||
seen: dict[tuple[str, str], dict] = {}
|
||||
for cat in all_cats:
|
||||
key = (cat.name.strip().lower(), str(cat.type))
|
||||
if key not in seen:
|
||||
seen[key] = {"name": cat.name, "type": cat.type, "category_ids": [], "provider_count": 0}
|
||||
seen[key]["category_ids"].append(cat.id)
|
||||
|
||||
if not seen:
|
||||
return []
|
||||
|
||||
# Map each category_id back to its group key for the count query
|
||||
cat_id_to_key: dict[int, tuple[str, str]] = {}
|
||||
for key, group in seen.items():
|
||||
for cat_id in group["category_ids"]:
|
||||
cat_id_to_key[cat_id] = key
|
||||
|
||||
# Single query: distinct providers per category_id — avoids locale-dependent lower() issues
|
||||
pcount_q = await db.execute(
|
||||
select(
|
||||
Channel.category_id,
|
||||
func.count(func.distinct(ChannelProviderMap.provider_account_id)).label("pcount"),
|
||||
)
|
||||
.join(ChannelProviderMap, ChannelProviderMap.channel_id == Channel.id)
|
||||
.where(Channel.category_id.in_(list(cat_id_to_key.keys())))
|
||||
.group_by(Channel.category_id)
|
||||
)
|
||||
for row in pcount_q:
|
||||
key = cat_id_to_key.get(row.category_id)
|
||||
if key and key in seen:
|
||||
seen[key]["provider_count"] = max(seen[key]["provider_count"], row.pcount)
|
||||
|
||||
return list(seen.values())
|
||||
@@ -0,0 +1,186 @@
|
||||
"""CRUD for custom categories and their channel assignments."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from ...database import get_db
|
||||
from ...models.channel import Channel, ContentType
|
||||
from ...models.custom_category import CustomCategory, CustomCategoryItem
|
||||
from ..auth import get_current_admin
|
||||
|
||||
router = APIRouter(prefix="/custom-categories", tags=["custom-categories"])
|
||||
|
||||
|
||||
class CategoryCreate(BaseModel):
|
||||
name: str
|
||||
type: str # live, movie, series
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
is_visible: bool | None = None
|
||||
position: int | None = None
|
||||
|
||||
|
||||
class AddChannelRequest(BaseModel):
|
||||
channel_id: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Category CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("")
|
||||
async def list_categories(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(
|
||||
select(CustomCategory)
|
||||
.options(selectinload(CustomCategory.items))
|
||||
.order_by(CustomCategory.position, CustomCategory.id)
|
||||
)
|
||||
cats = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"type": c.type.value,
|
||||
"position": c.position,
|
||||
"is_visible": c.is_visible,
|
||||
"item_count": len(c.items),
|
||||
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||
}
|
||||
for c in cats
|
||||
]
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_category(data: CategoryCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
try:
|
||||
ctype = ContentType(data.type)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "type must be one of: live, movie, series")
|
||||
cat = CustomCategory(name=data.name.strip(), type=ctype)
|
||||
db.add(cat)
|
||||
await db.commit()
|
||||
await db.refresh(cat)
|
||||
return {"id": cat.id, "name": cat.name, "type": cat.type.value, "position": cat.position, "is_visible": cat.is_visible, "item_count": 0}
|
||||
|
||||
|
||||
@router.patch("/{cat_id}")
|
||||
async def update_category(cat_id: int, data: CategoryUpdate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(select(CustomCategory).where(CustomCategory.id == cat_id))
|
||||
cat = result.scalar_one_or_none()
|
||||
if not cat:
|
||||
raise HTTPException(404, "Not found")
|
||||
if data.name is not None:
|
||||
cat.name = data.name.strip()
|
||||
if data.is_visible is not None:
|
||||
cat.is_visible = data.is_visible
|
||||
if data.position is not None:
|
||||
cat.position = data.position
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/{cat_id}", status_code=204)
|
||||
async def delete_category(cat_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(select(CustomCategory).where(CustomCategory.id == cat_id))
|
||||
cat = result.scalar_one_or_none()
|
||||
if not cat:
|
||||
raise HTTPException(404, "Not found")
|
||||
await db.delete(cat)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Channel assignments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/{cat_id}/channels")
|
||||
async def list_category_channels(cat_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(
|
||||
select(CustomCategoryItem, Channel)
|
||||
.join(Channel, Channel.id == CustomCategoryItem.channel_id)
|
||||
.where(CustomCategoryItem.custom_category_id == cat_id)
|
||||
.order_by(CustomCategoryItem.position, CustomCategoryItem.id)
|
||||
)
|
||||
rows = result.all()
|
||||
return [
|
||||
{
|
||||
"item_id": row.CustomCategoryItem.id,
|
||||
"channel_id": row.Channel.id,
|
||||
"name": row.Channel.name,
|
||||
"logo": row.Channel.tvg_logo or "",
|
||||
"type": row.Channel.type.value,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{cat_id}/channels", status_code=201)
|
||||
async def add_channel(cat_id: int, data: AddChannelRequest, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
# Verify category exists
|
||||
cat_result = await db.execute(select(CustomCategory).where(CustomCategory.id == cat_id))
|
||||
if not cat_result.scalar_one_or_none():
|
||||
raise HTTPException(404, "Category not found")
|
||||
# Verify channel exists
|
||||
ch_result = await db.execute(select(Channel).where(Channel.id == data.channel_id))
|
||||
if not ch_result.scalar_one_or_none():
|
||||
raise HTTPException(404, "Channel not found")
|
||||
# Check duplicate
|
||||
dup = await db.execute(
|
||||
select(CustomCategoryItem).where(
|
||||
CustomCategoryItem.custom_category_id == cat_id,
|
||||
CustomCategoryItem.channel_id == data.channel_id,
|
||||
)
|
||||
)
|
||||
if dup.scalar_one_or_none():
|
||||
raise HTTPException(409, "Channel already in category")
|
||||
item = CustomCategoryItem(custom_category_id=cat_id, channel_id=data.channel_id)
|
||||
db.add(item)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/{cat_id}/channels/{item_id}", status_code=204)
|
||||
async def remove_channel(cat_id: int, item_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(
|
||||
select(CustomCategoryItem).where(
|
||||
CustomCategoryItem.id == item_id,
|
||||
CustomCategoryItem.custom_category_id == cat_id,
|
||||
)
|
||||
)
|
||||
item = result.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(404, "Not found")
|
||||
await db.delete(item)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Channel search (for the picker)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/channels/search")
|
||||
async def search_channels(
|
||||
q: str = "",
|
||||
type: str = "",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(get_current_admin),
|
||||
):
|
||||
query = select(Channel).where(Channel.is_active == True) # noqa: E712
|
||||
if type:
|
||||
try:
|
||||
query = query.where(Channel.type == ContentType(type))
|
||||
except ValueError:
|
||||
pass
|
||||
if q:
|
||||
query = query.where(Channel.name.ilike(f"%{q}%"))
|
||||
query = query.order_by(Channel.name).limit(60)
|
||||
result = await db.execute(query)
|
||||
channels = result.scalars().all()
|
||||
return [
|
||||
{"id": ch.id, "name": ch.name, "logo": ch.tvg_logo or "", "type": ch.type.value}
|
||||
for ch in channels
|
||||
]
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Admin API: EPG source management."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ...database import get_db
|
||||
from ...models.epg import EpgSource
|
||||
from ...core.epg_manager import refresh_epg
|
||||
from ..auth import get_current_admin
|
||||
|
||||
router = APIRouter(prefix="/epg", tags=["epg"])
|
||||
|
||||
|
||||
class EpgSourceCreate(BaseModel):
|
||||
name: str
|
||||
url: str
|
||||
|
||||
|
||||
class EpgSourceOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
url: str
|
||||
is_active: bool
|
||||
last_fetched_at: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/", response_model=list[EpgSourceOut])
|
||||
async def list_epg_sources(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(select(EpgSource).order_by(EpgSource.id))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/", response_model=EpgSourceOut)
|
||||
async def create_epg_source(data: EpgSourceCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
source = EpgSource(**data.model_dump())
|
||||
db.add(source)
|
||||
await db.commit()
|
||||
await db.refresh(source)
|
||||
return source
|
||||
|
||||
|
||||
@router.delete("/{source_id}")
|
||||
async def delete_epg_source(source_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(select(EpgSource).where(EpgSource.id == source_id))
|
||||
source = result.scalar_one_or_none()
|
||||
if not source:
|
||||
raise HTTPException(404, "EPG source not found")
|
||||
await db.delete(source)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def trigger_refresh(background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
background_tasks.add_task(refresh_epg, db)
|
||||
return {"ok": True, "message": "EPG refresh started in background"}
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Admin API for Jellyfin integration — CRUD, library discovery, sync."""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from ...core.jellyfin_client import JellyfinClient
|
||||
from ...database import get_db, AsyncSessionLocal
|
||||
from ...models.channel import Channel, Category, ChannelProviderMap, ContentType
|
||||
from ...models.jellyfin import JellyfinConfig, JellyfinItem
|
||||
from ...models.provider import ProviderAccount
|
||||
from ..auth import get_current_admin
|
||||
|
||||
router = APIRouter(prefix="/jellyfin", tags=["jellyfin"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# sync_id → {"status": "running"|"done"|"error", "message": str, "synced": int}
|
||||
_sync_status: dict[int, dict] = {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class JellyfinConfigCreate(BaseModel):
|
||||
name: str
|
||||
url: str
|
||||
api_key: str
|
||||
movies_category_name: str = "Películas KiraTV"
|
||||
series_category_name: str = "Series KiraTV"
|
||||
movies_library_id: str | None = None
|
||||
series_library_id: str | None = None
|
||||
|
||||
|
||||
class JellyfinConfigUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
url: str | None = None
|
||||
api_key: str | None = None
|
||||
movies_category_name: str | None = None
|
||||
series_category_name: str | None = None
|
||||
movies_library_id: str | None = None
|
||||
series_library_id: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("", dependencies=[Depends(get_current_admin)])
|
||||
async def list_configs(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(JellyfinConfig).options(selectinload(JellyfinConfig.items))
|
||||
.order_by(JellyfinConfig.id)
|
||||
)
|
||||
configs = result.scalars().all()
|
||||
return [_config_dict(c) for c in configs]
|
||||
|
||||
|
||||
@router.post("", dependencies=[Depends(get_current_admin)])
|
||||
async def create_config(body: JellyfinConfigCreate, db: AsyncSession = Depends(get_db)):
|
||||
cfg = JellyfinConfig(
|
||||
name=body.name,
|
||||
url=body.url.rstrip("/"),
|
||||
api_key=body.api_key,
|
||||
movies_category_name=body.movies_category_name,
|
||||
series_category_name=body.series_category_name,
|
||||
movies_library_id=body.movies_library_id,
|
||||
series_library_id=body.series_library_id,
|
||||
)
|
||||
db.add(cfg)
|
||||
await db.commit()
|
||||
await db.refresh(cfg)
|
||||
return _config_dict(cfg)
|
||||
|
||||
|
||||
@router.put("/{cfg_id}", dependencies=[Depends(get_current_admin)])
|
||||
async def update_config(cfg_id: int, body: JellyfinConfigUpdate, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(JellyfinConfig).where(JellyfinConfig.id == cfg_id))
|
||||
cfg = result.scalar_one_or_none()
|
||||
if not cfg:
|
||||
raise HTTPException(404, "Not found")
|
||||
if body.name is not None:
|
||||
cfg.name = body.name
|
||||
if body.url is not None:
|
||||
cfg.url = body.url.rstrip("/")
|
||||
if body.api_key is not None:
|
||||
cfg.api_key = body.api_key
|
||||
if body.movies_category_name is not None:
|
||||
cfg.movies_category_name = body.movies_category_name
|
||||
if body.series_category_name is not None:
|
||||
cfg.series_category_name = body.series_category_name
|
||||
if body.movies_library_id is not None:
|
||||
cfg.movies_library_id = body.movies_library_id
|
||||
if body.series_library_id is not None:
|
||||
cfg.series_library_id = body.series_library_id
|
||||
if body.is_active is not None:
|
||||
cfg.is_active = body.is_active
|
||||
await db.commit()
|
||||
await db.refresh(cfg)
|
||||
return _config_dict(cfg)
|
||||
|
||||
|
||||
@router.delete("/{cfg_id}", dependencies=[Depends(get_current_admin)])
|
||||
async def delete_config(cfg_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(JellyfinConfig).where(JellyfinConfig.id == cfg_id))
|
||||
cfg = result.scalar_one_or_none()
|
||||
if not cfg:
|
||||
raise HTTPException(404, "Not found")
|
||||
await db.delete(cfg)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test connection + library list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/{cfg_id}/test", dependencies=[Depends(get_current_admin)])
|
||||
async def test_connection(cfg_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(JellyfinConfig).where(JellyfinConfig.id == cfg_id))
|
||||
cfg = result.scalar_one_or_none()
|
||||
if not cfg:
|
||||
raise HTTPException(404, "Not found")
|
||||
client = JellyfinClient(cfg.url, cfg.api_key)
|
||||
try:
|
||||
info = await client.test_connection()
|
||||
return {"ok": True, "server_name": info.get("ServerName", ""), "version": info.get("Version", "")}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.get("/{cfg_id}/libraries", dependencies=[Depends(get_current_admin)])
|
||||
async def get_libraries(cfg_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(JellyfinConfig).where(JellyfinConfig.id == cfg_id))
|
||||
cfg = result.scalar_one_or_none()
|
||||
if not cfg:
|
||||
raise HTTPException(404, "Not found")
|
||||
client = JellyfinClient(cfg.url, cfg.api_key)
|
||||
try:
|
||||
libs = await client.get_libraries()
|
||||
return [
|
||||
{
|
||||
"id": lib.get("ItemId", ""),
|
||||
"name": lib.get("Name", ""),
|
||||
"type": lib.get("CollectionType", ""),
|
||||
}
|
||||
for lib in libs
|
||||
]
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"Jellyfin unreachable: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/{cfg_id}/sync", dependencies=[Depends(get_current_admin)])
|
||||
async def start_sync(cfg_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(JellyfinConfig).where(JellyfinConfig.id == cfg_id))
|
||||
cfg = result.scalar_one_or_none()
|
||||
if not cfg:
|
||||
raise HTTPException(404, "Not found")
|
||||
if _sync_status.get(cfg_id, {}).get("status") == "running":
|
||||
return {"status": "already_running"}
|
||||
_sync_status[cfg_id] = {"status": "running", "message": "Iniciando…", "synced": 0}
|
||||
asyncio.create_task(_do_sync(cfg_id))
|
||||
return {"status": "started"}
|
||||
|
||||
|
||||
@router.get("/{cfg_id}/sync-status", dependencies=[Depends(get_current_admin)])
|
||||
async def sync_status(cfg_id: int):
|
||||
return _sync_status.get(cfg_id, {"status": "idle", "message": "", "synced": 0})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal sync implementation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _do_sync(cfg_id: int) -> None:
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
await _sync_jellyfin(cfg_id, db)
|
||||
_sync_status[cfg_id] = {"status": "done", "message": "Sincronización completada", "synced": _sync_status[cfg_id].get("synced", 0)}
|
||||
except Exception as e:
|
||||
logger.error(f"Jellyfin sync error for config {cfg_id}: {e}", exc_info=True)
|
||||
_sync_status[cfg_id] = {"status": "error", "message": str(e), "synced": 0}
|
||||
|
||||
|
||||
async def _sync_jellyfin(cfg_id: int, db: AsyncSession) -> None:
|
||||
result = await db.execute(select(JellyfinConfig).where(JellyfinConfig.id == cfg_id))
|
||||
cfg = result.scalar_one_or_none()
|
||||
if not cfg:
|
||||
raise ValueError(f"JellyfinConfig {cfg_id} not found")
|
||||
|
||||
client = JellyfinClient(cfg.url, cfg.api_key)
|
||||
_sync_status[cfg_id]["message"] = "Conectando a Jellyfin…"
|
||||
|
||||
# 1. Ensure dummy ProviderAccount exists
|
||||
provider = await _get_or_create_provider(cfg, db)
|
||||
if cfg.provider_account_id != provider.id:
|
||||
cfg.provider_account_id = provider.id
|
||||
await db.flush()
|
||||
|
||||
total_synced = 0
|
||||
|
||||
# 2. Sync movies
|
||||
if cfg.movies_library_id:
|
||||
_sync_status[cfg_id]["message"] = "Obteniendo películas…"
|
||||
movies = await client.get_items(cfg.movies_library_id, "Movie")
|
||||
_sync_status[cfg_id]["message"] = f"Sincronizando {len(movies)} películas…"
|
||||
movie_cat = await _get_or_create_category(
|
||||
db, cfg.movies_category_name, ContentType.movie, provider.id, "jellyfin_movies"
|
||||
)
|
||||
for item in movies:
|
||||
await _upsert_channel(db, cfg, provider, movie_cat, item, ContentType.movie, client)
|
||||
total_synced += 1
|
||||
_sync_status[cfg_id]["synced"] = total_synced
|
||||
|
||||
# 3. Sync series
|
||||
if cfg.series_library_id:
|
||||
_sync_status[cfg_id]["message"] = "Obteniendo series…"
|
||||
series_list = await client.get_items(cfg.series_library_id, "Series")
|
||||
_sync_status[cfg_id]["message"] = f"Sincronizando {len(series_list)} series…"
|
||||
series_cat = await _get_or_create_category(
|
||||
db, cfg.series_category_name, ContentType.series, provider.id, "jellyfin_series"
|
||||
)
|
||||
for item in series_list:
|
||||
await _upsert_channel(db, cfg, provider, series_cat, item, ContentType.series, client)
|
||||
total_synced += 1
|
||||
_sync_status[cfg_id]["synced"] = total_synced
|
||||
|
||||
cfg.last_sync_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
logger.info(f"Jellyfin sync complete for config {cfg_id}: {total_synced} items")
|
||||
|
||||
|
||||
async def _get_or_create_provider(cfg: JellyfinConfig, db: AsyncSession) -> ProviderAccount:
|
||||
if cfg.provider_account_id:
|
||||
result = await db.execute(
|
||||
select(ProviderAccount).where(ProviderAccount.id == cfg.provider_account_id)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
provider = ProviderAccount(
|
||||
name=f"[Jellyfin] {cfg.name}",
|
||||
base_url=cfg.url,
|
||||
username="jellyfin",
|
||||
password=cfg.api_key,
|
||||
max_connections=999,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(provider)
|
||||
await db.flush()
|
||||
return provider
|
||||
|
||||
|
||||
async def _get_or_create_category(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
ctype: ContentType,
|
||||
provider_id: int,
|
||||
provider_cat_id: str,
|
||||
) -> Category:
|
||||
result = await db.execute(
|
||||
select(Category).where(
|
||||
Category.provider_category_id == provider_cat_id,
|
||||
Category.provider_account_id == provider_id,
|
||||
Category.type == ctype,
|
||||
)
|
||||
)
|
||||
cat = result.scalar_one_or_none()
|
||||
if cat:
|
||||
cat.name = name
|
||||
return cat
|
||||
cat = Category(
|
||||
name=name,
|
||||
type=ctype,
|
||||
provider_category_id=provider_cat_id,
|
||||
provider_account_id=provider_id,
|
||||
)
|
||||
db.add(cat)
|
||||
await db.flush()
|
||||
return cat
|
||||
|
||||
|
||||
async def _upsert_channel(
|
||||
db: AsyncSession,
|
||||
cfg: JellyfinConfig,
|
||||
provider: ProviderAccount,
|
||||
category: Category,
|
||||
item: dict,
|
||||
ctype: ContentType,
|
||||
client: JellyfinClient,
|
||||
) -> None:
|
||||
jellyfin_id = item.get("Id", "")
|
||||
if not jellyfin_id:
|
||||
return
|
||||
|
||||
stream_id = f"jf_{jellyfin_id}"
|
||||
stream_url = client.build_stream_url(jellyfin_id)
|
||||
image_url = client.build_image_url(jellyfin_id)
|
||||
name = item.get("Name", "")
|
||||
plot = item.get("Overview") or ""
|
||||
year = item.get("ProductionYear")
|
||||
genres = ", ".join(item.get("Genres", []))
|
||||
rating = item.get("OfficialRating") or ""
|
||||
|
||||
# Find or create Channel
|
||||
ch_result = await db.execute(
|
||||
select(Channel)
|
||||
.options(selectinload(Channel.provider_maps))
|
||||
.where(Channel.stream_id_at_provider == stream_id, Channel.type == ctype)
|
||||
)
|
||||
ch = ch_result.scalar_one_or_none()
|
||||
if not ch:
|
||||
ch = Channel(
|
||||
name=name,
|
||||
stream_id_at_provider=stream_id,
|
||||
type=ctype,
|
||||
category_id=category.id,
|
||||
tvg_logo=image_url,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(ch)
|
||||
await db.flush()
|
||||
else:
|
||||
ch.name = name
|
||||
ch.tvg_logo = image_url
|
||||
ch.category_id = category.id
|
||||
ch.is_active = True
|
||||
|
||||
# ChannelProviderMap
|
||||
pmap_result = await db.execute(
|
||||
select(ChannelProviderMap).where(
|
||||
ChannelProviderMap.channel_id == ch.id,
|
||||
ChannelProviderMap.provider_account_id == provider.id,
|
||||
)
|
||||
)
|
||||
pmap = pmap_result.scalar_one_or_none()
|
||||
if not pmap:
|
||||
pmap = ChannelProviderMap(
|
||||
channel_id=ch.id,
|
||||
provider_account_id=provider.id,
|
||||
stream_url=stream_url,
|
||||
)
|
||||
db.add(pmap)
|
||||
else:
|
||||
pmap.stream_url = stream_url
|
||||
|
||||
# JellyfinItem
|
||||
ji_result = await db.execute(
|
||||
select(JellyfinItem).where(
|
||||
JellyfinItem.jellyfin_config_id == cfg.id,
|
||||
JellyfinItem.jellyfin_item_id == jellyfin_id,
|
||||
)
|
||||
)
|
||||
ji = ji_result.scalar_one_or_none()
|
||||
if not ji:
|
||||
ji = JellyfinItem(
|
||||
jellyfin_config_id=cfg.id,
|
||||
channel_id=ch.id,
|
||||
jellyfin_item_id=jellyfin_id,
|
||||
item_type=ctype.value,
|
||||
plot=plot,
|
||||
year=year,
|
||||
genres=genres,
|
||||
rating=rating,
|
||||
)
|
||||
db.add(ji)
|
||||
else:
|
||||
ji.channel_id = ch.id
|
||||
ji.plot = plot
|
||||
ji.year = year
|
||||
ji.genres = genres
|
||||
ji.rating = rating
|
||||
|
||||
await db.flush()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _config_dict(cfg: JellyfinConfig) -> dict:
|
||||
return {
|
||||
"id": cfg.id,
|
||||
"name": cfg.name,
|
||||
"url": cfg.url,
|
||||
"api_key": cfg.api_key,
|
||||
"is_active": cfg.is_active,
|
||||
"movies_library_id": cfg.movies_library_id,
|
||||
"series_library_id": cfg.series_library_id,
|
||||
"movies_category_name": cfg.movies_category_name,
|
||||
"series_category_name": cfg.series_category_name,
|
||||
"last_sync_at": cfg.last_sync_at.isoformat() if cfg.last_sync_at else None,
|
||||
"item_count": len(cfg.__dict__.get("items", [])),
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Connection log API — query and stats."""
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select, func, desc, distinct
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...database import get_db
|
||||
from ...models.log import ConnectionLog
|
||||
from ..auth import get_current_admin
|
||||
|
||||
router = APIRouter(prefix="/logs", tags=["logs"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_logs(
|
||||
username: str | None = Query(None),
|
||||
channel: str | None = Query(None),
|
||||
content_type: str | None = Query(None),
|
||||
date_from: str | None = Query(None),
|
||||
date_to: str | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(get_current_admin),
|
||||
):
|
||||
q = select(ConnectionLog).order_by(desc(ConnectionLog.started_at))
|
||||
|
||||
if username:
|
||||
q = q.where(ConnectionLog.username.ilike(f"%{username}%"))
|
||||
if channel:
|
||||
q = q.where(ConnectionLog.channel_name.ilike(f"%{channel}%"))
|
||||
if content_type:
|
||||
q = q.where(ConnectionLog.content_type == content_type)
|
||||
if date_from:
|
||||
try:
|
||||
q = q.where(ConnectionLog.started_at >= datetime.fromisoformat(date_from))
|
||||
except ValueError:
|
||||
pass
|
||||
if date_to:
|
||||
try:
|
||||
q = q.where(ConnectionLog.started_at <= datetime.fromisoformat(date_to))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
total_result = await db.execute(select(func.count()).select_from(q.subquery()))
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
q = q.offset((page - 1) * limit).limit(limit)
|
||||
result = await db.execute(q)
|
||||
logs = result.scalars().all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"items": [_serialize(log) for log in logs],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_stats(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
top_users_q = await db.execute(
|
||||
select(
|
||||
ConnectionLog.username,
|
||||
func.count().label("sessions"),
|
||||
func.sum(ConnectionLog.bytes_transferred).label("bytes"),
|
||||
)
|
||||
.group_by(ConnectionLog.username)
|
||||
.order_by(desc("sessions"))
|
||||
.limit(10)
|
||||
)
|
||||
|
||||
top_channels_q = await db.execute(
|
||||
select(ConnectionLog.channel_name, func.count().label("sessions"))
|
||||
.where(ConnectionLog.channel_name.isnot(None))
|
||||
.group_by(ConnectionLog.channel_name)
|
||||
.order_by(desc("sessions"))
|
||||
.limit(10)
|
||||
)
|
||||
|
||||
totals_q = await db.execute(
|
||||
select(
|
||||
func.count().label("total_sessions"),
|
||||
func.sum(ConnectionLog.bytes_transferred).label("total_bytes"),
|
||||
func.avg(ConnectionLog.duration_seconds).label("avg_duration"),
|
||||
func.count(distinct(ConnectionLog.username)).label("unique_users"),
|
||||
)
|
||||
)
|
||||
t = totals_q.one()
|
||||
|
||||
return {
|
||||
"total_sessions": t.total_sessions or 0,
|
||||
"total_bytes": t.total_bytes or 0,
|
||||
"avg_duration_seconds": round(t.avg_duration or 0),
|
||||
"unique_users": t.unique_users or 0,
|
||||
"top_users": [
|
||||
{"username": r.username, "sessions": r.sessions, "bytes": r.bytes or 0}
|
||||
for r in top_users_q
|
||||
],
|
||||
"top_channels": [
|
||||
{"channel_name": r.channel_name, "sessions": r.sessions}
|
||||
for r in top_channels_q
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.delete("", status_code=204)
|
||||
async def clear_logs(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
"""Delete all logs (admin use)."""
|
||||
from sqlalchemy import delete
|
||||
await db.execute(delete(ConnectionLog))
|
||||
await db.commit()
|
||||
|
||||
|
||||
def _serialize(log: ConnectionLog) -> dict:
|
||||
return {
|
||||
"id": log.id,
|
||||
"user_id": log.user_id,
|
||||
"username": log.username,
|
||||
"channel_id": log.channel_id,
|
||||
"channel_name": log.channel_name,
|
||||
"content_type": log.content_type,
|
||||
"stream_id": log.stream_id,
|
||||
"started_at": log.started_at.isoformat() if log.started_at else None,
|
||||
"ended_at": log.ended_at.isoformat() if log.ended_at else None,
|
||||
"duration_seconds": log.duration_seconds,
|
||||
"bytes_transferred": log.bytes_transferred,
|
||||
"client_ip": log.client_ip,
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Admin API: real-time stream monitoring via REST and WebSocket."""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...config import settings
|
||||
from ...core.pool import pool, MONITORING_KEY
|
||||
from ...core.vod_tracker import vod_tracker
|
||||
from ...database import get_db
|
||||
from ...models.jellyfin import JellyfinConfig
|
||||
from ...models.provider import ProviderAccount
|
||||
from ..auth import get_current_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/monitoring", tags=["monitoring"])
|
||||
|
||||
|
||||
def _all_streams() -> list[dict]:
|
||||
"""Combined live + VOD/series sessions."""
|
||||
return pool.get_active_streams() + vod_tracker.get_all()
|
||||
|
||||
|
||||
@router.get("/streams")
|
||||
async def get_active_streams(_=Depends(get_current_admin)):
|
||||
return _all_streams()
|
||||
|
||||
|
||||
@router.get("/slots")
|
||||
async def get_provider_slots(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
# Exclude Jellyfin dummy providers — they are local, have no slot limits
|
||||
jf_subq = (
|
||||
select(JellyfinConfig.provider_account_id)
|
||||
.where(JellyfinConfig.provider_account_id.isnot(None))
|
||||
.scalar_subquery()
|
||||
)
|
||||
result = await db.execute(
|
||||
select(ProviderAccount).where(
|
||||
ProviderAccount.is_active == True, # noqa: E712
|
||||
ProviderAccount.id.notin_(jf_subq),
|
||||
)
|
||||
)
|
||||
accounts = result.scalars().all()
|
||||
|
||||
active_streams = _all_streams()
|
||||
busy_accounts = {s["provider_account_id"] for s in active_streams}
|
||||
|
||||
return [
|
||||
{
|
||||
"id": acc.id,
|
||||
"name": acc.name,
|
||||
"max_connections": acc.max_connections,
|
||||
"status": "busy" if acc.id in busy_accounts else "free",
|
||||
"channel_id": next(
|
||||
(s["channel_id"] for s in active_streams if s["provider_account_id"] == acc.id), None
|
||||
),
|
||||
}
|
||||
for acc in accounts
|
||||
]
|
||||
|
||||
|
||||
class KillRequest(BaseModel):
|
||||
client_id: str
|
||||
|
||||
|
||||
@router.post("/kill")
|
||||
async def kill_connection(body: KillRequest, _=Depends(get_current_admin)):
|
||||
"""Force-disconnect a client. Works for both live streams and VOD/series sessions."""
|
||||
cid = body.client_id
|
||||
if cid.startswith("vod-"):
|
||||
killed = vod_tracker.kill(cid)
|
||||
else:
|
||||
killed = await pool.kill_client(cid)
|
||||
if not killed:
|
||||
raise HTTPException(404, f"Client '{cid}' not found in any active stream")
|
||||
return {"ok": True, "client_id": cid}
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def monitoring_websocket(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
|
||||
pubsub = redis_client.pubsub()
|
||||
|
||||
try:
|
||||
await pubsub.subscribe(MONITORING_KEY)
|
||||
|
||||
# Send initial combined state
|
||||
await websocket.send_text(json.dumps({"type": "state", "data": _all_streams()}))
|
||||
|
||||
async def redis_reader():
|
||||
"""Forward live-stream pool events from Redis pub/sub."""
|
||||
async for message in pubsub.listen():
|
||||
if message["type"] == "message":
|
||||
try:
|
||||
# Pool publishes only live streams; merge with VOD here
|
||||
await websocket.send_text(json.dumps({"type": "update", "data": _all_streams()}))
|
||||
except Exception:
|
||||
return
|
||||
|
||||
async def broadcaster():
|
||||
"""Push full state every 2 s for VOD bytes/bps updates."""
|
||||
while True:
|
||||
await asyncio.sleep(2)
|
||||
try:
|
||||
await websocket.send_text(json.dumps({"type": "update", "data": _all_streams()}))
|
||||
except Exception:
|
||||
return
|
||||
|
||||
reader_task = asyncio.create_task(redis_reader())
|
||||
broadcaster_task = asyncio.create_task(broadcaster())
|
||||
|
||||
try:
|
||||
await asyncio.gather(reader_task, broadcaster_task)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
reader_task.cancel()
|
||||
broadcaster_task.cancel()
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Monitoring WS error: {e}")
|
||||
finally:
|
||||
await pubsub.unsubscribe(MONITORING_KEY)
|
||||
await redis_client.aclose()
|
||||
@@ -0,0 +1,746 @@
|
||||
"""Admin API: provider account management + sync."""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ...database import get_db, AsyncSessionLocal
|
||||
from ...models.provider import ProviderAccount
|
||||
from ...models.channel import Category, Channel, ChannelProviderMap, ContentType
|
||||
from ...models.notification import NotificationDismissal
|
||||
from ...models.epg import EpgSource
|
||||
from ...core.xtream_client import XtreamClient
|
||||
from ...core.epg_manager import refresh_epg
|
||||
from ..auth import get_current_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/providers", tags=["providers"])
|
||||
|
||||
# Track ongoing sync tasks: provider_id -> status dict
|
||||
_sync_status: dict[int, dict] = {}
|
||||
|
||||
|
||||
class ProviderCreate(BaseModel):
|
||||
name: str
|
||||
base_url: str
|
||||
username: str
|
||||
password: str
|
||||
max_connections: int = 1
|
||||
auto_sync_hours: int = 0 # 0=disabled, 6/12/24=interval in hours
|
||||
|
||||
|
||||
class ProviderUpdate(BaseModel):
|
||||
name: str
|
||||
base_url: str
|
||||
username: str
|
||||
password: str | None = None # None = keep existing
|
||||
max_connections: int = 1
|
||||
auto_sync_hours: int = 0
|
||||
|
||||
|
||||
class ProviderOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
base_url: str
|
||||
username: str
|
||||
max_connections: int
|
||||
is_active: bool
|
||||
last_sync_at: datetime | None
|
||||
expiry_date: datetime | None = None
|
||||
auto_sync_hours: int
|
||||
last_sync_categories: str | None # raw JSON, parsed on frontend
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SyncRequest(BaseModel):
|
||||
live_category_ids: list[str] | None = None # None = import all
|
||||
movie_category_ids: list[str] | None = None
|
||||
series_category_ids: list[str] | None = None
|
||||
|
||||
|
||||
def _jellyfin_provider_subq():
|
||||
"""Subquery returning provider_account_ids used by Jellyfin configs."""
|
||||
from ...models.jellyfin import JellyfinConfig
|
||||
return (
|
||||
select(JellyfinConfig.provider_account_id)
|
||||
.where(JellyfinConfig.provider_account_id.isnot(None))
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[ProviderOut])
|
||||
async def list_providers(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(
|
||||
select(ProviderAccount)
|
||||
.where(ProviderAccount.id.notin_(_jellyfin_provider_subq()))
|
||||
.order_by(ProviderAccount.id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def _sync_epg_source(db: AsyncSession, provider: ProviderAccount) -> None:
|
||||
"""Create or update the auto-managed EPG source for a provider."""
|
||||
url = XtreamClient(provider.base_url, provider.username, provider.password).get_xmltv_url()
|
||||
name = f"EPG - {provider.name}"
|
||||
result = await db.execute(select(EpgSource).where(EpgSource.name == name))
|
||||
source = result.scalar_one_or_none()
|
||||
if source:
|
||||
source.url = url
|
||||
else:
|
||||
db.add(EpgSource(name=name, url=url, is_active=True))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _delete_epg_source(db: AsyncSession, provider: ProviderAccount) -> None:
|
||||
name = f"EPG - {provider.name}"
|
||||
result = await db.execute(select(EpgSource).where(EpgSource.name == name))
|
||||
source = result.scalar_one_or_none()
|
||||
if source:
|
||||
await db.delete(source)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/", response_model=ProviderOut)
|
||||
async def create_provider(data: ProviderCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
provider = ProviderAccount(**data.model_dump())
|
||||
db.add(provider)
|
||||
await db.commit()
|
||||
await db.refresh(provider)
|
||||
await _sync_epg_source(db, provider)
|
||||
return provider
|
||||
|
||||
|
||||
@router.put("/{provider_id}", response_model=ProviderOut)
|
||||
async def update_provider(provider_id: int, data: ProviderUpdate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
|
||||
provider = result.scalar_one_or_none()
|
||||
if not provider:
|
||||
raise HTTPException(404, "Provider not found")
|
||||
provider.name = data.name
|
||||
provider.base_url = data.base_url
|
||||
provider.username = data.username
|
||||
provider.max_connections = data.max_connections
|
||||
provider.auto_sync_hours = data.auto_sync_hours
|
||||
if data.password:
|
||||
provider.password = data.password
|
||||
await db.commit()
|
||||
await db.refresh(provider)
|
||||
await _sync_epg_source(db, provider)
|
||||
return provider
|
||||
|
||||
|
||||
@router.get("/{provider_id}/synced-category-ids")
|
||||
async def get_synced_category_ids(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
"""Returns the category IDs currently synced in DB for this provider (by content type)."""
|
||||
result = await db.execute(
|
||||
select(Category.provider_category_id, Category.type)
|
||||
.where(Category.provider_account_id == provider_id)
|
||||
)
|
||||
rows = result.all()
|
||||
return {
|
||||
"live": [r.provider_category_id for r in rows if r.type == ContentType.live],
|
||||
"movie": [r.provider_category_id for r in rows if r.type == ContentType.movie],
|
||||
"series": [r.provider_category_id for r in rows if r.type == ContentType.series],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{provider_id}/available-updates")
|
||||
async def get_available_updates(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
"""Return categories from the provider that are NOT yet synced into the DB."""
|
||||
result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
|
||||
provider = result.scalar_one_or_none()
|
||||
if not provider:
|
||||
raise HTTPException(404, "Provider not found")
|
||||
|
||||
client = XtreamClient(provider.base_url, provider.username, provider.password)
|
||||
live_task = asyncio.create_task(client.get_live_categories())
|
||||
vod_task = asyncio.create_task(client.get_vod_categories())
|
||||
series_task = asyncio.create_task(client.get_series_categories())
|
||||
live_raw, vod_raw, series_raw = await asyncio.gather(live_task, vod_task, series_task, return_exceptions=True)
|
||||
|
||||
def to_list(raw):
|
||||
if isinstance(raw, Exception):
|
||||
return []
|
||||
return raw if isinstance(raw, list) else []
|
||||
|
||||
# Get all category provider_category_ids already in DB for this provider
|
||||
db_cats = await db.execute(
|
||||
select(Category.provider_category_id, Category.type)
|
||||
.where(Category.provider_account_id == provider_id)
|
||||
)
|
||||
synced: set[tuple] = {(str(r.provider_category_id), r.type) for r in db_cats}
|
||||
|
||||
def filter_new(raw, ctype: ContentType):
|
||||
result = []
|
||||
for c in to_list(raw):
|
||||
cat_id = str(c.get("category_id", ""))
|
||||
if not cat_id:
|
||||
continue
|
||||
if (cat_id, ctype) not in synced:
|
||||
result.append({"id": cat_id, "name": c.get("category_name", ""), "type": ctype.value})
|
||||
return result
|
||||
|
||||
new_live = filter_new(live_raw, ContentType.live)
|
||||
new_movie = filter_new(vod_raw, ContentType.movie)
|
||||
new_series = filter_new(series_raw, ContentType.series)
|
||||
|
||||
return {
|
||||
"provider_id": provider_id,
|
||||
"provider_name": provider.name,
|
||||
"new_categories": new_live + new_movie + new_series,
|
||||
"total": len(new_live) + len(new_movie) + len(new_series),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{provider_id}")
|
||||
async def delete_provider(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
|
||||
provider = result.scalar_one_or_none()
|
||||
if not provider:
|
||||
raise HTTPException(404, "Provider not found")
|
||||
await _delete_epg_source(db, provider)
|
||||
await db.delete(provider)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/notifications/all")
|
||||
async def get_all_notifications(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
"""Return new (unsynced + undismissed) categories across all providers (Jellyfin excluded)."""
|
||||
providers_result = await db.execute(
|
||||
select(ProviderAccount).where(
|
||||
ProviderAccount.is_active == True, # noqa: E712
|
||||
ProviderAccount.last_sync_at.isnot(None),
|
||||
ProviderAccount.id.notin_(_jellyfin_provider_subq()),
|
||||
)
|
||||
)
|
||||
providers = providers_result.scalars().all()
|
||||
|
||||
# Load all dismissals
|
||||
dismissals_result = await db.execute(select(NotificationDismissal))
|
||||
dismissed: set[tuple] = {
|
||||
(str(d.provider_id), d.category_id_str, d.category_type)
|
||||
for d in dismissals_result.scalars().all()
|
||||
}
|
||||
|
||||
notifications = []
|
||||
for provider in providers:
|
||||
client = XtreamClient(provider.base_url, provider.username, provider.password)
|
||||
try:
|
||||
live_raw, vod_raw, series_raw = await asyncio.gather(
|
||||
client.get_live_categories(),
|
||||
client.get_vod_categories(),
|
||||
client.get_series_categories(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
db_cats = await db.execute(
|
||||
select(Category.provider_category_id, Category.type)
|
||||
.where(Category.provider_account_id == provider.id)
|
||||
)
|
||||
synced: set[tuple] = {(str(r.provider_category_id), r.type) for r in db_cats}
|
||||
|
||||
def collect_new(raw, ctype: ContentType):
|
||||
if isinstance(raw, Exception):
|
||||
return []
|
||||
items = []
|
||||
for c in (raw if isinstance(raw, list) else []):
|
||||
cat_id = str(c.get("category_id", ""))
|
||||
if not cat_id:
|
||||
continue
|
||||
if (cat_id, ctype) in synced:
|
||||
continue
|
||||
if (str(provider.id), cat_id, ctype.value) in dismissed:
|
||||
continue
|
||||
items.append({"id": cat_id, "name": c.get("category_name", ""), "type": ctype.value})
|
||||
return items
|
||||
|
||||
new_cats = (
|
||||
collect_new(live_raw, ContentType.live) +
|
||||
collect_new(vod_raw, ContentType.movie) +
|
||||
collect_new(series_raw, ContentType.series)
|
||||
)
|
||||
if new_cats:
|
||||
notifications.append({
|
||||
"provider_id": provider.id,
|
||||
"provider_name": provider.name,
|
||||
"categories": new_cats,
|
||||
})
|
||||
|
||||
total = sum(len(n["categories"]) for n in notifications)
|
||||
return {"notifications": notifications, "total": total}
|
||||
|
||||
|
||||
@router.get("/notifications/count")
|
||||
async def get_notifications_count(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
"""Fast count of undismissed new categories (checks DB only, no remote API calls)."""
|
||||
providers_result = await db.execute(
|
||||
select(ProviderAccount).where(ProviderAccount.is_active == True, ProviderAccount.last_sync_at.isnot(None)) # noqa: E712
|
||||
)
|
||||
providers = providers_result.scalars().all()
|
||||
|
||||
dismissals_result = await db.execute(select(NotificationDismissal))
|
||||
dismissed: set[tuple] = {
|
||||
(str(d.provider_id), d.category_id_str, d.category_type)
|
||||
for d in dismissals_result.scalars().all()
|
||||
}
|
||||
|
||||
# Compare last_sync_categories to what's available
|
||||
total = 0
|
||||
for provider in providers:
|
||||
if not provider.last_sync_categories:
|
||||
continue
|
||||
try:
|
||||
last = json.loads(provider.last_sync_categories)
|
||||
except Exception:
|
||||
continue
|
||||
# We can't do a fast count without hitting remote — just return 0 here
|
||||
# Real count comes from get_all_notifications
|
||||
return {"total": total}
|
||||
|
||||
|
||||
class DismissRequest(BaseModel):
|
||||
provider_id: int
|
||||
category_id: str
|
||||
category_type: str
|
||||
|
||||
|
||||
@router.post("/notifications/dismiss")
|
||||
async def dismiss_notification(data: DismissRequest, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
existing = await db.execute(
|
||||
select(NotificationDismissal).where(
|
||||
NotificationDismissal.provider_id == data.provider_id,
|
||||
NotificationDismissal.category_id_str == data.category_id,
|
||||
NotificationDismissal.category_type == data.category_type,
|
||||
)
|
||||
)
|
||||
if not existing.scalar_one_or_none():
|
||||
db.add(NotificationDismissal(
|
||||
provider_id=data.provider_id,
|
||||
category_id_str=data.category_id,
|
||||
category_type=data.category_type,
|
||||
))
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/notifications/dismiss-provider/{provider_id}")
|
||||
async def dismiss_all_for_provider(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
"""Dismiss all pending notifications for a specific provider."""
|
||||
result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
|
||||
provider = result.scalar_one_or_none()
|
||||
if not provider:
|
||||
raise HTTPException(404, "Provider not found")
|
||||
|
||||
client = XtreamClient(provider.base_url, provider.username, provider.password)
|
||||
try:
|
||||
live_raw, vod_raw, series_raw = await asyncio.gather(
|
||||
client.get_live_categories(),
|
||||
client.get_vod_categories(),
|
||||
client.get_series_categories(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to fetch provider categories: {e}")
|
||||
|
||||
db_cats = await db.execute(
|
||||
select(Category.provider_category_id, Category.type)
|
||||
.where(Category.provider_account_id == provider_id)
|
||||
)
|
||||
synced: set[tuple] = {(str(r.provider_category_id), r.type) for r in db_cats}
|
||||
|
||||
dismissed_result = await db.execute(
|
||||
select(NotificationDismissal.category_id_str, NotificationDismissal.category_type)
|
||||
.where(NotificationDismissal.provider_id == provider_id)
|
||||
)
|
||||
already_dismissed: set[tuple] = {(r[0], r[1]) for r in dismissed_result.all()}
|
||||
|
||||
count = 0
|
||||
for raw, ctype in [(live_raw, ContentType.live), (vod_raw, ContentType.movie), (series_raw, ContentType.series)]:
|
||||
if isinstance(raw, Exception):
|
||||
continue
|
||||
for c in (raw if isinstance(raw, list) else []):
|
||||
cat_id = str(c.get("category_id", ""))
|
||||
if not cat_id or (cat_id, ctype) in synced:
|
||||
continue
|
||||
if (cat_id, ctype.value) not in already_dismissed:
|
||||
db.add(NotificationDismissal(
|
||||
provider_id=provider_id,
|
||||
category_id_str=cat_id,
|
||||
category_type=ctype.value,
|
||||
))
|
||||
count += 1
|
||||
|
||||
await db.commit()
|
||||
return {"ok": True, "dismissed": count}
|
||||
|
||||
|
||||
@router.get("/{provider_id}/epg-url")
|
||||
async def get_provider_epg_url(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
"""Returns the XMLTV EPG URL for this provider (auto-registered as EPG source)."""
|
||||
result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
|
||||
provider = result.scalar_one_or_none()
|
||||
if not provider:
|
||||
raise HTTPException(404, "Provider not found")
|
||||
url = XtreamClient(provider.base_url, provider.username, provider.password).get_xmltv_url()
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@router.post("/{provider_id}/test")
|
||||
async def test_provider(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
|
||||
provider = result.scalar_one_or_none()
|
||||
if not provider:
|
||||
raise HTTPException(404, "Provider not found")
|
||||
client = XtreamClient(provider.base_url, provider.username, provider.password)
|
||||
ok = False
|
||||
try:
|
||||
info = await client.get_user_info()
|
||||
user_info = info.get("user_info", {})
|
||||
ok = user_info.get("status") == "Active"
|
||||
exp_ts = user_info.get("exp_date")
|
||||
if exp_ts:
|
||||
try:
|
||||
provider.expiry_date = datetime.fromtimestamp(int(exp_ts), tz=timezone.utc)
|
||||
await db.commit()
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Provider test failed: {e}")
|
||||
return {"connected": ok}
|
||||
|
||||
|
||||
@router.get("/{provider_id}/sync/categories")
|
||||
async def get_provider_categories(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
"""Fetch available categories from provider without importing — used for the category picker."""
|
||||
result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
|
||||
provider = result.scalar_one_or_none()
|
||||
if not provider:
|
||||
raise HTTPException(404, "Provider not found")
|
||||
|
||||
client = XtreamClient(provider.base_url, provider.username, provider.password)
|
||||
|
||||
live_task = asyncio.create_task(client.get_live_categories())
|
||||
vod_task = asyncio.create_task(client.get_vod_categories())
|
||||
series_task = asyncio.create_task(client.get_series_categories())
|
||||
|
||||
live_raw, vod_raw, series_raw = await asyncio.gather(live_task, vod_task, series_task, return_exceptions=True)
|
||||
|
||||
def to_list(raw):
|
||||
if isinstance(raw, Exception):
|
||||
logger.warning(f"Category fetch error: {raw}")
|
||||
return []
|
||||
return [{"id": str(c.get("category_id", "")), "name": c.get("category_name", "")} for c in raw if c.get("category_id")]
|
||||
|
||||
return {
|
||||
"live": to_list(live_raw),
|
||||
"movie": to_list(vod_raw),
|
||||
"series": to_list(series_raw),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{provider_id}/sync/status")
|
||||
async def sync_status(provider_id: int, _=Depends(get_current_admin)):
|
||||
return _sync_status.get(provider_id, {"status": "idle"})
|
||||
|
||||
|
||||
@router.post("/{provider_id}/sync")
|
||||
async def sync_provider(
|
||||
provider_id: int,
|
||||
body: SyncRequest = SyncRequest(),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(get_current_admin),
|
||||
):
|
||||
result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
|
||||
provider = result.scalar_one_or_none()
|
||||
if not provider:
|
||||
raise HTTPException(404, "Provider not found")
|
||||
|
||||
if _sync_status.get(provider_id, {}).get("status") == "running":
|
||||
return {"ok": False, "message": "Sync already in progress", "status": _sync_status[provider_id]}
|
||||
|
||||
_sync_status[provider_id] = {"status": "running", "progress": "Iniciando...", "stats": {}}
|
||||
asyncio.create_task(_run_sync(
|
||||
provider_id,
|
||||
provider.base_url,
|
||||
provider.username,
|
||||
provider.password,
|
||||
selected_live=set(body.live_category_ids) if body.live_category_ids is not None else None,
|
||||
selected_movie=set(body.movie_category_ids) if body.movie_category_ids is not None else None,
|
||||
selected_series=set(body.series_category_ids) if body.series_category_ids is not None else None,
|
||||
))
|
||||
return {"ok": True, "message": "Sync started in background", "status_url": f"/api/admin/providers/{provider_id}/sync/status"}
|
||||
|
||||
|
||||
async def _run_sync(
|
||||
provider_id: int,
|
||||
base_url: str,
|
||||
username: str,
|
||||
password: str,
|
||||
selected_live: set[str] | None = None,
|
||||
selected_movie: set[str] | None = None,
|
||||
selected_series: set[str] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Efficient sync: fetches ALL streams in one call per type (not per category).
|
||||
Total HTTP calls: 3 category lists + 3 stream lists = 6 calls regardless of provider size.
|
||||
selected_* = None means import all; set means only import those category IDs.
|
||||
"""
|
||||
client = XtreamClient(base_url, username, password)
|
||||
stats = {
|
||||
"live_categories": 0, "live_channels": 0,
|
||||
"vod_categories": 0, "vod_channels": 0,
|
||||
"series_categories": 0, "series": 0,
|
||||
}
|
||||
|
||||
def update_progress(msg: str):
|
||||
_sync_status[provider_id] = {"status": "running", "progress": msg, "stats": stats.copy()}
|
||||
logger.info(f"[Sync #{provider_id}] {msg}")
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
# ── LIVE ──────────────────────────────────────────────────
|
||||
update_progress("Descargando categorías Live...")
|
||||
try:
|
||||
live_cats = await client.get_live_categories()
|
||||
cat_map_live: dict[str, Category] = {}
|
||||
for cat_data in live_cats:
|
||||
cat_id_str = str(cat_data.get("category_id", ""))
|
||||
if selected_live is not None and cat_id_str not in selected_live:
|
||||
continue
|
||||
cat = await _upsert_category(db, cat_data, ContentType.live, provider_id)
|
||||
cat_map_live[cat_id_str] = cat
|
||||
stats["live_categories"] += 1
|
||||
await db.flush()
|
||||
|
||||
update_progress(f"Descargando canales Live ({stats['live_categories']} cats)...")
|
||||
all_live = await client.get_live_streams()
|
||||
for ch_data in all_live:
|
||||
cat_id_str = str(ch_data.get("category_id", ""))
|
||||
if cat_id_str not in cat_map_live:
|
||||
continue # category not selected or not loaded
|
||||
cat = cat_map_live.get(cat_id_str)
|
||||
await _upsert_channel(db, ch_data, cat, ContentType.live, provider_id, client)
|
||||
stats["live_channels"] += 1
|
||||
if stats["live_channels"] % 500 == 0:
|
||||
update_progress(f"Live: {stats['live_channels']} canales procesados...")
|
||||
await db.flush()
|
||||
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"[Sync #{provider_id}] Live error: {e}", exc_info=True)
|
||||
|
||||
# ── VOD ───────────────────────────────────────────────────
|
||||
update_progress("Descargando categorías VOD...")
|
||||
try:
|
||||
vod_cats = await client.get_vod_categories()
|
||||
cat_map_vod: dict[str, Category] = {}
|
||||
for cat_data in vod_cats:
|
||||
cat_id_str = str(cat_data.get("category_id", ""))
|
||||
if selected_movie is not None and cat_id_str not in selected_movie:
|
||||
continue
|
||||
cat = await _upsert_category(db, cat_data, ContentType.movie, provider_id)
|
||||
cat_map_vod[cat_id_str] = cat
|
||||
stats["vod_categories"] += 1
|
||||
await db.flush()
|
||||
|
||||
update_progress(f"Descargando películas VOD ({stats['vod_categories']} cats)...")
|
||||
all_vod = await client.get_vod_streams()
|
||||
for ch_data in all_vod:
|
||||
cat_id_str = str(ch_data.get("category_id", ""))
|
||||
if cat_id_str not in cat_map_vod:
|
||||
continue
|
||||
cat = cat_map_vod.get(cat_id_str)
|
||||
await _upsert_channel(db, ch_data, cat, ContentType.movie, provider_id, client)
|
||||
stats["vod_channels"] += 1
|
||||
if stats["vod_channels"] % 500 == 0:
|
||||
update_progress(f"VOD: {stats['vod_channels']} canales procesados...")
|
||||
await db.flush()
|
||||
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"[Sync #{provider_id}] VOD error: {e}", exc_info=True)
|
||||
|
||||
# ── SERIES ────────────────────────────────────────────────
|
||||
update_progress("Descargando categorías Series...")
|
||||
try:
|
||||
series_cats = await client.get_series_categories()
|
||||
cat_map_series: dict[str, Category] = {}
|
||||
for cat_data in series_cats:
|
||||
cat_id_str = str(cat_data.get("category_id", ""))
|
||||
if selected_series is not None and cat_id_str not in selected_series:
|
||||
continue
|
||||
cat = await _upsert_category(db, cat_data, ContentType.series, provider_id)
|
||||
cat_map_series[cat_id_str] = cat
|
||||
stats["series_categories"] += 1
|
||||
await db.flush()
|
||||
|
||||
update_progress(f"Descargando series ({stats['series_categories']} cats)...")
|
||||
all_series = await client.get_series()
|
||||
for s_data in all_series:
|
||||
cat_id_str = str(s_data.get("category_id", ""))
|
||||
if cat_id_str not in cat_map_series:
|
||||
continue
|
||||
cat = cat_map_series.get(cat_id_str)
|
||||
await _upsert_channel(db, s_data, cat, ContentType.series, provider_id, client)
|
||||
stats["series"] += 1
|
||||
if stats["series"] % 500 == 0:
|
||||
update_progress(f"Series: {stats['series']} procesadas...")
|
||||
await db.flush()
|
||||
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"[Sync #{provider_id}] Series error: {e}", exc_info=True)
|
||||
|
||||
# ── FINAL COMMIT ──────────────────────────────────────────
|
||||
update_progress("Guardando en base de datos...")
|
||||
result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
|
||||
provider = result.scalar_one_or_none()
|
||||
if provider:
|
||||
provider.last_sync_at = datetime.now(timezone.utc)
|
||||
provider.last_sync_categories = json.dumps({
|
||||
"live": list(selected_live) if selected_live is not None else None,
|
||||
"movie": list(selected_movie) if selected_movie is not None else None,
|
||||
"series": list(selected_series) if selected_series is not None else None,
|
||||
})
|
||||
await db.commit()
|
||||
|
||||
_sync_status[provider_id] = {"status": "done", "progress": "Sync completado", "stats": stats}
|
||||
logger.info(f"[Sync #{provider_id}] Completed: {stats}")
|
||||
|
||||
# Auto-update EPG source and trigger background refresh
|
||||
async with AsyncSessionLocal() as epg_db:
|
||||
prov_result = await epg_db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
|
||||
prov = prov_result.scalar_one_or_none()
|
||||
if prov:
|
||||
await _sync_epg_source(epg_db, prov)
|
||||
await refresh_epg(epg_db)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Sync #{provider_id}] Fatal error: {e}", exc_info=True)
|
||||
_sync_status[provider_id] = {"status": "error", "progress": str(e), "stats": stats}
|
||||
|
||||
|
||||
async def _upsert_category(db: AsyncSession, cat_data: dict, ctype: ContentType, provider_id: int) -> Category:
|
||||
provider_cat_id = str(cat_data.get("category_id", ""))
|
||||
cat_name = cat_data.get("category_name", "Unknown")
|
||||
|
||||
# First: check if a category with the same name+type already exists (any provider)
|
||||
# This merges duplicate categories from multiple providers automatically
|
||||
result = await db.execute(
|
||||
select(Category).where(
|
||||
Category.name == cat_name,
|
||||
Category.type == ctype,
|
||||
)
|
||||
)
|
||||
cat = result.scalar_one_or_none()
|
||||
|
||||
if not cat:
|
||||
# Check by provider+id (in case name changed)
|
||||
result2 = await db.execute(
|
||||
select(Category).where(
|
||||
Category.provider_category_id == provider_cat_id,
|
||||
Category.provider_account_id == provider_id,
|
||||
Category.type == ctype,
|
||||
)
|
||||
)
|
||||
cat = result2.scalar_one_or_none()
|
||||
|
||||
if not cat:
|
||||
cat = Category(
|
||||
name=cat_name,
|
||||
type=ctype,
|
||||
provider_category_id=provider_cat_id,
|
||||
provider_account_id=provider_id,
|
||||
)
|
||||
db.add(cat)
|
||||
await db.flush()
|
||||
else:
|
||||
cat.name = cat_name
|
||||
return cat
|
||||
|
||||
|
||||
async def _upsert_channel(
|
||||
db: AsyncSession,
|
||||
ch_data: dict,
|
||||
category: Category | None,
|
||||
ctype: ContentType,
|
||||
provider_id: int,
|
||||
client: XtreamClient,
|
||||
) -> Channel | None:
|
||||
stream_id = str(ch_data.get("stream_id") or ch_data.get("series_id", ""))
|
||||
if not stream_id:
|
||||
return None
|
||||
|
||||
result = await db.execute(
|
||||
select(Channel).where(
|
||||
Channel.stream_id_at_provider == stream_id,
|
||||
Channel.type == ctype,
|
||||
)
|
||||
)
|
||||
channel = result.scalar_one_or_none()
|
||||
|
||||
name = ch_data.get("name") or ch_data.get("title", "Unknown") or "Unknown"
|
||||
tvg_id = ch_data.get("epg_channel_id") or ch_data.get("tvg_id") or None
|
||||
logo = ch_data.get("stream_icon") or ch_data.get("cover") or None
|
||||
# Truncate logo to avoid DB column limit (base64 images from some providers)
|
||||
if logo and len(logo) > 900:
|
||||
logo = None
|
||||
cat_id = category.id if category else None
|
||||
|
||||
if not channel:
|
||||
channel = Channel(
|
||||
name=name,
|
||||
tvg_id=tvg_id,
|
||||
tvg_logo=logo,
|
||||
stream_id_at_provider=stream_id,
|
||||
category_id=cat_id,
|
||||
type=ctype,
|
||||
)
|
||||
db.add(channel)
|
||||
await db.flush()
|
||||
else:
|
||||
channel.name = name
|
||||
channel.tvg_id = tvg_id
|
||||
channel.tvg_logo = logo
|
||||
# Only update category if channel has none yet (first-provider-wins prevents
|
||||
# cross-provider syncs from breaking existing catalog assignments)
|
||||
if cat_id and not channel.category_id:
|
||||
channel.category_id = cat_id
|
||||
|
||||
# Build stream URL
|
||||
container_ext = ch_data.get("container_extension", "ts") or "ts"
|
||||
if ctype == ContentType.live:
|
||||
url = client.build_live_stream_url(stream_id)
|
||||
elif ctype == ContentType.movie:
|
||||
url = client.build_vod_stream_url(stream_id, container_ext)
|
||||
else:
|
||||
url = client.build_series_stream_url(stream_id, container_ext)
|
||||
|
||||
# Upsert provider map
|
||||
result = await db.execute(
|
||||
select(ChannelProviderMap).where(
|
||||
ChannelProviderMap.channel_id == channel.id,
|
||||
ChannelProviderMap.provider_account_id == provider_id,
|
||||
)
|
||||
)
|
||||
pmap = result.scalar_one_or_none()
|
||||
if not pmap:
|
||||
pmap = ChannelProviderMap(
|
||||
channel_id=channel.id,
|
||||
provider_account_id=provider_id,
|
||||
stream_url=url,
|
||||
)
|
||||
db.add(pmap)
|
||||
else:
|
||||
pmap.stream_url = url
|
||||
|
||||
return channel
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Admin settings — blocked image upload/management."""
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from ..auth import get_current_admin
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
BLOCKED_IMAGE_PATH = "/opt/kirastream/backend/static/blocked_image.jpg"
|
||||
MAX_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
|
||||
@router.get("/blocked-image")
|
||||
async def get_blocked_image(_=Depends(get_current_admin)):
|
||||
if not os.path.exists(BLOCKED_IMAGE_PATH):
|
||||
raise HTTPException(404, "No blocked image configured")
|
||||
return FileResponse(BLOCKED_IMAGE_PATH, media_type="image/jpeg")
|
||||
|
||||
|
||||
@router.post("/blocked-image", status_code=201)
|
||||
async def upload_blocked_image(file: UploadFile = File(...), _=Depends(get_current_admin)):
|
||||
if file.content_type not in ("image/jpeg", "image/png", "image/webp", "image/gif"):
|
||||
raise HTTPException(400, "Only JPEG, PNG, WebP or GIF images allowed")
|
||||
data = await file.read()
|
||||
if len(data) > MAX_SIZE:
|
||||
raise HTTPException(413, "Image too large (max 10 MB)")
|
||||
os.makedirs(os.path.dirname(BLOCKED_IMAGE_PATH), exist_ok=True)
|
||||
with open(BLOCKED_IMAGE_PATH, "wb") as f:
|
||||
f.write(data)
|
||||
return {"ok": True, "size": len(data)}
|
||||
|
||||
|
||||
@router.delete("/blocked-image", status_code=204)
|
||||
async def delete_blocked_image(_=Depends(get_current_admin)):
|
||||
if os.path.exists(BLOCKED_IMAGE_PATH):
|
||||
os.remove(BLOCKED_IMAGE_PATH)
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Admin API: user management."""
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ...database import get_db
|
||||
from ...models.user import User, UserCatalogEntry, UserCustomCatalogEntry, UserProviderPreference
|
||||
from ...models.channel import Category
|
||||
from ...models.provider import ProviderAccount
|
||||
from ...models.custom_category import CustomCategory
|
||||
from ..auth import get_current_admin
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
max_connections: int = 1
|
||||
expiry_date: datetime | None = None
|
||||
is_active: bool = True
|
||||
is_priority: bool = False
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
password: str | None = None
|
||||
max_connections: int | None = None
|
||||
expiry_date: datetime | None = None
|
||||
is_active: bool | None = None
|
||||
is_priority: bool | None = None
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
max_connections: int
|
||||
expiry_date: datetime | None
|
||||
is_active: bool
|
||||
is_priority: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/", response_model=list[UserOut])
|
||||
async def list_users(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(select(User).order_by(User.id))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/", response_model=UserOut)
|
||||
async def create_user(data: UserCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
existing = await db.execute(select(User).where(User.username == data.username))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(400, f"Username '{data.username}' already exists")
|
||||
user = User(
|
||||
username=data.username,
|
||||
max_connections=data.max_connections,
|
||||
expiry_date=data.expiry_date,
|
||||
is_active=data.is_active,
|
||||
is_priority=data.is_priority,
|
||||
password_hash="",
|
||||
)
|
||||
user.set_password(data.password)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserOut)
|
||||
async def update_user(user_id: int, data: UserUpdate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(404, "User not found")
|
||||
if data.password is not None:
|
||||
user.set_password(data.password)
|
||||
if data.max_connections is not None:
|
||||
user.max_connections = data.max_connections
|
||||
if data.expiry_date is not None:
|
||||
user.expiry_date = data.expiry_date
|
||||
if data.is_active is not None:
|
||||
user.is_active = data.is_active
|
||||
if data.is_priority is not None:
|
||||
user.is_priority = data.is_priority
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user(user_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(404, "User not found")
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/{user_id}/catalog")
|
||||
async def get_user_catalog(user_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(
|
||||
select(UserCatalogEntry)
|
||||
.options(selectinload(UserCatalogEntry.category))
|
||||
.where(UserCatalogEntry.user_id == user_id)
|
||||
)
|
||||
entries = result.scalars().all()
|
||||
return [
|
||||
{"id": e.id, "category_id": e.category_id, "category_name": e.category.name, "type": e.category.type}
|
||||
for e in entries
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{user_id}/catalog")
|
||||
async def assign_categories(user_id: int, category_ids: list[int], db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(404, "User not found")
|
||||
|
||||
# Remove existing entries
|
||||
existing = await db.execute(select(UserCatalogEntry).where(UserCatalogEntry.user_id == user_id))
|
||||
for entry in existing.scalars().all():
|
||||
await db.delete(entry)
|
||||
|
||||
# Add new entries
|
||||
added = 0
|
||||
for cat_id in category_ids:
|
||||
cat_result = await db.execute(select(Category).where(Category.id == cat_id))
|
||||
if cat_result.scalar_one_or_none():
|
||||
db.add(UserCatalogEntry(user_id=user_id, category_id=cat_id))
|
||||
added += 1
|
||||
|
||||
await db.commit()
|
||||
return {"ok": True, "assigned": added}
|
||||
|
||||
|
||||
@router.delete("/{user_id}/catalog/{category_id}")
|
||||
async def remove_category(user_id: int, category_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(
|
||||
select(UserCatalogEntry).where(
|
||||
UserCatalogEntry.user_id == user_id,
|
||||
UserCatalogEntry.category_id == category_id,
|
||||
)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if not entry:
|
||||
raise HTTPException(404, "Catalog entry not found")
|
||||
await db.delete(entry)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/{user_id}/custom-catalog")
|
||||
async def get_user_custom_catalog(user_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(
|
||||
select(UserCustomCatalogEntry.custom_category_id).where(UserCustomCatalogEntry.user_id == user_id)
|
||||
)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
|
||||
@router.post("/{user_id}/custom-catalog")
|
||||
async def assign_custom_categories(user_id: int, custom_category_ids: list[int], db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(404, "User not found")
|
||||
|
||||
existing = await db.execute(select(UserCustomCatalogEntry).where(UserCustomCatalogEntry.user_id == user_id))
|
||||
for entry in existing.scalars().all():
|
||||
await db.delete(entry)
|
||||
|
||||
added = 0
|
||||
for cc_id in custom_category_ids:
|
||||
cc_result = await db.execute(select(CustomCategory).where(CustomCategory.id == cc_id))
|
||||
if cc_result.scalar_one_or_none():
|
||||
db.add(UserCustomCatalogEntry(user_id=user_id, custom_category_id=cc_id))
|
||||
added += 1
|
||||
|
||||
await db.commit()
|
||||
return {"ok": True, "assigned": added}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider preferences
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ProviderPrefIn(BaseModel):
|
||||
provider_account_id: int
|
||||
priority: int = 0
|
||||
|
||||
|
||||
@router.get("/{user_id}/providers")
|
||||
async def get_user_provider_prefs(user_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(
|
||||
select(UserProviderPreference)
|
||||
.options(selectinload(UserProviderPreference.provider_account))
|
||||
.where(UserProviderPreference.user_id == user_id)
|
||||
.order_by(UserProviderPreference.priority)
|
||||
)
|
||||
prefs = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": p.id,
|
||||
"provider_account_id": p.provider_account_id,
|
||||
"provider_name": p.provider_account.name,
|
||||
"priority": p.priority,
|
||||
}
|
||||
for p in prefs
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{user_id}/providers")
|
||||
async def add_user_provider_pref(
|
||||
user_id: int, data: ProviderPrefIn, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)
|
||||
):
|
||||
user_result = await db.execute(select(User).where(User.id == user_id))
|
||||
if not user_result.scalar_one_or_none():
|
||||
raise HTTPException(404, "User not found")
|
||||
|
||||
provider_result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == data.provider_account_id))
|
||||
if not provider_result.scalar_one_or_none():
|
||||
raise HTTPException(404, "Provider account not found")
|
||||
|
||||
# Upsert
|
||||
existing = await db.execute(
|
||||
select(UserProviderPreference).where(
|
||||
UserProviderPreference.user_id == user_id,
|
||||
UserProviderPreference.provider_account_id == data.provider_account_id,
|
||||
)
|
||||
)
|
||||
pref = existing.scalar_one_or_none()
|
||||
if pref:
|
||||
pref.priority = data.priority
|
||||
else:
|
||||
pref = UserProviderPreference(
|
||||
user_id=user_id,
|
||||
provider_account_id=data.provider_account_id,
|
||||
priority=data.priority,
|
||||
)
|
||||
db.add(pref)
|
||||
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/{user_id}/providers/{provider_account_id}")
|
||||
async def remove_user_provider_pref(
|
||||
user_id: int, provider_account_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)
|
||||
):
|
||||
result = await db.execute(
|
||||
select(UserProviderPreference).where(
|
||||
UserProviderPreference.user_id == user_id,
|
||||
UserProviderPreference.provider_account_id == provider_account_id,
|
||||
)
|
||||
)
|
||||
pref = result.scalar_one_or_none()
|
||||
if not pref:
|
||||
raise HTTPException(404, "Preference not found")
|
||||
await db.delete(pref)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,47 @@
|
||||
"""JWT authentication for the admin panel."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from jose import JWTError, jwt
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import settings
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/admin/auth/token")
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
to_encode["exp"] = expire
|
||||
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
|
||||
async def get_current_admin(token: str = Depends(oauth2_scheme)) -> str:
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
if username != settings.admin_username:
|
||||
raise credentials_exception
|
||||
return username
|
||||
|
||||
|
||||
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> Token:
|
||||
if form_data.username != settings.admin_username or form_data.password != settings.admin_password:
|
||||
raise HTTPException(status_code=400, detail="Incorrect username or password")
|
||||
token = create_access_token({"sub": form_data.username})
|
||||
return Token(access_token=token, token_type="bearer")
|
||||
@@ -0,0 +1,928 @@
|
||||
"""Xtream Codes compatible API — used by IPTV player apps."""
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import AsyncIterator
|
||||
|
||||
import aiohttp
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.responses import StreamingResponse, PlainTextResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from ..config import settings
|
||||
from ..core.catalog import get_user_categories, get_user_channels, get_channel_provider_maps, can_user_access_channel, get_user_preferred_provider_ids
|
||||
from ..core.epg_manager import filter_epg_for_channels
|
||||
from ..core.pool import pool, NoSlotsAvailableError, MaxConnectionsExceededError
|
||||
from ..core.xtream_client import XtreamClient
|
||||
from ..core.vod_tracker import vod_tracker, VodSession
|
||||
from ..database import get_db, AsyncSessionLocal
|
||||
from ..models.channel import Channel, ContentType
|
||||
from ..models.custom_category import CustomCategory, CustomCategoryItem
|
||||
from ..models.jellyfin import JellyfinConfig, JellyfinItem
|
||||
from ..models.log import ConnectionLog
|
||||
from ..models.provider import ProviderAccount
|
||||
from ..models.user import User, UserCustomCatalogEntry
|
||||
|
||||
CC_PREFIX = "cc_" # prefix for custom category IDs in Xtream protocol
|
||||
BLOCKED_IMAGE_PATH = "/opt/kirastream/backend/static/blocked_image.jpg"
|
||||
|
||||
# episode_id → provider_account_id; populated by get_series_info calls so
|
||||
# series_stream can route episode requests without storing episodes in the DB
|
||||
_episode_provider_cache: dict[str, int] = {}
|
||||
|
||||
# jellyfin fake_ep_id → direct-play URL; populated in _get_jellyfin_series_info
|
||||
_jellyfin_episode_cache: dict[str, str] = {}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["xtream"])
|
||||
|
||||
SERVER_URL_PLACEHOLDER = "{server_url}"
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
xff = request.headers.get("X-Forwarded-For", "")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
xri = request.headers.get("X-Real-IP", "")
|
||||
if xri:
|
||||
return xri
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
async def _create_log(
|
||||
user_id: int,
|
||||
username: str,
|
||||
channel_id: int | None,
|
||||
channel_name: str | None,
|
||||
content_type: str,
|
||||
stream_id: str | None,
|
||||
client_ip: str,
|
||||
) -> int:
|
||||
async with AsyncSessionLocal() as db:
|
||||
log = ConnectionLog(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
channel_id=channel_id,
|
||||
channel_name=channel_name,
|
||||
content_type=content_type,
|
||||
stream_id=stream_id,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
await db.refresh(log)
|
||||
return log.id
|
||||
|
||||
|
||||
async def _finish_log(log_id: int, bytes_transferred: int, started: float) -> None:
|
||||
ended = datetime.now(timezone.utc)
|
||||
duration = int(time.time() - started)
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(ConnectionLog).where(ConnectionLog.id == log_id))
|
||||
log = result.scalar_one_or_none()
|
||||
if log:
|
||||
log.ended_at = ended
|
||||
log.duration_seconds = duration
|
||||
log.bytes_transferred = bytes_transferred
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _stream_blocked_image() -> StreamingResponse:
|
||||
"""Return a continuous MPEG-TS video stream of the blocked image via FFmpeg."""
|
||||
import os
|
||||
|
||||
if not os.path.exists(BLOCKED_IMAGE_PATH):
|
||||
raise HTTPException(503, "No provider slots available — try again later")
|
||||
|
||||
async def generate():
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ffmpeg",
|
||||
"-re", "-loop", "1", "-i", BLOCKED_IMAGE_PATH,
|
||||
"-c:v", "libx264", "-preset", "ultrafast", "-tune", "stillimage",
|
||||
"-b:v", "400k", "-pix_fmt", "yuv420p",
|
||||
"-vf", "scale=1280:720",
|
||||
"-f", "mpegts", "pipe:1",
|
||||
"-loglevel", "quiet",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
chunk = await proc.stdout.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
try:
|
||||
proc.terminate()
|
||||
await asyncio.wait_for(proc.wait(), timeout=2.0)
|
||||
except (asyncio.TimeoutError, ProcessLookupError):
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type="video/mp2t",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
async def _get_user_custom_cat_ids(db: AsyncSession, user_id: int) -> set[int] | None:
|
||||
"""Returns set of custom category IDs for the user, or None if unrestricted (show all)."""
|
||||
result = await db.execute(
|
||||
select(UserCustomCatalogEntry.custom_category_id)
|
||||
.where(UserCustomCatalogEntry.user_id == user_id)
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
return set(rows) if rows else None # None means "no restriction — show all"
|
||||
|
||||
|
||||
async def _can_access_via_custom_category(db: AsyncSession, channel_id: int, user_id: int) -> bool:
|
||||
allowed_ids = await _get_user_custom_cat_ids(db, user_id)
|
||||
q = select(CustomCategoryItem).where(CustomCategoryItem.channel_id == channel_id)
|
||||
if allowed_ids is not None:
|
||||
q = q.where(CustomCategoryItem.custom_category_id.in_(allowed_ids))
|
||||
result = await db.execute(q.limit(1))
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def _get_custom_category_channels(db: AsyncSession, cc_id: int) -> list[Channel]:
|
||||
result = await db.execute(
|
||||
select(Channel)
|
||||
.join(CustomCategoryItem, CustomCategoryItem.channel_id == Channel.id)
|
||||
.options(selectinload(Channel.provider_maps))
|
||||
.where(CustomCategoryItem.custom_category_id == cc_id, Channel.is_active == True) # noqa: E712
|
||||
.order_by(CustomCategoryItem.position, CustomCategoryItem.id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def authenticate_user(username: str, password: str, db: AsyncSession) -> User:
|
||||
result = await db.execute(select(User).where(User.username == username, User.is_active == True)) # noqa: E712
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(403, "Invalid credentials")
|
||||
if not user.verify_password(password):
|
||||
raise HTTPException(403, "Invalid credentials")
|
||||
if user.expiry_date and user.expiry_date < datetime.now(timezone.utc):
|
||||
raise HTTPException(403, "Account expired")
|
||||
return user
|
||||
|
||||
|
||||
def server_url(request: Request) -> str:
|
||||
return f"{request.url.scheme}://{request.url.netloc}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# player_api.php (main Xtream Codes endpoint)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/player_api.php")
|
||||
async def player_api(
|
||||
request: Request,
|
||||
username: str = "",
|
||||
password: str = "",
|
||||
action: str = "",
|
||||
category_id: str | None = None,
|
||||
stream_id: str | None = None,
|
||||
series_id: str | None = None,
|
||||
vod_id: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not username or not password:
|
||||
return {"user_info": {"status": "Disabled"}, "server_info": {}}
|
||||
|
||||
user = await authenticate_user(username, password, db)
|
||||
base = server_url(request)
|
||||
|
||||
if not action:
|
||||
return await _user_info(user, password, base, db)
|
||||
|
||||
if action in ("user_info", "get_account_info"):
|
||||
return await _user_info(user, password, base, db)
|
||||
|
||||
if action == "get_live_categories":
|
||||
return await _get_live_categories(user, db)
|
||||
|
||||
if action == "get_live_streams":
|
||||
return await _get_live_streams(user, db, category_id, base)
|
||||
|
||||
if action == "get_vod_categories":
|
||||
return await _get_categories(user, db, ContentType.movie)
|
||||
|
||||
if action == "get_vod_streams":
|
||||
return await _get_channels(user, db, ContentType.movie, category_id, base)
|
||||
|
||||
if action == "get_series_categories":
|
||||
return await _get_categories(user, db, ContentType.series)
|
||||
|
||||
if action == "get_series":
|
||||
return await _get_channels(user, db, ContentType.series, category_id, base)
|
||||
|
||||
if action == "get_series_info" and series_id:
|
||||
return await _get_series_info(series_id, user, db)
|
||||
|
||||
if action == "get_vod_info" and vod_id:
|
||||
return await _get_vod_info(vod_id, user, db)
|
||||
|
||||
if action == "get_short_epg" and stream_id:
|
||||
return {"epg_listings": []}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Playlist endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/get.php")
|
||||
async def get_playlist(
|
||||
request: Request,
|
||||
username: str = "",
|
||||
password: str = "",
|
||||
type: str = "m3u_plus",
|
||||
output: str = "ts",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await authenticate_user(username, password, db)
|
||||
base = server_url(request)
|
||||
|
||||
channels = await get_user_channels(db, user)
|
||||
lines = ["#EXTM3U"]
|
||||
for ch in channels:
|
||||
cat_name = ch.category.name if ch.category else ""
|
||||
tvg_id = ch.tvg_id or ""
|
||||
logo = ch.tvg_logo or ""
|
||||
if ch.type == ContentType.live:
|
||||
url = f"{base}/{username}/{password}/{ch.stream_id_at_provider}"
|
||||
elif ch.type == ContentType.movie:
|
||||
ext = "mkv"
|
||||
url = f"{base}/movie/{username}/{password}/{ch.stream_id_at_provider}.{ext}"
|
||||
else:
|
||||
url = f"{base}/series/{username}/{password}/{ch.stream_id_at_provider}.mkv"
|
||||
|
||||
lines.append(
|
||||
f'#EXTINF:-1 tvg-id="{tvg_id}" tvg-logo="{logo}" group-title="{cat_name}",{ch.name}'
|
||||
)
|
||||
lines.append(url)
|
||||
|
||||
return PlainTextResponse("\n".join(lines), media_type="application/x-mpegurl")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EPG endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/xmltv.php")
|
||||
async def xmltv(
|
||||
username: str = "",
|
||||
password: str = "",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await authenticate_user(username, password, db)
|
||||
channels = await get_user_channels(db, user)
|
||||
tvg_ids = {ch.tvg_id for ch in channels if ch.tvg_id}
|
||||
epg_data = filter_epg_for_channels(tvg_ids)
|
||||
return Response(content=epg_data, media_type="application/xml")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live stream proxy /{username}/{password}/{stream_id}
|
||||
# Also handles /live/{username}/{password}/{stream_id} used by Smarters/IPTVnator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/live/{username}/{password}/{stream_id}")
|
||||
@router.get("/{username}/{password}/{stream_id}")
|
||||
async def live_stream(
|
||||
username: str,
|
||||
password: str,
|
||||
stream_id: str,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# Strip extension if present (e.g. "123456.ts" -> "123456")
|
||||
stream_id_clean = stream_id.rsplit(".", 1)[0] if "." in stream_id else stream_id
|
||||
|
||||
user = await authenticate_user(username, password, db)
|
||||
|
||||
# Resolve channel by stream_id_at_provider
|
||||
result = await db.execute(
|
||||
select(Channel)
|
||||
.options(selectinload(Channel.provider_maps))
|
||||
.where(Channel.stream_id_at_provider == stream_id_clean, Channel.type == ContentType.live, Channel.is_active == True) # noqa: E712
|
||||
)
|
||||
channel = result.scalar_one_or_none()
|
||||
if not channel:
|
||||
raise HTTPException(404, "Channel not found")
|
||||
|
||||
# Check user can access this channel (regular catalog or custom category)
|
||||
if not await can_user_access_channel(db, user, channel.id):
|
||||
if not await _can_access_via_custom_category(db, channel.id, user.id):
|
||||
raise HTTPException(403, "Channel not in your catalog")
|
||||
|
||||
provider_maps = await get_channel_provider_maps(db, channel.id)
|
||||
if not provider_maps:
|
||||
raise HTTPException(503, "No provider available for this channel")
|
||||
|
||||
preferred_ids = await get_user_preferred_provider_ids(db, user)
|
||||
|
||||
try:
|
||||
handle = await pool.acquire(
|
||||
channel_id=channel.id,
|
||||
stream_url_resolver=None,
|
||||
provider_maps=provider_maps,
|
||||
user_id=str(user.id),
|
||||
user_max_conns=user.max_connections,
|
||||
is_priority=user.is_priority,
|
||||
preferred_provider_ids=preferred_ids,
|
||||
username=user.username,
|
||||
channel_name=channel.name,
|
||||
)
|
||||
except MaxConnectionsExceededError:
|
||||
return await _stream_blocked_image()
|
||||
except NoSlotsAvailableError:
|
||||
raise HTTPException(503, "No provider slots available — try again later")
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
log_id = await _create_log(user.id, user.username, channel.id, channel.name, "live", stream_id_clean, client_ip)
|
||||
stream_started = time.time()
|
||||
|
||||
async def stream_generator() -> AsyncIterator[bytes]:
|
||||
bytes_sent = 0
|
||||
try:
|
||||
async for chunk in handle.read():
|
||||
bytes_sent += len(chunk)
|
||||
yield chunk
|
||||
finally:
|
||||
try:
|
||||
await asyncio.shield(pool.release(channel.id, handle.client_id, str(user.id)))
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
asyncio.create_task(_finish_log(log_id, bytes_sent, stream_started))
|
||||
|
||||
return StreamingResponse(
|
||||
stream_generator(),
|
||||
media_type="video/mp2t",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VOD proxy /movie/{username}/{password}/{stream_id}.ext
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/movie/{username}/{password}/{stream_id}")
|
||||
async def vod_stream(
|
||||
username: str,
|
||||
password: str,
|
||||
stream_id: str,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# Parse extension
|
||||
parts = stream_id.rsplit(".", 1)
|
||||
sid = parts[0]
|
||||
ext = parts[1] if len(parts) > 1 else "mkv"
|
||||
|
||||
user = await authenticate_user(username, password, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Channel)
|
||||
.options(selectinload(Channel.provider_maps))
|
||||
.where(Channel.stream_id_at_provider == sid, Channel.type == ContentType.movie)
|
||||
)
|
||||
channel = result.scalar_one_or_none()
|
||||
# Fallback: Jellyfin channels have non-numeric stream_id_at_provider so the app
|
||||
# receives ch.id (DB int) as the stream_id — look it up by primary key.
|
||||
if not channel:
|
||||
try:
|
||||
ch_id = int(sid)
|
||||
fb = await db.execute(
|
||||
select(Channel)
|
||||
.options(selectinload(Channel.provider_maps))
|
||||
.where(Channel.id == ch_id, Channel.type == ContentType.movie)
|
||||
)
|
||||
channel = fb.scalar_one_or_none()
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if not channel:
|
||||
raise HTTPException(404, "VOD not found")
|
||||
|
||||
if not await can_user_access_channel(db, user, channel.id):
|
||||
if not await _can_access_via_custom_category(db, channel.id, user.id):
|
||||
raise HTTPException(403, "Not in your catalog")
|
||||
|
||||
if not channel.provider_maps:
|
||||
raise HTTPException(503, "No provider available")
|
||||
|
||||
pmap = channel.provider_maps[0]
|
||||
prov_res = await db.execute(select(ProviderAccount).where(ProviderAccount.id == pmap.provider_account_id))
|
||||
provider = prov_res.scalar_one_or_none()
|
||||
vod_session = vod_tracker.start(
|
||||
user_id=str(user.id),
|
||||
username=user.username,
|
||||
channel_id=channel.id,
|
||||
channel_name=channel.name,
|
||||
provider_account_id=pmap.provider_account_id,
|
||||
provider_name=provider.name if provider else f"#{pmap.provider_account_id}",
|
||||
stream_type="movie",
|
||||
stream_url=pmap.stream_url,
|
||||
user_max_connections=user.max_connections,
|
||||
)
|
||||
client_ip = get_client_ip(request)
|
||||
log_id = await _create_log(user.id, user.username, channel.id, channel.name, "movie", sid, client_ip)
|
||||
return await _proxy_vod(pmap.stream_url, request, vod_session=vod_session, log_id=log_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Series proxy /series/{username}/{password}/{stream_id}.ext
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/series/{username}/{password}/{stream_id}")
|
||||
async def series_stream(
|
||||
username: str,
|
||||
password: str,
|
||||
stream_id: str,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
parts = stream_id.rsplit(".", 1)
|
||||
sid = parts[0]
|
||||
ext = parts[1] if len(parts) > 1 else "mkv"
|
||||
|
||||
user = await authenticate_user(username, password, db)
|
||||
|
||||
# First: try the episode as a stored channel (future: when episodes are synced)
|
||||
result = await db.execute(
|
||||
select(Channel)
|
||||
.options(selectinload(Channel.provider_maps))
|
||||
.where(Channel.stream_id_at_provider == sid, Channel.type == ContentType.series)
|
||||
)
|
||||
channel = result.scalar_one_or_none()
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
if channel and channel.provider_maps:
|
||||
if not await can_user_access_channel(db, user, channel.id):
|
||||
if not await _can_access_via_custom_category(db, channel.id, user.id):
|
||||
raise HTTPException(403, "Not in your catalog")
|
||||
pmap = channel.provider_maps[0]
|
||||
prov_res = await db.execute(select(ProviderAccount).where(ProviderAccount.id == pmap.provider_account_id))
|
||||
provider = prov_res.scalar_one_or_none()
|
||||
vod_session = vod_tracker.start(
|
||||
user_id=str(user.id), username=user.username,
|
||||
channel_id=channel.id, channel_name=channel.name,
|
||||
provider_account_id=pmap.provider_account_id,
|
||||
provider_name=provider.name if provider else f"#{pmap.provider_account_id}",
|
||||
stream_type="series",
|
||||
stream_url=pmap.stream_url,
|
||||
user_max_connections=user.max_connections,
|
||||
)
|
||||
log_id = await _create_log(user.id, user.username, channel.id, channel.name, "series", sid, client_ip)
|
||||
return await _proxy_vod(pmap.stream_url, request, vod_session=vod_session, log_id=log_id)
|
||||
|
||||
# Second: episode_id was cached when the app called get_series_info
|
||||
provider_id = _episode_provider_cache.get(sid)
|
||||
if provider_id:
|
||||
prov_result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
|
||||
provider = prov_result.scalar_one_or_none()
|
||||
if provider:
|
||||
client = XtreamClient(provider.base_url, provider.username, provider.password)
|
||||
url = client.build_series_stream_url(sid, ext)
|
||||
vod_session = vod_tracker.start(
|
||||
user_id=str(user.id), username=user.username,
|
||||
channel_id=0, channel_name=f"Episode {sid}",
|
||||
provider_account_id=provider_id,
|
||||
provider_name=provider.name,
|
||||
stream_type="series",
|
||||
stream_url=url,
|
||||
user_max_connections=user.max_connections,
|
||||
)
|
||||
log_id = await _create_log(user.id, user.username, 0, f"Episode {sid}", "series", sid, client_ip)
|
||||
return await _proxy_vod(url, request, vod_session=vod_session, log_id=log_id)
|
||||
|
||||
# Third: check if it's a cached Jellyfin episode (fake_id → direct-play URL)
|
||||
jelly_url = _jellyfin_episode_cache.get(sid)
|
||||
if jelly_url:
|
||||
vod_session = vod_tracker.start(
|
||||
user_id=str(user.id), username=user.username,
|
||||
channel_id=0, channel_name=f"Episode {sid}",
|
||||
provider_account_id=0,
|
||||
provider_name="Jellyfin",
|
||||
stream_type="series",
|
||||
stream_url=jelly_url,
|
||||
user_max_connections=user.max_connections,
|
||||
)
|
||||
log_id = await _create_log(user.id, user.username, 0, f"Episode {sid}", "series", sid, client_ip)
|
||||
return await _proxy_vod(jelly_url, request, vod_session=vod_session, log_id=log_id)
|
||||
|
||||
raise HTTPException(404, "Episode not found — open the series detail first to load episode info")
|
||||
|
||||
|
||||
async def _proxy_vod(
|
||||
upstream_url: str,
|
||||
request: Request,
|
||||
vod_session: VodSession | None = None,
|
||||
log_id: int | None = None,
|
||||
) -> StreamingResponse:
|
||||
"""Transparent proxy for VOD/series — forwards status, Content-Type, and range headers."""
|
||||
req_headers = {}
|
||||
if "Range" in request.headers:
|
||||
req_headers["Range"] = request.headers["Range"]
|
||||
|
||||
timeout = aiohttp.ClientTimeout(connect=10, sock_read=300)
|
||||
http_session = aiohttp.ClientSession(timeout=timeout)
|
||||
if vod_session:
|
||||
vod_session._http_session = http_session
|
||||
try:
|
||||
resp = await http_session.get(upstream_url, headers=req_headers, ssl=False)
|
||||
except Exception as e:
|
||||
await http_session.close()
|
||||
if vod_session:
|
||||
vod_tracker.end(vod_session.session_id)
|
||||
raise HTTPException(502, f"Provider unreachable: {e}")
|
||||
|
||||
content_type = resp.headers.get("Content-Type", "application/octet-stream")
|
||||
forward_headers: dict[str, str] = {"Cache-Control": "no-cache"}
|
||||
for h in ("Content-Length", "Content-Range", "Accept-Ranges"):
|
||||
if h in resp.headers:
|
||||
forward_headers[h] = resp.headers[h]
|
||||
|
||||
log_started = time.time()
|
||||
|
||||
async def generate():
|
||||
prev_bytes = 0
|
||||
prev_time = time.time()
|
||||
bytes_sent = 0
|
||||
try:
|
||||
async for chunk in resp.content.iter_chunked(65536):
|
||||
if vod_session and vod_session.should_stop:
|
||||
break
|
||||
bytes_sent += len(chunk)
|
||||
if vod_session:
|
||||
now = time.time()
|
||||
vod_session.bytes_pumped += len(chunk)
|
||||
vod_session.last_active = now
|
||||
if now - prev_time >= 2.0:
|
||||
bps = (vod_session.bytes_pumped - prev_bytes) / (now - prev_time)
|
||||
vod_session.bps_down = bps
|
||||
vod_session.bps_up = bps
|
||||
prev_bytes = vod_session.bytes_pumped
|
||||
prev_time = now
|
||||
yield chunk
|
||||
finally:
|
||||
resp.release()
|
||||
await http_session.close()
|
||||
if vod_session:
|
||||
vod_session.running = False
|
||||
vod_tracker.end(vod_session.session_id)
|
||||
if log_id:
|
||||
asyncio.create_task(_finish_log(log_id, bytes_sent, log_started))
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
status_code=resp.status,
|
||||
media_type=content_type,
|
||||
headers=forward_headers,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _user_info(user: User, password: str, base: str, db: AsyncSession) -> dict:
|
||||
exp = int(user.expiry_date.timestamp()) if user.expiry_date else 4102444800 # 2100-01-01
|
||||
return {
|
||||
"user_info": {
|
||||
"username": user.username,
|
||||
"password": password,
|
||||
"message": "KiraStream",
|
||||
"auth": 1,
|
||||
"status": "Active" if user.is_active else "Disabled",
|
||||
"exp_date": str(exp),
|
||||
"is_trial": "0",
|
||||
"active_cons": "0",
|
||||
"created_at": str(int(user.created_at.timestamp())),
|
||||
"max_connections": str(user.max_connections),
|
||||
"allowed_output_formats": ["ts", "m3u8", "rtmp"],
|
||||
},
|
||||
"server_info": {
|
||||
"url": base,
|
||||
"port": "80",
|
||||
"https_port": "443",
|
||||
"server_protocol": "http",
|
||||
"rtmp_port": "1935",
|
||||
"timezone": "Europe/Madrid",
|
||||
"timestamp_now": int(time.time()),
|
||||
"time_now": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _get_custom_cats(db: AsyncSession, ctype: ContentType, user_id: int) -> list[dict]:
|
||||
allowed_ids = await _get_user_custom_cat_ids(db, user_id)
|
||||
q = (
|
||||
select(CustomCategory)
|
||||
.where(CustomCategory.type == ctype, CustomCategory.is_visible == True) # noqa: E712
|
||||
.order_by(CustomCategory.position, CustomCategory.id)
|
||||
)
|
||||
if allowed_ids is not None:
|
||||
q = q.where(CustomCategory.id.in_(allowed_ids))
|
||||
result = await db.execute(q)
|
||||
return [
|
||||
{"category_id": f"{CC_PREFIX}{c.id}", "category_name": c.name, "parent_id": 0}
|
||||
for c in result.scalars().all()
|
||||
]
|
||||
|
||||
|
||||
async def _get_live_categories(user: User, db: AsyncSession) -> list[dict]:
|
||||
cats = await get_user_categories(db, user, ContentType.live)
|
||||
result = [{"category_id": str(c.id), "category_name": c.name, "parent_id": 0} for c in cats]
|
||||
result += await _get_custom_cats(db, ContentType.live, user.id)
|
||||
return result
|
||||
|
||||
|
||||
async def _get_categories(user: User, db: AsyncSession, ctype: ContentType) -> list[dict]:
|
||||
cats = await get_user_categories(db, user, ctype)
|
||||
result = [{"category_id": str(c.id), "category_name": c.name, "parent_id": 0} for c in cats]
|
||||
result += await _get_custom_cats(db, ctype, user.id)
|
||||
return result
|
||||
|
||||
|
||||
async def _get_live_streams(user: User, db: AsyncSession, category_id: str | None, base: str) -> list[dict]:
|
||||
if category_id and category_id.startswith(CC_PREFIX):
|
||||
cc_id = int(category_id[len(CC_PREFIX):])
|
||||
channels = await _get_custom_category_channels(db, cc_id)
|
||||
else:
|
||||
cat_id = int(category_id) if category_id else None
|
||||
channels = await get_user_channels(db, user, ContentType.live, cat_id)
|
||||
return [
|
||||
{
|
||||
"num": i + 1,
|
||||
"name": ch.name,
|
||||
"stream_type": "live",
|
||||
"stream_id": int(ch.stream_id_at_provider) if ch.stream_id_at_provider.isdigit() else ch.id,
|
||||
"stream_icon": ch.tvg_logo or "",
|
||||
"epg_channel_id": ch.tvg_id or "",
|
||||
"added": "0",
|
||||
"category_id": str(ch.category_id) if ch.category_id else "0",
|
||||
"custom_sid": "",
|
||||
"tv_archive": 0,
|
||||
"direct_source": "",
|
||||
"tv_archive_duration": 0,
|
||||
}
|
||||
for i, ch in enumerate(channels)
|
||||
]
|
||||
|
||||
|
||||
async def _get_channels(user: User, db: AsyncSession, ctype: ContentType, category_id: str | None, base: str) -> list[dict]:
|
||||
if category_id and category_id.startswith(CC_PREFIX):
|
||||
cc_id = int(category_id[len(CC_PREFIX):])
|
||||
channels = await _get_custom_category_channels(db, cc_id)
|
||||
else:
|
||||
cat_id = int(category_id) if category_id else None
|
||||
channels = await get_user_channels(db, user, ctype, cat_id)
|
||||
if ctype == ContentType.movie:
|
||||
return [
|
||||
{
|
||||
"num": i + 1,
|
||||
"name": ch.name,
|
||||
"stream_type": "movie",
|
||||
"stream_id": int(ch.stream_id_at_provider) if ch.stream_id_at_provider.isdigit() else ch.id,
|
||||
"stream_icon": ch.tvg_logo or "",
|
||||
"added": "0",
|
||||
"category_id": str(ch.category_id) if ch.category_id else "0",
|
||||
"category_ids": [str(ch.category_id) if ch.category_id else "0"],
|
||||
"container_extension": "mkv",
|
||||
}
|
||||
for i, ch in enumerate(channels)
|
||||
]
|
||||
# Series: must return series_id (not stream_id) so apps can call get_series_info
|
||||
return [
|
||||
{
|
||||
"num": i + 1,
|
||||
"name": ch.name,
|
||||
"series_id": int(ch.stream_id_at_provider) if ch.stream_id_at_provider.isdigit() else ch.id,
|
||||
"stream_id": int(ch.stream_id_at_provider) if ch.stream_id_at_provider.isdigit() else ch.id,
|
||||
"cover": ch.tvg_logo or "",
|
||||
"stream_icon": ch.tvg_logo or "",
|
||||
"plot": "",
|
||||
"cast": "",
|
||||
"director": "",
|
||||
"genre": "",
|
||||
"release_date": "",
|
||||
"last_modified": "0",
|
||||
"rating": "",
|
||||
"rating_5based": 0,
|
||||
"backdrop_path": [],
|
||||
"youtube_trailer": "",
|
||||
"episode_run_time": "",
|
||||
"added": "0",
|
||||
"category_id": str(ch.category_id) if ch.category_id else "0",
|
||||
"category_ids": [str(ch.category_id) if ch.category_id else "0"],
|
||||
"container_extension": "mkv",
|
||||
}
|
||||
for i, ch in enumerate(channels)
|
||||
]
|
||||
|
||||
|
||||
async def _get_series_info(series_id: str, user: User, db: AsyncSession) -> dict:
|
||||
result = await db.execute(
|
||||
select(Channel)
|
||||
.options(selectinload(Channel.provider_maps))
|
||||
.where(Channel.stream_id_at_provider == series_id, Channel.type == ContentType.series)
|
||||
)
|
||||
ch = result.scalar_one_or_none()
|
||||
|
||||
# Fallback: Jellyfin series have non-numeric stream_id_at_provider so the app
|
||||
# receives ch.id (DB int) as series_id — look it up by primary key.
|
||||
if not ch:
|
||||
try:
|
||||
ch_id = int(series_id)
|
||||
fb = await db.execute(
|
||||
select(Channel)
|
||||
.options(selectinload(Channel.provider_maps))
|
||||
.where(Channel.id == ch_id, Channel.type == ContentType.series)
|
||||
)
|
||||
ch = fb.scalar_one_or_none()
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if not ch:
|
||||
return {"info": {}, "episodes": {}}
|
||||
|
||||
# Check if this channel belongs to a Jellyfin config
|
||||
ji_result = await db.execute(
|
||||
select(JellyfinItem).where(JellyfinItem.channel_id == ch.id)
|
||||
)
|
||||
ji = ji_result.scalar_one_or_none()
|
||||
if ji:
|
||||
cfg_result = await db.execute(
|
||||
select(JellyfinConfig).where(JellyfinConfig.id == ji.jellyfin_config_id)
|
||||
)
|
||||
cfg = cfg_result.scalar_one_or_none()
|
||||
if cfg:
|
||||
return await _get_jellyfin_series_info(ch, ji, cfg)
|
||||
|
||||
if not ch.provider_maps:
|
||||
return {"info": {}, "episodes": {}}
|
||||
|
||||
pmap = ch.provider_maps[0]
|
||||
prov_result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == pmap.provider_account_id))
|
||||
provider = prov_result.scalar_one_or_none()
|
||||
if not provider:
|
||||
return {"info": {}, "episodes": {}}
|
||||
client = XtreamClient(provider.base_url, provider.username, provider.password)
|
||||
try:
|
||||
info = await client.get_series_info(series_id)
|
||||
# Cache episode_id → provider_id so series_stream can route the playback request
|
||||
for season_eps in (info.get("episodes") or {}).values():
|
||||
if isinstance(season_eps, list):
|
||||
for ep in season_eps:
|
||||
ep_id = str(ep.get("id", ""))
|
||||
if ep_id:
|
||||
_episode_provider_cache[ep_id] = provider.id
|
||||
# Ensure cover is populated (some providers return it here, some in the list)
|
||||
if info.get("info") is not None and not info["info"].get("cover"):
|
||||
info["info"]["cover"] = ch.tvg_logo or ""
|
||||
return info
|
||||
except Exception as e:
|
||||
logger.warning(f"Series info fetch failed for series_id={series_id}: {e}")
|
||||
return {
|
||||
"info": {"name": ch.name, "cover": ch.tvg_logo or "", "plot": "", "cast": "", "director": "", "genre": "", "rating": ""},
|
||||
"episodes": {},
|
||||
}
|
||||
|
||||
|
||||
async def _get_jellyfin_series_info(ch: Channel, ji: JellyfinItem, cfg: JellyfinConfig) -> dict:
|
||||
"""Build get_series_info response from Jellyfin seasons/episodes."""
|
||||
from ..core.jellyfin_client import JellyfinClient as _JClient
|
||||
client = _JClient(cfg.url, cfg.api_key)
|
||||
episodes_by_season: dict[str, list[dict]] = {}
|
||||
try:
|
||||
seasons = await client.get_seasons(ji.jellyfin_item_id)
|
||||
for season in seasons:
|
||||
season_id = season.get("Id", "")
|
||||
season_num = str(season.get("IndexNumber", 1))
|
||||
eps = await client.get_episodes(ji.jellyfin_item_id, season_id)
|
||||
ep_list = []
|
||||
for ep_num, ep in enumerate(eps, 1):
|
||||
ep_jf_id = ep.get("Id", "")
|
||||
if not ep_jf_id:
|
||||
continue
|
||||
# Stable numeric ID derived from the Jellyfin episode ID
|
||||
fake_id = abs(hash(ep_jf_id)) % (10 ** 9)
|
||||
stream_url = client.build_stream_url(ep_jf_id)
|
||||
_jellyfin_episode_cache[str(fake_id)] = stream_url
|
||||
ep_list.append({
|
||||
"id": fake_id,
|
||||
"episode_num": ep.get("IndexNumber", ep_num),
|
||||
"title": ep.get("Name", f"Episodio {ep_num}"),
|
||||
"container_extension": "mkv",
|
||||
"info": {
|
||||
"plot": ep.get("Overview", ""),
|
||||
"duration_secs": 0,
|
||||
"rating": "",
|
||||
"name": ep.get("Name", ""),
|
||||
"air_date": "",
|
||||
},
|
||||
})
|
||||
if ep_list:
|
||||
episodes_by_season[season_num] = ep_list
|
||||
except Exception as e:
|
||||
logger.warning(f"Jellyfin seasons fetch failed for {ji.jellyfin_item_id}: {e}")
|
||||
|
||||
return {
|
||||
"info": {
|
||||
"name": ch.name,
|
||||
"cover": ch.tvg_logo or "",
|
||||
"plot": ji.plot or "",
|
||||
"cast": "",
|
||||
"director": "",
|
||||
"genre": ji.genres or "",
|
||||
"release_date": str(ji.year) if ji.year else "",
|
||||
"rating": ji.rating or "",
|
||||
"backdrop_path": ch.tvg_logo or "",
|
||||
"youtube_trailer": "",
|
||||
"episode_run_time": "",
|
||||
},
|
||||
"episodes": episodes_by_season,
|
||||
}
|
||||
|
||||
|
||||
async def _get_vod_info(vod_id: str, user: User, db: AsyncSession) -> dict:
|
||||
result = await db.execute(
|
||||
select(Channel)
|
||||
.options(selectinload(Channel.provider_maps))
|
||||
.where(Channel.stream_id_at_provider == vod_id, Channel.type == ContentType.movie)
|
||||
)
|
||||
ch = result.scalar_one_or_none()
|
||||
# Fallback: Jellyfin movies return ch.id as vod_id (non-numeric stream_id_at_provider)
|
||||
if not ch:
|
||||
try:
|
||||
ch_id = int(vod_id)
|
||||
fb = await db.execute(
|
||||
select(Channel)
|
||||
.options(selectinload(Channel.provider_maps))
|
||||
.where(Channel.id == ch_id, Channel.type == ContentType.movie)
|
||||
)
|
||||
ch = fb.scalar_one_or_none()
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if not ch:
|
||||
return {"info": {}, "movie_data": {}}
|
||||
# If this is a Jellyfin movie, return its metadata from JellyfinItem
|
||||
ji_result = await db.execute(select(JellyfinItem).where(JellyfinItem.channel_id == ch.id))
|
||||
ji = ji_result.scalar_one_or_none()
|
||||
if ji:
|
||||
return {
|
||||
"info": {
|
||||
"name": ch.name,
|
||||
"cover": ch.tvg_logo or "",
|
||||
"plot": ji.plot or "",
|
||||
"cast": "",
|
||||
"director": "",
|
||||
"genre": ji.genres or "",
|
||||
"release_date": str(ji.year) if ji.year else "",
|
||||
"rating": ji.rating or "",
|
||||
"duration_secs": 0,
|
||||
"backdrop_path": ch.tvg_logo or "",
|
||||
"youtube_trailer": "",
|
||||
"tmdb_id": "",
|
||||
},
|
||||
"movie_data": {
|
||||
"stream_id": str(ch.id),
|
||||
"name": ch.name,
|
||||
"added": "0",
|
||||
"category_id": str(ch.category_id) if ch.category_id else "0",
|
||||
"container_extension": "mkv",
|
||||
"custom_sid": "",
|
||||
"direct_source": "",
|
||||
},
|
||||
}
|
||||
if ch.provider_maps:
|
||||
pmap = ch.provider_maps[0]
|
||||
prov_result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == pmap.provider_account_id))
|
||||
provider = prov_result.scalar_one_or_none()
|
||||
if provider:
|
||||
client = XtreamClient(provider.base_url, provider.username, provider.password)
|
||||
try:
|
||||
return await client.get_vod_info(vod_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"VOD info fetch failed for vod_id={vod_id}: {e}")
|
||||
return {
|
||||
"info": {"name": ch.name, "cover": ch.tvg_logo or "", "plot": "", "cast": "", "director": "", "genre": "", "release_date": "", "rating": ""},
|
||||
"movie_data": {"stream_id": ch.stream_id_at_provider, "container_extension": "mkv"},
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
database_url: str = "postgresql+asyncpg://kirastream:kirastream_secret_2024@localhost/kirastream"
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
secret_key: str = "kirastream-super-secret-change-in-production-2024"
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 60 * 24 * 7 # 7 days
|
||||
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "kiraadmin"
|
||||
|
||||
stream_chunk_size: int = 65536 # 64 KB chunks
|
||||
stream_queue_maxsize: int = 100
|
||||
stream_connect_timeout: int = 10
|
||||
stream_read_timeout: int = 30
|
||||
|
||||
epg_refresh_hours: int = 12
|
||||
m3u_cache_minutes: int = 30
|
||||
|
||||
log_level: str = "INFO"
|
||||
|
||||
class Config:
|
||||
env_file = "/opt/kirastream/.env"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Resolve channel catalog for a given user based on their assigned categories."""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from ..models.user import User, UserCatalogEntry, UserProviderPreference
|
||||
from ..models.channel import Channel, Category, ChannelProviderMap, ContentType
|
||||
from ..models.provider import ProviderAccount
|
||||
|
||||
|
||||
async def get_user_categories(db: AsyncSession, user: User, content_type: ContentType | None = None) -> list[Category]:
|
||||
q = (
|
||||
select(Category)
|
||||
.join(UserCatalogEntry, UserCatalogEntry.category_id == Category.id)
|
||||
.where(UserCatalogEntry.user_id == user.id)
|
||||
)
|
||||
if content_type:
|
||||
q = q.where(Category.type == content_type)
|
||||
result = await db.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_user_channels(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
content_type: ContentType | None = None,
|
||||
category_id: int | None = None,
|
||||
) -> list[Channel]:
|
||||
category_ids_q = (
|
||||
select(UserCatalogEntry.category_id)
|
||||
.where(UserCatalogEntry.user_id == user.id)
|
||||
)
|
||||
|
||||
q = (
|
||||
select(Channel)
|
||||
.options(selectinload(Channel.provider_maps), selectinload(Channel.category))
|
||||
.where(Channel.category_id.in_(category_ids_q))
|
||||
.where(Channel.is_active == True) # noqa: E712
|
||||
)
|
||||
if content_type:
|
||||
q = q.where(Channel.type == content_type)
|
||||
if category_id:
|
||||
q = q.where(Channel.category_id == category_id)
|
||||
|
||||
result = await db.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_channel_provider_maps(db: AsyncSession, channel_id: int) -> list[dict]:
|
||||
q = (
|
||||
select(ChannelProviderMap, ProviderAccount.name.label("provider_name"))
|
||||
.join(ProviderAccount, ProviderAccount.id == ChannelProviderMap.provider_account_id)
|
||||
.where(ChannelProviderMap.channel_id == channel_id)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
rows = result.all()
|
||||
return [
|
||||
{
|
||||
"provider_account_id": row.ChannelProviderMap.provider_account_id,
|
||||
"stream_url": row.ChannelProviderMap.stream_url,
|
||||
"provider_name": row.provider_name or f"#{row.ChannelProviderMap.provider_account_id}",
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
async def can_user_access_channel(db: AsyncSession, user: User, channel_id: int) -> bool:
|
||||
channels = await get_user_channels(db, user)
|
||||
return any(ch.id == channel_id for ch in channels)
|
||||
|
||||
|
||||
async def get_user_preferred_provider_ids(db: AsyncSession, user: User) -> list[int]:
|
||||
result = await db.execute(
|
||||
select(UserProviderPreference.provider_account_id)
|
||||
.where(UserProviderPreference.user_id == user.id)
|
||||
.order_by(UserProviderPreference.priority)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,98 @@
|
||||
"""EPG manager: fetch XMLTV sources, merge by tvg-id, serve filtered by user catalog."""
|
||||
import asyncio
|
||||
import gzip
|
||||
import logging
|
||||
import time
|
||||
from io import BytesIO
|
||||
from lxml import etree
|
||||
import aiohttp
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..models.epg import EpgSource
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_merged_epg: bytes = b""
|
||||
_last_update: float = 0.0
|
||||
_update_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def refresh_epg(db: AsyncSession) -> None:
|
||||
global _merged_epg, _last_update
|
||||
async with _update_lock:
|
||||
result = await db.execute(select(EpgSource).where(EpgSource.is_active == True)) # noqa: E712
|
||||
sources = result.scalars().all()
|
||||
if not sources:
|
||||
logger.info("No active EPG sources configured")
|
||||
return
|
||||
|
||||
root = etree.Element("tv")
|
||||
timeout = aiohttp.ClientTimeout(total=60)
|
||||
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
for source in sources:
|
||||
try:
|
||||
data = await _fetch_xmltv(session, source.url)
|
||||
tree = etree.fromstring(data)
|
||||
for elem in tree:
|
||||
root.append(elem)
|
||||
logger.info(f"EPG loaded from {source.name}: {len(tree)} entries")
|
||||
except Exception as e:
|
||||
logger.error(f"EPG fetch failed for {source.name}: {e}")
|
||||
|
||||
_merged_epg = etree.tostring(root, xml_declaration=True, encoding="UTF-8")
|
||||
_last_update = time.time()
|
||||
|
||||
from sqlalchemy import update
|
||||
await db.execute(
|
||||
update(EpgSource).where(EpgSource.is_active == True).values(last_fetched_at=asyncio.get_event_loop().time()) # noqa: E712
|
||||
)
|
||||
await db.commit()
|
||||
logger.info(f"EPG merge complete, {len(_merged_epg)} bytes")
|
||||
|
||||
|
||||
async def _fetch_xmltv(session: aiohttp.ClientSession, url: str) -> bytes:
|
||||
async with session.get(url, ssl=False) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.read()
|
||||
if data[:2] == b"\x1f\x8b":
|
||||
data = gzip.decompress(data)
|
||||
return data
|
||||
|
||||
|
||||
def get_merged_epg() -> bytes:
|
||||
return _merged_epg
|
||||
|
||||
|
||||
def filter_epg_for_channels(tvg_ids: set[str]) -> bytes:
|
||||
"""Return EPG XML filtered to only contain channels/programs for given tvg-ids."""
|
||||
if not _merged_epg:
|
||||
return b'<?xml version="1.0" encoding="UTF-8"?><tv></tv>'
|
||||
try:
|
||||
tree = etree.fromstring(_merged_epg)
|
||||
root = etree.Element("tv")
|
||||
for elem in tree:
|
||||
tag = elem.tag
|
||||
if tag == "channel":
|
||||
if elem.get("id") in tvg_ids:
|
||||
root.append(elem)
|
||||
elif tag == "programme":
|
||||
if elem.get("channel") in tvg_ids:
|
||||
root.append(elem)
|
||||
return etree.tostring(root, xml_declaration=True, encoding="UTF-8")
|
||||
except Exception as e:
|
||||
logger.error(f"EPG filter error: {e}")
|
||||
return _merged_epg
|
||||
|
||||
|
||||
async def epg_background_task(get_db_func) -> None:
|
||||
"""Periodic EPG refresh task — runs every N hours."""
|
||||
while True:
|
||||
try:
|
||||
async for db in get_db_func():
|
||||
await refresh_epg(db)
|
||||
except Exception as e:
|
||||
logger.error(f"EPG background refresh error: {e}")
|
||||
await asyncio.sleep(settings.epg_refresh_hours * 3600)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Async Jellyfin API client — discovery, libraries, items, streaming."""
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SHORT = aiohttp.ClientTimeout(total=10)
|
||||
_MEDIUM = aiohttp.ClientTimeout(total=30)
|
||||
_LONG = aiohttp.ClientTimeout(total=60)
|
||||
|
||||
|
||||
class JellyfinClient:
|
||||
def __init__(self, url: str, api_key: str):
|
||||
self.url = url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
|
||||
@property
|
||||
def _headers(self) -> dict:
|
||||
return {"X-Emby-Token": self.api_key, "Accept": "application/json"}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Discovery
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def test_connection(self) -> dict:
|
||||
async with aiohttp.ClientSession() as s:
|
||||
async with s.get(
|
||||
f"{self.url}/System/Info/Public", timeout=_SHORT
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
return await r.json()
|
||||
|
||||
async def get_libraries(self) -> list[dict]:
|
||||
async with aiohttp.ClientSession() as s:
|
||||
async with s.get(
|
||||
f"{self.url}/Library/VirtualFolders",
|
||||
headers=self._headers,
|
||||
timeout=_SHORT,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
data = await r.json()
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Items (movies / series)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_items(self, library_id: str, item_type: str) -> list[dict]:
|
||||
"""Fetch all items of a given type from one library (paginated)."""
|
||||
all_items: list[dict] = []
|
||||
start = 0
|
||||
limit = 200
|
||||
while True:
|
||||
params = {
|
||||
"ParentId": library_id,
|
||||
"IncludeItemTypes": item_type,
|
||||
"Recursive": "true",
|
||||
"Fields": "Overview,Genres,OfficialRating,ProductionYear",
|
||||
"Limit": str(limit),
|
||||
"StartIndex": str(start),
|
||||
}
|
||||
async with aiohttp.ClientSession() as s:
|
||||
async with s.get(
|
||||
f"{self.url}/Items",
|
||||
params=params,
|
||||
headers=self._headers,
|
||||
timeout=_LONG,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
data = await r.json()
|
||||
batch = data.get("Items", [])
|
||||
all_items.extend(batch)
|
||||
total = data.get("TotalRecordCount", 0)
|
||||
if len(all_items) >= total or not batch:
|
||||
break
|
||||
start += len(batch)
|
||||
return all_items
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Series structure (seasons + episodes)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_seasons(self, series_id: str) -> list[dict]:
|
||||
async with aiohttp.ClientSession() as s:
|
||||
async with s.get(
|
||||
f"{self.url}/Shows/{series_id}/Seasons",
|
||||
params={"Fields": "Overview"},
|
||||
headers=self._headers,
|
||||
timeout=_MEDIUM,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
return (await r.json()).get("Items", [])
|
||||
|
||||
async def get_episodes(self, series_id: str, season_id: str) -> list[dict]:
|
||||
async with aiohttp.ClientSession() as s:
|
||||
async with s.get(
|
||||
f"{self.url}/Shows/{series_id}/Episodes",
|
||||
params={"SeasonId": season_id, "Fields": "Overview"},
|
||||
headers=self._headers,
|
||||
timeout=_MEDIUM,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
return (await r.json()).get("Items", [])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# URL builders
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def build_stream_url(self, item_id: str) -> str:
|
||||
return (
|
||||
f"{self.url}/Videos/{item_id}/stream"
|
||||
f"?static=true&api_key={self.api_key}&Container=mkv"
|
||||
)
|
||||
|
||||
def build_image_url(self, item_id: str, max_width: int = 400) -> str:
|
||||
return (
|
||||
f"{self.url}/Items/{item_id}/Images/Primary"
|
||||
f"?api_key={self.api_key}&MaxWidth={max_width}"
|
||||
)
|
||||
@@ -0,0 +1,276 @@
|
||||
"""ProviderPool: manages provider account slots and live stream allocation."""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import AsyncIterator
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from ..config import settings
|
||||
from .restream import BroadcastGroup, ClientHandle
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ACTIVE_STREAM_KEY = "ks:stream:{channel_id}"
|
||||
PROVIDER_SLOT_KEY = "ks:slot:{account_id}"
|
||||
USER_CONN_KEY = "ks:user_conns:{user_id}"
|
||||
MONITORING_KEY = "ks:monitoring"
|
||||
|
||||
|
||||
class NoSlotsAvailableError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MaxConnectionsExceededError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ProviderPool:
|
||||
"""
|
||||
Singleton managing all active BroadcastGroups and provider slot allocation.
|
||||
Uses Redis for cross-process state and in-memory dict for the asyncio objects.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._groups: dict[int, BroadcastGroup] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._redis: aioredis.Redis | None = None
|
||||
|
||||
async def init(self) -> None:
|
||||
self._redis = aioredis.from_url(settings.redis_url, decode_responses=True)
|
||||
await self._redis.ping()
|
||||
# Clear any stale keys left from a previous process crash or restart
|
||||
stale = await self._redis.keys("ks:*")
|
||||
if stale:
|
||||
await self._redis.delete(*stale)
|
||||
logger.info(f"ProviderPool cleared {len(stale)} stale Redis keys on startup")
|
||||
logger.info("ProviderPool initialized, Redis connected")
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._redis:
|
||||
await self._redis.aclose()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Public interface
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def acquire(
|
||||
self,
|
||||
channel_id: int,
|
||||
stream_url_resolver, # async callable(provider_account_id) -> str
|
||||
provider_maps: list[dict], # [{"provider_account_id": X, "stream_url": Y, "provider_name": Z}, ...]
|
||||
user_id: str,
|
||||
user_max_conns: int,
|
||||
is_priority: bool = False,
|
||||
preferred_provider_ids: list[int] | None = None,
|
||||
username: str = "",
|
||||
channel_name: str = "",
|
||||
) -> ClientHandle:
|
||||
await self._check_user_connections(user_id, user_max_conns)
|
||||
|
||||
async with self._lock:
|
||||
# Case 1: stream already active → attach to existing group
|
||||
if channel_id in self._groups:
|
||||
group = self._groups[channel_id]
|
||||
handle = await group.add_client(user_id, username=username, is_priority=is_priority)
|
||||
await self._redis.hincrby(ACTIVE_STREAM_KEY.format(channel_id=channel_id), "clients", 1)
|
||||
await self._redis.sadd(USER_CONN_KEY.format(user_id=user_id), handle.client_id)
|
||||
await self._publish_monitoring()
|
||||
logger.info(f"User {user_id} attached to existing stream ch={channel_id}")
|
||||
return handle
|
||||
|
||||
# Case 2: find a free slot (preferred providers sorted first)
|
||||
sorted_maps = self._sort_by_preference(provider_maps, preferred_provider_ids)
|
||||
account_id, stream_url = await self._find_free_slot(sorted_maps)
|
||||
|
||||
if account_id is None:
|
||||
if not is_priority:
|
||||
raise NoSlotsAvailableError(f"No free provider slots for channel {channel_id}")
|
||||
|
||||
# Case 3: priority eviction — bump a non-priority stream
|
||||
account_id, stream_url, victim_group = await self._find_eviction_candidate(sorted_maps)
|
||||
if account_id is None:
|
||||
raise NoSlotsAvailableError(f"No evictable non-priority streams for channel {channel_id}")
|
||||
|
||||
# Clean Redis for the victim stream inside the lock
|
||||
victim_channel_id = victim_group.channel_id
|
||||
victim_account_id = victim_group.provider_account_id
|
||||
self._groups.pop(victim_channel_id, None)
|
||||
await self._redis.delete(ACTIVE_STREAM_KEY.format(channel_id=victim_channel_id))
|
||||
await self._redis.delete(PROVIDER_SLOT_KEY.format(account_id=victim_account_id))
|
||||
# Stop victim group outside lock (disconnect all its clients via SENTINEL)
|
||||
asyncio.create_task(victim_group.stop())
|
||||
logger.info(
|
||||
f"Priority eviction: stopped ch={victim_channel_id} "
|
||||
f"(provider_account={victim_account_id}) for priority user {user_id}"
|
||||
)
|
||||
|
||||
# Reserve slot
|
||||
await self._redis.set(PROVIDER_SLOT_KEY.format(account_id=account_id), channel_id)
|
||||
|
||||
provider_name = next(
|
||||
(pm.get("provider_name", "") for pm in provider_maps if pm["provider_account_id"] == account_id), ""
|
||||
)
|
||||
group = BroadcastGroup(
|
||||
channel_id=channel_id,
|
||||
stream_url=stream_url,
|
||||
provider_account_id=account_id,
|
||||
channel_name=channel_name,
|
||||
provider_name=provider_name,
|
||||
)
|
||||
self._groups[channel_id] = group
|
||||
handle = await group.add_client(user_id, username=username, is_priority=is_priority)
|
||||
|
||||
await self._redis.hset(
|
||||
ACTIVE_STREAM_KEY.format(channel_id=channel_id),
|
||||
mapping={
|
||||
"provider_account_id": account_id,
|
||||
"stream_url": stream_url,
|
||||
"clients": 1,
|
||||
"started_at": time.time(),
|
||||
},
|
||||
)
|
||||
await self._redis.sadd(USER_CONN_KEY.format(user_id=user_id), handle.client_id)
|
||||
await group.start()
|
||||
await self._publish_monitoring()
|
||||
logger.info(f"New stream started ch={channel_id} provider_account={account_id}")
|
||||
return handle
|
||||
|
||||
async def release(self, channel_id: int, client_id: str, user_id: str) -> None:
|
||||
group_to_stop: BroadcastGroup | None = None
|
||||
stopped_account_id: int | None = None
|
||||
|
||||
async with self._lock:
|
||||
group = self._groups.get(channel_id)
|
||||
if not group:
|
||||
return
|
||||
|
||||
remaining = await group.remove_client(client_id)
|
||||
await self._redis.srem(USER_CONN_KEY.format(user_id=user_id), client_id)
|
||||
|
||||
if remaining == 0:
|
||||
# Remove from registry and clean Redis *while holding the lock*,
|
||||
# but defer group.stop() (which awaits task cancellation) until
|
||||
# after the lock is released to avoid blocking the pool.
|
||||
self._groups.pop(channel_id, None)
|
||||
group_to_stop = group
|
||||
stopped_account_id = group.provider_account_id
|
||||
await self._redis.delete(ACTIVE_STREAM_KEY.format(channel_id=channel_id))
|
||||
await self._redis.delete(PROVIDER_SLOT_KEY.format(account_id=stopped_account_id))
|
||||
else:
|
||||
await self._redis.hset(
|
||||
ACTIVE_STREAM_KEY.format(channel_id=channel_id), "clients", remaining
|
||||
)
|
||||
|
||||
await self._publish_monitoring()
|
||||
|
||||
if group_to_stop is not None:
|
||||
await group_to_stop.stop()
|
||||
logger.info(f"Stream stopped ch={channel_id}, slot freed: provider_account={stopped_account_id}")
|
||||
|
||||
async def kill_client(self, client_id: str) -> bool:
|
||||
"""Admin: force-disconnect a specific client by sending SENTINEL to their queue."""
|
||||
async with self._lock:
|
||||
for group in self._groups.values():
|
||||
if client_id in group._clients:
|
||||
return await group.force_disconnect_client(client_id)
|
||||
return False
|
||||
|
||||
def get_active_streams(self) -> list[dict]:
|
||||
return [g.stats() for g in self._groups.values()]
|
||||
|
||||
async def get_user_connection_count(self, user_id: str) -> int:
|
||||
return await self._redis.scard(USER_CONN_KEY.format(user_id=user_id))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def _check_user_connections(self, user_id: str, max_conns: int) -> None:
|
||||
# Cross-check Redis set against in-memory groups to auto-heal stale entries
|
||||
redis_conns = await self._redis.smembers(USER_CONN_KEY.format(user_id=user_id))
|
||||
if redis_conns:
|
||||
active = {cid for g in self._groups.values() for cid in g.client_ids}
|
||||
stale = redis_conns - active
|
||||
if stale:
|
||||
await self._redis.srem(USER_CONN_KEY.format(user_id=user_id), *stale)
|
||||
logger.warning(f"Auto-cleaned {len(stale)} stale conn(s) for user {user_id}")
|
||||
redis_conns -= stale
|
||||
count = len(redis_conns)
|
||||
if count >= max_conns:
|
||||
# Determine if this is a zapping scenario (same device switching channels quickly)
|
||||
# or a genuine multi-device connection attempt.
|
||||
# Zapping: the existing connection is very recent (< 10 s) — the TV app opens the
|
||||
# new stream before the HTTP response of the old one closes. In that case we evict
|
||||
# silently so the channel switch is seamless.
|
||||
# Multi-device: the existing connection is older — keep it alive and raise so the
|
||||
# caller can show a "blocked" stream on the new device instead.
|
||||
now = time.time()
|
||||
groups_snapshot = list(self._groups.values())
|
||||
oldest_age = 0.0
|
||||
for group in groups_snapshot:
|
||||
for cid, handle in group._clients.items():
|
||||
if cid in redis_conns:
|
||||
oldest_age = max(oldest_age, now - handle.connected_at)
|
||||
|
||||
if oldest_age < 10.0:
|
||||
# Zapping — evict silently
|
||||
logger.info(f"User {user_id} zapping ({oldest_age:.1f}s old conn) — evicting old stream")
|
||||
for client_id in list(redis_conns):
|
||||
for group in groups_snapshot:
|
||||
if client_id in group._clients:
|
||||
await group.force_disconnect_client(client_id)
|
||||
break
|
||||
await self._redis.srem(USER_CONN_KEY.format(user_id=user_id), client_id)
|
||||
else:
|
||||
# Multi-device — reject so caller can show blocked image
|
||||
raise MaxConnectionsExceededError(
|
||||
f"User {user_id} already has {count}/{max_conns} connections"
|
||||
)
|
||||
|
||||
def _sort_by_preference(
|
||||
self, provider_maps: list[dict], preferred_ids: list[int] | None
|
||||
) -> list[dict]:
|
||||
if not preferred_ids:
|
||||
return provider_maps
|
||||
pref_set = set(preferred_ids)
|
||||
preferred = [pm for pm in provider_maps if pm["provider_account_id"] in pref_set]
|
||||
rest = [pm for pm in provider_maps if pm["provider_account_id"] not in pref_set]
|
||||
return preferred + rest
|
||||
|
||||
async def _find_free_slot(self, provider_maps: list[dict]) -> tuple[int | None, str | None]:
|
||||
for pm in provider_maps:
|
||||
account_id = pm["provider_account_id"]
|
||||
slot_key = PROVIDER_SLOT_KEY.format(account_id=account_id)
|
||||
busy = await self._redis.exists(slot_key)
|
||||
if not busy:
|
||||
return account_id, pm["stream_url"]
|
||||
return None, None
|
||||
|
||||
async def _find_eviction_candidate(
|
||||
self, provider_maps: list[dict]
|
||||
) -> tuple[int | None, str | None, BroadcastGroup | None]:
|
||||
"""Find a stream with only non-priority clients that uses a slot we need."""
|
||||
available = {pm["provider_account_id"]: pm["stream_url"] for pm in provider_maps}
|
||||
|
||||
candidate: BroadcastGroup | None = None
|
||||
for group in self._groups.values():
|
||||
if group.provider_account_id in available and group.all_non_priority:
|
||||
if candidate is None or group.client_count < candidate.client_count:
|
||||
candidate = group
|
||||
|
||||
if candidate is None:
|
||||
return None, None, None
|
||||
|
||||
return candidate.provider_account_id, available[candidate.provider_account_id], candidate
|
||||
|
||||
async def _publish_monitoring(self) -> None:
|
||||
try:
|
||||
data = json.dumps([g.stats() for g in self._groups.values()])
|
||||
await self._redis.publish(MONITORING_KEY, data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
pool = ProviderPool()
|
||||
@@ -0,0 +1,112 @@
|
||||
"""FFprobe-based stream metadata — resolution, FPS, codecs, bitrate."""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Simple in-process cache: url → (info_dict, fetched_at)
|
||||
_cache: dict[str, tuple[dict, float]] = {}
|
||||
_CACHE_TTL = 3600 # 1 hour — re-probe if URL expires or stream restarts
|
||||
|
||||
# Locks per URL to avoid parallel probes for the same stream
|
||||
_locks: dict[str, asyncio.Lock] = {}
|
||||
|
||||
|
||||
async def probe_stream(url: str) -> dict:
|
||||
"""
|
||||
Run ffprobe against *url* and return a dict with video/audio metadata.
|
||||
Returns {} on failure (non-fatal — the stream still works).
|
||||
Results are cached for CACHE_TTL seconds.
|
||||
"""
|
||||
now = time.time()
|
||||
cached = _cache.get(url)
|
||||
if cached and now - cached[1] < _CACHE_TTL:
|
||||
return cached[0]
|
||||
|
||||
if url not in _locks:
|
||||
_locks[url] = asyncio.Lock()
|
||||
|
||||
async with _locks[url]:
|
||||
# Re-check after acquiring lock (another task may have already probed)
|
||||
cached = _cache.get(url)
|
||||
if cached and now - cached[1] < _CACHE_TTL:
|
||||
return cached[0]
|
||||
|
||||
info = await _run_ffprobe(url)
|
||||
_cache[url] = (info, time.time())
|
||||
return info
|
||||
|
||||
|
||||
async def _run_ffprobe(url: str) -> dict:
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ffprobe",
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_streams",
|
||||
"-show_format",
|
||||
"-analyzeduration", "3000000", # 3 s — fast enough for live streams
|
||||
"-probesize", "1000000", # 1 MB
|
||||
url,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=20)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
logger.debug(f"ffprobe timed out for {url}")
|
||||
return {}
|
||||
|
||||
data = json.loads(stdout)
|
||||
except Exception as e:
|
||||
logger.debug(f"ffprobe failed for {url}: {e}")
|
||||
return {}
|
||||
|
||||
info: dict = {}
|
||||
|
||||
for stream in data.get("streams", []):
|
||||
ctype = stream.get("codec_type", "")
|
||||
if ctype == "video" and "video_codec" not in info:
|
||||
info["video_codec"] = stream.get("codec_name", "")
|
||||
info["video_profile"] = stream.get("profile", "")
|
||||
info["width"] = stream.get("width", 0)
|
||||
info["height"] = stream.get("height", 0)
|
||||
# FPS: prefer avg_frame_rate, fallback r_frame_rate
|
||||
for fps_field in ("avg_frame_rate", "r_frame_rate"):
|
||||
fps_str = stream.get(fps_field, "")
|
||||
if fps_str and "/" in fps_str:
|
||||
try:
|
||||
num, den = fps_str.split("/")
|
||||
fps = round(int(num) / int(den), 3) if int(den) else 0
|
||||
if 1 < fps < 200: # sanity check
|
||||
info["fps"] = round(fps, 2)
|
||||
break
|
||||
except (ValueError, ZeroDivisionError):
|
||||
pass
|
||||
|
||||
elif ctype == "audio" and "audio_codec" not in info:
|
||||
info["audio_codec"] = stream.get("codec_name", "")
|
||||
info["audio_channels"] = stream.get("channels", 0)
|
||||
info["audio_sample_rate"] = int(stream.get("sample_rate", 0) or 0)
|
||||
|
||||
fmt = data.get("format", {})
|
||||
try:
|
||||
br = int(fmt.get("bit_rate", 0) or 0)
|
||||
if br > 0:
|
||||
info["bitrate_bps"] = br
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
logger.debug(f"Probed {url}: {info}")
|
||||
return info
|
||||
|
||||
|
||||
def invalidate(url: str) -> None:
|
||||
"""Evict a URL from the cache (call when stream URL changes)."""
|
||||
_cache.pop(url, None)
|
||||
@@ -0,0 +1,350 @@
|
||||
"""BroadcastGroup: fan-out a single provider stream to N simultaneous clients.
|
||||
|
||||
Recovery model
|
||||
--------------
|
||||
The pump loop runs indefinitely while self._running is True. Each iteration
|
||||
opens a fresh upstream connection. If the connection fails or stalls the loop
|
||||
sends SENTINEL to every client (forcing the player to reconnect from scratch),
|
||||
sleeps briefly, then retries the upstream connection.
|
||||
|
||||
Sending SENTINEL on reconnect is intentional: keeping the TCP connection alive
|
||||
while splicing in chunks from a new provider connection causes a PTS/DTS jump
|
||||
that most players cannot reconcile silently — the result is permanent audio
|
||||
desync. A clean player reconnect gives the player a fresh timestamp timeline
|
||||
and guarantees A/V sync from the first frame of the new connection.
|
||||
|
||||
Stall detection
|
||||
---------------
|
||||
Each chunk read is wrapped in asyncio.wait_for(STALL_TIMEOUT). If the
|
||||
upstream stops sending bytes for STALL_TIMEOUT seconds we treat it as a dead
|
||||
stream and reconnect immediately rather than waiting for a TCP timeout that
|
||||
could take minutes.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import AsyncIterator
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHUNK_SIZE = settings.stream_chunk_size
|
||||
QUEUE_MAX = settings.stream_queue_maxsize
|
||||
STALL_TIMEOUT = 10.0 # seconds of silence → reconnect (fast stall detection)
|
||||
MAX_RETRIES = 999 # effectively infinite — pool stops the group when 0 clients remain
|
||||
# Back-off: 0.5 s, 1 s, 2 s, 4 s … capped at 5 s
|
||||
_BACKOFF_CAP = 5.0
|
||||
|
||||
_SENTINEL = object() # signals end-of-stream to client queues
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientHandle:
|
||||
client_id: str
|
||||
queue: asyncio.Queue = field(default_factory=lambda: asyncio.Queue(maxsize=QUEUE_MAX))
|
||||
connected_at: float = field(default_factory=time.time)
|
||||
is_priority: bool = False
|
||||
username: str = ""
|
||||
_overflow_count: int = 0
|
||||
_overflow_since: float | None = None
|
||||
|
||||
async def read(self) -> AsyncIterator[bytes]:
|
||||
while True:
|
||||
chunk = await self.queue.get()
|
||||
if chunk is _SENTINEL:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
|
||||
class BroadcastGroup:
|
||||
"""Streams one provider URL and distributes chunks to all registered clients."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
channel_id: int,
|
||||
stream_url: str,
|
||||
provider_account_id: int,
|
||||
channel_name: str = "",
|
||||
provider_name: str = "",
|
||||
):
|
||||
self.channel_id = channel_id
|
||||
self.channel_name = channel_name
|
||||
self.stream_url = stream_url
|
||||
self.provider_account_id = provider_account_id
|
||||
self.provider_name = provider_name
|
||||
self.started_at = time.time()
|
||||
|
||||
self._clients: dict[str, ClientHandle] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._task: asyncio.Task | None = None
|
||||
self._running = False
|
||||
|
||||
# Throughput tracking
|
||||
self._bytes_pumped: int = 0
|
||||
self._bytes_prev: int = 0
|
||||
self._bps_time: float = time.time()
|
||||
self._bps_down: float = 0.0
|
||||
|
||||
# Health / metadata
|
||||
self.stream_info: dict = {} # populated by background ffprobe
|
||||
self._reconnect_count: int = 0
|
||||
self._status: str = "connecting" # connecting | ok | reconnecting | stopped
|
||||
self._last_data_at: float = time.time()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def client_count(self) -> int:
|
||||
return len(self._clients)
|
||||
|
||||
@property
|
||||
def client_ids(self) -> list[str]:
|
||||
return list(self._clients.keys())
|
||||
|
||||
@property
|
||||
def all_non_priority(self) -> bool:
|
||||
return all(not h.is_priority for h in self._clients.values())
|
||||
|
||||
async def start(self) -> None:
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(
|
||||
self._pump_with_retry(), name=f"pump-ch{self.channel_id}"
|
||||
)
|
||||
# Probe stream info in background — does NOT delay the first chunk
|
||||
asyncio.create_task(self._probe_info(), name=f"probe-ch{self.channel_id}")
|
||||
|
||||
async def add_client(self, user_id: str, username: str = "", is_priority: bool = False) -> ClientHandle:
|
||||
client_id = f"{user_id}-{uuid.uuid4().hex[:8]}"
|
||||
handle = ClientHandle(client_id=client_id, is_priority=is_priority, username=username)
|
||||
async with self._lock:
|
||||
self._clients[client_id] = handle
|
||||
logger.info(
|
||||
f"Client {client_id} ({username}) joined channel {self.channel_id} "
|
||||
f"(total={len(self._clients)})"
|
||||
)
|
||||
return handle
|
||||
|
||||
async def force_disconnect_client(self, client_id: str) -> bool:
|
||||
async with self._lock:
|
||||
handle = self._clients.get(client_id)
|
||||
if handle is None:
|
||||
return False
|
||||
_drain_queue(handle.queue)
|
||||
try:
|
||||
handle.queue.put_nowait(_SENTINEL)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
return True
|
||||
|
||||
async def remove_client(self, client_id: str) -> int:
|
||||
async with self._lock:
|
||||
self._clients.pop(client_id, None)
|
||||
remaining = len(self._clients)
|
||||
logger.info(f"Client {client_id} left channel {self.channel_id} (remaining={remaining})")
|
||||
return remaining
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._running = False
|
||||
self._status = "stopped"
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
async with self._lock:
|
||||
for handle in self._clients.values():
|
||||
try:
|
||||
handle.queue.put_nowait(_SENTINEL)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
def stats(self) -> dict:
|
||||
n = self.client_count or 1
|
||||
return {
|
||||
"stream_type": "live",
|
||||
"channel_id": self.channel_id,
|
||||
"channel_name": self.channel_name or f"Canal #{self.channel_id}",
|
||||
"provider_account_id": self.provider_account_id,
|
||||
"provider_name": self.provider_name,
|
||||
"client_count": self.client_count,
|
||||
"clients": [
|
||||
{
|
||||
"client_id": h.client_id,
|
||||
"username": h.username or h.client_id.rsplit("-", 1)[0],
|
||||
}
|
||||
for h in self._clients.values()
|
||||
],
|
||||
"started_at": self.started_at,
|
||||
"bytes_pumped": self._bytes_pumped,
|
||||
"bps_down": self._bps_down,
|
||||
"bps_up": self._bps_down * n,
|
||||
"running": self._running,
|
||||
# Health
|
||||
"status": self._status,
|
||||
"reconnect_count": self._reconnect_count,
|
||||
"last_data_ago": round(time.time() - self._last_data_at, 1),
|
||||
# Stream metadata from ffprobe
|
||||
"stream_info": self.stream_info,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _probe_info(self) -> None:
|
||||
from .probe import probe_stream
|
||||
try:
|
||||
info = await probe_stream(self.stream_url)
|
||||
self.stream_info.update(info)
|
||||
except Exception as e:
|
||||
logger.debug(f"probe_info failed for ch{self.channel_id}: {e}")
|
||||
|
||||
async def _pump_with_retry(self) -> None:
|
||||
"""
|
||||
Outer retry loop. Each iteration calls _pump_once() which runs until
|
||||
the upstream connection dies or stalls. On failure we back off and
|
||||
retry without touching client connections.
|
||||
"""
|
||||
consecutive = 0
|
||||
base_wait = 1.0
|
||||
|
||||
try:
|
||||
while self._running:
|
||||
try:
|
||||
await self._pump_once()
|
||||
# _pump_once returned cleanly (self._running went False)
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
if not self._running:
|
||||
break
|
||||
consecutive += 1
|
||||
self._reconnect_count += 1
|
||||
wait = min(base_wait * (2 ** (consecutive - 1)), _BACKOFF_CAP)
|
||||
self._status = "reconnecting"
|
||||
logger.warning(
|
||||
f"[ch{self.channel_id}] Upstream error (attempt {consecutive}), "
|
||||
f"reconnecting in {wait:.1f}s: {e}"
|
||||
)
|
||||
# Send SENTINEL to every client so the player drops its
|
||||
# own buffers and reconnects from scratch. This is the
|
||||
# only reliable way to prevent A/V desync: if we keep the
|
||||
# TCP connection alive but splice in chunks from a new
|
||||
# provider connection, the player sees a PTS/DTS jump it
|
||||
# cannot reconcile silently. A clean player reconnect
|
||||
# gives it a fresh timeline and guarantees sync.
|
||||
async with self._lock:
|
||||
for handle in self._clients.values():
|
||||
_drain_queue(handle.queue)
|
||||
try:
|
||||
handle.queue.put_nowait(_SENTINEL)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
await asyncio.sleep(wait)
|
||||
finally:
|
||||
self._status = "stopped"
|
||||
self._running = False
|
||||
# Signal all waiting clients that the stream is over
|
||||
async with self._lock:
|
||||
for handle in self._clients.values():
|
||||
_drain_queue(handle.queue)
|
||||
try:
|
||||
handle.queue.put_nowait(_SENTINEL)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
async def _pump_once(self) -> None:
|
||||
"""
|
||||
Open one upstream connection and pump until EOF, stall, or error.
|
||||
Resets consecutive-failure counter on first successful chunk.
|
||||
"""
|
||||
timeout = aiohttp.ClientTimeout(
|
||||
connect=settings.stream_connect_timeout,
|
||||
sock_read=None, # we control read timeouts via wait_for ourselves
|
||||
)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(self.stream_url, ssl=False) as resp:
|
||||
if resp.status not in (200, 206):
|
||||
raise RuntimeError(f"Provider returned HTTP {resp.status}")
|
||||
|
||||
self._status = "ok"
|
||||
self._last_data_at = time.time()
|
||||
logger.info(
|
||||
f"[ch{self.channel_id}] Stream connected "
|
||||
f"(reconnects so far: {self._reconnect_count})"
|
||||
)
|
||||
|
||||
while self._running:
|
||||
# --- read one chunk with stall timeout ---
|
||||
try:
|
||||
chunk = await asyncio.wait_for(
|
||||
resp.content.readany(), timeout=STALL_TIMEOUT
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
raise RuntimeError(
|
||||
f"Stream stalled: no data for {STALL_TIMEOUT:.0f}s"
|
||||
)
|
||||
|
||||
if not chunk:
|
||||
raise RuntimeError("Provider closed the stream (EOF)")
|
||||
|
||||
# --- throughput accounting ---
|
||||
self._bytes_pumped += len(chunk)
|
||||
self._last_data_at = time.time()
|
||||
now = self._last_data_at
|
||||
if now - self._bps_time >= 2.0:
|
||||
self._bps_down = (self._bytes_pumped - self._bytes_prev) / (now - self._bps_time)
|
||||
self._bytes_prev = self._bytes_pumped
|
||||
self._bps_time = now
|
||||
|
||||
# --- distribute to clients ---
|
||||
await self._dispatch(chunk, now)
|
||||
|
||||
async def _dispatch(self, chunk: bytes, now: float) -> None:
|
||||
"""Push chunk to every client queue; evict clients that have been full for >30 s."""
|
||||
async with self._lock:
|
||||
dead: list[str] = []
|
||||
for cid, handle in self._clients.items():
|
||||
try:
|
||||
handle.queue.put_nowait(chunk)
|
||||
handle._overflow_count = 0
|
||||
handle._overflow_since = None
|
||||
except asyncio.QueueFull:
|
||||
# Drop-newest: discard this chunk for the slow client.
|
||||
# Preserves the already-buffered sequence → no A/V desync from gaps.
|
||||
handle._overflow_count += 1
|
||||
if handle._overflow_since is None:
|
||||
handle._overflow_since = now
|
||||
elif now - handle._overflow_since > 30:
|
||||
dead.append(cid)
|
||||
|
||||
for cid in dead:
|
||||
logger.warning(
|
||||
f"[ch{self.channel_id}] Evicting unresponsive client {cid} "
|
||||
f"after 30 s of sustained queue overflow"
|
||||
)
|
||||
dropped = self._clients.pop(cid, None)
|
||||
if dropped is not None:
|
||||
_drain_queue(dropped.queue)
|
||||
try:
|
||||
dropped.queue.put_nowait(_SENTINEL)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
|
||||
def _drain_queue(q: asyncio.Queue) -> None:
|
||||
"""Empty a queue non-blockingly."""
|
||||
while not q.empty():
|
||||
try:
|
||||
q.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
@@ -0,0 +1,164 @@
|
||||
"""VodTracker: lightweight session tracking for VOD and Series streams."""
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STALE_TIMEOUT = 10 # seconds of no new bytes before a session is considered dead
|
||||
|
||||
|
||||
@dataclass
|
||||
class VodSession:
|
||||
session_id: str
|
||||
user_id: str
|
||||
username: str
|
||||
channel_id: int
|
||||
channel_name: str
|
||||
provider_account_id: int
|
||||
provider_name: str
|
||||
stream_type: str # "movie" or "series"
|
||||
stream_url: str
|
||||
started_at: float = field(default_factory=time.time)
|
||||
last_active: float = field(default_factory=time.time)
|
||||
bytes_pumped: int = 0
|
||||
bps_down: float = 0.0 # download speed from provider
|
||||
bps_up: float = 0.0 # upload speed to client (same as down for VOD, 1 client)
|
||||
running: bool = True
|
||||
_stop: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
# Set by _proxy_vod so kill() can immediately abort the upstream connection
|
||||
_http_session: object = field(default=None, repr=False)
|
||||
|
||||
@property
|
||||
def should_stop(self) -> bool:
|
||||
return self._stop.is_set()
|
||||
|
||||
def kill(self) -> None:
|
||||
self._stop.set()
|
||||
# Close the upstream aiohttp session immediately so iter_chunked() raises
|
||||
# and the generator's finally block runs without waiting for the next chunk.
|
||||
http = self._http_session
|
||||
if http is not None:
|
||||
try:
|
||||
http.connector.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"stream_type": self.stream_type,
|
||||
"channel_id": self.channel_id,
|
||||
"channel_name": self.channel_name,
|
||||
"provider_account_id": self.provider_account_id,
|
||||
"provider_name": self.provider_name,
|
||||
"client_count": 1,
|
||||
"clients": [{"client_id": self.session_id, "username": self.username}],
|
||||
"started_at": self.started_at,
|
||||
"bytes_pumped": self.bytes_pumped,
|
||||
"bps_down": self.bps_down,
|
||||
"bps_up": self.bps_up,
|
||||
"running": self.running,
|
||||
}
|
||||
|
||||
|
||||
class VodTracker:
|
||||
def __init__(self) -> None:
|
||||
self._sessions: dict[str, VodSession] = {}
|
||||
self._user_channel_map: dict[tuple[str, int], str] = {}
|
||||
self._cleanup_task: asyncio.Task | None = None
|
||||
|
||||
def _ensure_cleanup_running(self) -> None:
|
||||
"""Start the background staleness sweep if not already running."""
|
||||
if self._cleanup_task is None or self._cleanup_task.done():
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
self._cleanup_task = loop.create_task(self._cleanup_loop(), name="vod-cleanup")
|
||||
except RuntimeError:
|
||||
pass # no event loop yet — task will start on first session
|
||||
|
||||
async def _cleanup_loop(self) -> None:
|
||||
"""Every 5 s, remove sessions that have received no new bytes for STALE_TIMEOUT s."""
|
||||
while True:
|
||||
await asyncio.sleep(5)
|
||||
now = time.time()
|
||||
stale = [
|
||||
s for s in list(self._sessions.values())
|
||||
if now - s.last_active > STALE_TIMEOUT
|
||||
]
|
||||
for s in stale:
|
||||
logger.info(
|
||||
f"Removing stale VOD session {s.session_id} "
|
||||
f"(user={s.user_id}, ch={s.channel_id}, "
|
||||
f"idle={now - s.last_active:.0f}s)"
|
||||
)
|
||||
s.kill()
|
||||
self._sessions.pop(s.session_id, None)
|
||||
key = (s.user_id, s.channel_id)
|
||||
if self._user_channel_map.get(key) == s.session_id:
|
||||
self._user_channel_map.pop(key, None)
|
||||
|
||||
def start(
|
||||
self,
|
||||
user_id: str,
|
||||
username: str,
|
||||
channel_id: int,
|
||||
channel_name: str,
|
||||
provider_account_id: int,
|
||||
provider_name: str,
|
||||
stream_type: str,
|
||||
stream_url: str,
|
||||
user_max_connections: int = 1,
|
||||
) -> VodSession:
|
||||
self._ensure_cleanup_running()
|
||||
|
||||
# Kill ALL existing VOD sessions for this user — one active VOD per user.
|
||||
# Handles both probe-zombies (same channel) and movie switching (different channel).
|
||||
for s in list(self._sessions.values()):
|
||||
if s.user_id == user_id:
|
||||
self._sessions.pop(s.session_id, None)
|
||||
old_key = (s.user_id, s.channel_id)
|
||||
if self._user_channel_map.get(old_key) == s.session_id:
|
||||
self._user_channel_map.pop(old_key, None)
|
||||
s.kill()
|
||||
logger.info(
|
||||
f"Killed VOD session {s.session_id} (ch={s.channel_id}) "
|
||||
f"for user={user_id} — new session starting"
|
||||
)
|
||||
|
||||
session_id = f"vod-{uuid.uuid4().hex[:8]}"
|
||||
session = VodSession(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
channel_id=channel_id,
|
||||
channel_name=channel_name,
|
||||
provider_account_id=provider_account_id,
|
||||
provider_name=provider_name,
|
||||
stream_type=stream_type,
|
||||
stream_url=stream_url,
|
||||
)
|
||||
self._sessions[session_id] = session
|
||||
self._user_channel_map[(user_id, channel_id)] = session_id
|
||||
return session
|
||||
|
||||
def end(self, session_id: str) -> None:
|
||||
session = self._sessions.pop(session_id, None)
|
||||
if session:
|
||||
key = (session.user_id, session.channel_id)
|
||||
if self._user_channel_map.get(key) == session_id:
|
||||
self._user_channel_map.pop(key, None)
|
||||
|
||||
def kill(self, session_id: str) -> bool:
|
||||
s = self._sessions.get(session_id)
|
||||
if s:
|
||||
s.kill()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_all(self) -> list[dict]:
|
||||
return [s.to_dict() for s in self._sessions.values()]
|
||||
|
||||
|
||||
vod_tracker = VodTracker()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Client for consuming Xtream Codes API from provider."""
|
||||
import aiohttp
|
||||
import asyncio
|
||||
from typing import Any
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class XtreamClient:
|
||||
def __init__(self, base_url: str, username: str, password: str):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.username = username
|
||||
self.password = password
|
||||
|
||||
def _api_url(self) -> str:
|
||||
return f"{self.base_url}/player_api.php"
|
||||
|
||||
async def _get(self, params: dict) -> Any:
|
||||
params = {"username": self.username, "password": self.password, **params}
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(self._api_url(), params=params, ssl=False) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.json(content_type=None)
|
||||
|
||||
async def get_user_info(self) -> dict:
|
||||
return await self._get({})
|
||||
|
||||
async def get_live_categories(self) -> list[dict]:
|
||||
return await self._get({"action": "get_live_categories"})
|
||||
|
||||
async def get_live_streams(self, category_id: str | None = None) -> list[dict]:
|
||||
params = {"action": "get_live_streams"}
|
||||
if category_id:
|
||||
params["category_id"] = category_id
|
||||
return await self._get(params)
|
||||
|
||||
async def get_vod_categories(self) -> list[dict]:
|
||||
return await self._get({"action": "get_vod_categories"})
|
||||
|
||||
async def get_vod_streams(self, category_id: str | None = None) -> list[dict]:
|
||||
params = {"action": "get_vod_streams"}
|
||||
if category_id:
|
||||
params["category_id"] = category_id
|
||||
return await self._get(params)
|
||||
|
||||
async def get_series_categories(self) -> list[dict]:
|
||||
return await self._get({"action": "get_series_categories"})
|
||||
|
||||
async def get_series(self, category_id: str | None = None) -> list[dict]:
|
||||
params = {"action": "get_series"}
|
||||
if category_id:
|
||||
params["category_id"] = category_id
|
||||
return await self._get(params)
|
||||
|
||||
async def get_series_info(self, series_id: str) -> dict:
|
||||
return await self._get({"action": "get_series_info", "series_id": series_id})
|
||||
|
||||
async def get_vod_info(self, vod_id: str) -> dict:
|
||||
return await self._get({"action": "get_vod_info", "vod_id": vod_id})
|
||||
|
||||
async def get_epg(self, stream_id: str | None = None) -> dict:
|
||||
params = {"action": "get_short_epg"}
|
||||
if stream_id:
|
||||
params["stream_id"] = stream_id
|
||||
return await self._get(params)
|
||||
|
||||
def build_live_stream_url(self, stream_id: str, ext: str = "ts") -> str:
|
||||
return f"{self.base_url}/{self.username}/{self.password}/{stream_id}.{ext}"
|
||||
|
||||
def build_vod_stream_url(self, stream_id: str, ext: str = "mkv") -> str:
|
||||
return f"{self.base_url}/movie/{self.username}/{self.password}/{stream_id}.{ext}"
|
||||
|
||||
def build_series_stream_url(self, stream_id: str, ext: str = "mkv") -> str:
|
||||
return f"{self.base_url}/series/{self.username}/{self.password}/{stream_id}.{ext}"
|
||||
|
||||
def get_xmltv_url(self) -> str:
|
||||
return f"{self.base_url}/xmltv.php?username={self.username}&password={self.password}"
|
||||
|
||||
async def test_connection(self) -> bool:
|
||||
try:
|
||||
info = await self.get_user_info()
|
||||
return info.get("user_info", {}).get("status") == "Active"
|
||||
except Exception as e:
|
||||
logger.warning(f"Provider connection test failed: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,31 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from .config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False,
|
||||
pool_size=20,
|
||||
max_overflow=10,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db():
|
||||
from .models import provider, channel, user, epg, custom_category, log, notification, jellyfin # noqa: ensure all models imported
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
"""KiraStream — IPTV Management Server."""
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Depends
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
import json
|
||||
from .config import settings
|
||||
from .database import init_db, AsyncSessionLocal
|
||||
from .core.pool import pool
|
||||
from .core.epg_manager import epg_background_task
|
||||
from .database import get_db
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.log_level),
|
||||
format="%(asctime)s %(levelname)s %(name)s — %(message)s",
|
||||
handlers=[logging.StreamHandler(sys.stdout)],
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _auto_sync_loop() -> None:
|
||||
"""Check every 30 minutes if any provider is due for auto-sync."""
|
||||
from sqlalchemy import select
|
||||
from .models.provider import ProviderAccount
|
||||
from .api.admin.providers import _run_sync, _sync_status
|
||||
|
||||
await asyncio.sleep(60) # delay first check so startup completes
|
||||
while True:
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(
|
||||
select(ProviderAccount).where(ProviderAccount.auto_sync_hours > 0, ProviderAccount.is_active == True) # noqa: E712
|
||||
)
|
||||
providers = result.scalars().all()
|
||||
now = asyncio.get_event_loop().time()
|
||||
import time as _time
|
||||
wall_now = _time.time()
|
||||
for p in providers:
|
||||
interval_secs = p.auto_sync_hours * 3600
|
||||
last_sync_ts = p.last_sync_at.timestamp() if p.last_sync_at else 0
|
||||
if wall_now - last_sync_ts >= interval_secs:
|
||||
if _sync_status.get(p.id, {}).get("status") == "running":
|
||||
continue
|
||||
logger.info(f"Auto-sync triggered for provider {p.id} ({p.name})")
|
||||
cats = json.loads(p.last_sync_categories) if p.last_sync_categories else {}
|
||||
asyncio.create_task(_run_sync(
|
||||
p.id, p.base_url, p.username, p.password,
|
||||
selected_live=set(cats["live"]) if cats.get("live") is not None else None,
|
||||
selected_movie=set(cats["movie"]) if cats.get("movie") is not None else None,
|
||||
selected_series=set(cats["series"]) if cats.get("series") is not None else None,
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error(f"Auto-sync loop error: {e}")
|
||||
await asyncio.sleep(1800) # check every 30 minutes
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("KiraStream starting up...")
|
||||
await init_db()
|
||||
await pool.init()
|
||||
epg_task = asyncio.create_task(epg_background_task(get_db))
|
||||
auto_sync_task = asyncio.create_task(_auto_sync_loop())
|
||||
logger.info("KiraStream ready")
|
||||
yield
|
||||
logger.info("KiraStream shutting down...")
|
||||
await pool.close()
|
||||
epg_task.cancel()
|
||||
auto_sync_task.cancel()
|
||||
for t in (epg_task, auto_sync_task):
|
||||
try:
|
||||
await t
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="KiraStream",
|
||||
description="Professional IPTV Management with Xtream Codes",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# --- Admin auth ---
|
||||
from .api.auth import login, Token # noqa: E402
|
||||
|
||||
@app.post("/api/admin/auth/token", response_model=Token, tags=["admin"])
|
||||
async def admin_login(form_data: OAuth2PasswordRequestForm = Depends()):
|
||||
return await login(form_data)
|
||||
|
||||
# --- Admin routers ---
|
||||
from .api.admin.providers import router as providers_router # noqa: E402
|
||||
from .api.admin.users import router as users_router # noqa: E402
|
||||
from .api.admin.catalogs import router as catalogs_router # noqa: E402
|
||||
from .api.admin.epg_admin import router as epg_admin_router # noqa: E402
|
||||
from .api.admin.monitoring import router as monitoring_router # noqa: E402
|
||||
from .api.admin.custom_categories import router as custom_cats_router # noqa: E402
|
||||
from .api.admin.logs import router as logs_router # noqa: E402
|
||||
from .api.admin.settings import router as settings_router # noqa: E402
|
||||
from .api.admin.jellyfin_admin import router as jellyfin_router # noqa: E402
|
||||
|
||||
app.include_router(providers_router, prefix="/api/admin")
|
||||
app.include_router(users_router, prefix="/api/admin")
|
||||
app.include_router(catalogs_router, prefix="/api/admin")
|
||||
app.include_router(epg_admin_router, prefix="/api/admin")
|
||||
app.include_router(monitoring_router, prefix="/api/admin")
|
||||
app.include_router(custom_cats_router, prefix="/api/admin")
|
||||
app.include_router(logs_router, prefix="/api/admin")
|
||||
app.include_router(settings_router, prefix="/api/admin")
|
||||
app.include_router(jellyfin_router, prefix="/api/admin")
|
||||
|
||||
# --- Xtream Codes compatible API (public) ---
|
||||
from .api.xtream import router as xtream_router # noqa: E402
|
||||
app.include_router(xtream_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "service": "kirastream"}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return JSONResponse({"message": "KiraStream IPTV Server", "docs": "/api/docs"})
|
||||
@@ -0,0 +1,15 @@
|
||||
from .provider import ProviderAccount
|
||||
from .channel import Category, Channel, ChannelProviderMap, ContentType
|
||||
from .user import User, UserCatalogEntry
|
||||
from .epg import EpgSource
|
||||
|
||||
__all__ = [
|
||||
"ProviderAccount",
|
||||
"Category",
|
||||
"Channel",
|
||||
"ChannelProviderMap",
|
||||
"ContentType",
|
||||
"User",
|
||||
"UserCatalogEntry",
|
||||
"EpgSource",
|
||||
]
|
||||
@@ -0,0 +1,61 @@
|
||||
import enum
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Enum, UniqueConstraint, func
|
||||
from sqlalchemy.orm import relationship
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class ContentType(str, enum.Enum):
|
||||
live = "live"
|
||||
movie = "movie"
|
||||
series = "series"
|
||||
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = "categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(300), nullable=False)
|
||||
type = Column(Enum(ContentType), nullable=False)
|
||||
provider_category_id = Column(String(100), nullable=True)
|
||||
provider_account_id = Column(Integer, ForeignKey("provider_accounts.id", ondelete="CASCADE"), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
channels = relationship("Channel", back_populates="category")
|
||||
catalog_entries = relationship("UserCatalogEntry", back_populates="category", cascade="all, delete-orphan")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("provider_category_id", "provider_account_id", "type", name="uq_category_provider"),
|
||||
)
|
||||
|
||||
|
||||
class Channel(Base):
|
||||
__tablename__ = "channels"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(500), nullable=False)
|
||||
tvg_id = Column(String(200), nullable=True, index=True)
|
||||
tvg_logo = Column(String(1000), nullable=True)
|
||||
stream_id_at_provider = Column(String(100), nullable=False)
|
||||
category_id = Column(Integer, ForeignKey("categories.id", ondelete="SET NULL"), nullable=True)
|
||||
type = Column(Enum(ContentType), nullable=False, default=ContentType.live)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
category = relationship("Category", back_populates="channels")
|
||||
provider_maps = relationship("ChannelProviderMap", back_populates="channel", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ChannelProviderMap(Base):
|
||||
__tablename__ = "channel_provider_map"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
channel_id = Column(Integer, ForeignKey("channels.id", ondelete="CASCADE"), nullable=False)
|
||||
provider_account_id = Column(Integer, ForeignKey("provider_accounts.id", ondelete="CASCADE"), nullable=False)
|
||||
stream_url = Column(String(2000), nullable=False)
|
||||
|
||||
channel = relationship("Channel", back_populates="provider_maps")
|
||||
provider_account = relationship("ProviderAccount", back_populates="channel_maps")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("channel_id", "provider_account_id", name="uq_channel_provider"),
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Enum, ForeignKey, func
|
||||
from sqlalchemy.orm import relationship
|
||||
from ..database import Base
|
||||
from .channel import ContentType
|
||||
|
||||
|
||||
class CustomCategory(Base):
|
||||
__tablename__ = "custom_categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
type = Column(Enum(ContentType), nullable=False)
|
||||
position = Column(Integer, default=0, nullable=False)
|
||||
is_visible = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
items = relationship("CustomCategoryItem", back_populates="category", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class CustomCategoryItem(Base):
|
||||
__tablename__ = "custom_category_items"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
custom_category_id = Column(Integer, ForeignKey("custom_categories.id"), nullable=False)
|
||||
channel_id = Column(Integer, ForeignKey("channels.id"), nullable=False)
|
||||
position = Column(Integer, default=0, nullable=False)
|
||||
|
||||
category = relationship("CustomCategory", back_populates="items")
|
||||
channel = relationship("Channel")
|
||||
@@ -0,0 +1,13 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, func
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class EpgSource(Base):
|
||||
__tablename__ = "epg_sources"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
url = Column(String(2000), nullable=False)
|
||||
last_fetched_at = Column(DateTime(timezone=True), nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,47 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, func
|
||||
from sqlalchemy.orm import relationship
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class JellyfinConfig(Base):
|
||||
__tablename__ = "jellyfin_configs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
url = Column(String(500), nullable=False)
|
||||
api_key = Column(String(300), nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
movies_library_id = Column(String(100), nullable=True)
|
||||
series_library_id = Column(String(100), nullable=True)
|
||||
movies_category_name = Column(String(200), default="Películas KiraTV", nullable=False)
|
||||
series_category_name = Column(String(200), default="Series KiraTV", nullable=False)
|
||||
last_sync_at = Column(DateTime(timezone=True), nullable=True)
|
||||
# Dummy ProviderAccount auto-created to satisfy ChannelProviderMap FK
|
||||
provider_account_id = Column(
|
||||
Integer,
|
||||
ForeignKey("provider_accounts.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
items = relationship("JellyfinItem", back_populates="config", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class JellyfinItem(Base):
|
||||
__tablename__ = "jellyfin_items"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
jellyfin_config_id = Column(
|
||||
Integer, ForeignKey("jellyfin_configs.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
channel_id = Column(
|
||||
Integer, ForeignKey("channels.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
jellyfin_item_id = Column(String(100), nullable=False, index=True) # Jellyfin hex ID
|
||||
item_type = Column(String(20), nullable=False) # "movie" | "series"
|
||||
plot = Column(Text, nullable=True)
|
||||
year = Column(Integer, nullable=True)
|
||||
genres = Column(String(500), nullable=True)
|
||||
rating = Column(String(20), nullable=True)
|
||||
|
||||
config = relationship("JellyfinConfig", back_populates="items")
|
||||
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy import Column, Integer, BigInteger, String, DateTime, func
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class ConnectionLog(Base):
|
||||
__tablename__ = "connection_logs"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
user_id = Column(Integer, nullable=False, index=True)
|
||||
username = Column(String(200), nullable=False, index=True)
|
||||
channel_id = Column(Integer, nullable=True)
|
||||
channel_name = Column(String(500), nullable=True)
|
||||
content_type = Column(String(20), nullable=True) # live, movie, series
|
||||
stream_id = Column(String(100), nullable=True)
|
||||
started_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
ended_at = Column(DateTime(timezone=True), nullable=True)
|
||||
duration_seconds = Column(Integer, nullable=True)
|
||||
bytes_transferred = Column(BigInteger, default=0, nullable=False)
|
||||
client_ip = Column(String(100), nullable=True)
|
||||
@@ -0,0 +1,16 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, UniqueConstraint, func
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class NotificationDismissal(Base):
|
||||
__tablename__ = "notification_dismissals"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
provider_id = Column(Integer, ForeignKey("provider_accounts.id", ondelete="CASCADE"), nullable=False)
|
||||
category_id_str = Column(String(100), nullable=False)
|
||||
category_type = Column(String(20), nullable=False)
|
||||
dismissed_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("provider_id", "category_id_str", "category_type", name="uq_dismissal"),
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, func
|
||||
from sqlalchemy.orm import relationship
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class ProviderAccount(Base):
|
||||
__tablename__ = "provider_accounts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
base_url = Column(String(500), nullable=False)
|
||||
username = Column(String(200), nullable=False)
|
||||
password = Column(String(200), nullable=False)
|
||||
max_connections = Column(Integer, default=1, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
last_sync_at = Column(DateTime(timezone=True), nullable=True)
|
||||
expiry_date = Column(DateTime(timezone=True), nullable=True)
|
||||
# 0 = disabled; 6/12/24 = sync every N hours automatically
|
||||
auto_sync_hours = Column(Integer, default=0, nullable=False)
|
||||
# JSON: {"live": ["cat_id", ...], "movie": [...], "series": [...]}
|
||||
last_sync_categories = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
channel_maps = relationship("ChannelProviderMap", back_populates="provider_account", cascade="all, delete-orphan")
|
||||
@@ -0,0 +1,66 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint, func
|
||||
from sqlalchemy.orm import relationship
|
||||
import bcrypt as _bcrypt
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(100), unique=True, nullable=False, index=True)
|
||||
password_hash = Column(String(200), nullable=False)
|
||||
max_connections = Column(Integer, default=1, nullable=False)
|
||||
expiry_date = Column(DateTime(timezone=True), nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
is_priority = Column(Boolean, default=False, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
catalog_entries = relationship("UserCatalogEntry", back_populates="user", cascade="all, delete-orphan")
|
||||
provider_preferences = relationship("UserProviderPreference", back_populates="user", cascade="all, delete-orphan", order_by="UserProviderPreference.priority")
|
||||
|
||||
def set_password(self, password: str) -> None:
|
||||
hashed = _bcrypt.hashpw(password.encode(), _bcrypt.gensalt())
|
||||
self.password_hash = hashed.decode()
|
||||
|
||||
def verify_password(self, password: str) -> bool:
|
||||
return _bcrypt.checkpw(password.encode(), self.password_hash.encode())
|
||||
|
||||
|
||||
class UserCatalogEntry(Base):
|
||||
__tablename__ = "user_catalog_entries"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
category_id = Column(Integer, ForeignKey("categories.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
user = relationship("User", back_populates="catalog_entries")
|
||||
category = relationship("Category", back_populates="catalog_entries")
|
||||
|
||||
|
||||
class UserCustomCatalogEntry(Base):
|
||||
__tablename__ = "user_custom_catalog_entries"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
custom_category_id = Column(Integer, ForeignKey("custom_categories.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "custom_category_id", name="uq_user_custom_cat"),
|
||||
)
|
||||
|
||||
|
||||
class UserProviderPreference(Base):
|
||||
__tablename__ = "user_provider_preferences"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
provider_account_id = Column(Integer, ForeignKey("provider_accounts.id", ondelete="CASCADE"), nullable=False)
|
||||
priority = Column(Integer, default=0, nullable=False)
|
||||
|
||||
user = relationship("User", back_populates="provider_preferences")
|
||||
provider_account = relationship("ProviderAccount")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "provider_account_id", name="uq_user_provider_pref"),
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>KiraStream Admin</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "kirastream-admin",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"axios": "^1.7.9",
|
||||
"recharts": "^2.13.3",
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import Login from './pages/Login'
|
||||
import Layout from './components/Layout'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Providers from './pages/Providers'
|
||||
import Users from './pages/Users'
|
||||
import Catalogs from './pages/Catalogs'
|
||||
import EPG from './pages/EPG'
|
||||
import CustomCategories from './pages/CustomCategories'
|
||||
import Logs from './pages/Logs'
|
||||
import Settings from './pages/Settings'
|
||||
import Jellyfin from './pages/Jellyfin'
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) return <Navigate to="/login" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter basename="/panel">
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<RequireAuth><Layout /></RequireAuth>}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="providers" element={<Providers />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="catalogs" element={<Catalogs />} />
|
||||
<Route path="custom-categories" element={<CustomCategories />} />
|
||||
<Route path="epg" element={<EPG />} />
|
||||
<Route path="logs" element={<Logs />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="jellyfin" element={<Jellyfin />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
|
||||
import { LayoutDashboard, Server, Users, List, Tv, LogOut, Wifi, Star, ScrollText, Settings, Bell, RefreshCw, X, ChevronRight, Film } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import api from '../lib/api'
|
||||
|
||||
const NAV = [
|
||||
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ to: '/providers', icon: Server, label: 'Proveedores' },
|
||||
{ to: '/users', icon: Users, label: 'Usuarios' },
|
||||
{ to: '/catalogs', icon: List, label: 'Catálogos' },
|
||||
{ to: '/custom-categories', icon: Star, label: 'Cat. personalizadas' },
|
||||
{ to: '/jellyfin', icon: Film, label: 'Jellyfin' },
|
||||
{ to: '/epg', icon: Tv, label: 'EPG' },
|
||||
{ to: '/logs', icon: ScrollText, label: 'Registros' },
|
||||
{ to: '/settings', icon: Settings, label: 'Ajustes' },
|
||||
]
|
||||
|
||||
interface NotificationCategory { id: string; name: string; type: string }
|
||||
interface ProviderNotification { provider_id: number; provider_name: string; categories: NotificationCategory[] }
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = { live: 'Directo', movie: 'Película', series: 'Serie' }
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
live: 'bg-blue-900/30 text-blue-300 border-blue-800',
|
||||
movie: 'bg-purple-900/30 text-purple-300 border-purple-800',
|
||||
series: 'bg-green-900/30 text-green-300 border-green-800',
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const navigate = useNavigate()
|
||||
const [notifOpen, setNotifOpen] = useState(false)
|
||||
const [notifications, setNotifications] = useState<ProviderNotification[]>([])
|
||||
const [notifTotal, setNotifTotal] = useState(0)
|
||||
const [notifLoading, setNotifLoading] = useState(false)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('token')
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
async function loadNotifications() {
|
||||
setNotifLoading(true)
|
||||
try {
|
||||
const { data } = await api.get('/providers/notifications/all')
|
||||
setNotifications(data.notifications || [])
|
||||
setNotifTotal(data.total || 0)
|
||||
} catch {
|
||||
setNotifications([])
|
||||
setNotifTotal(0)
|
||||
} finally {
|
||||
setNotifLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Poll notification count every 2 minutes
|
||||
const check = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/providers/notifications/all')
|
||||
setNotifTotal(data.total || 0)
|
||||
if (notifOpen) setNotifications(data.notifications || [])
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
check()
|
||||
const interval = setInterval(check, 120_000)
|
||||
return () => clearInterval(interval)
|
||||
}, [notifOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (notifOpen) loadNotifications()
|
||||
}, [notifOpen])
|
||||
|
||||
// Close panel when clicking outside
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
||||
setNotifOpen(false)
|
||||
}
|
||||
}
|
||||
if (notifOpen) document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [notifOpen])
|
||||
|
||||
async function dismiss(providerId: number, catId: string, catType: string) {
|
||||
await api.post('/providers/notifications/dismiss', {
|
||||
provider_id: providerId,
|
||||
category_id: catId,
|
||||
category_type: catType,
|
||||
})
|
||||
setNotifications(prev =>
|
||||
prev
|
||||
.map(p =>
|
||||
p.provider_id === providerId
|
||||
? { ...p, categories: p.categories.filter(c => !(c.id === catId && c.type === catType)) }
|
||||
: p
|
||||
)
|
||||
.filter(p => p.categories.length > 0)
|
||||
)
|
||||
setNotifTotal(t => Math.max(0, t - 1))
|
||||
}
|
||||
|
||||
async function dismissAll(providerId: number) {
|
||||
await api.post(`/providers/notifications/dismiss-provider/${providerId}`)
|
||||
setNotifications(prev => prev.filter(p => p.provider_id !== providerId))
|
||||
const removed = notifications.find(p => p.provider_id === providerId)?.categories.length ?? 0
|
||||
setNotifTotal(t => Math.max(0, t - removed))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-56 bg-dark-800 border-r border-dark-700 flex flex-col shrink-0">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-dark-700">
|
||||
<Wifi className="text-primary-500" size={22} />
|
||||
<span className="font-bold text-lg tracking-tight flex-1">KiraStream</span>
|
||||
{/* Notification bell */}
|
||||
<div className="relative" ref={panelRef}>
|
||||
<button
|
||||
onClick={() => setNotifOpen(v => !v)}
|
||||
className={clsx(
|
||||
'relative p-1 rounded-lg transition-colors',
|
||||
notifOpen ? 'bg-dark-600 text-yellow-400' : 'text-gray-500 hover:text-yellow-400 hover:bg-dark-700'
|
||||
)}
|
||||
title="Notificaciones"
|
||||
>
|
||||
<Bell size={16} />
|
||||
{notifTotal > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-yellow-500 text-black text-[9px] font-bold rounded-full w-4 h-4 flex items-center justify-center">
|
||||
{notifTotal > 99 ? '99+' : notifTotal}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{notifOpen && (
|
||||
<div className="absolute left-0 top-full mt-2 w-80 bg-dark-800 border border-dark-700 rounded-xl shadow-2xl z-50 flex flex-col max-h-[70vh]">
|
||||
<div className="p-3 border-b border-dark-700 flex items-center justify-between shrink-0">
|
||||
<span className="font-semibold text-sm">Nuevas categorías</span>
|
||||
<button onClick={() => setNotifOpen(false)} className="text-gray-500 hover:text-white">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{notifLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<RefreshCw size={18} className="animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="p-6 text-center text-gray-500 text-sm">
|
||||
Sin novedades pendientes ✓
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-y-auto flex-1 p-2 space-y-3">
|
||||
{notifications.map(prov => (
|
||||
<div key={prov.provider_id} className="bg-dark-900/50 rounded-lg border border-dark-700 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-dark-700/40">
|
||||
<span className="text-xs font-semibold text-gray-300 truncate">{prov.provider_name}</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className="text-xs text-gray-500">{prov.categories.length}</span>
|
||||
<button
|
||||
onClick={() => dismissAll(prov.provider_id)}
|
||||
className="text-xs text-gray-500 hover:text-red-400 ml-1 px-1.5 py-0.5 rounded hover:bg-dark-600"
|
||||
title="Descartar todas"
|
||||
>
|
||||
Ignorar todas
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 space-y-1 max-h-48 overflow-y-auto">
|
||||
{prov.categories.map(cat => (
|
||||
<div key={`${cat.id}_${cat.type}`} className="flex items-center gap-2 px-2 py-1 rounded hover:bg-dark-700 group">
|
||||
<span className="text-xs flex-1 truncate">{cat.name}</span>
|
||||
<span className={`text-[10px] px-1 py-0.5 rounded border shrink-0 ${TYPE_COLORS[cat.type]}`}>
|
||||
{TYPE_LABELS[cat.type]}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => dismiss(prov.provider_id, cat.id, cat.type)}
|
||||
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-opacity shrink-0"
|
||||
title="Descartar"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-3 py-2 border-t border-dark-700">
|
||||
<button
|
||||
onClick={() => {
|
||||
setNotifOpen(false)
|
||||
navigate('/providers')
|
||||
}}
|
||||
className="text-xs text-primary-400 hover:text-primary-300 flex items-center gap-1"
|
||||
>
|
||||
Ir a Proveedores para sincronizar <ChevronRight size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 p-3 space-y-1">
|
||||
{NAV.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-primary-500/20 text-primary-400'
|
||||
: 'text-gray-400 hover:bg-dark-700 hover:text-gray-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon size={17} />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex items-center gap-3 p-4 text-sm text-gray-500 hover:text-red-400 border-t border-dark-700 transition-colors"
|
||||
>
|
||||
<LogOut size={16} /> Cerrar sesión
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 overflow-auto bg-dark-900">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-dark-900 text-gray-100 font-sans antialiased;
|
||||
}
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { @apply bg-dark-800; }
|
||||
::-webkit-scrollbar-thumb { @apply bg-dark-600 rounded; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({ baseURL: '/api/admin' })
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(r) => r,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
|
||||
export async function login(username: string, password: string): Promise<string> {
|
||||
const form = new FormData()
|
||||
form.append('username', username)
|
||||
form.append('password', password)
|
||||
const { data } = await axios.post('/api/admin/auth/token', form)
|
||||
return data.access_token
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
|
||||
interface GroupedCategory { name: string; type: string; category_ids: number[]; provider_count: number }
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = { live: 'Directo', movie: 'Películas', series: 'Series' }
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
live: 'bg-blue-900/40 text-blue-300',
|
||||
movie: 'bg-purple-900/40 text-purple-300',
|
||||
series: 'bg-green-900/40 text-green-300',
|
||||
}
|
||||
|
||||
export default function Catalogs() {
|
||||
const [categories, setCategories] = useState<GroupedCategory[]>([])
|
||||
const [filter, setFilter] = useState<string>('')
|
||||
const [typeFilter, setTypeFilter] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/catalogs/grouped').then(r => setCategories(r.data))
|
||||
}, [])
|
||||
|
||||
const filtered = categories.filter(c => {
|
||||
const matchType = !typeFilter || c.type === typeFilter
|
||||
const matchSearch = !filter || c.name.toLowerCase().includes(filter.toLowerCase())
|
||||
return matchType && matchSearch
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
<h1 className="text-xl font-bold">Catálogos de Categorías</h1>
|
||||
<p className="text-sm text-gray-400">
|
||||
Categorías importadas de los proveedores. El badge indica cuántos proveedores cubren cada categoría
|
||||
(= streams simultáneos posibles). Asígnalas a usuarios desde la sección Usuarios.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
placeholder="Buscar categoría..."
|
||||
className="w-full bg-dark-800 border border-dark-700 rounded-lg pl-8 pr-3 py-2 text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={e => setTypeFilter(e.target.value)}
|
||||
className="bg-dark-800 border border-dark-700 rounded-lg px-3 py-2 text-sm focus:outline-none"
|
||||
>
|
||||
<option value="">Todos los tipos</option>
|
||||
<option value="live">Directo</option>
|
||||
<option value="movie">Películas</option>
|
||||
<option value="series">Series</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{filtered.map(c => (
|
||||
<div key={c.name + c.type} className="bg-dark-800 border border-dark-700 rounded-xl p-3 flex items-center gap-2">
|
||||
<span className="text-sm truncate flex-1 min-w-0" title={c.name}>{c.name}</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{c.provider_count > 0 && (
|
||||
<span
|
||||
title={`${c.provider_count} proveedor${c.provider_count !== 1 ? 'es' : ''} — ${c.provider_count} stream${c.provider_count !== 1 ? 's' : ''} simultáneo${c.provider_count !== 1 ? 's' : ''} posible${c.provider_count !== 1 ? 's' : ''}`}
|
||||
className={`text-xs px-1.5 py-0.5 rounded font-medium ${c.provider_count > 1 ? 'bg-green-900/50 text-green-400' : 'bg-dark-600 text-gray-500'}`}
|
||||
>
|
||||
{c.provider_count}p
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${TYPE_COLORS[c.type]}`}>
|
||||
{TYPE_LABELS[c.type]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="col-span-full bg-dark-800 border border-dark-700 border-dashed rounded-xl p-8 text-center text-gray-500 text-sm">
|
||||
{categories.length === 0 ? 'Sincroniza un proveedor para ver las categorías' : 'No se encontraron categorías'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">{filtered.length} categorías mostradas de {categories.length} totales</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import api from '../lib/api'
|
||||
import { Plus, Trash2, Search, X, Tv, Film, Clapperboard, Star } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type CatType = 'live' | 'movie' | 'series'
|
||||
|
||||
interface Category {
|
||||
id: number
|
||||
name: string
|
||||
type: CatType
|
||||
is_visible: boolean
|
||||
item_count: number
|
||||
}
|
||||
|
||||
interface CategoryChannel {
|
||||
item_id: number
|
||||
channel_id: number
|
||||
name: string
|
||||
logo: string
|
||||
type: string
|
||||
}
|
||||
|
||||
interface SearchChannel {
|
||||
id: number
|
||||
name: string
|
||||
logo: string
|
||||
type: string
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<CatType, string> = { live: 'Directo', movie: 'Película', series: 'Serie' }
|
||||
const TYPE_ICONS: Record<CatType, typeof Tv> = { live: Tv, movie: Film, series: Clapperboard }
|
||||
const TYPE_COLORS: Record<CatType, string> = {
|
||||
live: 'bg-blue-500/20 text-blue-400',
|
||||
movie: 'bg-purple-500/20 text-purple-400',
|
||||
series: 'bg-orange-500/20 text-orange-400',
|
||||
}
|
||||
|
||||
export default function CustomCategories() {
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [selected, setSelected] = useState<Category | null>(null)
|
||||
const [channels, setChannels] = useState<CategoryChannel[]>([])
|
||||
const [searchQ, setSearchQ] = useState('')
|
||||
const [searchType, setSearchType] = useState<CatType | ''>('')
|
||||
const [searchResults, setSearchResults] = useState<SearchChannel[]>([])
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newType, setNewType] = useState<CatType>('live')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const loadCategories = useCallback(async () => {
|
||||
const { data } = await api.get('/custom-categories')
|
||||
setCategories(data)
|
||||
}, [])
|
||||
|
||||
const loadChannels = useCallback(async (cat: Category) => {
|
||||
const { data } = await api.get(`/custom-categories/${cat.id}/channels`)
|
||||
setChannels(data)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadCategories() }, [loadCategories])
|
||||
|
||||
useEffect(() => {
|
||||
if (selected) loadChannels(selected)
|
||||
}, [selected, loadChannels])
|
||||
|
||||
const selectCategory = (cat: Category) => {
|
||||
setSelected(cat)
|
||||
setShowSearch(false)
|
||||
setSearchQ('')
|
||||
setSearchResults([])
|
||||
}
|
||||
|
||||
const createCategory = async () => {
|
||||
if (!newName.trim()) return
|
||||
await api.post('/custom-categories', { name: newName.trim(), type: newType })
|
||||
setNewName('')
|
||||
setShowCreate(false)
|
||||
await loadCategories()
|
||||
}
|
||||
|
||||
const deleteCategory = async (cat: Category) => {
|
||||
if (!confirm(`¿Eliminar categoría "${cat.name}"?`)) return
|
||||
await api.delete(`/custom-categories/${cat.id}`)
|
||||
if (selected?.id === cat.id) setSelected(null)
|
||||
await loadCategories()
|
||||
}
|
||||
|
||||
const toggleVisible = async (cat: Category) => {
|
||||
await api.patch(`/custom-categories/${cat.id}`, { is_visible: !cat.is_visible })
|
||||
await loadCategories()
|
||||
if (selected?.id === cat.id) setSelected({ ...cat, is_visible: !cat.is_visible })
|
||||
}
|
||||
|
||||
const doSearch = useCallback(async (q: string, t: string) => {
|
||||
if (!q && !t) { setSearchResults([]); return }
|
||||
const { data } = await api.get('/custom-categories/channels/search', { params: { q, type: t } })
|
||||
setSearchResults(data)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => doSearch(searchQ, searchType), 300)
|
||||
return () => clearTimeout(id)
|
||||
}, [searchQ, searchType, doSearch])
|
||||
|
||||
const addChannel = async (ch: SearchChannel) => {
|
||||
if (!selected) return
|
||||
try {
|
||||
await api.post(`/custom-categories/${selected.id}/channels`, { channel_id: ch.id })
|
||||
await loadChannels(selected)
|
||||
await loadCategories()
|
||||
} catch (e: any) {
|
||||
if (e.response?.status !== 409) alert('Error al añadir canal')
|
||||
}
|
||||
}
|
||||
|
||||
const removeChannel = async (item: CategoryChannel) => {
|
||||
if (!selected) return
|
||||
await api.delete(`/custom-categories/${selected.id}/channels/${item.item_id}`)
|
||||
await loadChannels(selected)
|
||||
await loadCategories()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Left: Category list */}
|
||||
<aside className="w-72 border-r border-dark-700 flex flex-col bg-dark-800">
|
||||
<div className="p-4 border-b border-dark-700 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star size={18} className="text-yellow-400" />
|
||||
<h2 className="font-semibold text-sm">Categorías personalizadas</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="p-1.5 rounded-lg bg-primary-500/20 text-primary-400 hover:bg-primary-500/30 transition-colors"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create form */}
|
||||
{showCreate && (
|
||||
<div className="p-3 border-b border-dark-700 bg-dark-900 space-y-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && createCategory()}
|
||||
placeholder="Nombre de la categoría"
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
<select
|
||||
value={newType}
|
||||
onChange={e => setNewType(e.target.value as CatType)}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none"
|
||||
>
|
||||
<option value="live">Directo</option>
|
||||
<option value="movie">Película</option>
|
||||
<option value="series">Serie</option>
|
||||
</select>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={createCategory} className="flex-1 bg-primary-500 hover:bg-primary-600 text-white text-xs py-1.5 rounded-lg transition-colors">
|
||||
Crear
|
||||
</button>
|
||||
<button onClick={() => setShowCreate(false)} className="flex-1 bg-dark-700 hover:bg-dark-600 text-xs py-1.5 rounded-lg transition-colors">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{categories.length === 0 && (
|
||||
<p className="text-xs text-gray-500 text-center py-8">No hay categorías personalizadas</p>
|
||||
)}
|
||||
{categories.map(cat => {
|
||||
const Icon = TYPE_ICONS[cat.type]
|
||||
return (
|
||||
<div
|
||||
key={cat.id}
|
||||
onClick={() => selectCategory(cat)}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer group transition-colors',
|
||||
selected?.id === cat.id ? 'bg-primary-500/20' : 'hover:bg-dark-700'
|
||||
)}
|
||||
>
|
||||
<Icon size={14} className={TYPE_COLORS[cat.type].split(' ')[1]} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={clsx('text-sm font-medium truncate', selected?.id === cat.id ? 'text-primary-300' : 'text-gray-200')}>
|
||||
{cat.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{TYPE_LABELS[cat.type]} · {cat.item_count} canales</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => toggleVisible(cat)}
|
||||
className={clsx('text-xs px-1.5 py-0.5 rounded', cat.is_visible ? 'text-green-400' : 'text-gray-600')}
|
||||
title={cat.is_visible ? 'Visible (clic para ocultar)' : 'Oculto (clic para mostrar)'}
|
||||
>
|
||||
{cat.is_visible ? '●' : '○'}
|
||||
</button>
|
||||
<button onClick={() => deleteCategory(cat)} className="text-red-500 hover:text-red-400 p-0.5 rounded">
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Right: Channel list */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden">
|
||||
{!selected ? (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<Star size={40} className="mx-auto mb-3 text-gray-700" />
|
||||
<p>Selecciona una categoría para gestionar sus canales</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-dark-700 flex items-center justify-between bg-dark-800">
|
||||
<div>
|
||||
<h2 className="font-semibold flex items-center gap-2">
|
||||
{(() => { const Icon = TYPE_ICONS[selected.type]; return <Icon size={16} className={TYPE_COLORS[selected.type].split(' ')[1]} /> })()}
|
||||
{selected.name}
|
||||
<span className={clsx('text-xs px-2 py-0.5 rounded-full', TYPE_COLORS[selected.type])}>{TYPE_LABELS[selected.type]}</span>
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{channels.length} canales asignados</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowSearch(!showSearch)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-primary-500/20 text-primary-400 hover:bg-primary-500/30 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
<Plus size={14} /> Añadir canales
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search panel */}
|
||||
{showSearch && (
|
||||
<div className="border-b border-dark-700 bg-dark-900 p-4 space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
autoFocus
|
||||
value={searchQ}
|
||||
onChange={e => setSearchQ(e.target.value)}
|
||||
placeholder="Buscar canal..."
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg pl-9 pr-3 py-2 text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={searchType}
|
||||
onChange={e => setSearchType(e.target.value as CatType | '')}
|
||||
className="bg-dark-700 border border-dark-600 rounded-lg px-3 text-sm focus:outline-none"
|
||||
>
|
||||
<option value="">Todos los tipos</option>
|
||||
<option value="live">Directo</option>
|
||||
<option value="movie">Película</option>
|
||||
<option value="series">Serie</option>
|
||||
</select>
|
||||
<button onClick={() => setShowSearch(false)} className="text-gray-500 hover:text-gray-300 p-2">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1 max-h-48 overflow-y-auto">
|
||||
{searchResults.map(ch => {
|
||||
const already = channels.some(c => c.channel_id === ch.id)
|
||||
return (
|
||||
<button
|
||||
key={ch.id}
|
||||
onClick={() => !already && addChannel(ch)}
|
||||
disabled={already}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg text-left text-sm transition-colors',
|
||||
already ? 'opacity-40 cursor-not-allowed bg-dark-700' : 'bg-dark-700 hover:bg-dark-600 cursor-pointer'
|
||||
)}
|
||||
>
|
||||
{ch.logo ? (
|
||||
<img src={ch.logo} alt="" className="w-6 h-6 object-contain rounded" onError={e => (e.currentTarget.style.display = 'none')} />
|
||||
) : (
|
||||
<div className="w-6 h-6 bg-dark-600 rounded" />
|
||||
)}
|
||||
<span className="truncate flex-1">{ch.name}</span>
|
||||
{already && <span className="text-xs text-green-500">✓</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{searchResults.length === 0 && (searchQ || searchType) && (
|
||||
<p className="col-span-2 text-xs text-gray-500 text-center py-4">Sin resultados</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channel list */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{channels.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-500">
|
||||
<p>Esta categoría está vacía.</p>
|
||||
<p className="text-xs mt-1">Usa "Añadir canales" para añadir contenido.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{channels.map((ch, idx) => (
|
||||
<div key={ch.item_id} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-dark-800 hover:bg-dark-700 group transition-colors">
|
||||
<span className="text-xs text-gray-600 w-6 text-right">{idx + 1}</span>
|
||||
{ch.logo ? (
|
||||
<img src={ch.logo} alt="" className="w-8 h-8 object-contain rounded" onError={e => (e.currentTarget.style.display = 'none')} />
|
||||
) : (
|
||||
<div className="w-8 h-8 bg-dark-600 rounded" />
|
||||
)}
|
||||
<span className="flex-1 text-sm">{ch.name}</span>
|
||||
<span className={clsx('text-xs px-1.5 py-0.5 rounded', TYPE_COLORS[ch.type as CatType] || 'bg-gray-700 text-gray-400')}>
|
||||
{TYPE_LABELS[ch.type as CatType] || ch.type}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeChannel(ch)}
|
||||
className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-400 p-1 transition-all"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Activity, Server, Users, Wifi, Clock, X, Film, Tv, Clapperboard, RefreshCw, AlertTriangle } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
|
||||
interface StreamClient {
|
||||
client_id: string
|
||||
username: string
|
||||
}
|
||||
|
||||
interface StreamInfo {
|
||||
video_codec?: string
|
||||
video_profile?: string
|
||||
width?: number
|
||||
height?: number
|
||||
fps?: number
|
||||
audio_codec?: string
|
||||
audio_channels?: number
|
||||
audio_sample_rate?: number
|
||||
bitrate_bps?: number
|
||||
}
|
||||
|
||||
interface StreamSession {
|
||||
stream_type: 'live' | 'movie' | 'series'
|
||||
channel_id: number
|
||||
channel_name: string
|
||||
provider_account_id: number
|
||||
provider_name: string
|
||||
client_count: number
|
||||
clients: StreamClient[]
|
||||
started_at: number
|
||||
bytes_pumped: number
|
||||
bps_down: number
|
||||
bps_up: number
|
||||
running: boolean
|
||||
// health (live streams only)
|
||||
status?: 'connecting' | 'ok' | 'reconnecting' | 'stopped'
|
||||
reconnect_count?: number
|
||||
last_data_ago?: number
|
||||
stream_info?: StreamInfo
|
||||
}
|
||||
|
||||
interface SlotInfo {
|
||||
id: number
|
||||
name: string
|
||||
max_connections: number
|
||||
status: 'busy' | 'free'
|
||||
channel_id: number | null
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB'
|
||||
return (bytes / 1073741824).toFixed(2) + ' GB'
|
||||
}
|
||||
|
||||
function formatBps(bps: number) {
|
||||
if (bps <= 0) return '—'
|
||||
if (bps < 1024) return bps.toFixed(0) + ' B/s'
|
||||
if (bps < 1048576) return (bps / 1024).toFixed(0) + ' KB/s'
|
||||
return (bps / 1048576).toFixed(1) + ' MB/s'
|
||||
}
|
||||
|
||||
function formatDuration(startTs: number) {
|
||||
const secs = Math.floor(Date.now() / 1000 - startTs)
|
||||
const h = Math.floor(secs / 3600)
|
||||
const m = Math.floor((secs % 3600) / 60)
|
||||
const s = secs % 60
|
||||
return h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s}s` : `${s}s`
|
||||
}
|
||||
|
||||
function formatResolution(info: StreamInfo) {
|
||||
if (!info.width || !info.height) return null
|
||||
const res = info.height >= 1060 ? '4K' : info.height >= 1060 ? '4K'
|
||||
: info.height >= 2000 ? '4K'
|
||||
: info.height >= 1060 ? 'FHD'
|
||||
: info.height >= 1040 ? 'FHD'
|
||||
: info.height >= 700 ? 'HD'
|
||||
: info.height >= 460 ? 'SD'
|
||||
: 'LD'
|
||||
const label =
|
||||
info.width >= 3840 ? '4K' :
|
||||
info.width >= 1920 ? 'FHD' :
|
||||
info.width >= 1280 ? 'HD' :
|
||||
info.width >= 720 ? 'SD' : 'LD'
|
||||
return `${info.width}×${info.height} (${label})`
|
||||
}
|
||||
|
||||
function StreamInfoBadge({ info }: { info: StreamInfo }) {
|
||||
if (!info || Object.keys(info).length === 0) {
|
||||
return <span className="text-gray-600 text-xs italic">Detectando...</span>
|
||||
}
|
||||
const res = formatResolution(info)
|
||||
const fps = info.fps ? `${info.fps} fps` : null
|
||||
const vcodec = info.video_codec ? info.video_codec.toUpperCase() : null
|
||||
const acodec = info.audio_codec ? info.audio_codec.toUpperCase() : null
|
||||
const br = info.bitrate_bps ? `${(info.bitrate_bps / 1_000_000).toFixed(1)} Mb/s` : null
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{res && <div className="text-gray-200 font-mono text-xs">{res}{fps ? ` · ${fps}` : ''}</div>}
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{vcodec && (
|
||||
<span className="bg-blue-900/40 text-blue-300 text-[10px] px-1.5 py-0.5 rounded border border-blue-800">
|
||||
{vcodec}
|
||||
</span>
|
||||
)}
|
||||
{acodec && (
|
||||
<span className="bg-purple-900/40 text-purple-300 text-[10px] px-1.5 py-0.5 rounded border border-purple-800">
|
||||
{acodec}{info.audio_channels ? ` ${info.audio_channels}ch` : ''}
|
||||
</span>
|
||||
)}
|
||||
{br && <span className="text-gray-500 text-[10px]">{br}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HealthBadge({ status, reconnectCount, lastDataAgo }: {
|
||||
status?: string
|
||||
reconnectCount?: number
|
||||
lastDataAgo?: number
|
||||
}) {
|
||||
if (!status || status === 'ok') {
|
||||
if (reconnectCount && reconnectCount > 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-yellow-900/40 text-yellow-300 border border-yellow-800" title={`${reconnectCount} reconexión(es)`}>
|
||||
<RefreshCw size={9} /> {reconnectCount}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <span className="inline-flex items-center gap-1 text-[10px] text-green-500"><span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse inline-block" /> OK</span>
|
||||
}
|
||||
if (status === 'connecting') {
|
||||
return <span className="inline-flex items-center gap-1 text-[10px] text-gray-400"><RefreshCw size={9} className="animate-spin" /> Conectando</span>
|
||||
}
|
||||
if (status === 'reconnecting') {
|
||||
return <span className="inline-flex items-center gap-1 text-[10px] text-orange-400"><RefreshCw size={9} className="animate-spin" /> Reconectando {reconnectCount ? `(×${reconnectCount})` : ''}</span>
|
||||
}
|
||||
return <span className="inline-flex items-center gap-1 text-[10px] text-red-400"><AlertTriangle size={9} /> {status}</span>
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
live: <Tv size={12} className="text-blue-400" />,
|
||||
movie: <Film size={12} className="text-purple-400" />,
|
||||
series: <Clapperboard size={12} className="text-green-400" />,
|
||||
}
|
||||
const TYPE_LABELS: Record<string, string> = { live: 'Directo', movie: 'Película', series: 'Serie' }
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
live: 'bg-blue-900/30 border-blue-800 text-blue-300',
|
||||
movie: 'bg-purple-900/30 border-purple-800 text-purple-300',
|
||||
series: 'bg-green-900/30 border-green-800 text-green-300',
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [streams, setStreams] = useState<StreamSession[]>([])
|
||||
const [slots, setSlots] = useState<SlotInfo[]>([])
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [killing, setKilling] = useState<string | null>(null)
|
||||
const [killError, setKillError] = useState<string | null>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const wsUrl = `${proto}://${window.location.host}/api/admin/monitoring/ws`
|
||||
const ws = new WebSocket(wsUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => setConnected(true)
|
||||
ws.onclose = () => setConnected(false)
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data)
|
||||
if (msg.type === 'state' || msg.type === 'update') {
|
||||
setStreams(msg.data)
|
||||
}
|
||||
}
|
||||
return () => ws.close()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/monitoring/slots').then(r => setSlots(r.data)).catch(() => {})
|
||||
}, [streams])
|
||||
|
||||
async function killClient(clientId: string, username: string) {
|
||||
if (!confirm(`¿Cortar la conexión de "${username}"?`)) return
|
||||
setKilling(clientId)
|
||||
setKillError(null)
|
||||
try {
|
||||
await api.post('/monitoring/kill', { client_id: clientId })
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
||||
setKillError(msg || 'Error al cortar la conexión')
|
||||
setTimeout(() => setKillError(null), 4000)
|
||||
} finally {
|
||||
setKilling(null)
|
||||
}
|
||||
}
|
||||
|
||||
const totalClients = streams.reduce((a, s) => a + s.client_count, 0)
|
||||
const busySlots = slots.filter(s => s.status === 'busy').length
|
||||
const totalBpsDown = streams.reduce((a, s) => a + (s.bps_down || 0), 0)
|
||||
const anyReconnecting = streams.some(s => s.status === 'reconnecting')
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">Dashboard</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{anyReconnecting && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-orange-400 bg-orange-900/30 border border-orange-800 px-2 py-1 rounded-lg">
|
||||
<RefreshCw size={11} className="animate-spin" />
|
||||
Reconectando stream(s)...
|
||||
</span>
|
||||
)}
|
||||
<div className={`flex items-center gap-2 text-xs ${connected ? 'text-green-400' : 'text-red-400'}`}>
|
||||
<span className={`w-2 h-2 rounded-full ${connected ? 'bg-green-400 animate-pulse' : 'bg-red-400'}`} />
|
||||
{connected ? 'Conectado en tiempo real' : 'Desconectado'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{killError && (
|
||||
<div className="bg-red-900/40 border border-red-700 rounded-lg px-4 py-2 text-sm text-red-300">
|
||||
{killError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard icon={<Activity size={20} className="text-blue-400" />} label="Streams activos" value={streams.length} />
|
||||
<StatCard icon={<Users size={20} className="text-green-400" />} label="Usuarios conectados" value={totalClients} />
|
||||
<StatCard icon={<Server size={20} className="text-orange-400" />} label="Slots ocupados" value={`${busySlots}/${slots.length}`} />
|
||||
<StatCard icon={<Wifi size={20} className="text-purple-400" />} label="↓ Total proveedor" value={formatBps(totalBpsDown)} />
|
||||
</div>
|
||||
|
||||
{/* Provider slots */}
|
||||
{slots.length > 0 && (
|
||||
<div className="bg-dark-800 rounded-xl border border-dark-700 p-4">
|
||||
<h2 className="text-sm font-semibold text-gray-400 mb-3">Estado de Slots del Proveedor</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{slots.map(slot => (
|
||||
<div
|
||||
key={slot.id}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium border ${
|
||||
slot.status === 'busy'
|
||||
? 'bg-orange-900/30 border-orange-700 text-orange-300'
|
||||
: 'bg-green-900/30 border-green-800 text-green-400'
|
||||
}`}
|
||||
>
|
||||
<span className="font-bold">{slot.name}</span>
|
||||
<span className="ml-2 opacity-70">{slot.status === 'busy' ? `ch#${slot.channel_id}` : 'libre'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active streams table */}
|
||||
<div className="bg-dark-800 rounded-xl border border-dark-700">
|
||||
<div className="p-4 border-b border-dark-700">
|
||||
<h2 className="text-sm font-semibold text-gray-300">Streams en Reproducción</h2>
|
||||
</div>
|
||||
{streams.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500 text-sm">No hay streams activos</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-gray-500 border-b border-dark-700">
|
||||
<th className="px-4 py-2">Tipo</th>
|
||||
<th className="px-4 py-2">Canal</th>
|
||||
<th className="px-4 py-2">Proveedor</th>
|
||||
<th className="px-4 py-2">Stream</th>
|
||||
<th className="px-4 py-2">Estado</th>
|
||||
<th className="px-4 py-2">Usuarios</th>
|
||||
<th className="px-4 py-2">↓ / ↑</th>
|
||||
<th className="px-4 py-2">Tiempo</th>
|
||||
<th className="px-4 py-2">Datos</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{streams.map((s, idx) => (
|
||||
<tr key={idx} className="border-b border-dark-700 hover:bg-dark-700/30 align-top">
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded border ${TYPE_COLORS[s.stream_type]}`}>
|
||||
{TYPE_ICONS[s.stream_type]}
|
||||
{TYPE_LABELS[s.stream_type]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="font-medium text-gray-200 text-xs leading-tight block max-w-[160px] truncate" title={s.channel_name}>
|
||||
{s.channel_name}
|
||||
</span>
|
||||
<span className="text-gray-600 text-xs">#{s.channel_id}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="bg-orange-900/40 text-orange-300 text-xs px-2 py-0.5 rounded">
|
||||
{s.provider_name || `#${s.provider_account_id}`}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 min-w-[160px]">
|
||||
{s.stream_info
|
||||
? <StreamInfoBadge info={s.stream_info} />
|
||||
: <span className="text-gray-600 text-xs italic">—</span>
|
||||
}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<HealthBadge
|
||||
status={s.status}
|
||||
reconnectCount={s.reconnect_count}
|
||||
lastDataAgo={s.last_data_ago}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex flex-col gap-1">
|
||||
{s.clients.map(c => (
|
||||
<div key={c.client_id} className="flex items-center gap-1.5">
|
||||
<span className="text-gray-300 text-xs font-medium">{c.username}</span>
|
||||
<button
|
||||
onClick={() => killClient(c.client_id, c.username)}
|
||||
disabled={killing === c.client_id}
|
||||
title={`Cortar conexión de ${c.username}`}
|
||||
className="p-0.5 rounded hover:bg-red-900/40 text-gray-600 hover:text-red-400 transition-colors disabled:opacity-40 shrink-0"
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs">
|
||||
<div className="text-cyan-400">↓ {formatBps(s.bps_down)}</div>
|
||||
<div className="text-green-400">↑ {formatBps(s.bps_up)}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-400 font-mono text-xs">
|
||||
<Clock size={11} className="inline mr-1" />
|
||||
{formatDuration(s.started_at)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 text-xs font-mono">{formatBytes(s.bytes_pumped)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="bg-dark-800 border border-dark-700 rounded-xl p-4 flex items-center gap-4">
|
||||
<div className="bg-dark-700 p-2.5 rounded-lg">{icon}</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">{label}</p>
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Plus, Trash2, RefreshCw } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
|
||||
interface EpgSource { id: number; name: string; url: string; is_active: boolean; last_fetched_at: string | null }
|
||||
|
||||
export default function EPG() {
|
||||
const [sources, setSources] = useState<EpgSource[]>([])
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [form, setForm] = useState({ name: '', url: '' })
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get('/epg/')
|
||||
setSources(data)
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
await api.post('/epg/', form)
|
||||
setForm({ name: '', url: '' })
|
||||
setShowForm(false)
|
||||
load()
|
||||
}
|
||||
|
||||
async function del(id: number) {
|
||||
if (!confirm('¿Eliminar fuente EPG?')) return
|
||||
await api.delete(`/epg/${id}`)
|
||||
load()
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await api.post('/epg/refresh')
|
||||
alert('EPG actualización iniciada en segundo plano')
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">Gestión EPG</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-2 bg-dark-700 hover:bg-dark-600 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
<RefreshCw size={15} className={refreshing ? 'animate-spin' : ''} />
|
||||
Actualizar EPG
|
||||
</button>
|
||||
<button onClick={() => setShowForm(v => !v)} className="flex items-center gap-2 bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium">
|
||||
<Plus size={16} /> Añadir fuente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-dark-800 border border-primary-500/20 rounded-xl p-4 text-sm text-gray-400">
|
||||
<strong className="text-gray-200">Endpoint EPG para tus apps IPTV:</strong>
|
||||
<div className="font-mono text-xs mt-1 bg-dark-700 rounded p-2 text-green-400">
|
||||
http://10.10.10.234/xmltv.php?username=USUARIO&password=CONTRASEÑA
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={submit} className="bg-dark-800 border border-dark-700 rounded-xl p-5 space-y-3">
|
||||
<h3 className="font-semibold text-sm">Nueva fuente XMLTV</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Nombre</label>
|
||||
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} required
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">URL XMLTV (o del proveedor)</label>
|
||||
<input value={form.url} onChange={e => setForm(f => ({ ...f, url: e.target.value }))} required
|
||||
placeholder="http://proveedor.com/xmltv.php?username=X&password=Y"
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" className="bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium">Guardar</button>
|
||||
<button type="button" onClick={() => setShowForm(false)} className="bg-dark-700 px-4 py-2 rounded-lg text-sm">Cancelar</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{sources.map(s => (
|
||||
<div key={s.id} className="bg-dark-800 border border-dark-700 rounded-xl p-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="font-semibold">{s.name}</div>
|
||||
<div className="text-xs text-gray-500 truncate max-w-xl">{s.url}</div>
|
||||
{s.last_fetched_at && (
|
||||
<div className="text-xs text-gray-600 mt-0.5">
|
||||
Última actualización: {new Date(s.last_fetched_at).toLocaleString('es-ES')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => del(s.id)} className="p-2 rounded-lg bg-dark-700 hover:bg-red-900/40 text-gray-500 hover:text-red-400 transition-colors">
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{sources.length === 0 && (
|
||||
<div className="bg-dark-800 border border-dark-700 border-dashed rounded-xl p-8 text-center text-gray-500 text-sm">
|
||||
No hay fuentes EPG configuradas
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Film, Tv, RefreshCw, Trash2, Plus, Check, X, ChevronDown, ChevronUp, Loader, Pencil } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
|
||||
interface JellyfinConfig {
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
api_key: string
|
||||
is_active: boolean
|
||||
movies_library_id: string | null
|
||||
series_library_id: string | null
|
||||
movies_category_name: string
|
||||
series_category_name: string
|
||||
last_sync_at: string | null
|
||||
item_count: number
|
||||
}
|
||||
|
||||
interface Library {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
interface SyncStatus {
|
||||
status: 'idle' | 'running' | 'done' | 'error'
|
||||
message: string
|
||||
synced: number
|
||||
}
|
||||
|
||||
const EMPTY_FORM = {
|
||||
name: '',
|
||||
url: 'http://10.10.10.15:8096',
|
||||
api_key: 'c339ccd3b7234c9a874bcfb70e92364a',
|
||||
movies_category_name: 'Películas KiraTV',
|
||||
series_category_name: 'Series KiraTV',
|
||||
movies_library_id: '',
|
||||
series_library_id: '',
|
||||
}
|
||||
|
||||
export default function Jellyfin() {
|
||||
const [configs, setConfigs] = useState<JellyfinConfig[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editId, setEditId] = useState<number | null>(null)
|
||||
const [form, setForm] = useState({ ...EMPTY_FORM })
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; server_name?: string; error?: string } | null>(null)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [libsLoading, setLibsLoading] = useState(false)
|
||||
const [syncStatus, setSyncStatus] = useState<Record<number, SyncStatus>>({})
|
||||
const [expanded, setExpanded] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await api.get('/jellyfin')
|
||||
setConfigs(data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setEditId(null)
|
||||
setForm({ ...EMPTY_FORM })
|
||||
setTestResult(null)
|
||||
setLibraries([])
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
function openEdit(cfg: JellyfinConfig) {
|
||||
setEditId(cfg.id)
|
||||
setForm({
|
||||
name: cfg.name,
|
||||
url: cfg.url,
|
||||
api_key: cfg.api_key,
|
||||
movies_category_name: cfg.movies_category_name,
|
||||
series_category_name: cfg.series_category_name,
|
||||
movies_library_id: cfg.movies_library_id ?? '',
|
||||
series_library_id: cfg.series_library_id ?? '',
|
||||
})
|
||||
setTestResult(null)
|
||||
setLibraries([])
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
if (!form.url || !form.api_key) return
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
// Test via temporary save-less call using an existing config if editId, else test inline
|
||||
if (editId) {
|
||||
const { data } = await api.post(`/jellyfin/${editId}/test`)
|
||||
setTestResult(data)
|
||||
} else {
|
||||
// Create temp config, test, then delete
|
||||
const { data: created } = await api.post('/jellyfin', {
|
||||
name: '__test__',
|
||||
url: form.url,
|
||||
api_key: form.api_key,
|
||||
movies_category_name: form.movies_category_name,
|
||||
series_category_name: form.series_category_name,
|
||||
})
|
||||
const { data: result } = await api.post(`/jellyfin/${created.id}/test`)
|
||||
await api.delete(`/jellyfin/${created.id}`)
|
||||
setTestResult(result)
|
||||
}
|
||||
} catch {
|
||||
setTestResult({ ok: false, error: 'Error de red' })
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLibraries() {
|
||||
if (!editId) {
|
||||
// Need to save first
|
||||
await save()
|
||||
return
|
||||
}
|
||||
setLibsLoading(true)
|
||||
try {
|
||||
const { data } = await api.get(`/jellyfin/${editId}/libraries`)
|
||||
setLibraries(data)
|
||||
} catch {
|
||||
setLibraries([])
|
||||
} finally {
|
||||
setLibsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
movies_library_id: form.movies_library_id || null,
|
||||
series_library_id: form.series_library_id || null,
|
||||
}
|
||||
if (editId) {
|
||||
await api.put(`/jellyfin/${editId}`, payload)
|
||||
} else {
|
||||
const { data } = await api.post('/jellyfin', payload)
|
||||
setEditId(data.id)
|
||||
}
|
||||
await load()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCfg(id: number) {
|
||||
if (!confirm('¿Eliminar esta configuración de Jellyfin? Se borrarán todos los canales sincronizados.')) return
|
||||
await api.delete(`/jellyfin/${id}`)
|
||||
setConfigs(prev => prev.filter(c => c.id !== id))
|
||||
if (editId === id) setShowForm(false)
|
||||
}
|
||||
|
||||
async function startSync(id: number) {
|
||||
setSyncStatus(prev => ({ ...prev, [id]: { status: 'running', message: 'Iniciando…', synced: 0 } }))
|
||||
await api.post(`/jellyfin/${id}/sync`)
|
||||
pollSync(id)
|
||||
}
|
||||
|
||||
function pollSync(id: number) {
|
||||
const iv = setInterval(async () => {
|
||||
try {
|
||||
const { data } = await api.get(`/jellyfin/${id}/sync-status`)
|
||||
setSyncStatus(prev => ({ ...prev, [id]: data }))
|
||||
if (data.status !== 'running') {
|
||||
clearInterval(iv)
|
||||
if (data.status === 'done') load()
|
||||
}
|
||||
} catch {
|
||||
clearInterval(iv)
|
||||
}
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
const movieLibs = libraries.filter(l => l.type === 'movies' || l.type === '')
|
||||
const tvLibs = libraries.filter(l => l.type === 'tvshows' || l.type === '')
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Biblioteca Jellyfin</h1>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
Integra tu catálogo local de Jellyfin como VOD y Series en KiraStream
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="flex items-center gap-2 bg-primary-600 hover:bg-primary-500 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
<Plus size={16} /> Nueva conexión
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Config list */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader size={24} className="animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : configs.length === 0 ? (
|
||||
<div className="bg-dark-800 border border-dark-700 rounded-xl p-12 text-center text-gray-500">
|
||||
<Film size={36} className="mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">No hay conexiones Jellyfin configuradas</p>
|
||||
<button onClick={openCreate} className="mt-4 text-primary-400 hover:text-primary-300 text-sm">
|
||||
+ Añadir primera conexión
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{configs.map(cfg => {
|
||||
const ss = syncStatus[cfg.id]
|
||||
return (
|
||||
<div key={cfg.id} className="bg-dark-800 border border-dark-700 rounded-xl overflow-hidden">
|
||||
<div className="flex items-center gap-4 p-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-900/30 flex items-center justify-center shrink-0">
|
||||
<Film size={20} className="text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{cfg.name}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${cfg.is_active ? 'bg-green-900/30 text-green-400' : 'bg-gray-700 text-gray-400'}`}>
|
||||
{cfg.is_active ? 'activo' : 'inactivo'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5 truncate">{cfg.url}</div>
|
||||
<div className="text-xs text-gray-600 mt-0.5">
|
||||
{cfg.item_count} elementos
|
||||
{cfg.last_sync_at && ` · Última sync: ${new Date(cfg.last_sync_at).toLocaleString('es-ES')}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{ss?.status === 'running' ? (
|
||||
<span className="flex items-center gap-1 text-xs text-yellow-400">
|
||||
<RefreshCw size={12} className="animate-spin" /> {ss.message}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => startSync(cfg.id)}
|
||||
className="flex items-center gap-1.5 text-xs bg-dark-700 hover:bg-primary-600 text-gray-300 hover:text-white px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw size={12} /> Sincronizar
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openEdit(cfg)}
|
||||
className="text-gray-500 hover:text-primary-400 p-1.5 rounded transition-colors"
|
||||
title="Editar"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteCfg(cfg.id)}
|
||||
className="text-gray-500 hover:text-red-400 p-1.5 rounded transition-colors"
|
||||
title="Eliminar"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExpanded(expanded === cfg.id ? null : cfg.id)}
|
||||
className="text-gray-500 hover:text-gray-300 p-1.5 rounded transition-colors"
|
||||
>
|
||||
{expanded === cfg.id ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ss?.status === 'done' && (
|
||||
<div className="px-4 pb-3 text-xs text-green-400 flex items-center gap-1">
|
||||
<Check size={12} /> {ss.message} · {ss.synced} elementos importados
|
||||
</div>
|
||||
)}
|
||||
{ss?.status === 'error' && (
|
||||
<div className="px-4 pb-3 text-xs text-red-400 flex items-center gap-1">
|
||||
<X size={12} /> Error: {ss.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded === cfg.id && (
|
||||
<div className="px-4 pb-4 border-t border-dark-700 pt-3 grid grid-cols-2 gap-4 text-xs text-gray-400">
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1 text-purple-400 font-medium">
|
||||
<Film size={12} /> Películas
|
||||
</div>
|
||||
<div>Categoría: <span className="text-gray-300">{cfg.movies_category_name}</span></div>
|
||||
<div>Biblioteca: <span className="text-gray-300">{cfg.movies_library_id || '—'}</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1 text-blue-400 font-medium">
|
||||
<Tv size={12} /> Series
|
||||
</div>
|
||||
<div>Categoría: <span className="text-gray-300">{cfg.series_category_name}</span></div>
|
||||
<div>Biblioteca: <span className="text-gray-300">{cfg.series_library_id || '—'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form modal */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 border border-dark-700 rounded-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
||||
<h2 className="font-semibold">{editId ? 'Editar conexión' : 'Nueva conexión Jellyfin'}</h2>
|
||||
<button onClick={() => setShowForm(false)} className="text-gray-500 hover:text-white">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Nombre</label>
|
||||
<input
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"
|
||||
value={form.name}
|
||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder="Mi Jellyfin"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">URL del servidor</label>
|
||||
<input
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm font-mono"
|
||||
value={form.url}
|
||||
onChange={e => setForm(f => ({ ...f, url: e.target.value }))}
|
||||
placeholder="http://192.168.1.x:8096"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">API Key</label>
|
||||
<input
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm font-mono"
|
||||
value={form.api_key}
|
||||
onChange={e => setForm(f => ({ ...f, api_key: e.target.value }))}
|
||||
placeholder="Tu API key de Jellyfin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Test connection */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={testConnection}
|
||||
disabled={testing || !form.url || !form.api_key}
|
||||
className="flex items-center gap-1.5 bg-dark-700 hover:bg-dark-600 text-sm px-3 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{testing ? <Loader size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
Probar conexión
|
||||
</button>
|
||||
{testResult && (
|
||||
<span className={`text-xs flex items-center gap-1 ${testResult.ok ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{testResult.ok ? (
|
||||
<><Check size={12} /> {testResult.server_name}</>
|
||||
) : (
|
||||
<><X size={12} /> {testResult.error}</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr className="border-dark-600" />
|
||||
|
||||
{/* Library selectors */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400 font-medium uppercase tracking-wider">Bibliotecas</span>
|
||||
<button
|
||||
onClick={loadLibraries}
|
||||
disabled={libsLoading}
|
||||
className="flex items-center gap-1 text-xs text-primary-400 hover:text-primary-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{libsLoading ? <Loader size={11} className="animate-spin" /> : <RefreshCw size={11} />}
|
||||
{editId ? 'Cargar bibliotecas' : 'Guardar primero para cargar'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block flex items-center gap-1">
|
||||
<Film size={11} /> Biblioteca de Películas
|
||||
</label>
|
||||
{libraries.length > 0 ? (
|
||||
<select
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"
|
||||
value={form.movies_library_id}
|
||||
onChange={e => setForm(f => ({ ...f, movies_library_id: e.target.value }))}
|
||||
>
|
||||
<option value="">— ninguna —</option>
|
||||
{libraries.map(l => (
|
||||
<option key={l.id} value={l.id}>{l.name} ({l.type || 'sin tipo'})</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm font-mono text-xs"
|
||||
value={form.movies_library_id}
|
||||
onChange={e => setForm(f => ({ ...f, movies_library_id: e.target.value }))}
|
||||
placeholder="ID de biblioteca"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block flex items-center gap-1">
|
||||
<Tv size={11} /> Biblioteca de Series
|
||||
</label>
|
||||
{libraries.length > 0 ? (
|
||||
<select
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"
|
||||
value={form.series_library_id}
|
||||
onChange={e => setForm(f => ({ ...f, series_library_id: e.target.value }))}
|
||||
>
|
||||
<option value="">— ninguna —</option>
|
||||
{libraries.map(l => (
|
||||
<option key={l.id} value={l.id}>{l.name} ({l.type || 'sin tipo'})</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm font-mono text-xs"
|
||||
value={form.series_library_id}
|
||||
onChange={e => setForm(f => ({ ...f, series_library_id: e.target.value }))}
|
||||
placeholder="ID de biblioteca"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Nombre categoría Películas</label>
|
||||
<input
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"
|
||||
value={form.movies_category_name}
|
||||
onChange={e => setForm(f => ({ ...f, movies_category_name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Nombre categoría Series</label>
|
||||
<input
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"
|
||||
value={form.series_category_name}
|
||||
onChange={e => setForm(f => ({ ...f, series_category_name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
|
||||
<button
|
||||
onClick={() => setShowForm(false)}
|
||||
className="px-4 py-2 text-sm text-gray-400 hover:text-white"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => { await save(); setShowForm(false) }}
|
||||
disabled={saving || !form.name || !form.url || !form.api_key}
|
||||
className="flex items-center gap-2 bg-primary-600 hover:bg-primary-500 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving && <Loader size={14} className="animate-spin" />}
|
||||
{editId ? 'Guardar cambios' : 'Crear'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Wifi } from 'lucide-react'
|
||||
import { login } from '../lib/api'
|
||||
|
||||
export default function Login() {
|
||||
const [user, setUser] = useState('')
|
||||
const [pass, setPass] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const token = await login(user, pass)
|
||||
localStorage.setItem('token', token)
|
||||
navigate('/dashboard')
|
||||
} catch {
|
||||
setError('Credenciales incorrectas')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-900 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex items-center justify-center gap-3 mb-8">
|
||||
<Wifi className="text-primary-500" size={32} />
|
||||
<h1 className="text-3xl font-bold">KiraStream</h1>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="bg-dark-800 rounded-xl p-6 space-y-4 border border-dark-700">
|
||||
<h2 className="text-center text-gray-400 text-sm">Panel de Administración</h2>
|
||||
{error && (
|
||||
<div className="bg-red-900/30 border border-red-700 text-red-400 text-sm rounded p-2 text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Usuario</label>
|
||||
<input
|
||||
value={user}
|
||||
onChange={e => setUser(e.target.value)}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-primary-500"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
value={pass}
|
||||
onChange={e => setPass(e.target.value)}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-primary-500"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-primary-500 hover:bg-primary-600 disabled:opacity-50 rounded-lg py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
{loading ? 'Entrando...' : 'Entrar'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import api from '../lib/api'
|
||||
import { Search, Trash2, RefreshCw, Users, Tv, Database, Clock } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface LogEntry {
|
||||
id: number
|
||||
user_id: number
|
||||
username: string
|
||||
channel_name: string | null
|
||||
content_type: string | null
|
||||
stream_id: string | null
|
||||
started_at: string | null
|
||||
ended_at: string | null
|
||||
duration_seconds: number | null
|
||||
bytes_transferred: number
|
||||
client_ip: string | null
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
total_sessions: number
|
||||
total_bytes: number
|
||||
avg_duration_seconds: number
|
||||
unique_users: number
|
||||
top_users: { username: string; sessions: number; bytes: number }[]
|
||||
top_channels: { channel_name: string; sessions: number }[]
|
||||
}
|
||||
|
||||
function fmtBytes(b: number): string {
|
||||
if (b >= 1e9) return (b / 1e9).toFixed(2) + ' GB'
|
||||
if (b >= 1e6) return (b / 1e6).toFixed(1) + ' MB'
|
||||
if (b >= 1e3) return (b / 1e3).toFixed(0) + ' KB'
|
||||
return b + ' B'
|
||||
}
|
||||
|
||||
function fmtDuration(s: number | null): string {
|
||||
if (!s) return '—'
|
||||
if (s < 60) return `${s}s`
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`
|
||||
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
|
||||
}
|
||||
|
||||
function fmtDate(iso: string | null): string {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleString('es-ES', { dateStyle: 'short', timeStyle: 'short' })
|
||||
}
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
live: 'bg-blue-500/20 text-blue-400',
|
||||
movie: 'bg-purple-500/20 text-purple-400',
|
||||
series: 'bg-orange-500/20 text-orange-400',
|
||||
}
|
||||
const TYPE_LABELS: Record<string, string> = { live: 'Directo', movie: 'Película', series: 'Serie' }
|
||||
|
||||
export default function Logs() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const [filterUsername, setFilterUsername] = useState('')
|
||||
const [filterChannel, setFilterChannel] = useState('')
|
||||
const [filterType, setFilterType] = useState('')
|
||||
const [filterDateFrom, setFilterDateFrom] = useState('')
|
||||
const [filterDateTo, setFilterDateTo] = useState('')
|
||||
|
||||
const limit = 50
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/logs/stats')
|
||||
setStats(data)
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
const loadLogs = useCallback(async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: Record<string, string | number> = { page: p, limit }
|
||||
if (filterUsername) params.username = filterUsername
|
||||
if (filterChannel) params.channel = filterChannel
|
||||
if (filterType) params.content_type = filterType
|
||||
if (filterDateFrom) params.date_from = filterDateFrom
|
||||
if (filterDateTo) params.date_to = filterDateTo
|
||||
const { data } = await api.get('/logs', { params })
|
||||
setLogs(data.items)
|
||||
setTotal(data.total)
|
||||
setPage(p)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [filterUsername, filterChannel, filterType, filterDateFrom, filterDateTo])
|
||||
|
||||
useEffect(() => { loadStats(); loadLogs(1) }, []) // eslint-disable-line
|
||||
|
||||
const doSearch = () => { loadStats(); loadLogs(1) }
|
||||
|
||||
const clearLogs = async () => {
|
||||
if (!confirm('¿Eliminar todos los registros?')) return
|
||||
await api.delete('/logs')
|
||||
setLogs([])
|
||||
setTotal(0)
|
||||
loadStats()
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">Registros de conexión</h1>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => { loadStats(); loadLogs(page) }} className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 hover:bg-dark-600 rounded-lg text-sm transition-colors">
|
||||
<RefreshCw size={14} /> Actualizar
|
||||
</button>
|
||||
<button onClick={clearLogs} className="flex items-center gap-2 px-3 py-1.5 bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded-lg text-sm transition-colors">
|
||||
<Trash2 size={14} /> Limpiar todo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard icon={Clock} label="Total sesiones" value={stats.total_sessions.toLocaleString()} color="text-primary-400" />
|
||||
<StatCard icon={Database} label="Datos totales" value={fmtBytes(stats.total_bytes)} color="text-green-400" />
|
||||
<StatCard icon={Clock} label="Duración media" value={fmtDuration(stats.avg_duration_seconds)} color="text-yellow-400" />
|
||||
<StatCard icon={Users} label="Usuarios únicos" value={stats.unique_users.toString()} color="text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top lists */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-dark-800 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold mb-3 text-gray-300 flex items-center gap-2"><Users size={14} /> Top usuarios</h3>
|
||||
<div className="space-y-2">
|
||||
{stats.top_users.map((u, i) => (
|
||||
<div key={u.username} className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-600 w-4">{i + 1}</span>
|
||||
<span className="flex-1 text-gray-200 font-medium">{u.username}</span>
|
||||
<span className="text-gray-400">{u.sessions} sesiones</span>
|
||||
<span className="text-green-400 text-xs">{fmtBytes(u.bytes)}</span>
|
||||
</div>
|
||||
))}
|
||||
{stats.top_users.length === 0 && <p className="text-xs text-gray-600">Sin datos</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-dark-800 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold mb-3 text-gray-300 flex items-center gap-2"><Tv size={14} /> Top canales/contenido</h3>
|
||||
<div className="space-y-2">
|
||||
{stats.top_channels.map((c, i) => (
|
||||
<div key={c.channel_name} className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-600 w-4">{i + 1}</span>
|
||||
<span className="flex-1 text-gray-200 truncate">{c.channel_name}</span>
|
||||
<span className="text-gray-400">{c.sessions}</span>
|
||||
</div>
|
||||
))}
|
||||
{stats.top_channels.length === 0 && <p className="text-xs text-gray-600">Sin datos</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-dark-800 rounded-xl p-4">
|
||||
<div className="flex flex-wrap gap-2 items-end">
|
||||
<div className="relative">
|
||||
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
value={filterUsername}
|
||||
onChange={e => setFilterUsername(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && doSearch()}
|
||||
placeholder="Usuario"
|
||||
className="bg-dark-700 border border-dark-600 rounded-lg pl-8 pr-3 py-1.5 text-sm w-36 focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
value={filterChannel}
|
||||
onChange={e => setFilterChannel(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && doSearch()}
|
||||
placeholder="Canal / película"
|
||||
className="bg-dark-700 border border-dark-600 rounded-lg pl-8 pr-3 py-1.5 text-sm w-48 focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={e => setFilterType(e.target.value)}
|
||||
className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none"
|
||||
>
|
||||
<option value="">Todos los tipos</option>
|
||||
<option value="live">Directo</option>
|
||||
<option value="movie">Película</option>
|
||||
<option value="series">Serie</option>
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
value={filterDateFrom}
|
||||
onChange={e => setFilterDateFrom(e.target.value)}
|
||||
className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none"
|
||||
/>
|
||||
<span className="text-gray-500 text-sm">→</span>
|
||||
<input
|
||||
type="date"
|
||||
value={filterDateTo}
|
||||
onChange={e => setFilterDateTo(e.target.value)}
|
||||
className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={doSearch}
|
||||
className="flex items-center gap-2 px-4 py-1.5 bg-primary-500 hover:bg-primary-600 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
<Search size={13} /> Buscar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-dark-800 rounded-xl overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
|
||||
<span className="text-sm text-gray-400">{total.toLocaleString()} registros</span>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<button onClick={() => loadLogs(page - 1)} disabled={page === 1} className="px-2 py-1 bg-dark-700 rounded disabled:opacity-40">←</button>
|
||||
<span className="text-gray-400">{page} / {totalPages}</span>
|
||||
<button onClick={() => loadLogs(page + 1)} disabled={page === totalPages} className="px-2 py-1 bg-dark-700 rounded disabled:opacity-40">→</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 border-b border-dark-700">
|
||||
<th className="text-left px-4 py-2">Usuario</th>
|
||||
<th className="text-left px-4 py-2">IP</th>
|
||||
<th className="text-left px-4 py-2">Canal / Contenido</th>
|
||||
<th className="text-left px-4 py-2">Tipo</th>
|
||||
<th className="text-left px-4 py-2">Inicio</th>
|
||||
<th className="text-left px-4 py-2">Duración</th>
|
||||
<th className="text-right px-4 py-2">Datos</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr><td colSpan={7} className="text-center py-8 text-gray-500">Cargando...</td></tr>
|
||||
)}
|
||||
{!loading && logs.length === 0 && (
|
||||
<tr><td colSpan={7} className="text-center py-8 text-gray-500">No hay registros</td></tr>
|
||||
)}
|
||||
{logs.map(log => (
|
||||
<tr key={log.id} className="border-b border-dark-700/50 hover:bg-dark-700/30 transition-colors">
|
||||
<td className="px-4 py-2 font-medium text-gray-200">{log.username}</td>
|
||||
<td className="px-4 py-2 text-gray-400 font-mono text-xs">{log.client_ip || '—'}</td>
|
||||
<td className="px-4 py-2 text-gray-300 max-w-xs truncate">{log.channel_name || log.stream_id || '—'}</td>
|
||||
<td className="px-4 py-2">
|
||||
{log.content_type ? (
|
||||
<span className={clsx('text-xs px-2 py-0.5 rounded-full', TYPE_COLORS[log.content_type] || 'bg-gray-700 text-gray-400')}>
|
||||
{TYPE_LABELS[log.content_type] || log.content_type}
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-400 text-xs">{fmtDate(log.started_at)}</td>
|
||||
<td className="px-4 py-2 text-gray-400">{fmtDuration(log.duration_seconds)}</td>
|
||||
<td className="px-4 py-2 text-right text-green-400 text-xs">{fmtBytes(log.bytes_transferred)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, label, value, color }: { icon: typeof Clock; label: string; value: string; color: string }) {
|
||||
return (
|
||||
<div className="bg-dark-800 rounded-xl p-4 flex items-center gap-3">
|
||||
<Icon size={20} className={color} />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">{label}</p>
|
||||
<p className="text-lg font-bold">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Plus, Trash2, RefreshCw, Wifi, Search, ChevronDown, ChevronRight, Bell, Edit2 } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
|
||||
interface Provider {
|
||||
id: number
|
||||
name: string
|
||||
base_url: string
|
||||
username: string
|
||||
max_connections: number
|
||||
is_active: boolean
|
||||
last_sync_at: string | null
|
||||
expiry_date: string | null
|
||||
auto_sync_hours: number
|
||||
last_sync_categories: string | null // JSON
|
||||
}
|
||||
|
||||
function expiryBadge(dateStr: string | null): { label: string; cls: string } | null {
|
||||
if (!dateStr) return null
|
||||
const exp = new Date(dateStr)
|
||||
const daysLeft = Math.ceil((exp.getTime() - Date.now()) / 86400000)
|
||||
if (daysLeft < 0) return { label: 'Expirado', cls: 'text-red-400 bg-red-900/20 border border-red-800' }
|
||||
if (daysLeft <= 7) return { label: `Caduca en ${daysLeft}d`, cls: 'text-red-400 bg-red-900/20 border border-red-800' }
|
||||
if (daysLeft <= 30) return { label: `Caduca en ${daysLeft}d`, cls: 'text-yellow-400 bg-yellow-900/20 border border-yellow-800' }
|
||||
return { label: `Caduca: ${exp.toLocaleDateString('es-ES')}`, cls: 'text-green-400 bg-green-900/20 border border-green-800' }
|
||||
}
|
||||
|
||||
interface CatOption { id: string; name: string }
|
||||
interface AvailableCategories {
|
||||
live: CatOption[]
|
||||
movie: CatOption[]
|
||||
series: CatOption[]
|
||||
}
|
||||
|
||||
interface UpdateCategory { id: string; name: string; type: string }
|
||||
|
||||
interface FormData {
|
||||
name: string
|
||||
base_url: string
|
||||
username: string
|
||||
password: string
|
||||
max_connections: number
|
||||
auto_sync_hours: number
|
||||
}
|
||||
|
||||
const TYPES: Array<{ key: keyof AvailableCategories; label: string }> = [
|
||||
{ key: 'live', label: 'Canales en directo' },
|
||||
{ key: 'movie', label: 'Películas (VOD)' },
|
||||
{ key: 'series', label: 'Series' },
|
||||
]
|
||||
|
||||
const AUTO_SYNC_OPTIONS = [
|
||||
{ value: 0, label: 'Desactivado' },
|
||||
{ value: 6, label: 'Cada 6 horas' },
|
||||
{ value: 12, label: 'Cada 12 horas' },
|
||||
{ value: 24, label: 'Cada 24 horas' },
|
||||
]
|
||||
|
||||
const empty: FormData = { name: '', base_url: '', username: '', password: '', max_connections: 1, auto_sync_hours: 0 }
|
||||
|
||||
export default function Providers() {
|
||||
const [providers, setProviders] = useState<Provider[]>([])
|
||||
const [form, setForm] = useState<FormData>(empty)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [syncing, setSyncing] = useState<Set<number>>(new Set())
|
||||
const [syncProgress, setSyncProgress] = useState<Record<number, string>>({})
|
||||
const [syncStats, setSyncStats] = useState<Record<number, Record<string, number>>>({})
|
||||
const [testing, setTesting] = useState<number | null>(null)
|
||||
const [testResult, setTestResult] = useState<Record<number, boolean>>({})
|
||||
const pollRefs = useRef<Record<number, boolean>>({})
|
||||
|
||||
// Category picker state
|
||||
const [pickerProvider, setPickerProvider] = useState<Provider | null>(null)
|
||||
const [pickerLoading, setPickerLoading] = useState(false)
|
||||
const [availableCats, setAvailableCats] = useState<AvailableCategories>({ live: [], movie: [], series: [] })
|
||||
const [selectedCats, setSelectedCats] = useState<Record<keyof AvailableCategories, Set<string>>>({ live: new Set(), movie: new Set(), series: new Set() })
|
||||
const [catSearch, setCatSearch] = useState('')
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
|
||||
// Edit provider modal state
|
||||
const [editProvider, setEditProvider] = useState<Provider | null>(null)
|
||||
const [editForm, setEditForm] = useState({ name: '', base_url: '', username: '', password: '', max_connections: 1, auto_sync_hours: 0 })
|
||||
|
||||
// Updates modal state
|
||||
const [updatesProvider, setUpdatesProvider] = useState<Provider | null>(null)
|
||||
const [updatesLoading, setUpdatesLoading] = useState(false)
|
||||
const [updatesData, setUpdatesData] = useState<UpdateCategory[]>([])
|
||||
const [selectedUpdates, setSelectedUpdates] = useState<Set<string>>(new Set())
|
||||
const [updatesSearch, setUpdatesSearch] = useState('')
|
||||
const [updatesCount, setUpdatesCount] = useState<Record<number, number>>({})
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get('/providers/')
|
||||
setProviders(data)
|
||||
return data as Provider[]
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load().then(async (provs: Provider[]) => {
|
||||
for (const p of provs) {
|
||||
try {
|
||||
const { data } = await api.get(`/providers/${p.id}/sync/status`)
|
||||
if (data.status === 'running') {
|
||||
setSyncing(s => new Set(s).add(p.id))
|
||||
setSyncProgress(prev => ({ ...prev, [p.id]: data.progress || 'Sincronizando...' }))
|
||||
startPolling(p.id)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
})
|
||||
return () => { Object.keys(pollRefs.current).forEach(id => { pollRefs.current[+id] = false }) }
|
||||
}, [])
|
||||
|
||||
function startPolling(id: number) {
|
||||
pollRefs.current[id] = true
|
||||
const poll = async () => {
|
||||
if (!pollRefs.current[id]) return
|
||||
try {
|
||||
const { data } = await api.get(`/providers/${id}/sync/status`)
|
||||
setSyncProgress(p => ({ ...p, [id]: data.progress || '' }))
|
||||
setSyncStats(s => ({ ...s, [id]: data.stats || {} }))
|
||||
if (data.status === 'running') {
|
||||
setTimeout(poll, 1500)
|
||||
} else {
|
||||
pollRefs.current[id] = false
|
||||
setSyncing(s => { const n = new Set(s); n.delete(id); return n })
|
||||
setSyncProgress(p => ({ ...p, [id]: data.status === 'done' ? '✓ Sync completado' : `Error: ${data.progress}` }))
|
||||
load()
|
||||
}
|
||||
} catch {
|
||||
pollRefs.current[id] = false
|
||||
setSyncing(s => { const n = new Set(s); n.delete(id); return n })
|
||||
}
|
||||
}
|
||||
poll()
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
await api.post('/providers/', form)
|
||||
setForm(empty)
|
||||
setShowForm(false)
|
||||
load()
|
||||
}
|
||||
|
||||
async function del(id: number) {
|
||||
if (!confirm('¿Eliminar este proveedor y todos sus canales?')) return
|
||||
await api.delete(`/providers/${id}`)
|
||||
load()
|
||||
}
|
||||
|
||||
async function test(id: number) {
|
||||
setTesting(id)
|
||||
try {
|
||||
const { data } = await api.post(`/providers/${id}/test`)
|
||||
setTestResult(r => ({ ...r, [id]: data.connected }))
|
||||
if (data.connected) load() // refresh to show updated expiry_date
|
||||
} finally {
|
||||
setTesting(null)
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(provider: Provider) {
|
||||
setEditProvider(provider)
|
||||
setEditForm({
|
||||
name: provider.name,
|
||||
base_url: provider.base_url,
|
||||
username: provider.username,
|
||||
password: '',
|
||||
max_connections: provider.max_connections,
|
||||
auto_sync_hours: provider.auto_sync_hours,
|
||||
})
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editProvider) return
|
||||
try {
|
||||
await api.put(`/providers/${editProvider.id}`, editForm)
|
||||
setEditProvider(null)
|
||||
load()
|
||||
} catch (e: any) {
|
||||
alert('Error al guardar: ' + (e.response?.data?.detail || e.message))
|
||||
}
|
||||
}
|
||||
|
||||
async function openCategoryPicker(provider: Provider) {
|
||||
setPickerProvider(provider)
|
||||
setPickerLoading(true)
|
||||
setCatSearch('')
|
||||
setCollapsed({})
|
||||
try {
|
||||
const [availRes, syncedRes] = await Promise.all([
|
||||
api.get(`/providers/${provider.id}/sync/categories`),
|
||||
api.get(`/providers/${provider.id}/synced-category-ids`),
|
||||
])
|
||||
setAvailableCats(availRes.data)
|
||||
|
||||
// Primary: last_sync_categories (explicit selection from previous sync — works for
|
||||
// all providers including those whose channels share another provider's categories)
|
||||
// Fallback: synced-category-ids (actual DB state for providers that own their categories)
|
||||
let live: string[] = syncedRes.data.live
|
||||
let movie: string[] = syncedRes.data.movie
|
||||
let series: string[] = syncedRes.data.series
|
||||
|
||||
if (provider.last_sync_categories) {
|
||||
try {
|
||||
const lsc = JSON.parse(provider.last_sync_categories)
|
||||
if ((lsc.live?.length ?? 0) + (lsc.movie?.length ?? 0) + (lsc.series?.length ?? 0) > 0) {
|
||||
live = lsc.live ?? []
|
||||
movie = lsc.movie ?? []
|
||||
series = lsc.series ?? []
|
||||
}
|
||||
} catch { /* ignore malformed JSON */ }
|
||||
}
|
||||
|
||||
setSelectedCats({
|
||||
live: new Set<string>(live),
|
||||
movie: new Set<string>(movie),
|
||||
series: new Set<string>(series),
|
||||
})
|
||||
} catch {
|
||||
setPickerProvider(null)
|
||||
} finally {
|
||||
setPickerLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function startSyncFromPicker() {
|
||||
if (!pickerProvider) return
|
||||
const id = pickerProvider.id
|
||||
setPickerProvider(null)
|
||||
setSyncing(s => new Set(s).add(id))
|
||||
setSyncProgress(p => ({ ...p, [id]: 'Iniciando...' }))
|
||||
try {
|
||||
await api.post(`/providers/${id}/sync`, {
|
||||
live_category_ids: Array.from(selectedCats.live),
|
||||
movie_category_ids: Array.from(selectedCats.movie),
|
||||
series_category_ids: Array.from(selectedCats.series),
|
||||
})
|
||||
startPolling(id)
|
||||
} catch {
|
||||
setSyncing(s => { const n = new Set(s); n.delete(id); return n })
|
||||
setSyncProgress(p => ({ ...p, [id]: 'Error al iniciar sync' }))
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCat(type: keyof AvailableCategories, id: string) {
|
||||
setSelectedCats(prev => {
|
||||
const n = new Set(prev[type])
|
||||
n.has(id) ? n.delete(id) : n.add(id)
|
||||
return { ...prev, [type]: n }
|
||||
})
|
||||
}
|
||||
|
||||
function selectAll(type: keyof AvailableCategories, cats: CatOption[]) {
|
||||
setSelectedCats(prev => ({ ...prev, [type]: new Set(cats.map(c => c.id)) }))
|
||||
}
|
||||
|
||||
function deselectAll(type: keyof AvailableCategories) {
|
||||
setSelectedCats(prev => ({ ...prev, [type]: new Set() }))
|
||||
}
|
||||
|
||||
async function openUpdates(provider: Provider) {
|
||||
setUpdatesProvider(provider)
|
||||
setUpdatesLoading(true)
|
||||
setUpdatesSearch('')
|
||||
setSelectedUpdates(new Set())
|
||||
try {
|
||||
const { data } = await api.get(`/providers/${provider.id}/available-updates`)
|
||||
setUpdatesData(data.new_categories || [])
|
||||
} catch {
|
||||
setUpdatesData([])
|
||||
} finally {
|
||||
setUpdatesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function checkUpdatesCount(providerId: number) {
|
||||
try {
|
||||
const { data } = await api.get(`/providers/${providerId}/available-updates`)
|
||||
setUpdatesCount(prev => ({ ...prev, [providerId]: data.total || 0 }))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Check update counts for all providers after load
|
||||
providers.forEach(p => {
|
||||
if (p.last_sync_at) checkUpdatesCount(p.id)
|
||||
})
|
||||
}, [providers.length])
|
||||
|
||||
async function syncSelectedUpdates() {
|
||||
if (!updatesProvider || selectedUpdates.size === 0) return
|
||||
const id = updatesProvider.id
|
||||
|
||||
// Group selected updates by type
|
||||
const live: string[] = []
|
||||
const movie: string[] = []
|
||||
const series: string[] = []
|
||||
updatesData.filter(c => selectedUpdates.has(c.id + '_' + c.type)).forEach(c => {
|
||||
if (c.type === 'live') live.push(c.id)
|
||||
else if (c.type === 'movie') movie.push(c.id)
|
||||
else if (c.type === 'series') series.push(c.id)
|
||||
})
|
||||
|
||||
setUpdatesProvider(null)
|
||||
setSyncing(s => new Set(s).add(id))
|
||||
setSyncProgress(p => ({ ...p, [id]: 'Iniciando...' }))
|
||||
try {
|
||||
await api.post(`/providers/${id}/sync`, {
|
||||
live_category_ids: live.length > 0 ? live : undefined,
|
||||
movie_category_ids: movie.length > 0 ? movie : undefined,
|
||||
series_category_ids: series.length > 0 ? series : undefined,
|
||||
})
|
||||
startPolling(id)
|
||||
} catch {
|
||||
setSyncing(s => { const n = new Set(s); n.delete(id); return n })
|
||||
setSyncProgress(p => ({ ...p, [id]: 'Error al iniciar sync' }))
|
||||
}
|
||||
}
|
||||
|
||||
const totalSelected = Object.values(selectedCats).reduce((a, s) => a + s.size, 0)
|
||||
const totalAvailable = Object.values(availableCats).reduce((a, arr) => a + arr.length, 0)
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = { live: 'Directo', movie: 'Película', series: 'Serie' }
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
live: 'bg-blue-900/30 text-blue-300 border-blue-800',
|
||||
movie: 'bg-purple-900/30 text-purple-300 border-purple-800',
|
||||
series: 'bg-green-900/30 text-green-300 border-green-800',
|
||||
}
|
||||
|
||||
const filteredUpdates = updatesData.filter(c =>
|
||||
!updatesSearch || c.name.toLowerCase().includes(updatesSearch.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">Proveedores IPTV</h1>
|
||||
<button
|
||||
onClick={() => setShowForm(v => !v)}
|
||||
className="flex items-center gap-2 bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
<Plus size={16} /> Añadir proveedor
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={submit} className="bg-dark-800 border border-dark-700 rounded-xl p-5 space-y-3">
|
||||
<h3 className="font-semibold text-sm mb-2">Nueva cuenta de proveedor</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Nombre" value={form.name} onChange={v => setForm(f => ({ ...f, name: v }))} required />
|
||||
<Field label="URL base (ej: http://provider.com:8080)" value={form.base_url} onChange={v => setForm(f => ({ ...f, base_url: v }))} required />
|
||||
<Field label="Usuario" value={form.username} onChange={v => setForm(f => ({ ...f, username: v }))} required />
|
||||
<Field label="Contraseña" value={form.password} onChange={v => setForm(f => ({ ...f, password: v }))} required />
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Conexiones máx.</label>
|
||||
<input
|
||||
type="number" min={1} max={100}
|
||||
value={form.max_connections}
|
||||
onChange={e => setForm(f => ({ ...f, max_connections: +e.target.value }))}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Auto-sincronización</label>
|
||||
<select
|
||||
value={form.auto_sync_hours}
|
||||
onChange={e => setForm(f => ({ ...f, auto_sync_hours: +e.target.value }))}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
{AUTO_SYNC_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button type="submit" className="bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium">Guardar</button>
|
||||
<button type="button" onClick={() => setShowForm(false)} className="bg-dark-700 hover:bg-dark-600 px-4 py-2 rounded-lg text-sm">Cancelar</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Category picker modal */}
|
||||
{pickerProvider && (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 rounded-xl border border-dark-700 w-full max-w-2xl flex flex-col max-h-[90vh]">
|
||||
<div className="p-4 border-b border-dark-700 flex items-center justify-between shrink-0">
|
||||
<div>
|
||||
<h2 className="font-semibold">Seleccionar categorías — <span className="text-primary-400">{pickerProvider.name}</span></h2>
|
||||
{!pickerLoading && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">{totalSelected} de {totalAvailable} categorías seleccionadas</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => setPickerProvider(null)} className="text-gray-500 hover:text-white">✕</button>
|
||||
</div>
|
||||
|
||||
{pickerLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center p-12">
|
||||
<div className="text-center text-gray-400">
|
||||
<RefreshCw size={24} className="animate-spin mx-auto mb-3" />
|
||||
<p className="text-sm">Obteniendo categorías del proveedor...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-3 border-b border-dark-700 shrink-0">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
value={catSearch}
|
||||
onChange={e => setCatSearch(e.target.value)}
|
||||
placeholder="Buscar categoría..."
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg pl-9 pr-3 py-2 text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 p-3 space-y-2">
|
||||
{TYPES.map(({ key, label }) => {
|
||||
const cats = availableCats[key].filter(c =>
|
||||
!catSearch || c.name.toLowerCase().includes(catSearch.toLowerCase())
|
||||
)
|
||||
if (!cats.length && !catSearch) return null
|
||||
const selCount = cats.filter(c => selectedCats[key].has(c.id)).length
|
||||
const isCollapsed = collapsed[key]
|
||||
return (
|
||||
<div key={key} className="bg-dark-900/50 rounded-lg border border-dark-700 overflow-hidden">
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-dark-700/50 select-none"
|
||||
onClick={() => setCollapsed(c => ({ ...c, [key]: !c[key] }))}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isCollapsed ? <ChevronRight size={14} className="text-gray-500" /> : <ChevronDown size={14} className="text-gray-500" />}
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<span className="text-xs text-gray-500">({selCount}/{cats.length} sel.)</span>
|
||||
</div>
|
||||
<div className="flex gap-2" onClick={e => e.stopPropagation()}>
|
||||
<button onClick={() => selectAll(key, cats)} className="text-xs text-primary-400 hover:text-primary-300 px-2 py-0.5 rounded hover:bg-dark-600">Todo</button>
|
||||
<button onClick={() => deselectAll(key)} className="text-xs text-gray-500 hover:text-gray-300 px-2 py-0.5 rounded hover:bg-dark-600">Nada</button>
|
||||
</div>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="grid grid-cols-2 gap-0.5 p-2 border-t border-dark-700 max-h-64 overflow-y-auto">
|
||||
{cats.length === 0 ? (
|
||||
<div className="col-span-2 text-center text-gray-500 text-xs py-3">Sin resultados</div>
|
||||
) : cats.map(cat => (
|
||||
<label key={cat.id} className="flex items-center gap-2 px-2 py-1 rounded hover:bg-dark-700 cursor-pointer text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCats[key].has(cat.id)}
|
||||
onChange={() => toggleCat(key, cat.id)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="truncate text-xs">{cat.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-dark-700 flex gap-2 justify-end shrink-0">
|
||||
<button onClick={() => setPickerProvider(null)} className="bg-dark-700 hover:bg-dark-600 px-4 py-2 rounded-lg text-sm">Cancelar</button>
|
||||
<button
|
||||
onClick={startSyncFromPicker}
|
||||
disabled={totalSelected === 0}
|
||||
className="bg-primary-500 hover:bg-primary-600 disabled:opacity-40 px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Importar {totalSelected} {totalSelected === 1 ? 'categoría' : 'categorías'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Updates modal */}
|
||||
{updatesProvider && (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 rounded-xl border border-dark-700 w-full max-w-lg flex flex-col max-h-[80vh]">
|
||||
<div className="p-4 border-b border-dark-700 flex items-center justify-between shrink-0">
|
||||
<div>
|
||||
<h2 className="font-semibold">Novedades — <span className="text-primary-400">{updatesProvider.name}</span></h2>
|
||||
{!updatesLoading && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{updatesData.length === 0
|
||||
? 'No hay categorías nuevas'
|
||||
: `${updatesData.length} categorías nuevas sin sincronizar`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => setUpdatesProvider(null)} className="text-gray-500 hover:text-white">✕</button>
|
||||
</div>
|
||||
|
||||
{updatesLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center p-10">
|
||||
<RefreshCw size={22} className="animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : updatesData.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center p-10 text-gray-500 text-sm">
|
||||
Todo está actualizado ✓
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-3 border-b border-dark-700 shrink-0">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
value={updatesSearch}
|
||||
onChange={e => setUpdatesSearch(e.target.value)}
|
||||
placeholder="Buscar..."
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg pl-9 pr-3 py-2 text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => setSelectedUpdates(new Set(filteredUpdates.map(c => c.id + '_' + c.type)))}
|
||||
className="text-xs text-primary-400 hover:text-primary-300 px-2 py-0.5 rounded hover:bg-dark-600"
|
||||
>
|
||||
Seleccionar todo
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedUpdates(new Set())}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 px-2 py-0.5 rounded hover:bg-dark-600"
|
||||
>
|
||||
Ninguna
|
||||
</button>
|
||||
<span className="text-xs text-gray-600 ml-auto self-center">{selectedUpdates.size} seleccionadas</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 p-3 space-y-1">
|
||||
{filteredUpdates.map(cat => {
|
||||
const key = cat.id + '_' + cat.type
|
||||
return (
|
||||
<label key={key} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-dark-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedUpdates.has(key)}
|
||||
onChange={() => {
|
||||
setSelectedUpdates(prev => {
|
||||
const n = new Set(prev)
|
||||
n.has(key) ? n.delete(key) : n.add(key)
|
||||
return n
|
||||
})
|
||||
}}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="text-sm flex-1 truncate">{cat.name}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded border ${TYPE_COLORS[cat.type]}`}>
|
||||
{TYPE_LABELS[cat.type]}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="p-4 border-t border-dark-700 flex gap-2 justify-end shrink-0">
|
||||
<button onClick={() => setUpdatesProvider(null)} className="bg-dark-700 hover:bg-dark-600 px-4 py-2 rounded-lg text-sm">Cancelar</button>
|
||||
<button
|
||||
onClick={syncSelectedUpdates}
|
||||
disabled={selectedUpdates.size === 0}
|
||||
className="bg-primary-500 hover:bg-primary-600 disabled:opacity-40 px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Importar {selectedUpdates.size} {selectedUpdates.size === 1 ? 'categoría' : 'categorías'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit provider modal */}
|
||||
{editProvider && (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 rounded-xl border border-dark-700 w-full max-w-lg">
|
||||
<div className="p-4 border-b border-dark-700 flex items-center justify-between">
|
||||
<h2 className="font-semibold">Editar proveedor — <span className="text-primary-400">{editProvider.name}</span></h2>
|
||||
<button onClick={() => setEditProvider(null)} className="text-gray-500 hover:text-white">✕</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Nombre" value={editForm.name} onChange={v => setEditForm(f => ({ ...f, name: v }))} required />
|
||||
<Field label="URL base" value={editForm.base_url} onChange={v => setEditForm(f => ({ ...f, base_url: v }))} required />
|
||||
<Field label="Usuario" value={editForm.username} onChange={v => setEditForm(f => ({ ...f, username: v }))} required />
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Contraseña (vacío = no cambiar)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={editForm.password}
|
||||
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder="••••••••"
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Conexiones máx.</label>
|
||||
<input
|
||||
type="number" min={1} max={100}
|
||||
value={editForm.max_connections}
|
||||
onChange={e => setEditForm(f => ({ ...f, max_connections: +e.target.value }))}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Auto-sincronización</label>
|
||||
<select
|
||||
value={editForm.auto_sync_hours}
|
||||
onChange={e => setEditForm(f => ({ ...f, auto_sync_hours: +e.target.value }))}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
{AUTO_SYNC_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border-t border-dark-700 flex gap-2 justify-end">
|
||||
<button onClick={() => setEditProvider(null)} className="bg-dark-700 hover:bg-dark-600 px-4 py-2 rounded-lg text-sm">Cancelar</button>
|
||||
<button onClick={saveEdit} className="bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium">Guardar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{providers.map(p => (
|
||||
<div key={p.id} className="bg-dark-800 border border-dark-700 rounded-xl p-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{p.name}</span>
|
||||
{p.auto_sync_hours > 0 && (
|
||||
<span className="text-xs bg-dark-700 text-gray-400 px-1.5 py-0.5 rounded">
|
||||
Auto {p.auto_sync_hours}h
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap mt-0.5">
|
||||
<span className="text-xs text-gray-500 truncate">{p.base_url} • {p.username} • {p.max_connections} conex.</span>
|
||||
{(() => { const b = expiryBadge(p.expiry_date); return b ? <span className={`text-xs px-1.5 py-0.5 rounded ${b.cls}`}>{b.label}</span> : null })()}
|
||||
</div>
|
||||
{p.last_sync_at && <div className="text-xs text-gray-600 mt-0.5">Último sync: {new Date(p.last_sync_at).toLocaleString('es-ES')}</div>}
|
||||
{testResult[p.id] !== undefined && (
|
||||
<div className={`text-xs mt-0.5 ${testResult[p.id] ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{testResult[p.id] ? '✓ Conexión OK' : '✗ Sin conexión'}
|
||||
</div>
|
||||
)}
|
||||
{syncProgress[p.id] && (
|
||||
<div className={`text-xs mt-1 ${syncProgress[p.id].startsWith('✓') ? 'text-green-400' : syncProgress[p.id].startsWith('Error') ? 'text-red-400' : 'text-blue-400'}`}>
|
||||
{syncing.has(p.id) && <RefreshCw size={10} className="inline animate-spin mr-1" />}
|
||||
{syncProgress[p.id]}
|
||||
{syncStats[p.id] && syncing.has(p.id) && (
|
||||
<span className="text-gray-500 ml-1">
|
||||
({Object.entries(syncStats[p.id]).filter(([, v]) => v > 0).map(([k, v]) => `${v} ${k}`).join(', ')})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => test(p.id)}
|
||||
disabled={testing === p.id}
|
||||
title="Probar conexión"
|
||||
className="p-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{testing === p.id ? <RefreshCw size={15} className="animate-spin" /> : <Wifi size={15} />}
|
||||
</button>
|
||||
{p.last_sync_at && (
|
||||
<button
|
||||
onClick={() => openUpdates(p)}
|
||||
title="Ver novedades del proveedor"
|
||||
className="relative p-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-yellow-400 transition-colors"
|
||||
>
|
||||
<Bell size={15} />
|
||||
{(updatesCount[p.id] ?? 0) > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-yellow-500 text-black text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center">
|
||||
{updatesCount[p.id] > 9 ? '9+' : updatesCount[p.id]}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openCategoryPicker(p)}
|
||||
disabled={syncing.has(p.id)}
|
||||
title="Sincronizar canales"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-dark-700 hover:bg-dark-600 text-xs text-gray-300 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={13} className={syncing.has(p.id) ? 'animate-spin' : ''} />
|
||||
{syncing.has(p.id) ? 'Sincronizando...' : 'Sync canales'}
|
||||
</button>
|
||||
<button onClick={() => openEdit(p)} title="Editar proveedor" className="p-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-yellow-400 transition-colors">
|
||||
<Edit2 size={15} />
|
||||
</button>
|
||||
<button onClick={() => del(p.id)} className="p-2 rounded-lg bg-dark-700 hover:bg-red-900/40 text-gray-500 hover:text-red-400 transition-colors">
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{providers.length === 0 && (
|
||||
<div className="bg-dark-800 border border-dark-700 border-dashed rounded-xl p-10 text-center text-gray-500 text-sm">
|
||||
No hay proveedores. Añade uno para empezar.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, value, onChange, required }: { label: string; value: string; onChange: (v: string) => void; required?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">{label}</label>
|
||||
<input
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
required={required}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import api from '../lib/api'
|
||||
import { Upload, Trash2, ImageIcon, Info } from 'lucide-react'
|
||||
|
||||
export default function Settings() {
|
||||
const [hasImage, setHasImage] = useState(false)
|
||||
const [imageUrl, setImageUrl] = useState('')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const checkImage = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const res = await fetch('/api/admin/settings/blocked-image', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
setHasImage(true)
|
||||
// Use the static file URL (no auth needed) for the <img> tag
|
||||
setImageUrl('/panel/blocked_image.jpg?t=' + Date.now())
|
||||
} else {
|
||||
setHasImage(false)
|
||||
setImageUrl('')
|
||||
}
|
||||
} catch {
|
||||
setHasImage(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { checkImage() }, [])
|
||||
|
||||
const upload = async (file: File) => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('Solo se permiten imágenes (JPEG, PNG, WebP, GIF)')
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
await api.post('/settings/blocked-image', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
await checkImage()
|
||||
} catch (e: any) {
|
||||
alert('Error al subir la imagen: ' + (e.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) upload(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) upload(file)
|
||||
}
|
||||
|
||||
const deleteImage = async () => {
|
||||
if (!confirm('¿Eliminar la imagen de bloqueo?')) return
|
||||
await api.delete('/settings/blocked-image')
|
||||
setHasImage(false)
|
||||
setImageUrl('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl space-y-6">
|
||||
<h1 className="text-xl font-bold">Ajustes</h1>
|
||||
|
||||
{/* Blocked image section */}
|
||||
<div className="bg-dark-800 rounded-xl p-6 space-y-4">
|
||||
<div>
|
||||
<h2 className="font-semibold text-base flex items-center gap-2">
|
||||
<ImageIcon size={18} className="text-primary-400" />
|
||||
Imagen de conexión bloqueada
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Esta imagen se mostrará como canal de vídeo cuando un usuario intente reproducir
|
||||
en un segundo dispositivo habiendo alcanzado su límite de conexiones simultáneas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 text-xs text-blue-400 bg-blue-500/10 rounded-lg px-3 py-2">
|
||||
<Info size={13} className="mt-0.5 shrink-0" />
|
||||
<span>
|
||||
El zapping normal (cambiar de canal en el mismo dispositivo) sigue funcionando
|
||||
sin interrupciones. La imagen solo aparece cuando se conecta un segundo dispositivo
|
||||
diferente con la misma cuenta.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Upload area */}
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); setDragging(true) }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !uploading && fileRef.current?.click()}
|
||||
className={`
|
||||
relative border-2 border-dashed rounded-xl cursor-pointer transition-colors
|
||||
${dragging ? 'border-primary-400 bg-primary-500/10' : 'border-dark-600 hover:border-dark-500'}
|
||||
`}
|
||||
>
|
||||
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleFileChange} />
|
||||
|
||||
{hasImage && imageUrl ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Blocked image"
|
||||
className="w-full rounded-xl object-contain max-h-72"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 hover:opacity-100 transition-opacity rounded-xl flex items-center justify-center">
|
||||
<span className="text-white text-sm font-medium">Haz clic para cambiar</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-12 flex flex-col items-center gap-3 text-gray-500">
|
||||
<Upload size={32} className={dragging ? 'text-primary-400' : ''} />
|
||||
<div className="text-center">
|
||||
<p className="font-medium text-sm">Arrastra una imagen o haz clic para subir</p>
|
||||
<p className="text-xs mt-1">JPEG, PNG, WebP o GIF · máx. 10 MB</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 bg-dark-900/80 rounded-xl flex items-center justify-center">
|
||||
<div className="text-sm text-primary-400">Subiendo...</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasImage && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={deleteImage}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
<Trash2 size={14} /> Eliminar imagen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Plus, Trash2, Edit2, List, Star, Server, Search } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
|
||||
interface User { id: number; username: string; max_connections: number; expiry_date: string | null; is_active: boolean; is_priority: boolean; created_at: string }
|
||||
interface GroupedCategory { name: string; type: string; category_ids: number[]; provider_count: number }
|
||||
interface CatalogEntry { id: number; category_id: number; category_name: string; type: string }
|
||||
interface ProviderPref { id: number; provider_account_id: number; provider_name: string; priority: number }
|
||||
interface Provider { id: number; name: string }
|
||||
interface CustomCat { id: number; name: string; type: string }
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = { live: 'Canales en directo', movie: 'Películas', series: 'Series' }
|
||||
|
||||
export default function Users() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editUser, setEditUser] = useState<User | null>(null)
|
||||
const [catalogUser, setCatalogUser] = useState<User | null>(null)
|
||||
const [providerUser, setProviderUser] = useState<User | null>(null)
|
||||
const [allCategories, setAllCategories] = useState<GroupedCategory[]>([])
|
||||
const [selectedCats, setSelectedCats] = useState<Set<number>>(new Set())
|
||||
const [allCustomCats, setAllCustomCats] = useState<CustomCat[]>([])
|
||||
const [selectedCustomCats, setSelectedCustomCats] = useState<Set<number>>(new Set())
|
||||
const [catSearch, setCatSearch] = useState('')
|
||||
const [allProviders, setAllProviders] = useState<Provider[]>([])
|
||||
const [userProviderPrefs, setUserProviderPrefs] = useState<ProviderPref[]>([])
|
||||
|
||||
const [form, setForm] = useState({ username: '', password: '', max_connections: 1, expiry_date: '', is_active: true, is_priority: false })
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get('/users/')
|
||||
setUsers(data)
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const payload = { ...form, expiry_date: form.expiry_date || null }
|
||||
if (editUser) {
|
||||
await api.put(`/users/${editUser.id}`, payload)
|
||||
} else {
|
||||
await api.post('/users/', payload)
|
||||
}
|
||||
resetForm()
|
||||
load()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setForm({ username: '', password: '', max_connections: 1, expiry_date: '', is_active: true, is_priority: false })
|
||||
setShowForm(false)
|
||||
setEditUser(null)
|
||||
}
|
||||
|
||||
function startEdit(u: User) {
|
||||
setForm({ username: u.username, password: '', max_connections: u.max_connections, expiry_date: u.expiry_date?.split('T')[0] || '', is_active: u.is_active, is_priority: u.is_priority })
|
||||
setEditUser(u)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
async function del(id: number) {
|
||||
if (!confirm('¿Eliminar usuario?')) return
|
||||
await api.delete(`/users/${id}`)
|
||||
load()
|
||||
}
|
||||
|
||||
async function togglePriority(u: User) {
|
||||
await api.put(`/users/${u.id}`, { is_priority: !u.is_priority })
|
||||
load()
|
||||
}
|
||||
|
||||
async function openCatalog(u: User) {
|
||||
setCatalogUser(u)
|
||||
setCatSearch('')
|
||||
const [grouped, catalog, customCats, userCustomCatalog] = await Promise.all([
|
||||
api.get('/catalogs/grouped').then(r => r.data as GroupedCategory[]),
|
||||
api.get(`/users/${u.id}/catalog`).then(r => r.data as CatalogEntry[]),
|
||||
api.get('/custom-categories/').then(r => r.data as CustomCat[]),
|
||||
api.get(`/users/${u.id}/custom-catalog`).then(r => r.data as number[]),
|
||||
])
|
||||
setAllCategories(grouped)
|
||||
setSelectedCats(new Set(catalog.map((c: CatalogEntry) => c.category_id)))
|
||||
setAllCustomCats(customCats)
|
||||
setSelectedCustomCats(new Set(userCustomCatalog))
|
||||
}
|
||||
|
||||
function isGroupSelected(g: GroupedCategory): boolean {
|
||||
return g.category_ids.some(id => selectedCats.has(id))
|
||||
}
|
||||
|
||||
function toggleGroup(g: GroupedCategory, checked: boolean) {
|
||||
setSelectedCats(prev => {
|
||||
const n = new Set(prev)
|
||||
if (checked) {
|
||||
g.category_ids.forEach(id => n.add(id))
|
||||
} else {
|
||||
g.category_ids.forEach(id => n.delete(id))
|
||||
}
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
function selectAllFiltered(cats: GroupedCategory[]) {
|
||||
setSelectedCats(prev => {
|
||||
const n = new Set(prev)
|
||||
cats.forEach((g: GroupedCategory) => g.category_ids.forEach((id: number) => n.add(id)))
|
||||
return n
|
||||
})
|
||||
// Also select all filtered custom cats
|
||||
const filteredCC = allCustomCats.filter(c => !catSearch || c.name.toLowerCase().includes(catSearch.toLowerCase()))
|
||||
setSelectedCustomCats(prev => {
|
||||
const n = new Set(prev)
|
||||
filteredCC.forEach(c => n.add(c.id))
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
function selectNoneFiltered(cats: GroupedCategory[]) {
|
||||
setSelectedCats(prev => {
|
||||
const n = new Set(prev)
|
||||
cats.forEach((g: GroupedCategory) => g.category_ids.forEach((id: number) => n.delete(id)))
|
||||
return n
|
||||
})
|
||||
const filteredCC = allCustomCats.filter(c => !catSearch || c.name.toLowerCase().includes(catSearch.toLowerCase()))
|
||||
setSelectedCustomCats(prev => {
|
||||
const n = new Set(prev)
|
||||
filteredCC.forEach(c => n.delete(c.id))
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
async function saveCatalog() {
|
||||
if (!catalogUser) return
|
||||
await Promise.all([
|
||||
api.post(`/users/${catalogUser.id}/catalog`, Array.from(selectedCats)),
|
||||
api.post(`/users/${catalogUser.id}/custom-catalog`, Array.from(selectedCustomCats)),
|
||||
])
|
||||
setCatalogUser(null)
|
||||
}
|
||||
|
||||
async function openProviderPrefs(u: User) {
|
||||
setProviderUser(u)
|
||||
const [providers, prefs] = await Promise.all([
|
||||
api.get('/providers/').then(r => r.data as Provider[]),
|
||||
api.get(`/users/${u.id}/providers`).then(r => r.data as ProviderPref[]),
|
||||
])
|
||||
setAllProviders(providers)
|
||||
setUserProviderPrefs(prefs)
|
||||
}
|
||||
|
||||
async function addProviderPref(providerId: number) {
|
||||
if (!providerUser) return
|
||||
await api.post(`/users/${providerUser.id}/providers`, { provider_account_id: providerId, priority: userProviderPrefs.length })
|
||||
const { data } = await api.get(`/users/${providerUser.id}/providers`)
|
||||
setUserProviderPrefs(data)
|
||||
}
|
||||
|
||||
async function removeProviderPref(providerId: number) {
|
||||
if (!providerUser) return
|
||||
await api.delete(`/users/${providerUser.id}/providers/${providerId}`)
|
||||
const { data } = await api.get(`/users/${providerUser.id}/providers`)
|
||||
setUserProviderPrefs(data)
|
||||
}
|
||||
|
||||
const prefProviderIds = new Set(userProviderPrefs.map(p => p.provider_account_id))
|
||||
|
||||
// Catalog modal filtered + grouped by type
|
||||
const filteredCats = allCategories.filter(c =>
|
||||
!catSearch || c.name.toLowerCase().includes(catSearch.toLowerCase())
|
||||
)
|
||||
const filteredCustomCats = allCustomCats.filter(c =>
|
||||
!catSearch || c.name.toLowerCase().includes(catSearch.toLowerCase())
|
||||
)
|
||||
const selectedGroupCount = allCategories.filter(g => isGroupSelected(g)).length + selectedCustomCats.size
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">Usuarios</h1>
|
||||
<button onClick={() => setShowForm(v => !v)} className="flex items-center gap-2 bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium transition-colors">
|
||||
<Plus size={16} /> Nuevo usuario
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={submit} className="bg-dark-800 border border-dark-700 rounded-xl p-5 space-y-3">
|
||||
<h3 className="font-semibold text-sm">{editUser ? 'Editar usuario' : 'Nuevo usuario'}</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Usuario</label>
|
||||
<input value={form.username} onChange={e => setForm(f => ({ ...f, username: e.target.value }))} required disabled={!!editUser}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Contraseña {editUser && '(dejar vacío para no cambiar)'}</label>
|
||||
<input type="password" value={form.password} onChange={e => setForm(f => ({ ...f, password: e.target.value }))} required={!editUser}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Max. conexiones simultáneas</label>
|
||||
<input type="number" min={1} value={form.max_connections} onChange={e => setForm(f => ({ ...f, max_connections: +e.target.value }))}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Fecha expiración</label>
|
||||
<input type="date" value={form.expiry_date} onChange={e => setForm(f => ({ ...f, expiry_date: e.target.value }))}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4 col-span-2">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={form.is_active} onChange={e => setForm(f => ({ ...f, is_active: e.target.checked }))} />
|
||||
Activo
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={form.is_priority} onChange={e => setForm(f => ({ ...f, is_priority: e.target.checked }))} />
|
||||
<Star size={13} className="text-yellow-400" />
|
||||
Usuario prioritario
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" className="bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium">Guardar</button>
|
||||
<button type="button" onClick={resetForm} className="bg-dark-700 hover:bg-dark-600 px-4 py-2 rounded-lg text-sm">Cancelar</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Catalog assignment modal */}
|
||||
{catalogUser && (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 rounded-xl border border-dark-700 w-full max-w-2xl max-h-[85vh] flex flex-col">
|
||||
<div className="p-4 border-b border-dark-700 flex items-center justify-between shrink-0">
|
||||
<div>
|
||||
<h2 className="font-semibold">Catálogo de <span className="text-primary-400">{catalogUser.username}</span></h2>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{selectedGroupCount} de {allCategories.length + allCustomCats.length} categorías seleccionadas</p>
|
||||
</div>
|
||||
<button onClick={() => setCatalogUser(null)} className="text-gray-500 hover:text-white">✕</button>
|
||||
</div>
|
||||
|
||||
{/* Search + bulk actions */}
|
||||
<div className="p-3 border-b border-dark-700 shrink-0 space-y-2">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
value={catSearch}
|
||||
onChange={e => setCatSearch(e.target.value)}
|
||||
placeholder="Buscar categoría..."
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg pl-9 pr-3 py-2 text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => selectAllFiltered(filteredCats)}
|
||||
className="text-xs px-3 py-1 rounded-lg bg-primary-900/40 hover:bg-primary-900/70 text-primary-300 border border-primary-800 transition-colors"
|
||||
>
|
||||
Seleccionar todo
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectNoneFiltered(filteredCats)}
|
||||
className="text-xs px-3 py-1 rounded-lg bg-dark-700 hover:bg-dark-600 text-gray-400 border border-dark-600 transition-colors"
|
||||
>
|
||||
Ninguna
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 p-4 space-y-4">
|
||||
{(['live', 'movie', 'series'] as const).map(type => {
|
||||
const cats = filteredCats.filter(c => c.type === type)
|
||||
if (!cats.length) return null
|
||||
return (
|
||||
<div key={type}>
|
||||
<h3 className="text-xs text-gray-500 uppercase tracking-wider mb-2 flex items-center justify-between">
|
||||
<span>{TYPE_LABELS[type]}</span>
|
||||
<span className="normal-case text-gray-600">{cats.filter(g => isGroupSelected(g)).length}/{cats.length}</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{cats.map(cat => (
|
||||
<label
|
||||
key={cat.name}
|
||||
className={`flex items-center gap-2 text-sm p-2 rounded-lg cursor-pointer transition-colors ${isGroupSelected(cat) ? 'bg-primary-900/20 hover:bg-primary-900/30' : 'hover:bg-dark-700'}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isGroupSelected(cat)}
|
||||
onChange={e => toggleGroup(cat, e.target.checked)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="truncate">{cat.name}</span>
|
||||
{cat.provider_count > 0 && (
|
||||
<span className={`shrink-0 text-xs px-1.5 py-0.5 rounded ml-auto font-medium ${cat.provider_count > 1 ? 'bg-green-900/50 text-green-400' : 'bg-dark-600 text-gray-500'}`}>
|
||||
{cat.provider_count}p
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{filteredCats.length === 0 && filteredCustomCats.length === 0 && (
|
||||
<div className="text-center text-gray-500 text-sm py-8">Sin resultados</div>
|
||||
)}
|
||||
|
||||
{/* Custom categories section */}
|
||||
{filteredCustomCats.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs text-gray-500 uppercase tracking-wider mb-2 flex items-center gap-2">
|
||||
<Star size={11} className="text-yellow-400" />
|
||||
<span>Categorías personalizadas</span>
|
||||
<span className="normal-case text-gray-600">{filteredCustomCats.filter(c => selectedCustomCats.has(c.id)).length}/{filteredCustomCats.length}</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{filteredCustomCats.map(cat => (
|
||||
<label
|
||||
key={cat.id}
|
||||
className={`flex items-center gap-2 text-sm p-2 rounded-lg cursor-pointer transition-colors ${selectedCustomCats.has(cat.id) ? 'bg-yellow-900/20 hover:bg-yellow-900/30' : 'hover:bg-dark-700'}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCustomCats.has(cat.id)}
|
||||
onChange={e => {
|
||||
setSelectedCustomCats(prev => {
|
||||
const n = new Set(prev)
|
||||
e.target.checked ? n.add(cat.id) : n.delete(cat.id)
|
||||
return n
|
||||
})
|
||||
}}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="truncate">{cat.name}</span>
|
||||
<Star size={10} className="shrink-0 text-yellow-500 ml-auto" />
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-dark-700 flex gap-2 justify-end shrink-0">
|
||||
<button onClick={() => setCatalogUser(null)} className="bg-dark-700 px-4 py-2 rounded-lg text-sm">Cancelar</button>
|
||||
<button onClick={saveCatalog} className="bg-primary-500 hover:bg-primary-600 px-4 py-2 rounded-lg text-sm font-medium">Guardar catálogo</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider preferences modal */}
|
||||
{providerUser && (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 rounded-xl border border-dark-700 w-full max-w-md flex flex-col">
|
||||
<div className="p-4 border-b border-dark-700 flex items-center justify-between">
|
||||
<h2 className="font-semibold">Proveedores preferidos — <span className="text-primary-400">{providerUser.username}</span></h2>
|
||||
<button onClick={() => setProviderUser(null)} className="text-gray-500 hover:text-white">✕</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
<p className="text-xs text-gray-500 mb-3">Los proveedores marcados se intentan primero al asignar un slot. Si ninguno está disponible, se usa cualquiera con slot libre.</p>
|
||||
{allProviders.map(p => {
|
||||
const isPref = prefProviderIds.has(p.id)
|
||||
return (
|
||||
<div key={p.id} className={`flex items-center justify-between p-3 rounded-lg border ${isPref ? 'border-primary-700 bg-primary-900/20' : 'border-dark-600 bg-dark-700'}`}>
|
||||
<span className="text-sm">{p.name}</span>
|
||||
<button
|
||||
onClick={() => isPref ? removeProviderPref(p.id) : addProviderPref(p.id)}
|
||||
className={`text-xs px-3 py-1 rounded-lg font-medium transition-colors ${isPref ? 'bg-primary-600 hover:bg-red-700 text-white' : 'bg-dark-600 hover:bg-primary-700 text-gray-300 hover:text-white'}`}
|
||||
>
|
||||
{isPref ? 'Quitar' : 'Preferido'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{allProviders.length === 0 && (
|
||||
<div className="text-center text-gray-500 text-sm py-4">No hay proveedores configurados</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 border-t border-dark-700 flex justify-end">
|
||||
<button onClick={() => setProviderUser(null)} className="bg-dark-700 px-4 py-2 rounded-lg text-sm">Cerrar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-dark-800 border border-dark-700 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-dark-700 text-xs text-gray-500">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Usuario</th>
|
||||
<th className="text-left px-4 py-3">Conexiones</th>
|
||||
<th className="text-left px-4 py-3">Expiración</th>
|
||||
<th className="text-left px-4 py-3">Estado</th>
|
||||
<th className="text-left px-4 py-3">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id} className="border-b border-dark-700 hover:bg-dark-700/30">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{u.is_priority && <Star size={12} className="text-yellow-400 shrink-0" />}
|
||||
<span className="font-mono">{u.username}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">{u.max_connections}</td>
|
||||
<td className="px-4 py-3 text-gray-400">{u.expiry_date ? new Date(u.expiry_date).toLocaleDateString('es-ES') : '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${u.is_active ? 'bg-green-900/40 text-green-400' : 'bg-red-900/40 text-red-400'}`}>
|
||||
{u.is_active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => openCatalog(u)} title="Asignar catálogo" className="p-1.5 rounded hover:bg-dark-600 text-gray-400 hover:text-blue-400">
|
||||
<List size={14} />
|
||||
</button>
|
||||
<button onClick={() => openProviderPrefs(u)} title="Proveedores preferidos" className="p-1.5 rounded hover:bg-dark-600 text-gray-400 hover:text-purple-400">
|
||||
<Server size={14} />
|
||||
</button>
|
||||
<button onClick={() => togglePriority(u)} title={u.is_priority ? 'Quitar prioridad' : 'Hacer prioritario'} className={`p-1.5 rounded hover:bg-dark-600 transition-colors ${u.is_priority ? 'text-yellow-400' : 'text-gray-400 hover:text-yellow-400'}`}>
|
||||
<Star size={14} />
|
||||
</button>
|
||||
<button onClick={() => startEdit(u)} title="Editar" className="p-1.5 rounded hover:bg-dark-600 text-gray-400 hover:text-yellow-400">
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
<button onClick={() => del(u.id)} title="Eliminar" className="p-1.5 rounded hover:bg-dark-600 text-gray-400 hover:text-red-400">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-500">No hay usuarios</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0f4ff',
|
||||
500: '#3b5bdb',
|
||||
600: '#3451c7',
|
||||
700: '#2c44a8',
|
||||
900: '#1a2a6b',
|
||||
},
|
||||
dark: {
|
||||
900: '#0f172a',
|
||||
800: '#1e293b',
|
||||
700: '#334155',
|
||||
600: '#475569',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/panel/',
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: '../backend/static',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
[Unit]
|
||||
Description=KiraStream IPTV Server
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/kirastream
|
||||
|
||||
ExecStartPre=/bin/bash -c 'pg_ctlcluster 17 main start 2>/dev/null; true'
|
||||
ExecStartPre=/bin/bash -c 'redis-cli ping 2>/dev/null || (LANG=C LC_ALL=C redis-server --port 6379 --bind 127.0.0.1 --daemonize yes --dir /var/lib/redis/ && sleep 1); true'
|
||||
ExecStartPre=/bin/bash -c 'pkill -9 uvicorn 2>/dev/null; sleep 1; true'
|
||||
|
||||
ExecStart=/opt/kirastream/venv/bin/uvicorn backend.main:app \
|
||||
--host 0.0.0.0 --port 8000 --workers 1 --log-level info
|
||||
|
||||
StandardOutput=append:/opt/kirastream/logs/backend.log
|
||||
StandardError=append:/opt/kirastream/logs/backend.log
|
||||
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
KillMode=control-group
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,63 @@
|
||||
upstream kirastream_backend {
|
||||
server 127.0.0.1:8000;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
|
||||
# Streaming: long timeouts, no buffering
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_connect_timeout 10s;
|
||||
|
||||
# Frontend static build
|
||||
location /admin {
|
||||
alias /opt/kirastream/backend/static;
|
||||
try_files $uri $uri/ /admin/index.html;
|
||||
}
|
||||
|
||||
# API and Xtream Codes endpoints — proxy to FastAPI
|
||||
location / {
|
||||
proxy_pass http://kirastream_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# No buffering for streams
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
add_header X-Accel-Buffering no;
|
||||
}
|
||||
|
||||
# WebSocket monitoring
|
||||
location /api/admin/monitoring/ws {
|
||||
proxy_pass http://kirastream_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
|
||||
# Static files for frontend (direct serve)
|
||||
location /assets/ {
|
||||
alias /opt/kirastream/backend/static/assets/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_types application/json application/javascript text/css text/plain application/x-mpegurl;
|
||||
gzip_min_length 1024;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/kirastream_access.log;
|
||||
error_log /var/log/nginx/kirastream_error.log;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
# KiraStream installation/startup script for PCT 117 (kiraIPTV)
|
||||
set -e
|
||||
|
||||
APP_DIR=/opt/kirastream
|
||||
VENV="$APP_DIR/venv"
|
||||
PYTHON="$VENV/bin/python"
|
||||
UV="$VENV/bin/uvicorn"
|
||||
|
||||
echo "=== KiraStream startup ==="
|
||||
|
||||
# Fix locale for Redis
|
||||
export LANG=C
|
||||
export LC_ALL=C
|
||||
|
||||
# --- PostgreSQL ---
|
||||
if ! pg_ctlcluster 17 main status &>/dev/null; then
|
||||
echo "Starting PostgreSQL..."
|
||||
pg_ctlcluster 17 main start
|
||||
fi
|
||||
|
||||
# Ensure DB exists
|
||||
su - postgres -s /bin/bash -c "createdb -O kirastream kirastream 2>/dev/null || true"
|
||||
|
||||
# --- Redis ---
|
||||
if ! redis-cli ping &>/dev/null; then
|
||||
echo "Starting Redis..."
|
||||
LANG=C LC_ALL=C redis-server \
|
||||
--port 6379 \
|
||||
--bind 127.0.0.1 \
|
||||
--daemonize yes \
|
||||
--logfile /var/log/redis/kirastream-redis.log \
|
||||
--dir /var/lib/redis/
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
# --- Backend ---
|
||||
echo "Starting KiraStream backend..."
|
||||
cd "$APP_DIR"
|
||||
exec "$UV" backend.main:app \
|
||||
--host 0.0.0.0 \
|
||||
--port 8000 \
|
||||
--workers 4 \
|
||||
--loop uvloop \
|
||||
--log-level info \
|
||||
--access-log
|
||||
Reference in New Issue
Block a user