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