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
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}