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:
KiraStream
2026-05-19 14:43:31 +00:00
commit b969b7e5af
65 changed files with 13279 additions and 0 deletions
+45
View File
@@ -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/
View File
View File
View File
+90
View File
@@ -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())
+186
View File
@@ -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
]
+60
View File
@@ -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"}
+406
View File
@@ -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", [])),
}
+195
View File
@@ -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,
}
+132
View File
@@ -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()
+833
View File
@@ -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
+38
View File
@@ -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)
+267
View File
@@ -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}
+47
View File
@@ -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")
+928
View File
@@ -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"},
}
+34
View File
@@ -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()
View File
+78
View File
@@ -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())
+99
View File
@@ -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)
+115
View File
@@ -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}")
+120
View File
@@ -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}"
)
+314
View File
@@ -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()
+112
View File
@@ -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)
+610
View File
@@ -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
+164
View File
@@ -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()
+87
View File
@@ -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
+42
View File
@@ -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
View File
@@ -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"})
+15
View File
@@ -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",
]
+61
View File
@@ -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"),
)
+29
View File
@@ -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")
+13
View File
@@ -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())
+47
View File
@@ -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")
+19
View File
@@ -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)
+16
View File
@@ -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"),
)
+41
View File
@@ -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")
+16
View File
@@ -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)
+66
View File
@@ -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

+14
View File
@@ -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>
+13
View File
@@ -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>
+3465
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+41
View File
@@ -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>
)
}
+238
View File
@@ -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>
)
}
+12
View File
@@ -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; }
}
+30
View File
@@ -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
}
+10
View File
@@ -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>,
)
+87
View File
@@ -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>
)
}
+337
View File
@@ -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>
)
}
+517
View File
@@ -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>
)
}
+118
View File
@@ -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>
)
}
+480
View File
@@ -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>
)
}
+74
View File
@@ -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>
)
}
+469
View File
@@ -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>
)
}
+896
View File
@@ -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>
)
}
+153
View File
@@ -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>
)
}
+439
View File
@@ -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>
)
}
+24
View File
@@ -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: [],
}
+17
View File
@@ -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"]
}
+16
View File
@@ -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,
},
})
+46
View File
@@ -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