Mejoras de estabilidad de stream IPTV y nuevo registro de fallos
- restream.py: eliminar -reconnect_streamed de ffmpeg para evitar rebobinados al reconectar (el proveedor re-enviaba desde keyframe anterior causando PTS backward). El loop de Python gestiona las reconexiones con conexión fresca. - restream.py: eliminar _drain_queue del loop de reconexión para que el buffer de la cola cubra el tiempo de reconexión sin pantalla negra en el player. - restream.py: añadir buffer_server_bytes/secs/capacity a stats() y método _log_stream_event() para persistir eventos de caída y recuperación en BD. - models/stream_event.py: nuevo modelo StreamEvent para registro de fallos. - database.py: registrar StreamEvent en init_db. - api/admin/logs.py: nuevos endpoints GET/DELETE /logs/events. - Dashboard.tsx: mostrar reserva de buffer del servidor (capacidad + estado). - Logs.tsx: añadir pestaña "Fallos de stream" con tabla de eventos persistidos. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+45
@@ -0,0 +1,45 @@
|
|||||||
|
# Python
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Environment & secrets
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Logs & runtime
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
backend.pid
|
||||||
|
dump.rdb
|
||||||
|
|
||||||
|
# Frontend build artifacts (built files are in backend/static, kept)
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/.vite/
|
||||||
|
frontend/tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# Nginx runtime
|
||||||
|
nginx/logs/
|
||||||
@@ -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,195 @@
|
|||||||
|
"""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 ...models.stream_event import StreamEvent
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events")
|
||||||
|
async def get_events(
|
||||||
|
channel: str | None = Query(None),
|
||||||
|
event_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(StreamEvent).order_by(desc(StreamEvent.created_at))
|
||||||
|
|
||||||
|
if channel:
|
||||||
|
q = q.where(StreamEvent.channel_name.ilike(f"%{channel}%"))
|
||||||
|
if event_type:
|
||||||
|
q = q.where(StreamEvent.event_type == event_type)
|
||||||
|
if date_from:
|
||||||
|
try:
|
||||||
|
q = q.where(StreamEvent.created_at >= datetime.fromisoformat(date_from))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if date_to:
|
||||||
|
try:
|
||||||
|
q = q.where(StreamEvent.created_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)
|
||||||
|
events = result.scalars().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
"items": [_serialize_event(ev) for ev in events],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/events", status_code=204)
|
||||||
|
async def clear_events(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||||
|
from sqlalchemy import delete
|
||||||
|
await db.execute(delete(StreamEvent))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_event(ev: StreamEvent) -> dict:
|
||||||
|
return {
|
||||||
|
"id": ev.id,
|
||||||
|
"channel_id": ev.channel_id,
|
||||||
|
"channel_name": ev.channel_name,
|
||||||
|
"provider_name": ev.provider_name,
|
||||||
|
"event_type": ev.event_type,
|
||||||
|
"domain": ev.domain,
|
||||||
|
"error_message": ev.error_message,
|
||||||
|
"attempt_number": ev.attempt_number,
|
||||||
|
"created_at": ev.created_at.isoformat() if ev.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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,833 @@
|
|||||||
|
"""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 sqlalchemy.orm import selectinload
|
||||||
|
from ...database import get_db, AsyncSessionLocal
|
||||||
|
from ...models.provider import ProviderAccount, ProviderUrl
|
||||||
|
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 ProviderUrlOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
url: str
|
||||||
|
priority: int
|
||||||
|
is_active: bool
|
||||||
|
status: str
|
||||||
|
response_ms: int | None = None
|
||||||
|
last_checked_at: datetime | None = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
urls: list[ProviderUrlOut] = []
|
||||||
|
|
||||||
|
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)
|
||||||
|
.options(selectinload(ProviderAccount.extra_urls))
|
||||||
|
.where(ProviderAccount.id.notin_(_jellyfin_provider_subq()))
|
||||||
|
.order_by(ProviderAccount.id)
|
||||||
|
)
|
||||||
|
providers = result.scalars().all()
|
||||||
|
# Map extra_urls to the urls field expected by ProviderOut
|
||||||
|
for p in providers:
|
||||||
|
p.urls = p.extra_urls
|
||||||
|
return providers
|
||||||
|
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Provider URL management ────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ProviderUrlCreate(BaseModel):
|
||||||
|
url: str
|
||||||
|
priority: int = 10
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{provider_id}/urls", response_model=list[ProviderUrlOut])
|
||||||
|
async def list_provider_urls(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(ProviderUrl)
|
||||||
|
.where(ProviderUrl.provider_account_id == provider_id)
|
||||||
|
.order_by(ProviderUrl.priority)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{provider_id}/urls", response_model=ProviderUrlOut)
|
||||||
|
async def add_provider_url(provider_id: int, data: ProviderUrlCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||||
|
pu = ProviderUrl(provider_account_id=provider_id, **data.model_dump())
|
||||||
|
db.add(pu)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(pu)
|
||||||
|
return pu
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{provider_id}/urls/{url_id}", response_model=ProviderUrlOut)
|
||||||
|
async def update_provider_url(provider_id: int, url_id: int, data: ProviderUrlCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(ProviderUrl).where(ProviderUrl.id == url_id, ProviderUrl.provider_account_id == provider_id)
|
||||||
|
)
|
||||||
|
pu = result.scalar_one_or_none()
|
||||||
|
if not pu:
|
||||||
|
raise HTTPException(404, "URL not found")
|
||||||
|
pu.url = data.url
|
||||||
|
pu.priority = data.priority
|
||||||
|
pu.is_active = data.is_active
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(pu)
|
||||||
|
return pu
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{provider_id}/urls/{url_id}")
|
||||||
|
async def delete_provider_url(provider_id: int, url_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(ProviderUrl).where(ProviderUrl.id == url_id, ProviderUrl.provider_account_id == provider_id)
|
||||||
|
)
|
||||||
|
pu = result.scalar_one_or_none()
|
||||||
|
if not pu:
|
||||||
|
raise HTTPException(404, "URL not found")
|
||||||
|
await db.delete(pu)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{provider_id}/urls/check-all")
|
||||||
|
async def check_all_provider_urls(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||||
|
from ...core.health_checker import check_provider_urls
|
||||||
|
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")
|
||||||
|
results = await check_provider_urls(provider_id)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
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 = 512
|
||||||
|
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,99 @@
|
|||||||
|
"""EPG manager: fetch XMLTV sources, merge by tvg-id, serve filtered by user catalog."""
|
||||||
|
import asyncio
|
||||||
|
import gzip
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
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=datetime.now(timezone.utc)) # 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,115 @@
|
|||||||
|
"""Background health checker for provider URLs."""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from ..database import AsyncSessionLocal
|
||||||
|
from ..models.provider import ProviderAccount, ProviderUrl
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CHECK_INTERVAL = 300 # 5 minutes
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_url(base_url: str, username: str, password: str) -> tuple[str, int | None]:
|
||||||
|
url = f"{base_url.rstrip('/')}/player_api.php"
|
||||||
|
params = {"username": username, "password": password, "action": "user_info"}
|
||||||
|
start = time.time()
|
||||||
|
try:
|
||||||
|
timeout = aiohttp.ClientTimeout(total=8)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
async with session.get(url, params=params, ssl=False) as resp:
|
||||||
|
elapsed_ms = int((time.time() - start) * 1000)
|
||||||
|
if resp.status == 200:
|
||||||
|
try:
|
||||||
|
data = await resp.json(content_type=None)
|
||||||
|
if "user_info" in data:
|
||||||
|
return "ok", elapsed_ms
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "error", elapsed_ms
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return "timeout", None
|
||||||
|
except Exception:
|
||||||
|
return "error", None
|
||||||
|
|
||||||
|
|
||||||
|
async def check_provider_urls(provider_account_id: int) -> list[dict]:
|
||||||
|
"""Check all URLs for a specific provider. Returns list of result dicts."""
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ProviderUrl.id, ProviderUrl.url, ProviderAccount.username, ProviderAccount.password)
|
||||||
|
.join(ProviderAccount)
|
||||||
|
.where(
|
||||||
|
ProviderUrl.provider_account_id == provider_account_id,
|
||||||
|
ProviderUrl.is_active == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
|
||||||
|
tasks = [_check_url(url, username, password) for _, url, username, password in rows]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
output = []
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
for (url_id, url, _, _), check_result in zip(rows, results):
|
||||||
|
if isinstance(check_result, Exception):
|
||||||
|
status, response_ms = "error", None
|
||||||
|
else:
|
||||||
|
status, response_ms = check_result
|
||||||
|
pu = await db.get(ProviderUrl, url_id)
|
||||||
|
if pu:
|
||||||
|
pu.status = status
|
||||||
|
pu.response_ms = response_ms
|
||||||
|
pu.last_checked_at = now
|
||||||
|
output.append({"id": url_id, "url": url, "status": status, "response_ms": response_ms})
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
async def health_check_loop() -> None:
|
||||||
|
"""Runs forever, checking all active provider URLs every CHECK_INTERVAL seconds."""
|
||||||
|
logger.info("Provider URL health checker started")
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(CHECK_INTERVAL)
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ProviderUrl.id, ProviderUrl.url, ProviderAccount.username, ProviderAccount.password)
|
||||||
|
.join(ProviderAccount)
|
||||||
|
.where(ProviderUrl.is_active == True) # noqa: E712
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tasks = [_check_url(url, username, password) for _, url, username, password in rows]
|
||||||
|
check_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
for (url_id, _, _, _), check_result in zip(rows, check_results):
|
||||||
|
if isinstance(check_result, Exception):
|
||||||
|
status, response_ms = "error", None
|
||||||
|
else:
|
||||||
|
status, response_ms = check_result
|
||||||
|
pu = await db.get(ProviderUrl, url_id)
|
||||||
|
if pu:
|
||||||
|
pu.status = status
|
||||||
|
pu.response_ms = response_ms
|
||||||
|
pu.last_checked_at = now
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Health check done: {len(rows)} URLs checked")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Health check loop error: {e}")
|
||||||
@@ -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,314 @@
|
|||||||
|
"""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), ""
|
||||||
|
)
|
||||||
|
stream_urls = await self._build_stream_urls(account_id, stream_url)
|
||||||
|
group = BroadcastGroup(
|
||||||
|
channel_id=channel_id,
|
||||||
|
stream_urls=stream_urls,
|
||||||
|
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 _build_stream_urls(self, account_id: int, primary_stream_url: str) -> list[str]:
|
||||||
|
"""Build list of stream URLs for failover: one per active ProviderUrl, substituting base URL."""
|
||||||
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
from ..models.provider import ProviderUrl
|
||||||
|
from ..database import AsyncSessionLocal
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
parsed = urlparse(primary_stream_url)
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ProviderUrl)
|
||||||
|
.where(
|
||||||
|
ProviderUrl.provider_account_id == account_id,
|
||||||
|
ProviderUrl.is_active == True, # noqa: E712
|
||||||
|
)
|
||||||
|
.order_by(ProviderUrl.priority)
|
||||||
|
)
|
||||||
|
provider_urls = result.scalars().all()
|
||||||
|
|
||||||
|
if not provider_urls:
|
||||||
|
return [primary_stream_url]
|
||||||
|
|
||||||
|
urls = []
|
||||||
|
for pu in provider_urls:
|
||||||
|
alt = urlparse(pu.url.rstrip('/'))
|
||||||
|
reconstructed = urlunparse((
|
||||||
|
alt.scheme or parsed.scheme,
|
||||||
|
alt.netloc,
|
||||||
|
parsed.path,
|
||||||
|
parsed.params,
|
||||||
|
parsed.query,
|
||||||
|
parsed.fragment,
|
||||||
|
))
|
||||||
|
urls.append(reconstructed)
|
||||||
|
return urls
|
||||||
|
|
||||||
|
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,610 @@
|
|||||||
|
"""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
|
||||||
|
spawns an ffmpeg process in copy (remux) mode. ffmpeg owns the upstream
|
||||||
|
HTTP connection. When the provider closes the session (typically every
|
||||||
|
~2 minutes) ffmpeg exits and the outer loop spawns a fresh process — the
|
||||||
|
provider delivers from the current live edge so timestamps are always
|
||||||
|
forward-moving and the player never sees a backward seek (rewind).
|
||||||
|
|
||||||
|
If ffmpeg itself exits (unrecoverable error or stall beyond its own timeout)
|
||||||
|
the outer loop drains all client queues, backs off briefly, and spawns a
|
||||||
|
fresh ffmpeg process — again without disconnecting clients.
|
||||||
|
|
||||||
|
SENTINEL is only pushed when stop() is called or the pump truly gives up.
|
||||||
|
|
||||||
|
Stall detection
|
||||||
|
---------------
|
||||||
|
ffmpeg's -timeout option (socket-level read timeout) handles provider stalls.
|
||||||
|
A safety-net asyncio.wait_for wraps each stdout read in case ffmpeg hangs
|
||||||
|
without producing output or exiting.
|
||||||
|
|
||||||
|
A/V sync
|
||||||
|
--------
|
||||||
|
ffmpeg re-muxes the incoming MPEG-TS into a fresh output stream. On every
|
||||||
|
reconnect the output PTS/DTS sequence is continuous regardless of provider-
|
||||||
|
side timestamp resets, eliminating audio desync entirely. No video or audio
|
||||||
|
is re-encoded — quality is bit-for-bit identical to the source.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import AsyncIterator
|
||||||
|
|
||||||
|
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 — passed to ffmpeg -timeout (in µs below)
|
||||||
|
MAX_RETRIES = 999 # effectively infinite; pool stops the group when 0 clients
|
||||||
|
_BACKOFF_CAP = 5.0 # max seconds between outer retries
|
||||||
|
|
||||||
|
_SENTINEL = object() # signals end-of-stream to client queues
|
||||||
|
|
||||||
|
_FFMPEG = shutil.which("ffmpeg") or "ffmpeg"
|
||||||
|
|
||||||
|
# ffmpeg stderr patterns that indicate input-side A/V issues (demuxer warnings).
|
||||||
|
# These don't affect output quality with -use_wallclock_as_timestamps but are
|
||||||
|
# counted as a health metric to surface in the dashboard.
|
||||||
|
_AV_WARN_PATTERNS = (
|
||||||
|
"timestamp discontinuity",
|
||||||
|
"Packet corrupt",
|
||||||
|
"PES packet size mismatch",
|
||||||
|
"DTS, out of order",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Health check: data-age thresholds
|
||||||
|
_HEALTH_WARN_SECS = 12.0 # > 12 s without data → warning (normal reconnects are 1-4 s)
|
||||||
|
_HEALTH_ERROR_SECS = 25.0 # > 25 s → error, trigger correction
|
||||||
|
|
||||||
|
|
||||||
|
def _url_netloc(url: str) -> str:
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
try:
|
||||||
|
return urlparse(url).netloc
|
||||||
|
except Exception:
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
@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_urls: list[str],
|
||||||
|
provider_account_id: int,
|
||||||
|
channel_name: str = "",
|
||||||
|
provider_name: str = "",
|
||||||
|
# kept for backward compat in callers that still pass stream_url as kwarg
|
||||||
|
stream_url: str | None = None,
|
||||||
|
):
|
||||||
|
self.channel_id = channel_id
|
||||||
|
self.channel_name = channel_name
|
||||||
|
# Support old callers that pass a single stream_url
|
||||||
|
if stream_urls:
|
||||||
|
self._stream_urls = stream_urls
|
||||||
|
elif stream_url:
|
||||||
|
self._stream_urls = [stream_url]
|
||||||
|
else:
|
||||||
|
self._stream_urls = []
|
||||||
|
self.stream_url = self._stream_urls[0] if self._stream_urls else ""
|
||||||
|
self._active_url: str = self.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 = {}
|
||||||
|
self._reconnect_count: int = 0
|
||||||
|
self._status: str = "connecting"
|
||||||
|
self._last_data_at: float = time.time()
|
||||||
|
|
||||||
|
# ffmpeg process tracking
|
||||||
|
self._ffmpeg_pid: int | None = None
|
||||||
|
self._ffmpeg_start_time: float | None = None # wall time when ffmpeg started
|
||||||
|
self._ffmpeg_cpu: float = 0.0
|
||||||
|
self._cpu_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
# A/V health tracking
|
||||||
|
self._av_health: str = "ok" # ok / warning / error
|
||||||
|
self._av_desync_count: int = 0 # cumulative stderr A/V-warning events
|
||||||
|
self._corrections: int = 0 # how many times health monitor killed stuck ffmpeg
|
||||||
|
self._health_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 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}"
|
||||||
|
)
|
||||||
|
self._cpu_task = asyncio.create_task(
|
||||||
|
self._monitor_cpu(), name=f"cpu-ch{self.channel_id}"
|
||||||
|
)
|
||||||
|
self._health_task = asyncio.create_task(
|
||||||
|
self._health_check_loop(), name=f"health-ch{self.channel_id}"
|
||||||
|
)
|
||||||
|
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"
|
||||||
|
for task in (self._cpu_task, self._health_task):
|
||||||
|
if task and not task.done():
|
||||||
|
task.cancel()
|
||||||
|
self._cpu_task = self._health_task = None
|
||||||
|
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,
|
||||||
|
"status": self._status,
|
||||||
|
"reconnect_count": self._reconnect_count,
|
||||||
|
"last_data_ago": round(time.time() - self._last_data_at, 1),
|
||||||
|
"stream_info": self.stream_info,
|
||||||
|
# ffmpeg process info
|
||||||
|
"ffmpeg_pid": self._ffmpeg_pid,
|
||||||
|
"ffmpeg_cpu_pct": round(self._ffmpeg_cpu, 2),
|
||||||
|
# multi-domain failover info
|
||||||
|
"active_url_domain": _url_netloc(self._active_url),
|
||||||
|
"url_count": len(self._stream_urls),
|
||||||
|
# A/V health
|
||||||
|
"av_health": self._av_health,
|
||||||
|
"av_desync_count": self._av_desync_count,
|
||||||
|
"corrections": self._corrections,
|
||||||
|
"buffer_pct": self._buffer_pct(),
|
||||||
|
"buffer_server_bytes": self._buffer_server_bytes(),
|
||||||
|
"buffer_server_secs": self._buffer_server_secs(),
|
||||||
|
"buffer_capacity": QUEUE_MAX * CHUNK_SIZE,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _monitor_cpu(self) -> None:
|
||||||
|
"""Update self._ffmpeg_cpu every 3 s using cumulative average since start.
|
||||||
|
|
||||||
|
Delta-based sampling fails for low-CPU processes (copy-mode ffmpeg uses
|
||||||
|
<1% CPU) because the tick delta in 2 s is 0-1 integer ticks, making the
|
||||||
|
result always 0.0%. Cumulative average ((total_cpu_ticks / clk_tck) /
|
||||||
|
elapsed_wall) gives a stable, accurate reading — the same method ps uses.
|
||||||
|
"""
|
||||||
|
clk_tck = float(os.sysconf(os.sysconf_names.get("SC_CLK_TCK", 2)) or 100)
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
pid = self._ffmpeg_pid
|
||||||
|
start_time = self._ffmpeg_start_time
|
||||||
|
if pid is None or start_time is None:
|
||||||
|
self._ffmpeg_cpu = 0.0
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{pid}/stat") as f:
|
||||||
|
parts = f.read().split()
|
||||||
|
cpu_ticks = int(parts[13]) + int(parts[14]) # utime + stime
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
if elapsed > 0:
|
||||||
|
self._ffmpeg_cpu = cpu_ticks / clk_tck / elapsed * 100
|
||||||
|
except (FileNotFoundError, IndexError, ValueError, OSError):
|
||||||
|
self._ffmpeg_cpu = 0.0
|
||||||
|
|
||||||
|
def _buffer_pct(self) -> float:
|
||||||
|
"""Return queue fill % of the slowest (most backlogged) client (0–100)."""
|
||||||
|
if not self._clients:
|
||||||
|
return 0.0
|
||||||
|
max_fill = max(h.queue.qsize() for h in self._clients.values())
|
||||||
|
return round(max_fill / QUEUE_MAX * 100, 1)
|
||||||
|
|
||||||
|
def _buffer_server_bytes(self) -> int:
|
||||||
|
"""Bytes buffered for the least-buffered client (first to run dry on provider drop)."""
|
||||||
|
if not self._clients:
|
||||||
|
return 0
|
||||||
|
return min(h.queue.qsize() for h in self._clients.values()) * CHUNK_SIZE
|
||||||
|
|
||||||
|
def _buffer_server_secs(self) -> float:
|
||||||
|
"""Estimated seconds of coverage at current bitrate."""
|
||||||
|
if not self._clients or self._bps_down <= 0:
|
||||||
|
return 0.0
|
||||||
|
return round(self._buffer_server_bytes() / self._bps_down, 1)
|
||||||
|
|
||||||
|
async def _health_check_loop(self) -> None:
|
||||||
|
"""Update _av_health every 5 s based on data-flow age.
|
||||||
|
|
||||||
|
If ffmpeg is running but producing no output for _HEALTH_ERROR_SECS,
|
||||||
|
kill it so _pump_with_retry can restart it immediately — this is the
|
||||||
|
auto-correction the caller sees as a 'corrections' counter tick.
|
||||||
|
"""
|
||||||
|
consecutive_error = 0
|
||||||
|
while self._running:
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
age = time.time() - self._last_data_at
|
||||||
|
if age < _HEALTH_WARN_SECS:
|
||||||
|
self._av_health = "ok"
|
||||||
|
consecutive_error = 0
|
||||||
|
elif age < _HEALTH_ERROR_SECS:
|
||||||
|
self._av_health = "warning"
|
||||||
|
consecutive_error = 0
|
||||||
|
else:
|
||||||
|
self._av_health = "error"
|
||||||
|
consecutive_error += 1
|
||||||
|
# After 2 consecutive error checks (≈10 s) kill a stuck ffmpeg
|
||||||
|
# so _pump_with_retry can restart it without waiting for the
|
||||||
|
# 25-second safety-net timeout in _pump_once.
|
||||||
|
if consecutive_error >= 2 and self._ffmpeg_pid is not None:
|
||||||
|
import signal as _sig
|
||||||
|
try:
|
||||||
|
os.kill(self._ffmpeg_pid, _sig.SIGTERM)
|
||||||
|
self._corrections += 1
|
||||||
|
logger.warning(
|
||||||
|
f"[ch{self.channel_id}] Health correction #{self._corrections}: "
|
||||||
|
f"killed stuck ffmpeg pid={self._ffmpeg_pid} "
|
||||||
|
f"(no data for {age:.1f}s)"
|
||||||
|
)
|
||||||
|
except (ProcessLookupError, PermissionError):
|
||||||
|
pass
|
||||||
|
consecutive_error = 0 # reset so we don't spam kills
|
||||||
|
|
||||||
|
async def _log_stream_event(
|
||||||
|
self,
|
||||||
|
event_type: str,
|
||||||
|
domain: str,
|
||||||
|
error_message: str,
|
||||||
|
attempt: int,
|
||||||
|
) -> None:
|
||||||
|
"""Persist a stream failure/recovery event to the database (fire-and-forget)."""
|
||||||
|
try:
|
||||||
|
from ..database import AsyncSessionLocal
|
||||||
|
from ..models.stream_event import StreamEvent
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
ev = StreamEvent(
|
||||||
|
channel_id=self.channel_id,
|
||||||
|
channel_name=self.channel_name or f"Canal #{self.channel_id}",
|
||||||
|
provider_name=self.provider_name,
|
||||||
|
event_type=event_type,
|
||||||
|
domain=domain,
|
||||||
|
error_message=error_message[:500] if error_message else None,
|
||||||
|
attempt_number=attempt,
|
||||||
|
)
|
||||||
|
db.add(ev)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(f"[ch{self.channel_id}] Failed to persist stream event: {exc}")
|
||||||
|
|
||||||
|
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:
|
||||||
|
consecutive = 0
|
||||||
|
base_wait = 0.5
|
||||||
|
url_idx = 0
|
||||||
|
n = len(self._stream_urls) or 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
while self._running:
|
||||||
|
current_url = self._stream_urls[url_idx % n]
|
||||||
|
self._active_url = current_url
|
||||||
|
pump_start = time.time()
|
||||||
|
try:
|
||||||
|
await self._pump_once(current_url)
|
||||||
|
url_idx = 0 # reset to primary after successful pump
|
||||||
|
break
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
pump_duration = time.time() - pump_start
|
||||||
|
|
||||||
|
# Recovery detection: had failures before but this attempt ran stably
|
||||||
|
# for >30s — the stream was healthy and dropped again (e.g. provider
|
||||||
|
# session timeout). Log recovery, then treat next failure as fresh drop.
|
||||||
|
if consecutive > 1 and pump_duration > 30:
|
||||||
|
asyncio.create_task(self._log_stream_event(
|
||||||
|
"recuperado",
|
||||||
|
_url_netloc(current_url),
|
||||||
|
f"Estable {int(pump_duration)}s antes de caer de nuevo",
|
||||||
|
consecutive,
|
||||||
|
))
|
||||||
|
consecutive = 0 # reset so next failure is logged as a fresh "caida"
|
||||||
|
|
||||||
|
consecutive += 1
|
||||||
|
self._reconnect_count += 1
|
||||||
|
next_idx = (url_idx + 1) % n
|
||||||
|
cycle_done = (consecutive % n == 0)
|
||||||
|
|
||||||
|
# Log the first drop of each failure sequence
|
||||||
|
if consecutive == 1:
|
||||||
|
asyncio.create_task(self._log_stream_event(
|
||||||
|
"caida",
|
||||||
|
_url_netloc(current_url),
|
||||||
|
str(e),
|
||||||
|
1,
|
||||||
|
))
|
||||||
|
|
||||||
|
if n > 1:
|
||||||
|
logger.warning(
|
||||||
|
f"[ch{self.channel_id}] Domain {_url_netloc(current_url)} failed "
|
||||||
|
f"(attempt {consecutive}), trying {_url_netloc(self._stream_urls[next_idx])}: {e}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"[ch{self.channel_id}] Upstream error (attempt {consecutive}): {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
url_idx = next_idx
|
||||||
|
self._status = "reconnecting"
|
||||||
|
|
||||||
|
if cycle_done:
|
||||||
|
# All domains tried — back off before next cycle.
|
||||||
|
# Do NOT drain client queues: the buffered data (up to 32 MB) covers
|
||||||
|
# the reconnect window so players never see a black screen.
|
||||||
|
wait = min(base_wait * (2 ** (consecutive // n - 1)), _BACKOFF_CAP)
|
||||||
|
logger.warning(f"[ch{self.channel_id}] All {n} domain(s) failed, retrying in {wait:.1f}s")
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
# else: try next domain immediately (no sleep)
|
||||||
|
finally:
|
||||||
|
self._status = "stopped"
|
||||||
|
self._running = False
|
||||||
|
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, url: str | None = None) -> None:
|
||||||
|
"""
|
||||||
|
Spawn ffmpeg in copy (remux) mode and pump its stdout to all client
|
||||||
|
queues until ffmpeg exits or self._running goes False.
|
||||||
|
|
||||||
|
Reconnection is intentionally NOT delegated to ffmpeg (-reconnect_streamed
|
||||||
|
is omitted). When the provider closes the TCP session (typically every
|
||||||
|
~2 minutes), ffmpeg exits cleanly and _pump_with_retry immediately spawns
|
||||||
|
a fresh process that opens a brand-new HTTP connection. The provider then
|
||||||
|
delivers the stream from the current live edge — timestamps are always
|
||||||
|
forward-moving. If ffmpeg were to reconnect internally it would often
|
||||||
|
receive a few seconds of already-seen data (the provider rewinds to the
|
||||||
|
last keyframe boundary), causing a visible backward seek in the player.
|
||||||
|
"""
|
||||||
|
stream_url = url if url is not None else self.stream_url
|
||||||
|
cmd = [
|
||||||
|
_FFMPEG,
|
||||||
|
"-hide_banner", "-loglevel", "warning",
|
||||||
|
"-timeout", str(int(STALL_TIMEOUT * 1_000_000)),
|
||||||
|
"-i", stream_url,
|
||||||
|
"-c", "copy",
|
||||||
|
"-f", "mpegts",
|
||||||
|
"pipe:1",
|
||||||
|
]
|
||||||
|
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._status = "ok"
|
||||||
|
self._last_data_at = time.time()
|
||||||
|
self._ffmpeg_pid = proc.pid
|
||||||
|
self._ffmpeg_start_time = time.time()
|
||||||
|
logger.info(
|
||||||
|
f"[ch{self.channel_id}] ffmpeg started pid={proc.pid} "
|
||||||
|
f"(reconnects so far: {self._reconnect_count})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drain stderr in background so the pipe never fills and blocks ffmpeg.
|
||||||
|
stderr_task = asyncio.create_task(self._log_ffmpeg_stderr(proc))
|
||||||
|
|
||||||
|
try:
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
chunk = await asyncio.wait_for(
|
||||||
|
proc.stdout.read(CHUNK_SIZE),
|
||||||
|
timeout=STALL_TIMEOUT + 15, # safety net beyond ffmpeg's own timeout
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"ffmpeg produced no output for {STALL_TIMEOUT + 15:.0f}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not chunk:
|
||||||
|
# ffmpeg exited cleanly or with error
|
||||||
|
rc = proc.returncode
|
||||||
|
raise RuntimeError(f"ffmpeg exited (rc={rc})")
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
await self._dispatch(chunk, now)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
self._ffmpeg_pid = None
|
||||||
|
self._ffmpeg_start_time = None
|
||||||
|
self._ffmpeg_cpu = 0.0
|
||||||
|
stderr_task.cancel()
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(proc.wait(), timeout=5)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _log_ffmpeg_stderr(self, proc: asyncio.subprocess.Process) -> None:
|
||||||
|
"""Read ffmpeg stderr lines, log them and count A/V-quality events."""
|
||||||
|
try:
|
||||||
|
async for line in proc.stderr:
|
||||||
|
text = line.decode(errors="replace").rstrip()
|
||||||
|
if text:
|
||||||
|
logger.warning(f"[ch{self.channel_id}] ffmpeg: {text}")
|
||||||
|
if any(p in text for p in _AV_WARN_PATTERNS):
|
||||||
|
self._av_desync_count += 1
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
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:
|
||||||
|
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,42 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from sqlalchemy import text
|
||||||
|
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, stream_event # noqa: ensure all models imported
|
||||||
|
from .models.provider import ProviderUrl # noqa: ensure ProviderUrl is registered
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
# Migrate: create provider_urls entries from existing base_url if not already done
|
||||||
|
await conn.execute(text("""
|
||||||
|
INSERT INTO provider_urls (provider_account_id, url, priority, is_active, status)
|
||||||
|
SELECT id, base_url, 0, true, 'unknown'
|
||||||
|
FROM provider_accounts
|
||||||
|
WHERE id NOT IN (SELECT DISTINCT provider_account_id FROM provider_urls WHERE provider_account_id IS NOT NULL)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
"""))
|
||||||
|
await conn.commit()
|
||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
"""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 .core.health_checker import health_check_loop
|
||||||
|
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())
|
||||||
|
health_task = asyncio.create_task(health_check_loop())
|
||||||
|
logger.info("KiraStream ready")
|
||||||
|
yield
|
||||||
|
logger.info("KiraStream shutting down...")
|
||||||
|
await pool.close()
|
||||||
|
epg_task.cancel()
|
||||||
|
auto_sync_task.cancel()
|
||||||
|
health_task.cancel()
|
||||||
|
for t in (epg_task, auto_sync_task, health_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,41 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, 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")
|
||||||
|
extra_urls = relationship("ProviderUrl", back_populates="provider_account", cascade="all, delete-orphan", order_by="ProviderUrl.priority")
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderUrl(Base):
|
||||||
|
__tablename__ = "provider_urls"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
provider_account_id = Column(Integer, ForeignKey("provider_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
url = Column(String(500), nullable=False)
|
||||||
|
priority = Column(Integer, default=0, nullable=False) # 0=primary, higher=lower priority
|
||||||
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
status = Column(String(20), default="unknown", nullable=False) # unknown|ok|error|timeout
|
||||||
|
response_ms = Column(Integer, nullable=True)
|
||||||
|
last_checked_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
provider_account = relationship("ProviderAccount", back_populates="extra_urls")
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from sqlalchemy import Column, BigInteger, Integer, String, Text, DateTime, func
|
||||||
|
from ..database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class StreamEvent(Base):
|
||||||
|
__tablename__ = "stream_events"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
channel_id = Column(Integer, nullable=True, index=True)
|
||||||
|
channel_name = Column(String(500), nullable=True)
|
||||||
|
provider_name = Column(String(200), nullable=True)
|
||||||
|
event_type = Column(String(50), nullable=False, index=True) # caida | recuperado
|
||||||
|
domain = Column(String(300), nullable=True)
|
||||||
|
error_message = Column(Text, nullable=True)
|
||||||
|
attempt_number = Column(Integer, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
@@ -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"),
|
||||||
|
)
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
@@ -0,0 +1,14 @@
|
|||||||
|
<!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" />
|
||||||
|
<script type="module" crossorigin src="/panel/assets/index-H1dlHjcY.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/panel/assets/index-Bf2TNXpE.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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>
|
||||||
Generated
+3465
File diff suppressed because it is too large
Load Diff
@@ -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,517 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Activity, Server, Users, Wifi, Clock, X, Film, Tv, Clapperboard, RefreshCw, AlertTriangle, Cpu, HeartPulse, ShieldCheck } 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
|
||||||
|
// stream health
|
||||||
|
status?: 'connecting' | 'ok' | 'reconnecting' | 'stopped'
|
||||||
|
reconnect_count?: number
|
||||||
|
last_data_ago?: number
|
||||||
|
stream_info?: StreamInfo
|
||||||
|
// ffmpeg process info
|
||||||
|
ffmpeg_pid?: number | null
|
||||||
|
ffmpeg_cpu_pct?: number
|
||||||
|
// A/V health (from health monitor)
|
||||||
|
av_health?: 'ok' | 'warning' | 'error'
|
||||||
|
av_desync_count?: number // cumulative ffmpeg AV-warning events
|
||||||
|
corrections?: number // auto-corrections by health monitor
|
||||||
|
buffer_pct?: number // queue fill % of slowest client (0=fast, 100=blocked)
|
||||||
|
buffer_server_bytes?: number // bytes buffered for fastest client (reconnect coverage)
|
||||||
|
buffer_server_secs?: number // estimated seconds that buffer covers
|
||||||
|
buffer_capacity?: number // total queue capacity in bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 }: {
|
||||||
|
status?: string
|
||||||
|
reconnectCount?: 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>
|
||||||
|
}
|
||||||
|
|
||||||
|
function AVHealthBadge({ health, desyncs, corrections }: {
|
||||||
|
health?: 'ok' | 'warning' | 'error'
|
||||||
|
desyncs?: number
|
||||||
|
corrections?: number
|
||||||
|
}) {
|
||||||
|
const hasCorrections = corrections != null && corrections > 0
|
||||||
|
const hasDesyncs = desyncs != null && desyncs > 0
|
||||||
|
|
||||||
|
const badge = health === 'error'
|
||||||
|
? <span className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-red-900/40 text-red-300 border border-red-800">
|
||||||
|
<AlertTriangle size={9} /> A/V error
|
||||||
|
</span>
|
||||||
|
: health === 'warning'
|
||||||
|
? <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">
|
||||||
|
<HeartPulse size={9} /> A/V aviso
|
||||||
|
</span>
|
||||||
|
: <span className="inline-flex items-center gap-1 text-[10px] text-emerald-500">
|
||||||
|
<ShieldCheck size={9} /> A/V ok
|
||||||
|
</span>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1 mt-1">
|
||||||
|
{badge}
|
||||||
|
{hasCorrections && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-violet-900/40 text-violet-300 border border-violet-800" title="Correcciones automáticas del monitor de salud">
|
||||||
|
<RefreshCw size={9} /> ×{corrections}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasDesyncs && (
|
||||||
|
<span className="text-[10px] text-gray-600 tabular-nums" title="Eventos A/V en stderr de ffmpeg (informativos)">
|
||||||
|
{desyncs} ev.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BufferBar({ pct, serverSecs, serverCapacity }: {
|
||||||
|
pct?: number
|
||||||
|
serverSecs?: number
|
||||||
|
serverCapacity?: number
|
||||||
|
}) {
|
||||||
|
const v = pct ?? 0
|
||||||
|
const color =
|
||||||
|
v >= 70 ? 'bg-red-500' :
|
||||||
|
v >= 40 ? 'bg-yellow-400' :
|
||||||
|
'bg-emerald-500'
|
||||||
|
const label =
|
||||||
|
v >= 70 ? 'text-red-400' :
|
||||||
|
v >= 40 ? 'text-yellow-400' :
|
||||||
|
'text-emerald-500'
|
||||||
|
|
||||||
|
const srvSecs = serverSecs ?? 0
|
||||||
|
const capMB = ((serverCapacity ?? 0) / 1048576).toFixed(0)
|
||||||
|
// 0 is healthy: queue is empty because client consumes at the same rate data arrives.
|
||||||
|
// The protection kicks in during a reconnect: the queue is NOT drained (unlike before),
|
||||||
|
// so whatever was buffered at the moment of disconnect is preserved. Capacity = 32 MB.
|
||||||
|
const srvLabel = srvSecs > 0.5 ? `${srvSecs.toFixed(1)}s` : 'ok'
|
||||||
|
const srvColor = srvSecs > 10 ? 'text-yellow-400' : 'text-emerald-500'
|
||||||
|
const srvTooltip = `Buffer servidor: ${srvSecs > 0 ? srvSecs.toFixed(1) + 's de cobertura' : '0 — normal en reposo'}. Capacidad máx: ${capMB} MB. El buffer se activa durante reconexiones del proveedor.`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-1.5 space-y-0.5">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
title={`Cola cliente: ${v.toFixed(0)}% — 0%=consume sin retraso, 100%=cliente lento`}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] text-gray-500">Cola cliente</span>
|
||||||
|
<span className={`text-[10px] font-mono tabular-nums ${label}`}>{v.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1.5 rounded-full bg-dark-700 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-700 ${color}`}
|
||||||
|
style={{ width: `${Math.min(v, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between pt-0.5"
|
||||||
|
title={srvTooltip}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] text-gray-500">Reserva ({capMB} MB)</span>
|
||||||
|
<span className={`text-[10px] font-mono tabular-nums ${srvColor}`}>{srvLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 totalCpu = streams.reduce((a, s) => a + (s.ffmpeg_cpu_pct ?? 0), 0)
|
||||||
|
const anyReconnecting = streams.some(s => s.status === 'reconnecting')
|
||||||
|
const streamsOk = streams.filter(s => !s.av_health || s.av_health === 'ok').length
|
||||||
|
const streamsWarn = streams.filter(s => s.av_health === 'warning').length
|
||||||
|
const streamsErr = streams.filter(s => s.av_health === 'error').length
|
||||||
|
const totalCorrections = streams.reduce((a, s) => a + (s.corrections ?? 0), 0)
|
||||||
|
|
||||||
|
const healthColor =
|
||||||
|
streamsErr > 0 ? 'text-red-400' :
|
||||||
|
streamsWarn > 0 ? 'text-yellow-400' :
|
||||||
|
'text-emerald-400'
|
||||||
|
const healthValue =
|
||||||
|
streams.length === 0 ? '—' :
|
||||||
|
streamsErr > 0 ? `${streamsErr} error${streamsErr > 1 ? 'es' : ''}` :
|
||||||
|
streamsWarn > 0 ? `${streamsWarn} aviso${streamsWarn > 1 ? 's' : ''}` :
|
||||||
|
`${streamsOk} ok`
|
||||||
|
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
{streamsErr > 0 && (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-red-400 bg-red-900/30 border border-red-800 px-2 py-1 rounded-lg">
|
||||||
|
<AlertTriangle size={11} />
|
||||||
|
{streamsErr} stream{streamsErr > 1 ? 's' : ''} con error A/V
|
||||||
|
</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-6 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)} />
|
||||||
|
<StatCard
|
||||||
|
icon={<Cpu size={20} className={totalCpu > 80 ? 'text-red-400' : totalCpu > 40 ? 'text-yellow-400' : 'text-cyan-400'} />}
|
||||||
|
label="ffmpeg CPU total"
|
||||||
|
value={`${totalCpu.toFixed(2)}%`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<HeartPulse size={20} className={healthColor} />}
|
||||||
|
label={`Salud A/V${totalCorrections > 0 ? ` · ${totalCorrections} corr.` : ''}`}
|
||||||
|
value={healthValue}
|
||||||
|
valueClass={healthColor}
|
||||||
|
/>
|
||||||
|
</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">Salud A/V</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>
|
||||||
|
|
||||||
|
{/* Stream status + ffmpeg */}
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<HealthBadge
|
||||||
|
status={s.status}
|
||||||
|
reconnectCount={s.reconnect_count}
|
||||||
|
/>
|
||||||
|
{s.ffmpeg_pid && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5">
|
||||||
|
<span className="inline-flex items-center gap-1 bg-cyan-900/40 text-cyan-300 text-[10px] px-1.5 py-0.5 rounded border border-cyan-800 font-mono">
|
||||||
|
<Cpu size={9} />
|
||||||
|
ffmpeg
|
||||||
|
</span>
|
||||||
|
{s.ffmpeg_cpu_pct !== undefined && (
|
||||||
|
<span className={`font-mono text-[10px] tabular-nums ${
|
||||||
|
s.ffmpeg_cpu_pct > 80 ? 'text-red-400' :
|
||||||
|
s.ffmpeg_cpu_pct > 40 ? 'text-yellow-400' :
|
||||||
|
'text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{s.ffmpeg_cpu_pct.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* A/V health + buffer */}
|
||||||
|
<td className="px-4 py-2.5 min-w-[140px]">
|
||||||
|
<AVHealthBadge
|
||||||
|
health={s.av_health}
|
||||||
|
desyncs={s.av_desync_count}
|
||||||
|
corrections={s.corrections}
|
||||||
|
/>
|
||||||
|
<BufferBar pct={s.buffer_pct} serverSecs={s.buffer_server_secs} serverCapacity={s.buffer_capacity} />
|
||||||
|
</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, valueClass }: {
|
||||||
|
icon: React.ReactNode
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
valueClass?: string
|
||||||
|
}) {
|
||||||
|
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 ${valueClass ?? ''}`}>{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,469 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import api from '../lib/api'
|
||||||
|
import { Search, Trash2, RefreshCw, Users, Tv, Database, Clock, AlertTriangle, CheckCircle } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
// ─── Connection log types ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Stream event types ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface StreamEvent {
|
||||||
|
id: number
|
||||||
|
channel_id: number | null
|
||||||
|
channel_name: string | null
|
||||||
|
provider_name: string | null
|
||||||
|
event_type: 'caida' | 'recuperado' | string
|
||||||
|
domain: string | null
|
||||||
|
error_message: string | null
|
||||||
|
attempt_number: number | null
|
||||||
|
created_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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' }
|
||||||
|
|
||||||
|
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Connection Logs tab ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ConnectionsTab() {
|
||||||
|
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 de conexión?')) return
|
||||||
|
await api.delete('/logs')
|
||||||
|
setLogs([])
|
||||||
|
setTotal(0)
|
||||||
|
loadStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / limit)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-end 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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Stream Events tab ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function EventsTab() {
|
||||||
|
const [events, setEvents] = useState<StreamEvent[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const [filterChannel, setFilterChannel] = useState('')
|
||||||
|
const [filterType, setFilterType] = useState('')
|
||||||
|
const [filterDateFrom, setFilterDateFrom] = useState('')
|
||||||
|
const [filterDateTo, setFilterDateTo] = useState('')
|
||||||
|
|
||||||
|
const limit = 50
|
||||||
|
|
||||||
|
const loadEvents = useCallback(async (p = 1) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const params: Record<string, string | number> = { page: p, limit }
|
||||||
|
if (filterChannel) params.channel = filterChannel
|
||||||
|
if (filterType) params.event_type = filterType
|
||||||
|
if (filterDateFrom) params.date_from = filterDateFrom
|
||||||
|
if (filterDateTo) params.date_to = filterDateTo
|
||||||
|
const { data } = await api.get('/logs/events', { params })
|
||||||
|
setEvents(data.items)
|
||||||
|
setTotal(data.total)
|
||||||
|
setPage(p)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [filterChannel, filterType, filterDateFrom, filterDateTo])
|
||||||
|
|
||||||
|
useEffect(() => { loadEvents(1) }, []) // eslint-disable-line
|
||||||
|
|
||||||
|
const doSearch = () => loadEvents(1)
|
||||||
|
|
||||||
|
const clearEvents = async () => {
|
||||||
|
if (!confirm('¿Eliminar todos los registros de fallos?')) return
|
||||||
|
await api.delete('/logs/events')
|
||||||
|
setEvents([])
|
||||||
|
setTotal(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / limit)
|
||||||
|
|
||||||
|
const caidas = events.filter(e => e.event_type === 'caida').length
|
||||||
|
const recuperados = events.filter(e => e.event_type === 'recuperado').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button onClick={() => loadEvents(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={clearEvents} 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>
|
||||||
|
|
||||||
|
{/* Summary cards */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<StatCard icon={AlertTriangle} label="Total fallos registrados" value={total.toLocaleString()} color="text-orange-400" />
|
||||||
|
<StatCard icon={AlertTriangle} label="Caídas (en vista actual)" value={caidas.toString()} color="text-red-400" />
|
||||||
|
<StatCard icon={CheckCircle} label="Recuperaciones (en vista actual)" value={recuperados.toString()} color="text-emerald-400" />
|
||||||
|
</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={filterChannel} onChange={e => setFilterChannel(e.target.value)} onKeyDown={e => e.key === 'Enter' && doSearch()} placeholder="Canal / proveedor" 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 eventos</option>
|
||||||
|
<option value="caida">Caídas</option>
|
||||||
|
<option value="recuperado">Recuperaciones</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>
|
||||||
|
|
||||||
|
{/* Events 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()} eventos</span>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<button onClick={() => loadEvents(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={() => loadEvents(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">Fecha</th>
|
||||||
|
<th className="text-left px-4 py-2">Tipo</th>
|
||||||
|
<th className="text-left px-4 py-2">Canal</th>
|
||||||
|
<th className="text-left px-4 py-2">Proveedor</th>
|
||||||
|
<th className="text-left px-4 py-2">Dominio</th>
|
||||||
|
<th className="text-left px-4 py-2">Detalle</th>
|
||||||
|
<th className="text-right px-4 py-2">Intento</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading && <tr><td colSpan={7} className="text-center py-8 text-gray-500">Cargando...</td></tr>}
|
||||||
|
{!loading && events.length === 0 && (
|
||||||
|
<tr><td colSpan={7} className="text-center py-8 text-gray-500">No hay eventos registrados — los fallos aparecerán aquí automáticamente</td></tr>
|
||||||
|
)}
|
||||||
|
{events.map(ev => (
|
||||||
|
<tr key={ev.id} className="border-b border-dark-700/50 hover:bg-dark-700/30 transition-colors">
|
||||||
|
<td className="px-4 py-2 text-gray-400 text-xs whitespace-nowrap">{fmtDate(ev.created_at)}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{ev.event_type === 'caida' ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-red-500/20 text-red-400">
|
||||||
|
<AlertTriangle size={10} /> Caída
|
||||||
|
</span>
|
||||||
|
) : ev.event_type === 'recuperado' ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-400">
|
||||||
|
<CheckCircle size={10} /> Recuperado
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">{ev.event_type}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-200 max-w-[180px] truncate" title={ev.channel_name ?? ''}>
|
||||||
|
{ev.channel_name || `#${ev.channel_id}` || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-400 text-xs">{ev.provider_name || '—'}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-400 font-mono text-xs">{ev.domain || '—'}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-500 text-xs max-w-[240px] truncate" title={ev.error_message ?? ''}>
|
||||||
|
{ev.error_message || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-500 text-xs tabular-nums">
|
||||||
|
{ev.attempt_number ?? '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Tab = 'connections' | 'events'
|
||||||
|
|
||||||
|
export default function Logs() {
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('connections')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-bold">Registro</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="flex gap-1 border-b border-dark-700 pb-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('connections')}
|
||||||
|
className={clsx(
|
||||||
|
'px-4 py-2 text-sm font-medium rounded-t-lg transition-colors border-b-2 -mb-px',
|
||||||
|
activeTab === 'connections'
|
||||||
|
? 'border-primary-500 text-primary-400 bg-dark-800'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Conexiones de usuarios
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('events')}
|
||||||
|
className={clsx(
|
||||||
|
'px-4 py-2 text-sm font-medium rounded-t-lg transition-colors border-b-2 -mb-px flex items-center gap-1.5',
|
||||||
|
activeTab === 'events'
|
||||||
|
? 'border-red-500 text-red-400 bg-dark-800'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={13} /> Fallos de stream
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'connections' && <ConnectionsTab />}
|
||||||
|
{activeTab === 'events' && <EventsTab />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,896 @@
|
|||||||
|
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 ProviderUrl {
|
||||||
|
id: number
|
||||||
|
url: string
|
||||||
|
priority: number
|
||||||
|
is_active: boolean
|
||||||
|
status: 'unknown' | 'ok' | 'error' | 'timeout'
|
||||||
|
response_ms: number | null
|
||||||
|
last_checked_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
urls: ProviderUrl[]
|
||||||
|
}
|
||||||
|
|
||||||
|
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>>({})
|
||||||
|
|
||||||
|
// URL management state
|
||||||
|
const [expandedUrls, setExpandedUrls] = useState<Set<number>>(new Set())
|
||||||
|
const [addingUrl, setAddingUrl] = useState<number | null>(null)
|
||||||
|
const [newUrlForm, setNewUrlForm] = useState({ url: '', priority: 10, is_active: true })
|
||||||
|
const [checkingUrls, setCheckingUrls] = useState<Set<number>>(new Set())
|
||||||
|
|
||||||
|
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' }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleUrlSection(id: number) {
|
||||||
|
setExpandedUrls(prev => {
|
||||||
|
const n = new Set(prev)
|
||||||
|
n.has(id) ? n.delete(id) : n.add(id)
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addUrl(providerId: number) {
|
||||||
|
await api.post(`/providers/${providerId}/urls`, newUrlForm)
|
||||||
|
setAddingUrl(null)
|
||||||
|
setNewUrlForm({ url: '', priority: 10, is_active: true })
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUrl(providerId: number, urlId: number) {
|
||||||
|
if (!confirm('¿Eliminar esta URL de failover?')) return
|
||||||
|
await api.delete(`/providers/${providerId}/urls/${urlId}`)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAllUrls(providerId: number) {
|
||||||
|
setCheckingUrls(c => new Set(c).add(providerId))
|
||||||
|
try {
|
||||||
|
await api.post(`/providers/${providerId}/urls/check-all`)
|
||||||
|
load()
|
||||||
|
} finally {
|
||||||
|
setCheckingUrls(c => { const n = new Set(c); n.delete(providerId); return n })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlStatusBadge(url: ProviderUrl) {
|
||||||
|
if (url.status === 'ok') {
|
||||||
|
return (
|
||||||
|
<span className="text-green-400 text-xs font-mono">
|
||||||
|
● {url.response_ms != null ? `${url.response_ms}ms` : 'OK'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (url.status === 'error') {
|
||||||
|
return <span className="text-red-400 text-xs font-mono">● Error</span>
|
||||||
|
}
|
||||||
|
if (url.status === 'timeout') {
|
||||||
|
return <span className="text-yellow-400 text-xs font-mono">● Timeout</span>
|
||||||
|
}
|
||||||
|
return <span className="text-gray-500 text-xs font-mono">○ Desconocido</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 overflow-hidden">
|
||||||
|
<div className="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={() => toggleUrlSection(p.id)}
|
||||||
|
title="Gestionar dominios de failover"
|
||||||
|
className={`p-2 rounded-lg bg-dark-700 hover:bg-dark-600 transition-colors ${expandedUrls.has(p.id) ? 'text-primary-400' : 'text-gray-400 hover:text-primary-400'}`}
|
||||||
|
>
|
||||||
|
{expandedUrls.has(p.id) ? <ChevronDown size={15} /> : <ChevronRight 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>
|
||||||
|
|
||||||
|
{/* URL management section */}
|
||||||
|
{expandedUrls.has(p.id) && (
|
||||||
|
<div className="border-t border-dark-700 bg-dark-900/40 p-4 space-y-2">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Dominios (failover)</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => checkAllUrls(p.id)}
|
||||||
|
disabled={checkingUrls.has(p.id)}
|
||||||
|
className="flex items-center gap-1 text-xs px-2 py-1 rounded bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-blue-400 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={11} className={checkingUrls.has(p.id) ? 'animate-spin' : ''} />
|
||||||
|
Verificar todos
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setAddingUrl(p.id); setNewUrlForm({ url: '', priority: 10, is_active: true }) }}
|
||||||
|
className="flex items-center gap-1 text-xs px-2 py-1 rounded bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-green-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={11} />
|
||||||
|
Añadir URL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(p.urls ?? []).length === 0 && addingUrl !== p.id && (
|
||||||
|
<div className="text-xs text-gray-600 text-center py-2">No hay URLs adicionales configuradas</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(p.urls ?? []).map(u => (
|
||||||
|
<div key={u.id} className="flex items-center gap-3 bg-dark-800 rounded-lg px-3 py-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-mono text-gray-300 truncate">{u.url}</span>
|
||||||
|
{u.priority === 0 && <span className="text-[10px] bg-primary-900/40 text-primary-400 px-1 rounded">primaria</span>}
|
||||||
|
{!u.is_active && <span className="text-[10px] bg-dark-600 text-gray-500 px-1 rounded">inactiva</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">
|
||||||
|
{urlStatusBadge(u)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteUrl(p.id, u.id)}
|
||||||
|
className="p-1 rounded text-gray-600 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{addingUrl === p.id && (
|
||||||
|
<div className="bg-dark-800 rounded-lg p-3 space-y-2 border border-dark-600">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-[10px] text-gray-500 mb-1">URL base (ej: http://mirror.com:8080)</label>
|
||||||
|
<input
|
||||||
|
value={newUrlForm.url}
|
||||||
|
onChange={e => setNewUrlForm(f => ({ ...f, url: e.target.value }))}
|
||||||
|
placeholder="http://..."
|
||||||
|
className="w-full bg-dark-700 border border-dark-600 rounded px-2 py-1.5 text-xs focus:outline-none focus:border-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] text-gray-500 mb-1">Prioridad (0=más alta)</label>
|
||||||
|
<input
|
||||||
|
type="number" min={0} max={999}
|
||||||
|
value={newUrlForm.priority}
|
||||||
|
onChange={e => setNewUrlForm(f => ({ ...f, priority: +e.target.value }))}
|
||||||
|
className="w-full bg-dark-700 border border-dark-600 rounded px-2 py-1.5 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-gray-400 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newUrlForm.is_active}
|
||||||
|
onChange={e => setNewUrlForm(f => ({ ...f, is_active: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
Activa
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 ml-auto">
|
||||||
|
<button onClick={() => setAddingUrl(null)} className="text-xs px-2 py-1 rounded bg-dark-700 hover:bg-dark-600 text-gray-400">Cancelar</button>
|
||||||
|
<button
|
||||||
|
onClick={() => addUrl(p.id)}
|
||||||
|
disabled={!newUrlForm.url}
|
||||||
|
className="text-xs px-2 py-1 rounded bg-primary-500 hover:bg-primary-600 disabled:opacity-40 font-medium"
|
||||||
|
>
|
||||||
|
Guardar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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,
|
||||||
|
},
|
||||||
|
})
|
||||||
Executable
+46
@@ -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