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
View File
+90
View File
@@ -0,0 +1,90 @@
"""Admin API: category/catalog management."""
from fastapi import APIRouter, Depends
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from ...database import get_db
from ...models.channel import Category, Channel, ChannelProviderMap, ContentType
from ..auth import get_current_admin
router = APIRouter(prefix="/catalogs", tags=["catalogs"])
class CategoryOut(BaseModel):
id: int
name: str
type: ContentType
provider_category_id: str | None
provider_account_id: int | None
class Config:
from_attributes = True
class GroupedCategoryOut(BaseModel):
name: str
type: ContentType
category_ids: list[int]
provider_count: int # distinct providers that can serve channels in this category
@router.get("/", response_model=list[CategoryOut])
async def list_categories(
content_type: ContentType | None = None,
db: AsyncSession = Depends(get_db),
_=Depends(get_current_admin),
):
q = select(Category).order_by(Category.type, Category.name)
if content_type:
q = q.where(Category.type == content_type)
result = await db.execute(q)
return result.scalars().all()
@router.get("/grouped", response_model=list[GroupedCategoryOut])
async def list_categories_grouped(
content_type: ContentType | None = None,
db: AsyncSession = Depends(get_db),
_=Depends(get_current_admin),
):
"""Returns categories merged by name+type. Includes how many providers can serve each."""
q = select(Category).order_by(Category.type, Category.name)
if content_type:
q = q.where(Category.type == content_type)
cats_result = await db.execute(q)
all_cats = cats_result.scalars().all()
# Group by (name_normalized, type)
seen: dict[tuple[str, str], dict] = {}
for cat in all_cats:
key = (cat.name.strip().lower(), str(cat.type))
if key not in seen:
seen[key] = {"name": cat.name, "type": cat.type, "category_ids": [], "provider_count": 0}
seen[key]["category_ids"].append(cat.id)
if not seen:
return []
# Map each category_id back to its group key for the count query
cat_id_to_key: dict[int, tuple[str, str]] = {}
for key, group in seen.items():
for cat_id in group["category_ids"]:
cat_id_to_key[cat_id] = key
# Single query: distinct providers per category_id — avoids locale-dependent lower() issues
pcount_q = await db.execute(
select(
Channel.category_id,
func.count(func.distinct(ChannelProviderMap.provider_account_id)).label("pcount"),
)
.join(ChannelProviderMap, ChannelProviderMap.channel_id == Channel.id)
.where(Channel.category_id.in_(list(cat_id_to_key.keys())))
.group_by(Channel.category_id)
)
for row in pcount_q:
key = cat_id_to_key.get(row.category_id)
if key and key in seen:
seen[key]["provider_count"] = max(seen[key]["provider_count"], row.pcount)
return list(seen.values())
+186
View File
@@ -0,0 +1,186 @@
"""CRUD for custom categories and their channel assignments."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ...database import get_db
from ...models.channel import Channel, ContentType
from ...models.custom_category import CustomCategory, CustomCategoryItem
from ..auth import get_current_admin
router = APIRouter(prefix="/custom-categories", tags=["custom-categories"])
class CategoryCreate(BaseModel):
name: str
type: str # live, movie, series
class CategoryUpdate(BaseModel):
name: str | None = None
is_visible: bool | None = None
position: int | None = None
class AddChannelRequest(BaseModel):
channel_id: int
# ---------------------------------------------------------------------------
# Category CRUD
# ---------------------------------------------------------------------------
@router.get("")
async def list_categories(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
result = await db.execute(
select(CustomCategory)
.options(selectinload(CustomCategory.items))
.order_by(CustomCategory.position, CustomCategory.id)
)
cats = result.scalars().all()
return [
{
"id": c.id,
"name": c.name,
"type": c.type.value,
"position": c.position,
"is_visible": c.is_visible,
"item_count": len(c.items),
"created_at": c.created_at.isoformat() if c.created_at else None,
}
for c in cats
]
@router.post("", status_code=201)
async def create_category(data: CategoryCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
try:
ctype = ContentType(data.type)
except ValueError:
raise HTTPException(400, "type must be one of: live, movie, series")
cat = CustomCategory(name=data.name.strip(), type=ctype)
db.add(cat)
await db.commit()
await db.refresh(cat)
return {"id": cat.id, "name": cat.name, "type": cat.type.value, "position": cat.position, "is_visible": cat.is_visible, "item_count": 0}
@router.patch("/{cat_id}")
async def update_category(cat_id: int, data: CategoryUpdate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
result = await db.execute(select(CustomCategory).where(CustomCategory.id == cat_id))
cat = result.scalar_one_or_none()
if not cat:
raise HTTPException(404, "Not found")
if data.name is not None:
cat.name = data.name.strip()
if data.is_visible is not None:
cat.is_visible = data.is_visible
if data.position is not None:
cat.position = data.position
await db.commit()
return {"ok": True}
@router.delete("/{cat_id}", status_code=204)
async def delete_category(cat_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
result = await db.execute(select(CustomCategory).where(CustomCategory.id == cat_id))
cat = result.scalar_one_or_none()
if not cat:
raise HTTPException(404, "Not found")
await db.delete(cat)
await db.commit()
# ---------------------------------------------------------------------------
# Channel assignments
# ---------------------------------------------------------------------------
@router.get("/{cat_id}/channels")
async def list_category_channels(cat_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
result = await db.execute(
select(CustomCategoryItem, Channel)
.join(Channel, Channel.id == CustomCategoryItem.channel_id)
.where(CustomCategoryItem.custom_category_id == cat_id)
.order_by(CustomCategoryItem.position, CustomCategoryItem.id)
)
rows = result.all()
return [
{
"item_id": row.CustomCategoryItem.id,
"channel_id": row.Channel.id,
"name": row.Channel.name,
"logo": row.Channel.tvg_logo or "",
"type": row.Channel.type.value,
}
for row in rows
]
@router.post("/{cat_id}/channels", status_code=201)
async def add_channel(cat_id: int, data: AddChannelRequest, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
# Verify category exists
cat_result = await db.execute(select(CustomCategory).where(CustomCategory.id == cat_id))
if not cat_result.scalar_one_or_none():
raise HTTPException(404, "Category not found")
# Verify channel exists
ch_result = await db.execute(select(Channel).where(Channel.id == data.channel_id))
if not ch_result.scalar_one_or_none():
raise HTTPException(404, "Channel not found")
# Check duplicate
dup = await db.execute(
select(CustomCategoryItem).where(
CustomCategoryItem.custom_category_id == cat_id,
CustomCategoryItem.channel_id == data.channel_id,
)
)
if dup.scalar_one_or_none():
raise HTTPException(409, "Channel already in category")
item = CustomCategoryItem(custom_category_id=cat_id, channel_id=data.channel_id)
db.add(item)
await db.commit()
return {"ok": True}
@router.delete("/{cat_id}/channels/{item_id}", status_code=204)
async def remove_channel(cat_id: int, item_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
result = await db.execute(
select(CustomCategoryItem).where(
CustomCategoryItem.id == item_id,
CustomCategoryItem.custom_category_id == cat_id,
)
)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(404, "Not found")
await db.delete(item)
await db.commit()
# ---------------------------------------------------------------------------
# Channel search (for the picker)
# ---------------------------------------------------------------------------
@router.get("/channels/search")
async def search_channels(
q: str = "",
type: str = "",
db: AsyncSession = Depends(get_db),
_=Depends(get_current_admin),
):
query = select(Channel).where(Channel.is_active == True) # noqa: E712
if type:
try:
query = query.where(Channel.type == ContentType(type))
except ValueError:
pass
if q:
query = query.where(Channel.name.ilike(f"%{q}%"))
query = query.order_by(Channel.name).limit(60)
result = await db.execute(query)
channels = result.scalars().all()
return [
{"id": ch.id, "name": ch.name, "logo": ch.tvg_logo or "", "type": ch.type.value}
for ch in channels
]
+60
View File
@@ -0,0 +1,60 @@
"""Admin API: EPG source management."""
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from ...database import get_db
from ...models.epg import EpgSource
from ...core.epg_manager import refresh_epg
from ..auth import get_current_admin
router = APIRouter(prefix="/epg", tags=["epg"])
class EpgSourceCreate(BaseModel):
name: str
url: str
class EpgSourceOut(BaseModel):
id: int
name: str
url: str
is_active: bool
last_fetched_at: str | None = None
class Config:
from_attributes = True
@router.get("/", response_model=list[EpgSourceOut])
async def list_epg_sources(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
result = await db.execute(select(EpgSource).order_by(EpgSource.id))
return result.scalars().all()
@router.post("/", response_model=EpgSourceOut)
async def create_epg_source(data: EpgSourceCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
source = EpgSource(**data.model_dump())
db.add(source)
await db.commit()
await db.refresh(source)
return source
@router.delete("/{source_id}")
async def delete_epg_source(source_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
result = await db.execute(select(EpgSource).where(EpgSource.id == source_id))
source = result.scalar_one_or_none()
if not source:
raise HTTPException(404, "EPG source not found")
await db.delete(source)
await db.commit()
return {"ok": True}
@router.post("/refresh")
async def trigger_refresh(background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
background_tasks.add_task(refresh_epg, db)
return {"ok": True, "message": "EPG refresh started in background"}
+406
View File
@@ -0,0 +1,406 @@
"""Admin API for Jellyfin integration — CRUD, library discovery, sync."""
import asyncio
import logging
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ...core.jellyfin_client import JellyfinClient
from ...database import get_db, AsyncSessionLocal
from ...models.channel import Channel, Category, ChannelProviderMap, ContentType
from ...models.jellyfin import JellyfinConfig, JellyfinItem
from ...models.provider import ProviderAccount
from ..auth import get_current_admin
router = APIRouter(prefix="/jellyfin", tags=["jellyfin"])
logger = logging.getLogger(__name__)
# sync_id → {"status": "running"|"done"|"error", "message": str, "synced": int}
_sync_status: dict[int, dict] = {}
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class JellyfinConfigCreate(BaseModel):
name: str
url: str
api_key: str
movies_category_name: str = "Películas KiraTV"
series_category_name: str = "Series KiraTV"
movies_library_id: str | None = None
series_library_id: str | None = None
class JellyfinConfigUpdate(BaseModel):
name: str | None = None
url: str | None = None
api_key: str | None = None
movies_category_name: str | None = None
series_category_name: str | None = None
movies_library_id: str | None = None
series_library_id: str | None = None
is_active: bool | None = None
# ---------------------------------------------------------------------------
# CRUD
# ---------------------------------------------------------------------------
@router.get("", dependencies=[Depends(get_current_admin)])
async def list_configs(db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(JellyfinConfig).options(selectinload(JellyfinConfig.items))
.order_by(JellyfinConfig.id)
)
configs = result.scalars().all()
return [_config_dict(c) for c in configs]
@router.post("", dependencies=[Depends(get_current_admin)])
async def create_config(body: JellyfinConfigCreate, db: AsyncSession = Depends(get_db)):
cfg = JellyfinConfig(
name=body.name,
url=body.url.rstrip("/"),
api_key=body.api_key,
movies_category_name=body.movies_category_name,
series_category_name=body.series_category_name,
movies_library_id=body.movies_library_id,
series_library_id=body.series_library_id,
)
db.add(cfg)
await db.commit()
await db.refresh(cfg)
return _config_dict(cfg)
@router.put("/{cfg_id}", dependencies=[Depends(get_current_admin)])
async def update_config(cfg_id: int, body: JellyfinConfigUpdate, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(JellyfinConfig).where(JellyfinConfig.id == cfg_id))
cfg = result.scalar_one_or_none()
if not cfg:
raise HTTPException(404, "Not found")
if body.name is not None:
cfg.name = body.name
if body.url is not None:
cfg.url = body.url.rstrip("/")
if body.api_key is not None:
cfg.api_key = body.api_key
if body.movies_category_name is not None:
cfg.movies_category_name = body.movies_category_name
if body.series_category_name is not None:
cfg.series_category_name = body.series_category_name
if body.movies_library_id is not None:
cfg.movies_library_id = body.movies_library_id
if body.series_library_id is not None:
cfg.series_library_id = body.series_library_id
if body.is_active is not None:
cfg.is_active = body.is_active
await db.commit()
await db.refresh(cfg)
return _config_dict(cfg)
@router.delete("/{cfg_id}", dependencies=[Depends(get_current_admin)])
async def delete_config(cfg_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(JellyfinConfig).where(JellyfinConfig.id == cfg_id))
cfg = result.scalar_one_or_none()
if not cfg:
raise HTTPException(404, "Not found")
await db.delete(cfg)
await db.commit()
return {"ok": True}
# ---------------------------------------------------------------------------
# Test connection + library list
# ---------------------------------------------------------------------------
@router.post("/{cfg_id}/test", dependencies=[Depends(get_current_admin)])
async def test_connection(cfg_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(JellyfinConfig).where(JellyfinConfig.id == cfg_id))
cfg = result.scalar_one_or_none()
if not cfg:
raise HTTPException(404, "Not found")
client = JellyfinClient(cfg.url, cfg.api_key)
try:
info = await client.test_connection()
return {"ok": True, "server_name": info.get("ServerName", ""), "version": info.get("Version", "")}
except Exception as e:
return {"ok": False, "error": str(e)}
@router.get("/{cfg_id}/libraries", dependencies=[Depends(get_current_admin)])
async def get_libraries(cfg_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(JellyfinConfig).where(JellyfinConfig.id == cfg_id))
cfg = result.scalar_one_or_none()
if not cfg:
raise HTTPException(404, "Not found")
client = JellyfinClient(cfg.url, cfg.api_key)
try:
libs = await client.get_libraries()
return [
{
"id": lib.get("ItemId", ""),
"name": lib.get("Name", ""),
"type": lib.get("CollectionType", ""),
}
for lib in libs
]
except Exception as e:
raise HTTPException(502, f"Jellyfin unreachable: {e}")
# ---------------------------------------------------------------------------
# Sync
# ---------------------------------------------------------------------------
@router.post("/{cfg_id}/sync", dependencies=[Depends(get_current_admin)])
async def start_sync(cfg_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(JellyfinConfig).where(JellyfinConfig.id == cfg_id))
cfg = result.scalar_one_or_none()
if not cfg:
raise HTTPException(404, "Not found")
if _sync_status.get(cfg_id, {}).get("status") == "running":
return {"status": "already_running"}
_sync_status[cfg_id] = {"status": "running", "message": "Iniciando…", "synced": 0}
asyncio.create_task(_do_sync(cfg_id))
return {"status": "started"}
@router.get("/{cfg_id}/sync-status", dependencies=[Depends(get_current_admin)])
async def sync_status(cfg_id: int):
return _sync_status.get(cfg_id, {"status": "idle", "message": "", "synced": 0})
# ---------------------------------------------------------------------------
# Internal sync implementation
# ---------------------------------------------------------------------------
async def _do_sync(cfg_id: int) -> None:
try:
async with AsyncSessionLocal() as db:
await _sync_jellyfin(cfg_id, db)
_sync_status[cfg_id] = {"status": "done", "message": "Sincronización completada", "synced": _sync_status[cfg_id].get("synced", 0)}
except Exception as e:
logger.error(f"Jellyfin sync error for config {cfg_id}: {e}", exc_info=True)
_sync_status[cfg_id] = {"status": "error", "message": str(e), "synced": 0}
async def _sync_jellyfin(cfg_id: int, db: AsyncSession) -> None:
result = await db.execute(select(JellyfinConfig).where(JellyfinConfig.id == cfg_id))
cfg = result.scalar_one_or_none()
if not cfg:
raise ValueError(f"JellyfinConfig {cfg_id} not found")
client = JellyfinClient(cfg.url, cfg.api_key)
_sync_status[cfg_id]["message"] = "Conectando a Jellyfin…"
# 1. Ensure dummy ProviderAccount exists
provider = await _get_or_create_provider(cfg, db)
if cfg.provider_account_id != provider.id:
cfg.provider_account_id = provider.id
await db.flush()
total_synced = 0
# 2. Sync movies
if cfg.movies_library_id:
_sync_status[cfg_id]["message"] = "Obteniendo películas…"
movies = await client.get_items(cfg.movies_library_id, "Movie")
_sync_status[cfg_id]["message"] = f"Sincronizando {len(movies)} películas…"
movie_cat = await _get_or_create_category(
db, cfg.movies_category_name, ContentType.movie, provider.id, "jellyfin_movies"
)
for item in movies:
await _upsert_channel(db, cfg, provider, movie_cat, item, ContentType.movie, client)
total_synced += 1
_sync_status[cfg_id]["synced"] = total_synced
# 3. Sync series
if cfg.series_library_id:
_sync_status[cfg_id]["message"] = "Obteniendo series…"
series_list = await client.get_items(cfg.series_library_id, "Series")
_sync_status[cfg_id]["message"] = f"Sincronizando {len(series_list)} series…"
series_cat = await _get_or_create_category(
db, cfg.series_category_name, ContentType.series, provider.id, "jellyfin_series"
)
for item in series_list:
await _upsert_channel(db, cfg, provider, series_cat, item, ContentType.series, client)
total_synced += 1
_sync_status[cfg_id]["synced"] = total_synced
cfg.last_sync_at = datetime.now(timezone.utc)
await db.commit()
logger.info(f"Jellyfin sync complete for config {cfg_id}: {total_synced} items")
async def _get_or_create_provider(cfg: JellyfinConfig, db: AsyncSession) -> ProviderAccount:
if cfg.provider_account_id:
result = await db.execute(
select(ProviderAccount).where(ProviderAccount.id == cfg.provider_account_id)
)
existing = result.scalar_one_or_none()
if existing:
return existing
provider = ProviderAccount(
name=f"[Jellyfin] {cfg.name}",
base_url=cfg.url,
username="jellyfin",
password=cfg.api_key,
max_connections=999,
is_active=True,
)
db.add(provider)
await db.flush()
return provider
async def _get_or_create_category(
db: AsyncSession,
name: str,
ctype: ContentType,
provider_id: int,
provider_cat_id: str,
) -> Category:
result = await db.execute(
select(Category).where(
Category.provider_category_id == provider_cat_id,
Category.provider_account_id == provider_id,
Category.type == ctype,
)
)
cat = result.scalar_one_or_none()
if cat:
cat.name = name
return cat
cat = Category(
name=name,
type=ctype,
provider_category_id=provider_cat_id,
provider_account_id=provider_id,
)
db.add(cat)
await db.flush()
return cat
async def _upsert_channel(
db: AsyncSession,
cfg: JellyfinConfig,
provider: ProviderAccount,
category: Category,
item: dict,
ctype: ContentType,
client: JellyfinClient,
) -> None:
jellyfin_id = item.get("Id", "")
if not jellyfin_id:
return
stream_id = f"jf_{jellyfin_id}"
stream_url = client.build_stream_url(jellyfin_id)
image_url = client.build_image_url(jellyfin_id)
name = item.get("Name", "")
plot = item.get("Overview") or ""
year = item.get("ProductionYear")
genres = ", ".join(item.get("Genres", []))
rating = item.get("OfficialRating") or ""
# Find or create Channel
ch_result = await db.execute(
select(Channel)
.options(selectinload(Channel.provider_maps))
.where(Channel.stream_id_at_provider == stream_id, Channel.type == ctype)
)
ch = ch_result.scalar_one_or_none()
if not ch:
ch = Channel(
name=name,
stream_id_at_provider=stream_id,
type=ctype,
category_id=category.id,
tvg_logo=image_url,
is_active=True,
)
db.add(ch)
await db.flush()
else:
ch.name = name
ch.tvg_logo = image_url
ch.category_id = category.id
ch.is_active = True
# ChannelProviderMap
pmap_result = await db.execute(
select(ChannelProviderMap).where(
ChannelProviderMap.channel_id == ch.id,
ChannelProviderMap.provider_account_id == provider.id,
)
)
pmap = pmap_result.scalar_one_or_none()
if not pmap:
pmap = ChannelProviderMap(
channel_id=ch.id,
provider_account_id=provider.id,
stream_url=stream_url,
)
db.add(pmap)
else:
pmap.stream_url = stream_url
# JellyfinItem
ji_result = await db.execute(
select(JellyfinItem).where(
JellyfinItem.jellyfin_config_id == cfg.id,
JellyfinItem.jellyfin_item_id == jellyfin_id,
)
)
ji = ji_result.scalar_one_or_none()
if not ji:
ji = JellyfinItem(
jellyfin_config_id=cfg.id,
channel_id=ch.id,
jellyfin_item_id=jellyfin_id,
item_type=ctype.value,
plot=plot,
year=year,
genres=genres,
rating=rating,
)
db.add(ji)
else:
ji.channel_id = ch.id
ji.plot = plot
ji.year = year
ji.genres = genres
ji.rating = rating
await db.flush()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _config_dict(cfg: JellyfinConfig) -> dict:
return {
"id": cfg.id,
"name": cfg.name,
"url": cfg.url,
"api_key": cfg.api_key,
"is_active": cfg.is_active,
"movies_library_id": cfg.movies_library_id,
"series_library_id": cfg.series_library_id,
"movies_category_name": cfg.movies_category_name,
"series_category_name": cfg.series_category_name,
"last_sync_at": cfg.last_sync_at.isoformat() if cfg.last_sync_at else None,
"item_count": len(cfg.__dict__.get("items", [])),
}
+195
View File
@@ -0,0 +1,195 @@
"""Connection log API — query and stats."""
from datetime import datetime
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, func, desc, distinct
from sqlalchemy.ext.asyncio import AsyncSession
from ...database import get_db
from ...models.log import ConnectionLog
from ...models.stream_event import StreamEvent
from ..auth import get_current_admin
router = APIRouter(prefix="/logs", tags=["logs"])
@router.get("")
async def get_logs(
username: str | None = Query(None),
channel: str | None = Query(None),
content_type: str | None = Query(None),
date_from: str | None = Query(None),
date_to: str | None = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
_=Depends(get_current_admin),
):
q = select(ConnectionLog).order_by(desc(ConnectionLog.started_at))
if username:
q = q.where(ConnectionLog.username.ilike(f"%{username}%"))
if channel:
q = q.where(ConnectionLog.channel_name.ilike(f"%{channel}%"))
if content_type:
q = q.where(ConnectionLog.content_type == content_type)
if date_from:
try:
q = q.where(ConnectionLog.started_at >= datetime.fromisoformat(date_from))
except ValueError:
pass
if date_to:
try:
q = q.where(ConnectionLog.started_at <= datetime.fromisoformat(date_to))
except ValueError:
pass
total_result = await db.execute(select(func.count()).select_from(q.subquery()))
total = total_result.scalar() or 0
q = q.offset((page - 1) * limit).limit(limit)
result = await db.execute(q)
logs = result.scalars().all()
return {
"total": total,
"page": page,
"limit": limit,
"items": [_serialize(log) for log in logs],
}
@router.get("/stats")
async def get_stats(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
top_users_q = await db.execute(
select(
ConnectionLog.username,
func.count().label("sessions"),
func.sum(ConnectionLog.bytes_transferred).label("bytes"),
)
.group_by(ConnectionLog.username)
.order_by(desc("sessions"))
.limit(10)
)
top_channels_q = await db.execute(
select(ConnectionLog.channel_name, func.count().label("sessions"))
.where(ConnectionLog.channel_name.isnot(None))
.group_by(ConnectionLog.channel_name)
.order_by(desc("sessions"))
.limit(10)
)
totals_q = await db.execute(
select(
func.count().label("total_sessions"),
func.sum(ConnectionLog.bytes_transferred).label("total_bytes"),
func.avg(ConnectionLog.duration_seconds).label("avg_duration"),
func.count(distinct(ConnectionLog.username)).label("unique_users"),
)
)
t = totals_q.one()
return {
"total_sessions": t.total_sessions or 0,
"total_bytes": t.total_bytes or 0,
"avg_duration_seconds": round(t.avg_duration or 0),
"unique_users": t.unique_users or 0,
"top_users": [
{"username": r.username, "sessions": r.sessions, "bytes": r.bytes or 0}
for r in top_users_q
],
"top_channels": [
{"channel_name": r.channel_name, "sessions": r.sessions}
for r in top_channels_q
],
}
@router.delete("", status_code=204)
async def clear_logs(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
"""Delete all logs (admin use)."""
from sqlalchemy import delete
await db.execute(delete(ConnectionLog))
await db.commit()
@router.get("/events")
async def get_events(
channel: str | None = Query(None),
event_type: str | None = Query(None),
date_from: str | None = Query(None),
date_to: str | None = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
_=Depends(get_current_admin),
):
q = select(StreamEvent).order_by(desc(StreamEvent.created_at))
if channel:
q = q.where(StreamEvent.channel_name.ilike(f"%{channel}%"))
if event_type:
q = q.where(StreamEvent.event_type == event_type)
if date_from:
try:
q = q.where(StreamEvent.created_at >= datetime.fromisoformat(date_from))
except ValueError:
pass
if date_to:
try:
q = q.where(StreamEvent.created_at <= datetime.fromisoformat(date_to))
except ValueError:
pass
total_result = await db.execute(select(func.count()).select_from(q.subquery()))
total = total_result.scalar() or 0
q = q.offset((page - 1) * limit).limit(limit)
result = await db.execute(q)
events = result.scalars().all()
return {
"total": total,
"page": page,
"limit": limit,
"items": [_serialize_event(ev) for ev in events],
}
@router.delete("/events", status_code=204)
async def clear_events(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
from sqlalchemy import delete
await db.execute(delete(StreamEvent))
await db.commit()
def _serialize_event(ev: StreamEvent) -> dict:
return {
"id": ev.id,
"channel_id": ev.channel_id,
"channel_name": ev.channel_name,
"provider_name": ev.provider_name,
"event_type": ev.event_type,
"domain": ev.domain,
"error_message": ev.error_message,
"attempt_number": ev.attempt_number,
"created_at": ev.created_at.isoformat() if ev.created_at else None,
}
def _serialize(log: ConnectionLog) -> dict:
return {
"id": log.id,
"user_id": log.user_id,
"username": log.username,
"channel_id": log.channel_id,
"channel_name": log.channel_name,
"content_type": log.content_type,
"stream_id": log.stream_id,
"started_at": log.started_at.isoformat() if log.started_at else None,
"ended_at": log.ended_at.isoformat() if log.ended_at else None,
"duration_seconds": log.duration_seconds,
"bytes_transferred": log.bytes_transferred,
"client_ip": log.client_ip,
}
+132
View File
@@ -0,0 +1,132 @@
"""Admin API: real-time stream monitoring via REST and WebSocket."""
import asyncio
import json
import logging
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ...config import settings
from ...core.pool import pool, MONITORING_KEY
from ...core.vod_tracker import vod_tracker
from ...database import get_db
from ...models.jellyfin import JellyfinConfig
from ...models.provider import ProviderAccount
from ..auth import get_current_admin
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/monitoring", tags=["monitoring"])
def _all_streams() -> list[dict]:
"""Combined live + VOD/series sessions."""
return pool.get_active_streams() + vod_tracker.get_all()
@router.get("/streams")
async def get_active_streams(_=Depends(get_current_admin)):
return _all_streams()
@router.get("/slots")
async def get_provider_slots(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
# Exclude Jellyfin dummy providers — they are local, have no slot limits
jf_subq = (
select(JellyfinConfig.provider_account_id)
.where(JellyfinConfig.provider_account_id.isnot(None))
.scalar_subquery()
)
result = await db.execute(
select(ProviderAccount).where(
ProviderAccount.is_active == True, # noqa: E712
ProviderAccount.id.notin_(jf_subq),
)
)
accounts = result.scalars().all()
active_streams = _all_streams()
busy_accounts = {s["provider_account_id"] for s in active_streams}
return [
{
"id": acc.id,
"name": acc.name,
"max_connections": acc.max_connections,
"status": "busy" if acc.id in busy_accounts else "free",
"channel_id": next(
(s["channel_id"] for s in active_streams if s["provider_account_id"] == acc.id), None
),
}
for acc in accounts
]
class KillRequest(BaseModel):
client_id: str
@router.post("/kill")
async def kill_connection(body: KillRequest, _=Depends(get_current_admin)):
"""Force-disconnect a client. Works for both live streams and VOD/series sessions."""
cid = body.client_id
if cid.startswith("vod-"):
killed = vod_tracker.kill(cid)
else:
killed = await pool.kill_client(cid)
if not killed:
raise HTTPException(404, f"Client '{cid}' not found in any active stream")
return {"ok": True, "client_id": cid}
@router.websocket("/ws")
async def monitoring_websocket(websocket: WebSocket):
await websocket.accept()
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
pubsub = redis_client.pubsub()
try:
await pubsub.subscribe(MONITORING_KEY)
# Send initial combined state
await websocket.send_text(json.dumps({"type": "state", "data": _all_streams()}))
async def redis_reader():
"""Forward live-stream pool events from Redis pub/sub."""
async for message in pubsub.listen():
if message["type"] == "message":
try:
# Pool publishes only live streams; merge with VOD here
await websocket.send_text(json.dumps({"type": "update", "data": _all_streams()}))
except Exception:
return
async def broadcaster():
"""Push full state every 2 s for VOD bytes/bps updates."""
while True:
await asyncio.sleep(2)
try:
await websocket.send_text(json.dumps({"type": "update", "data": _all_streams()}))
except Exception:
return
reader_task = asyncio.create_task(redis_reader())
broadcaster_task = asyncio.create_task(broadcaster())
try:
await asyncio.gather(reader_task, broadcaster_task)
except WebSocketDisconnect:
pass
finally:
reader_task.cancel()
broadcaster_task.cancel()
except WebSocketDisconnect:
pass
except Exception as e:
logger.error(f"Monitoring WS error: {e}")
finally:
await pubsub.unsubscribe(MONITORING_KEY)
await redis_client.aclose()
+833
View File
@@ -0,0 +1,833 @@
"""Admin API: provider account management + sync."""
import asyncio
import json
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from sqlalchemy.orm import selectinload
from ...database import get_db, AsyncSessionLocal
from ...models.provider import ProviderAccount, ProviderUrl
from ...models.channel import Category, Channel, ChannelProviderMap, ContentType
from ...models.notification import NotificationDismissal
from ...models.epg import EpgSource
from ...core.xtream_client import XtreamClient
from ...core.epg_manager import refresh_epg
from ..auth import get_current_admin
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/providers", tags=["providers"])
# Track ongoing sync tasks: provider_id -> status dict
_sync_status: dict[int, dict] = {}
class ProviderCreate(BaseModel):
name: str
base_url: str
username: str
password: str
max_connections: int = 1
auto_sync_hours: int = 0 # 0=disabled, 6/12/24=interval in hours
class ProviderUpdate(BaseModel):
name: str
base_url: str
username: str
password: str | None = None # None = keep existing
max_connections: int = 1
auto_sync_hours: int = 0
class ProviderUrlOut(BaseModel):
id: int
url: str
priority: int
is_active: bool
status: str
response_ms: int | None = None
last_checked_at: datetime | None = None
class Config:
from_attributes = True
class ProviderOut(BaseModel):
id: int
name: str
base_url: str
username: str
max_connections: int
is_active: bool
last_sync_at: datetime | None
expiry_date: datetime | None = None
auto_sync_hours: int
last_sync_categories: str | None # raw JSON, parsed on frontend
urls: list[ProviderUrlOut] = []
class Config:
from_attributes = True
class SyncRequest(BaseModel):
live_category_ids: list[str] | None = None # None = import all
movie_category_ids: list[str] | None = None
series_category_ids: list[str] | None = None
def _jellyfin_provider_subq():
"""Subquery returning provider_account_ids used by Jellyfin configs."""
from ...models.jellyfin import JellyfinConfig
return (
select(JellyfinConfig.provider_account_id)
.where(JellyfinConfig.provider_account_id.isnot(None))
.scalar_subquery()
)
@router.get("/", response_model=list[ProviderOut])
async def list_providers(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
result = await db.execute(
select(ProviderAccount)
.options(selectinload(ProviderAccount.extra_urls))
.where(ProviderAccount.id.notin_(_jellyfin_provider_subq()))
.order_by(ProviderAccount.id)
)
providers = result.scalars().all()
# Map extra_urls to the urls field expected by ProviderOut
for p in providers:
p.urls = p.extra_urls
return providers
async def _sync_epg_source(db: AsyncSession, provider: ProviderAccount) -> None:
"""Create or update the auto-managed EPG source for a provider."""
url = XtreamClient(provider.base_url, provider.username, provider.password).get_xmltv_url()
name = f"EPG - {provider.name}"
result = await db.execute(select(EpgSource).where(EpgSource.name == name))
source = result.scalar_one_or_none()
if source:
source.url = url
else:
db.add(EpgSource(name=name, url=url, is_active=True))
await db.commit()
async def _delete_epg_source(db: AsyncSession, provider: ProviderAccount) -> None:
name = f"EPG - {provider.name}"
result = await db.execute(select(EpgSource).where(EpgSource.name == name))
source = result.scalar_one_or_none()
if source:
await db.delete(source)
await db.commit()
@router.post("/", response_model=ProviderOut)
async def create_provider(data: ProviderCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
provider = ProviderAccount(**data.model_dump())
db.add(provider)
await db.commit()
await db.refresh(provider)
await _sync_epg_source(db, provider)
return provider
@router.put("/{provider_id}", response_model=ProviderOut)
async def update_provider(provider_id: int, data: ProviderUpdate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(404, "Provider not found")
provider.name = data.name
provider.base_url = data.base_url
provider.username = data.username
provider.max_connections = data.max_connections
provider.auto_sync_hours = data.auto_sync_hours
if data.password:
provider.password = data.password
await db.commit()
await db.refresh(provider)
await _sync_epg_source(db, provider)
return provider
@router.get("/{provider_id}/synced-category-ids")
async def get_synced_category_ids(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
"""Returns the category IDs currently synced in DB for this provider (by content type)."""
result = await db.execute(
select(Category.provider_category_id, Category.type)
.where(Category.provider_account_id == provider_id)
)
rows = result.all()
return {
"live": [r.provider_category_id for r in rows if r.type == ContentType.live],
"movie": [r.provider_category_id for r in rows if r.type == ContentType.movie],
"series": [r.provider_category_id for r in rows if r.type == ContentType.series],
}
@router.get("/{provider_id}/available-updates")
async def get_available_updates(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
"""Return categories from the provider that are NOT yet synced into the DB."""
result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(404, "Provider not found")
client = XtreamClient(provider.base_url, provider.username, provider.password)
live_task = asyncio.create_task(client.get_live_categories())
vod_task = asyncio.create_task(client.get_vod_categories())
series_task = asyncio.create_task(client.get_series_categories())
live_raw, vod_raw, series_raw = await asyncio.gather(live_task, vod_task, series_task, return_exceptions=True)
def to_list(raw):
if isinstance(raw, Exception):
return []
return raw if isinstance(raw, list) else []
# Get all category provider_category_ids already in DB for this provider
db_cats = await db.execute(
select(Category.provider_category_id, Category.type)
.where(Category.provider_account_id == provider_id)
)
synced: set[tuple] = {(str(r.provider_category_id), r.type) for r in db_cats}
def filter_new(raw, ctype: ContentType):
result = []
for c in to_list(raw):
cat_id = str(c.get("category_id", ""))
if not cat_id:
continue
if (cat_id, ctype) not in synced:
result.append({"id": cat_id, "name": c.get("category_name", ""), "type": ctype.value})
return result
new_live = filter_new(live_raw, ContentType.live)
new_movie = filter_new(vod_raw, ContentType.movie)
new_series = filter_new(series_raw, ContentType.series)
return {
"provider_id": provider_id,
"provider_name": provider.name,
"new_categories": new_live + new_movie + new_series,
"total": len(new_live) + len(new_movie) + len(new_series),
}
@router.delete("/{provider_id}")
async def delete_provider(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(404, "Provider not found")
await _delete_epg_source(db, provider)
await db.delete(provider)
await db.commit()
return {"ok": True}
@router.get("/notifications/all")
async def get_all_notifications(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
"""Return new (unsynced + undismissed) categories across all providers (Jellyfin excluded)."""
providers_result = await db.execute(
select(ProviderAccount).where(
ProviderAccount.is_active == True, # noqa: E712
ProviderAccount.last_sync_at.isnot(None),
ProviderAccount.id.notin_(_jellyfin_provider_subq()),
)
)
providers = providers_result.scalars().all()
# Load all dismissals
dismissals_result = await db.execute(select(NotificationDismissal))
dismissed: set[tuple] = {
(str(d.provider_id), d.category_id_str, d.category_type)
for d in dismissals_result.scalars().all()
}
notifications = []
for provider in providers:
client = XtreamClient(provider.base_url, provider.username, provider.password)
try:
live_raw, vod_raw, series_raw = await asyncio.gather(
client.get_live_categories(),
client.get_vod_categories(),
client.get_series_categories(),
return_exceptions=True,
)
except Exception:
continue
db_cats = await db.execute(
select(Category.provider_category_id, Category.type)
.where(Category.provider_account_id == provider.id)
)
synced: set[tuple] = {(str(r.provider_category_id), r.type) for r in db_cats}
def collect_new(raw, ctype: ContentType):
if isinstance(raw, Exception):
return []
items = []
for c in (raw if isinstance(raw, list) else []):
cat_id = str(c.get("category_id", ""))
if not cat_id:
continue
if (cat_id, ctype) in synced:
continue
if (str(provider.id), cat_id, ctype.value) in dismissed:
continue
items.append({"id": cat_id, "name": c.get("category_name", ""), "type": ctype.value})
return items
new_cats = (
collect_new(live_raw, ContentType.live) +
collect_new(vod_raw, ContentType.movie) +
collect_new(series_raw, ContentType.series)
)
if new_cats:
notifications.append({
"provider_id": provider.id,
"provider_name": provider.name,
"categories": new_cats,
})
total = sum(len(n["categories"]) for n in notifications)
return {"notifications": notifications, "total": total}
@router.get("/notifications/count")
async def get_notifications_count(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
"""Fast count of undismissed new categories (checks DB only, no remote API calls)."""
providers_result = await db.execute(
select(ProviderAccount).where(ProviderAccount.is_active == True, ProviderAccount.last_sync_at.isnot(None)) # noqa: E712
)
providers = providers_result.scalars().all()
dismissals_result = await db.execute(select(NotificationDismissal))
dismissed: set[tuple] = {
(str(d.provider_id), d.category_id_str, d.category_type)
for d in dismissals_result.scalars().all()
}
# Compare last_sync_categories to what's available
total = 0
for provider in providers:
if not provider.last_sync_categories:
continue
try:
last = json.loads(provider.last_sync_categories)
except Exception:
continue
# We can't do a fast count without hitting remote — just return 0 here
# Real count comes from get_all_notifications
return {"total": total}
class DismissRequest(BaseModel):
provider_id: int
category_id: str
category_type: str
@router.post("/notifications/dismiss")
async def dismiss_notification(data: DismissRequest, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
existing = await db.execute(
select(NotificationDismissal).where(
NotificationDismissal.provider_id == data.provider_id,
NotificationDismissal.category_id_str == data.category_id,
NotificationDismissal.category_type == data.category_type,
)
)
if not existing.scalar_one_or_none():
db.add(NotificationDismissal(
provider_id=data.provider_id,
category_id_str=data.category_id,
category_type=data.category_type,
))
await db.commit()
return {"ok": True}
@router.post("/notifications/dismiss-provider/{provider_id}")
async def dismiss_all_for_provider(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
"""Dismiss all pending notifications for a specific provider."""
result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(404, "Provider not found")
client = XtreamClient(provider.base_url, provider.username, provider.password)
try:
live_raw, vod_raw, series_raw = await asyncio.gather(
client.get_live_categories(),
client.get_vod_categories(),
client.get_series_categories(),
return_exceptions=True,
)
except Exception as e:
raise HTTPException(500, f"Failed to fetch provider categories: {e}")
db_cats = await db.execute(
select(Category.provider_category_id, Category.type)
.where(Category.provider_account_id == provider_id)
)
synced: set[tuple] = {(str(r.provider_category_id), r.type) for r in db_cats}
dismissed_result = await db.execute(
select(NotificationDismissal.category_id_str, NotificationDismissal.category_type)
.where(NotificationDismissal.provider_id == provider_id)
)
already_dismissed: set[tuple] = {(r[0], r[1]) for r in dismissed_result.all()}
count = 0
for raw, ctype in [(live_raw, ContentType.live), (vod_raw, ContentType.movie), (series_raw, ContentType.series)]:
if isinstance(raw, Exception):
continue
for c in (raw if isinstance(raw, list) else []):
cat_id = str(c.get("category_id", ""))
if not cat_id or (cat_id, ctype) in synced:
continue
if (cat_id, ctype.value) not in already_dismissed:
db.add(NotificationDismissal(
provider_id=provider_id,
category_id_str=cat_id,
category_type=ctype.value,
))
count += 1
await db.commit()
return {"ok": True, "dismissed": count}
@router.get("/{provider_id}/epg-url")
async def get_provider_epg_url(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
"""Returns the XMLTV EPG URL for this provider (auto-registered as EPG source)."""
result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(404, "Provider not found")
url = XtreamClient(provider.base_url, provider.username, provider.password).get_xmltv_url()
return {"url": url}
@router.post("/{provider_id}/test")
async def test_provider(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(404, "Provider not found")
client = XtreamClient(provider.base_url, provider.username, provider.password)
ok = False
try:
info = await client.get_user_info()
user_info = info.get("user_info", {})
ok = user_info.get("status") == "Active"
exp_ts = user_info.get("exp_date")
if exp_ts:
try:
provider.expiry_date = datetime.fromtimestamp(int(exp_ts), tz=timezone.utc)
await db.commit()
except (ValueError, TypeError):
pass
except Exception as e:
logger.warning(f"Provider test failed: {e}")
return {"connected": ok}
@router.get("/{provider_id}/sync/categories")
async def get_provider_categories(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
"""Fetch available categories from provider without importing — used for the category picker."""
result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(404, "Provider not found")
client = XtreamClient(provider.base_url, provider.username, provider.password)
live_task = asyncio.create_task(client.get_live_categories())
vod_task = asyncio.create_task(client.get_vod_categories())
series_task = asyncio.create_task(client.get_series_categories())
live_raw, vod_raw, series_raw = await asyncio.gather(live_task, vod_task, series_task, return_exceptions=True)
def to_list(raw):
if isinstance(raw, Exception):
logger.warning(f"Category fetch error: {raw}")
return []
return [{"id": str(c.get("category_id", "")), "name": c.get("category_name", "")} for c in raw if c.get("category_id")]
return {
"live": to_list(live_raw),
"movie": to_list(vod_raw),
"series": to_list(series_raw),
}
@router.get("/{provider_id}/sync/status")
async def sync_status(provider_id: int, _=Depends(get_current_admin)):
return _sync_status.get(provider_id, {"status": "idle"})
@router.post("/{provider_id}/sync")
async def sync_provider(
provider_id: int,
body: SyncRequest = SyncRequest(),
db: AsyncSession = Depends(get_db),
_=Depends(get_current_admin),
):
result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(404, "Provider not found")
if _sync_status.get(provider_id, {}).get("status") == "running":
return {"ok": False, "message": "Sync already in progress", "status": _sync_status[provider_id]}
_sync_status[provider_id] = {"status": "running", "progress": "Iniciando...", "stats": {}}
asyncio.create_task(_run_sync(
provider_id,
provider.base_url,
provider.username,
provider.password,
selected_live=set(body.live_category_ids) if body.live_category_ids is not None else None,
selected_movie=set(body.movie_category_ids) if body.movie_category_ids is not None else None,
selected_series=set(body.series_category_ids) if body.series_category_ids is not None else None,
))
return {"ok": True, "message": "Sync started in background", "status_url": f"/api/admin/providers/{provider_id}/sync/status"}
async def _run_sync(
provider_id: int,
base_url: str,
username: str,
password: str,
selected_live: set[str] | None = None,
selected_movie: set[str] | None = None,
selected_series: set[str] | None = None,
) -> None:
"""
Efficient sync: fetches ALL streams in one call per type (not per category).
Total HTTP calls: 3 category lists + 3 stream lists = 6 calls regardless of provider size.
selected_* = None means import all; set means only import those category IDs.
"""
client = XtreamClient(base_url, username, password)
stats = {
"live_categories": 0, "live_channels": 0,
"vod_categories": 0, "vod_channels": 0,
"series_categories": 0, "series": 0,
}
def update_progress(msg: str):
_sync_status[provider_id] = {"status": "running", "progress": msg, "stats": stats.copy()}
logger.info(f"[Sync #{provider_id}] {msg}")
try:
async with AsyncSessionLocal() as db:
# ── LIVE ──────────────────────────────────────────────────
update_progress("Descargando categorías Live...")
try:
live_cats = await client.get_live_categories()
cat_map_live: dict[str, Category] = {}
for cat_data in live_cats:
cat_id_str = str(cat_data.get("category_id", ""))
if selected_live is not None and cat_id_str not in selected_live:
continue
cat = await _upsert_category(db, cat_data, ContentType.live, provider_id)
cat_map_live[cat_id_str] = cat
stats["live_categories"] += 1
await db.flush()
update_progress(f"Descargando canales Live ({stats['live_categories']} cats)...")
all_live = await client.get_live_streams()
for ch_data in all_live:
cat_id_str = str(ch_data.get("category_id", ""))
if cat_id_str not in cat_map_live:
continue # category not selected or not loaded
cat = cat_map_live.get(cat_id_str)
await _upsert_channel(db, ch_data, cat, ContentType.live, provider_id, client)
stats["live_channels"] += 1
if stats["live_channels"] % 500 == 0:
update_progress(f"Live: {stats['live_channels']} canales procesados...")
await db.flush()
await db.flush()
except Exception as e:
logger.error(f"[Sync #{provider_id}] Live error: {e}", exc_info=True)
# ── VOD ───────────────────────────────────────────────────
update_progress("Descargando categorías VOD...")
try:
vod_cats = await client.get_vod_categories()
cat_map_vod: dict[str, Category] = {}
for cat_data in vod_cats:
cat_id_str = str(cat_data.get("category_id", ""))
if selected_movie is not None and cat_id_str not in selected_movie:
continue
cat = await _upsert_category(db, cat_data, ContentType.movie, provider_id)
cat_map_vod[cat_id_str] = cat
stats["vod_categories"] += 1
await db.flush()
update_progress(f"Descargando películas VOD ({stats['vod_categories']} cats)...")
all_vod = await client.get_vod_streams()
for ch_data in all_vod:
cat_id_str = str(ch_data.get("category_id", ""))
if cat_id_str not in cat_map_vod:
continue
cat = cat_map_vod.get(cat_id_str)
await _upsert_channel(db, ch_data, cat, ContentType.movie, provider_id, client)
stats["vod_channels"] += 1
if stats["vod_channels"] % 500 == 0:
update_progress(f"VOD: {stats['vod_channels']} canales procesados...")
await db.flush()
await db.flush()
except Exception as e:
logger.error(f"[Sync #{provider_id}] VOD error: {e}", exc_info=True)
# ── SERIES ────────────────────────────────────────────────
update_progress("Descargando categorías Series...")
try:
series_cats = await client.get_series_categories()
cat_map_series: dict[str, Category] = {}
for cat_data in series_cats:
cat_id_str = str(cat_data.get("category_id", ""))
if selected_series is not None and cat_id_str not in selected_series:
continue
cat = await _upsert_category(db, cat_data, ContentType.series, provider_id)
cat_map_series[cat_id_str] = cat
stats["series_categories"] += 1
await db.flush()
update_progress(f"Descargando series ({stats['series_categories']} cats)...")
all_series = await client.get_series()
for s_data in all_series:
cat_id_str = str(s_data.get("category_id", ""))
if cat_id_str not in cat_map_series:
continue
cat = cat_map_series.get(cat_id_str)
await _upsert_channel(db, s_data, cat, ContentType.series, provider_id, client)
stats["series"] += 1
if stats["series"] % 500 == 0:
update_progress(f"Series: {stats['series']} procesadas...")
await db.flush()
await db.flush()
except Exception as e:
logger.error(f"[Sync #{provider_id}] Series error: {e}", exc_info=True)
# ── FINAL COMMIT ──────────────────────────────────────────
update_progress("Guardando en base de datos...")
result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
provider = result.scalar_one_or_none()
if provider:
provider.last_sync_at = datetime.now(timezone.utc)
provider.last_sync_categories = json.dumps({
"live": list(selected_live) if selected_live is not None else None,
"movie": list(selected_movie) if selected_movie is not None else None,
"series": list(selected_series) if selected_series is not None else None,
})
await db.commit()
_sync_status[provider_id] = {"status": "done", "progress": "Sync completado", "stats": stats}
logger.info(f"[Sync #{provider_id}] Completed: {stats}")
# Auto-update EPG source and trigger background refresh
async with AsyncSessionLocal() as epg_db:
prov_result = await epg_db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
prov = prov_result.scalar_one_or_none()
if prov:
await _sync_epg_source(epg_db, prov)
await refresh_epg(epg_db)
except Exception as e:
logger.error(f"[Sync #{provider_id}] Fatal error: {e}", exc_info=True)
_sync_status[provider_id] = {"status": "error", "progress": str(e), "stats": stats}
# ── Provider URL management ────────────────────────────────────────────
class ProviderUrlCreate(BaseModel):
url: str
priority: int = 10
is_active: bool = True
@router.get("/{provider_id}/urls", response_model=list[ProviderUrlOut])
async def list_provider_urls(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
result = await db.execute(
select(ProviderUrl)
.where(ProviderUrl.provider_account_id == provider_id)
.order_by(ProviderUrl.priority)
)
return result.scalars().all()
@router.post("/{provider_id}/urls", response_model=ProviderUrlOut)
async def add_provider_url(provider_id: int, data: ProviderUrlCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
pu = ProviderUrl(provider_account_id=provider_id, **data.model_dump())
db.add(pu)
await db.commit()
await db.refresh(pu)
return pu
@router.put("/{provider_id}/urls/{url_id}", response_model=ProviderUrlOut)
async def update_provider_url(provider_id: int, url_id: int, data: ProviderUrlCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
result = await db.execute(
select(ProviderUrl).where(ProviderUrl.id == url_id, ProviderUrl.provider_account_id == provider_id)
)
pu = result.scalar_one_or_none()
if not pu:
raise HTTPException(404, "URL not found")
pu.url = data.url
pu.priority = data.priority
pu.is_active = data.is_active
await db.commit()
await db.refresh(pu)
return pu
@router.delete("/{provider_id}/urls/{url_id}")
async def delete_provider_url(provider_id: int, url_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
result = await db.execute(
select(ProviderUrl).where(ProviderUrl.id == url_id, ProviderUrl.provider_account_id == provider_id)
)
pu = result.scalar_one_or_none()
if not pu:
raise HTTPException(404, "URL not found")
await db.delete(pu)
await db.commit()
return {"ok": True}
@router.post("/{provider_id}/urls/check-all")
async def check_all_provider_urls(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
from ...core.health_checker import check_provider_urls
result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(404, "Provider not found")
results = await check_provider_urls(provider_id)
return results
async def _upsert_category(db: AsyncSession, cat_data: dict, ctype: ContentType, provider_id: int) -> Category:
provider_cat_id = str(cat_data.get("category_id", ""))
cat_name = cat_data.get("category_name", "Unknown")
# First: check if a category with the same name+type already exists (any provider)
# This merges duplicate categories from multiple providers automatically
result = await db.execute(
select(Category).where(
Category.name == cat_name,
Category.type == ctype,
)
)
cat = result.scalar_one_or_none()
if not cat:
# Check by provider+id (in case name changed)
result2 = await db.execute(
select(Category).where(
Category.provider_category_id == provider_cat_id,
Category.provider_account_id == provider_id,
Category.type == ctype,
)
)
cat = result2.scalar_one_or_none()
if not cat:
cat = Category(
name=cat_name,
type=ctype,
provider_category_id=provider_cat_id,
provider_account_id=provider_id,
)
db.add(cat)
await db.flush()
else:
cat.name = cat_name
return cat
async def _upsert_channel(
db: AsyncSession,
ch_data: dict,
category: Category | None,
ctype: ContentType,
provider_id: int,
client: XtreamClient,
) -> Channel | None:
stream_id = str(ch_data.get("stream_id") or ch_data.get("series_id", ""))
if not stream_id:
return None
result = await db.execute(
select(Channel).where(
Channel.stream_id_at_provider == stream_id,
Channel.type == ctype,
)
)
channel = result.scalar_one_or_none()
name = ch_data.get("name") or ch_data.get("title", "Unknown") or "Unknown"
tvg_id = ch_data.get("epg_channel_id") or ch_data.get("tvg_id") or None
logo = ch_data.get("stream_icon") or ch_data.get("cover") or None
# Truncate logo to avoid DB column limit (base64 images from some providers)
if logo and len(logo) > 900:
logo = None
cat_id = category.id if category else None
if not channel:
channel = Channel(
name=name,
tvg_id=tvg_id,
tvg_logo=logo,
stream_id_at_provider=stream_id,
category_id=cat_id,
type=ctype,
)
db.add(channel)
await db.flush()
else:
channel.name = name
channel.tvg_id = tvg_id
channel.tvg_logo = logo
# Only update category if channel has none yet (first-provider-wins prevents
# cross-provider syncs from breaking existing catalog assignments)
if cat_id and not channel.category_id:
channel.category_id = cat_id
# Build stream URL
container_ext = ch_data.get("container_extension", "ts") or "ts"
if ctype == ContentType.live:
url = client.build_live_stream_url(stream_id)
elif ctype == ContentType.movie:
url = client.build_vod_stream_url(stream_id, container_ext)
else:
url = client.build_series_stream_url(stream_id, container_ext)
# Upsert provider map
result = await db.execute(
select(ChannelProviderMap).where(
ChannelProviderMap.channel_id == channel.id,
ChannelProviderMap.provider_account_id == provider_id,
)
)
pmap = result.scalar_one_or_none()
if not pmap:
pmap = ChannelProviderMap(
channel_id=channel.id,
provider_account_id=provider_id,
stream_url=url,
)
db.add(pmap)
else:
pmap.stream_url = url
return channel
+38
View File
@@ -0,0 +1,38 @@
"""Admin settings — blocked image upload/management."""
import os
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import FileResponse
from ..auth import get_current_admin
router = APIRouter(prefix="/settings", tags=["settings"])
BLOCKED_IMAGE_PATH = "/opt/kirastream/backend/static/blocked_image.jpg"
MAX_SIZE = 10 * 1024 * 1024 # 10 MB
@router.get("/blocked-image")
async def get_blocked_image(_=Depends(get_current_admin)):
if not os.path.exists(BLOCKED_IMAGE_PATH):
raise HTTPException(404, "No blocked image configured")
return FileResponse(BLOCKED_IMAGE_PATH, media_type="image/jpeg")
@router.post("/blocked-image", status_code=201)
async def upload_blocked_image(file: UploadFile = File(...), _=Depends(get_current_admin)):
if file.content_type not in ("image/jpeg", "image/png", "image/webp", "image/gif"):
raise HTTPException(400, "Only JPEG, PNG, WebP or GIF images allowed")
data = await file.read()
if len(data) > MAX_SIZE:
raise HTTPException(413, "Image too large (max 10 MB)")
os.makedirs(os.path.dirname(BLOCKED_IMAGE_PATH), exist_ok=True)
with open(BLOCKED_IMAGE_PATH, "wb") as f:
f.write(data)
return {"ok": True, "size": len(data)}
@router.delete("/blocked-image", status_code=204)
async def delete_blocked_image(_=Depends(get_current_admin)):
if os.path.exists(BLOCKED_IMAGE_PATH):
os.remove(BLOCKED_IMAGE_PATH)
+267
View File
@@ -0,0 +1,267 @@
"""Admin API: user management."""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from pydantic import BaseModel
from ...database import get_db
from ...models.user import User, UserCatalogEntry, UserCustomCatalogEntry, UserProviderPreference
from ...models.channel import Category
from ...models.provider import ProviderAccount
from ...models.custom_category import CustomCategory
from ..auth import get_current_admin
router = APIRouter(prefix="/users", tags=["users"])
class UserCreate(BaseModel):
username: str
password: str
max_connections: int = 1
expiry_date: datetime | None = None
is_active: bool = True
is_priority: bool = False
class UserUpdate(BaseModel):
password: str | None = None
max_connections: int | None = None
expiry_date: datetime | None = None
is_active: bool | None = None
is_priority: bool | None = None
class UserOut(BaseModel):
id: int
username: str
max_connections: int
expiry_date: datetime | None
is_active: bool
is_priority: bool
created_at: datetime
class Config:
from_attributes = True
@router.get("/", response_model=list[UserOut])
async def list_users(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
result = await db.execute(select(User).order_by(User.id))
return result.scalars().all()
@router.post("/", response_model=UserOut)
async def create_user(data: UserCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
existing = await db.execute(select(User).where(User.username == data.username))
if existing.scalar_one_or_none():
raise HTTPException(400, f"Username '{data.username}' already exists")
user = User(
username=data.username,
max_connections=data.max_connections,
expiry_date=data.expiry_date,
is_active=data.is_active,
is_priority=data.is_priority,
password_hash="",
)
user.set_password(data.password)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.put("/{user_id}", response_model=UserOut)
async def update_user(user_id: int, data: UserUpdate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(404, "User not found")
if data.password is not None:
user.set_password(data.password)
if data.max_connections is not None:
user.max_connections = data.max_connections
if data.expiry_date is not None:
user.expiry_date = data.expiry_date
if data.is_active is not None:
user.is_active = data.is_active
if data.is_priority is not None:
user.is_priority = data.is_priority
await db.commit()
await db.refresh(user)
return user
@router.delete("/{user_id}")
async def delete_user(user_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(404, "User not found")
await db.delete(user)
await db.commit()
return {"ok": True}
@router.get("/{user_id}/catalog")
async def get_user_catalog(user_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
result = await db.execute(
select(UserCatalogEntry)
.options(selectinload(UserCatalogEntry.category))
.where(UserCatalogEntry.user_id == user_id)
)
entries = result.scalars().all()
return [
{"id": e.id, "category_id": e.category_id, "category_name": e.category.name, "type": e.category.type}
for e in entries
]
@router.post("/{user_id}/catalog")
async def assign_categories(user_id: int, category_ids: list[int], db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
result = await db.execute(select(User).where(User.id == user_id))
if not result.scalar_one_or_none():
raise HTTPException(404, "User not found")
# Remove existing entries
existing = await db.execute(select(UserCatalogEntry).where(UserCatalogEntry.user_id == user_id))
for entry in existing.scalars().all():
await db.delete(entry)
# Add new entries
added = 0
for cat_id in category_ids:
cat_result = await db.execute(select(Category).where(Category.id == cat_id))
if cat_result.scalar_one_or_none():
db.add(UserCatalogEntry(user_id=user_id, category_id=cat_id))
added += 1
await db.commit()
return {"ok": True, "assigned": added}
@router.delete("/{user_id}/catalog/{category_id}")
async def remove_category(user_id: int, category_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
result = await db.execute(
select(UserCatalogEntry).where(
UserCatalogEntry.user_id == user_id,
UserCatalogEntry.category_id == category_id,
)
)
entry = result.scalar_one_or_none()
if not entry:
raise HTTPException(404, "Catalog entry not found")
await db.delete(entry)
await db.commit()
return {"ok": True}
@router.get("/{user_id}/custom-catalog")
async def get_user_custom_catalog(user_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
result = await db.execute(
select(UserCustomCatalogEntry.custom_category_id).where(UserCustomCatalogEntry.user_id == user_id)
)
return [row[0] for row in result.all()]
@router.post("/{user_id}/custom-catalog")
async def assign_custom_categories(user_id: int, custom_category_ids: list[int], db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
result = await db.execute(select(User).where(User.id == user_id))
if not result.scalar_one_or_none():
raise HTTPException(404, "User not found")
existing = await db.execute(select(UserCustomCatalogEntry).where(UserCustomCatalogEntry.user_id == user_id))
for entry in existing.scalars().all():
await db.delete(entry)
added = 0
for cc_id in custom_category_ids:
cc_result = await db.execute(select(CustomCategory).where(CustomCategory.id == cc_id))
if cc_result.scalar_one_or_none():
db.add(UserCustomCatalogEntry(user_id=user_id, custom_category_id=cc_id))
added += 1
await db.commit()
return {"ok": True, "assigned": added}
# ---------------------------------------------------------------------------
# Provider preferences
# ---------------------------------------------------------------------------
class ProviderPrefIn(BaseModel):
provider_account_id: int
priority: int = 0
@router.get("/{user_id}/providers")
async def get_user_provider_prefs(user_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
result = await db.execute(
select(UserProviderPreference)
.options(selectinload(UserProviderPreference.provider_account))
.where(UserProviderPreference.user_id == user_id)
.order_by(UserProviderPreference.priority)
)
prefs = result.scalars().all()
return [
{
"id": p.id,
"provider_account_id": p.provider_account_id,
"provider_name": p.provider_account.name,
"priority": p.priority,
}
for p in prefs
]
@router.post("/{user_id}/providers")
async def add_user_provider_pref(
user_id: int, data: ProviderPrefIn, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)
):
user_result = await db.execute(select(User).where(User.id == user_id))
if not user_result.scalar_one_or_none():
raise HTTPException(404, "User not found")
provider_result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == data.provider_account_id))
if not provider_result.scalar_one_or_none():
raise HTTPException(404, "Provider account not found")
# Upsert
existing = await db.execute(
select(UserProviderPreference).where(
UserProviderPreference.user_id == user_id,
UserProviderPreference.provider_account_id == data.provider_account_id,
)
)
pref = existing.scalar_one_or_none()
if pref:
pref.priority = data.priority
else:
pref = UserProviderPreference(
user_id=user_id,
provider_account_id=data.provider_account_id,
priority=data.priority,
)
db.add(pref)
await db.commit()
return {"ok": True}
@router.delete("/{user_id}/providers/{provider_account_id}")
async def remove_user_provider_pref(
user_id: int, provider_account_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)
):
result = await db.execute(
select(UserProviderPreference).where(
UserProviderPreference.user_id == user_id,
UserProviderPreference.provider_account_id == provider_account_id,
)
)
pref = result.scalar_one_or_none()
if not pref:
raise HTTPException(404, "Preference not found")
await db.delete(pref)
await db.commit()
return {"ok": True}
+47
View File
@@ -0,0 +1,47 @@
"""JWT authentication for the admin panel."""
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import JWTError, jwt
from pydantic import BaseModel
from ..config import settings
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/admin/auth/token")
class Token(BaseModel):
access_token: str
token_type: str
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
to_encode["exp"] = expire
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
async def get_current_admin(token: str = Depends(oauth2_scheme)) -> str:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
if username != settings.admin_username:
raise credentials_exception
return username
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> Token:
if form_data.username != settings.admin_username or form_data.password != settings.admin_password:
raise HTTPException(status_code=400, detail="Incorrect username or password")
token = create_access_token({"sub": form_data.username})
return Token(access_token=token, token_type="bearer")
+928
View File
@@ -0,0 +1,928 @@
"""Xtream Codes compatible API — used by IPTV player apps."""
import asyncio
import logging
import time
from datetime import datetime, timezone
from typing import AsyncIterator
import aiohttp
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import StreamingResponse, PlainTextResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..config import settings
from ..core.catalog import get_user_categories, get_user_channels, get_channel_provider_maps, can_user_access_channel, get_user_preferred_provider_ids
from ..core.epg_manager import filter_epg_for_channels
from ..core.pool import pool, NoSlotsAvailableError, MaxConnectionsExceededError
from ..core.xtream_client import XtreamClient
from ..core.vod_tracker import vod_tracker, VodSession
from ..database import get_db, AsyncSessionLocal
from ..models.channel import Channel, ContentType
from ..models.custom_category import CustomCategory, CustomCategoryItem
from ..models.jellyfin import JellyfinConfig, JellyfinItem
from ..models.log import ConnectionLog
from ..models.provider import ProviderAccount
from ..models.user import User, UserCustomCatalogEntry
CC_PREFIX = "cc_" # prefix for custom category IDs in Xtream protocol
BLOCKED_IMAGE_PATH = "/opt/kirastream/backend/static/blocked_image.jpg"
# episode_id → provider_account_id; populated by get_series_info calls so
# series_stream can route episode requests without storing episodes in the DB
_episode_provider_cache: dict[str, int] = {}
# jellyfin fake_ep_id → direct-play URL; populated in _get_jellyfin_series_info
_jellyfin_episode_cache: dict[str, str] = {}
logger = logging.getLogger(__name__)
router = APIRouter(tags=["xtream"])
SERVER_URL_PLACEHOLDER = "{server_url}"
def get_client_ip(request: Request) -> str:
xff = request.headers.get("X-Forwarded-For", "")
if xff:
return xff.split(",")[0].strip()
xri = request.headers.get("X-Real-IP", "")
if xri:
return xri
return request.client.host if request.client else "unknown"
async def _create_log(
user_id: int,
username: str,
channel_id: int | None,
channel_name: str | None,
content_type: str,
stream_id: str | None,
client_ip: str,
) -> int:
async with AsyncSessionLocal() as db:
log = ConnectionLog(
user_id=user_id,
username=username,
channel_id=channel_id,
channel_name=channel_name,
content_type=content_type,
stream_id=stream_id,
client_ip=client_ip,
)
db.add(log)
await db.commit()
await db.refresh(log)
return log.id
async def _finish_log(log_id: int, bytes_transferred: int, started: float) -> None:
ended = datetime.now(timezone.utc)
duration = int(time.time() - started)
async with AsyncSessionLocal() as db:
result = await db.execute(select(ConnectionLog).where(ConnectionLog.id == log_id))
log = result.scalar_one_or_none()
if log:
log.ended_at = ended
log.duration_seconds = duration
log.bytes_transferred = bytes_transferred
await db.commit()
async def _stream_blocked_image() -> StreamingResponse:
"""Return a continuous MPEG-TS video stream of the blocked image via FFmpeg."""
import os
if not os.path.exists(BLOCKED_IMAGE_PATH):
raise HTTPException(503, "No provider slots available — try again later")
async def generate():
proc = await asyncio.create_subprocess_exec(
"ffmpeg",
"-re", "-loop", "1", "-i", BLOCKED_IMAGE_PATH,
"-c:v", "libx264", "-preset", "ultrafast", "-tune", "stillimage",
"-b:v", "400k", "-pix_fmt", "yuv420p",
"-vf", "scale=1280:720",
"-f", "mpegts", "pipe:1",
"-loglevel", "quiet",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
try:
while True:
chunk = await proc.stdout.read(65536)
if not chunk:
break
yield chunk
finally:
try:
proc.terminate()
await asyncio.wait_for(proc.wait(), timeout=2.0)
except (asyncio.TimeoutError, ProcessLookupError):
try:
proc.kill()
except ProcessLookupError:
pass
return StreamingResponse(
generate(),
media_type="video/mp2t",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
async def _get_user_custom_cat_ids(db: AsyncSession, user_id: int) -> set[int] | None:
"""Returns set of custom category IDs for the user, or None if unrestricted (show all)."""
result = await db.execute(
select(UserCustomCatalogEntry.custom_category_id)
.where(UserCustomCatalogEntry.user_id == user_id)
)
rows = result.scalars().all()
return set(rows) if rows else None # None means "no restriction — show all"
async def _can_access_via_custom_category(db: AsyncSession, channel_id: int, user_id: int) -> bool:
allowed_ids = await _get_user_custom_cat_ids(db, user_id)
q = select(CustomCategoryItem).where(CustomCategoryItem.channel_id == channel_id)
if allowed_ids is not None:
q = q.where(CustomCategoryItem.custom_category_id.in_(allowed_ids))
result = await db.execute(q.limit(1))
return result.scalar_one_or_none() is not None
async def _get_custom_category_channels(db: AsyncSession, cc_id: int) -> list[Channel]:
result = await db.execute(
select(Channel)
.join(CustomCategoryItem, CustomCategoryItem.channel_id == Channel.id)
.options(selectinload(Channel.provider_maps))
.where(CustomCategoryItem.custom_category_id == cc_id, Channel.is_active == True) # noqa: E712
.order_by(CustomCategoryItem.position, CustomCategoryItem.id)
)
return list(result.scalars().all())
# ---------------------------------------------------------------------------
# Auth helper
# ---------------------------------------------------------------------------
async def authenticate_user(username: str, password: str, db: AsyncSession) -> User:
result = await db.execute(select(User).where(User.username == username, User.is_active == True)) # noqa: E712
user = result.scalar_one_or_none()
if not user:
raise HTTPException(403, "Invalid credentials")
if not user.verify_password(password):
raise HTTPException(403, "Invalid credentials")
if user.expiry_date and user.expiry_date < datetime.now(timezone.utc):
raise HTTPException(403, "Account expired")
return user
def server_url(request: Request) -> str:
return f"{request.url.scheme}://{request.url.netloc}"
# ---------------------------------------------------------------------------
# player_api.php (main Xtream Codes endpoint)
# ---------------------------------------------------------------------------
@router.get("/player_api.php")
async def player_api(
request: Request,
username: str = "",
password: str = "",
action: str = "",
category_id: str | None = None,
stream_id: str | None = None,
series_id: str | None = None,
vod_id: str | None = None,
db: AsyncSession = Depends(get_db),
):
if not username or not password:
return {"user_info": {"status": "Disabled"}, "server_info": {}}
user = await authenticate_user(username, password, db)
base = server_url(request)
if not action:
return await _user_info(user, password, base, db)
if action in ("user_info", "get_account_info"):
return await _user_info(user, password, base, db)
if action == "get_live_categories":
return await _get_live_categories(user, db)
if action == "get_live_streams":
return await _get_live_streams(user, db, category_id, base)
if action == "get_vod_categories":
return await _get_categories(user, db, ContentType.movie)
if action == "get_vod_streams":
return await _get_channels(user, db, ContentType.movie, category_id, base)
if action == "get_series_categories":
return await _get_categories(user, db, ContentType.series)
if action == "get_series":
return await _get_channels(user, db, ContentType.series, category_id, base)
if action == "get_series_info" and series_id:
return await _get_series_info(series_id, user, db)
if action == "get_vod_info" and vod_id:
return await _get_vod_info(vod_id, user, db)
if action == "get_short_epg" and stream_id:
return {"epg_listings": []}
return {}
# ---------------------------------------------------------------------------
# Playlist endpoint
# ---------------------------------------------------------------------------
@router.get("/get.php")
async def get_playlist(
request: Request,
username: str = "",
password: str = "",
type: str = "m3u_plus",
output: str = "ts",
db: AsyncSession = Depends(get_db),
):
user = await authenticate_user(username, password, db)
base = server_url(request)
channels = await get_user_channels(db, user)
lines = ["#EXTM3U"]
for ch in channels:
cat_name = ch.category.name if ch.category else ""
tvg_id = ch.tvg_id or ""
logo = ch.tvg_logo or ""
if ch.type == ContentType.live:
url = f"{base}/{username}/{password}/{ch.stream_id_at_provider}"
elif ch.type == ContentType.movie:
ext = "mkv"
url = f"{base}/movie/{username}/{password}/{ch.stream_id_at_provider}.{ext}"
else:
url = f"{base}/series/{username}/{password}/{ch.stream_id_at_provider}.mkv"
lines.append(
f'#EXTINF:-1 tvg-id="{tvg_id}" tvg-logo="{logo}" group-title="{cat_name}",{ch.name}'
)
lines.append(url)
return PlainTextResponse("\n".join(lines), media_type="application/x-mpegurl")
# ---------------------------------------------------------------------------
# EPG endpoint
# ---------------------------------------------------------------------------
@router.get("/xmltv.php")
async def xmltv(
username: str = "",
password: str = "",
db: AsyncSession = Depends(get_db),
):
user = await authenticate_user(username, password, db)
channels = await get_user_channels(db, user)
tvg_ids = {ch.tvg_id for ch in channels if ch.tvg_id}
epg_data = filter_epg_for_channels(tvg_ids)
return Response(content=epg_data, media_type="application/xml")
# ---------------------------------------------------------------------------
# Live stream proxy /{username}/{password}/{stream_id}
# Also handles /live/{username}/{password}/{stream_id} used by Smarters/IPTVnator
# ---------------------------------------------------------------------------
@router.get("/live/{username}/{password}/{stream_id}")
@router.get("/{username}/{password}/{stream_id}")
async def live_stream(
username: str,
password: str,
stream_id: str,
request: Request,
db: AsyncSession = Depends(get_db),
):
# Strip extension if present (e.g. "123456.ts" -> "123456")
stream_id_clean = stream_id.rsplit(".", 1)[0] if "." in stream_id else stream_id
user = await authenticate_user(username, password, db)
# Resolve channel by stream_id_at_provider
result = await db.execute(
select(Channel)
.options(selectinload(Channel.provider_maps))
.where(Channel.stream_id_at_provider == stream_id_clean, Channel.type == ContentType.live, Channel.is_active == True) # noqa: E712
)
channel = result.scalar_one_or_none()
if not channel:
raise HTTPException(404, "Channel not found")
# Check user can access this channel (regular catalog or custom category)
if not await can_user_access_channel(db, user, channel.id):
if not await _can_access_via_custom_category(db, channel.id, user.id):
raise HTTPException(403, "Channel not in your catalog")
provider_maps = await get_channel_provider_maps(db, channel.id)
if not provider_maps:
raise HTTPException(503, "No provider available for this channel")
preferred_ids = await get_user_preferred_provider_ids(db, user)
try:
handle = await pool.acquire(
channel_id=channel.id,
stream_url_resolver=None,
provider_maps=provider_maps,
user_id=str(user.id),
user_max_conns=user.max_connections,
is_priority=user.is_priority,
preferred_provider_ids=preferred_ids,
username=user.username,
channel_name=channel.name,
)
except MaxConnectionsExceededError:
return await _stream_blocked_image()
except NoSlotsAvailableError:
raise HTTPException(503, "No provider slots available — try again later")
client_ip = get_client_ip(request)
log_id = await _create_log(user.id, user.username, channel.id, channel.name, "live", stream_id_clean, client_ip)
stream_started = time.time()
async def stream_generator() -> AsyncIterator[bytes]:
bytes_sent = 0
try:
async for chunk in handle.read():
bytes_sent += len(chunk)
yield chunk
finally:
try:
await asyncio.shield(pool.release(channel.id, handle.client_id, str(user.id)))
except asyncio.CancelledError:
pass
asyncio.create_task(_finish_log(log_id, bytes_sent, stream_started))
return StreamingResponse(
stream_generator(),
media_type="video/mp2t",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
# ---------------------------------------------------------------------------
# VOD proxy /movie/{username}/{password}/{stream_id}.ext
# ---------------------------------------------------------------------------
@router.get("/movie/{username}/{password}/{stream_id}")
async def vod_stream(
username: str,
password: str,
stream_id: str,
request: Request,
db: AsyncSession = Depends(get_db),
):
# Parse extension
parts = stream_id.rsplit(".", 1)
sid = parts[0]
ext = parts[1] if len(parts) > 1 else "mkv"
user = await authenticate_user(username, password, db)
result = await db.execute(
select(Channel)
.options(selectinload(Channel.provider_maps))
.where(Channel.stream_id_at_provider == sid, Channel.type == ContentType.movie)
)
channel = result.scalar_one_or_none()
# Fallback: Jellyfin channels have non-numeric stream_id_at_provider so the app
# receives ch.id (DB int) as the stream_id — look it up by primary key.
if not channel:
try:
ch_id = int(sid)
fb = await db.execute(
select(Channel)
.options(selectinload(Channel.provider_maps))
.where(Channel.id == ch_id, Channel.type == ContentType.movie)
)
channel = fb.scalar_one_or_none()
except (ValueError, TypeError):
pass
if not channel:
raise HTTPException(404, "VOD not found")
if not await can_user_access_channel(db, user, channel.id):
if not await _can_access_via_custom_category(db, channel.id, user.id):
raise HTTPException(403, "Not in your catalog")
if not channel.provider_maps:
raise HTTPException(503, "No provider available")
pmap = channel.provider_maps[0]
prov_res = await db.execute(select(ProviderAccount).where(ProviderAccount.id == pmap.provider_account_id))
provider = prov_res.scalar_one_or_none()
vod_session = vod_tracker.start(
user_id=str(user.id),
username=user.username,
channel_id=channel.id,
channel_name=channel.name,
provider_account_id=pmap.provider_account_id,
provider_name=provider.name if provider else f"#{pmap.provider_account_id}",
stream_type="movie",
stream_url=pmap.stream_url,
user_max_connections=user.max_connections,
)
client_ip = get_client_ip(request)
log_id = await _create_log(user.id, user.username, channel.id, channel.name, "movie", sid, client_ip)
return await _proxy_vod(pmap.stream_url, request, vod_session=vod_session, log_id=log_id)
# ---------------------------------------------------------------------------
# Series proxy /series/{username}/{password}/{stream_id}.ext
# ---------------------------------------------------------------------------
@router.get("/series/{username}/{password}/{stream_id}")
async def series_stream(
username: str,
password: str,
stream_id: str,
request: Request,
db: AsyncSession = Depends(get_db),
):
parts = stream_id.rsplit(".", 1)
sid = parts[0]
ext = parts[1] if len(parts) > 1 else "mkv"
user = await authenticate_user(username, password, db)
# First: try the episode as a stored channel (future: when episodes are synced)
result = await db.execute(
select(Channel)
.options(selectinload(Channel.provider_maps))
.where(Channel.stream_id_at_provider == sid, Channel.type == ContentType.series)
)
channel = result.scalar_one_or_none()
client_ip = get_client_ip(request)
if channel and channel.provider_maps:
if not await can_user_access_channel(db, user, channel.id):
if not await _can_access_via_custom_category(db, channel.id, user.id):
raise HTTPException(403, "Not in your catalog")
pmap = channel.provider_maps[0]
prov_res = await db.execute(select(ProviderAccount).where(ProviderAccount.id == pmap.provider_account_id))
provider = prov_res.scalar_one_or_none()
vod_session = vod_tracker.start(
user_id=str(user.id), username=user.username,
channel_id=channel.id, channel_name=channel.name,
provider_account_id=pmap.provider_account_id,
provider_name=provider.name if provider else f"#{pmap.provider_account_id}",
stream_type="series",
stream_url=pmap.stream_url,
user_max_connections=user.max_connections,
)
log_id = await _create_log(user.id, user.username, channel.id, channel.name, "series", sid, client_ip)
return await _proxy_vod(pmap.stream_url, request, vod_session=vod_session, log_id=log_id)
# Second: episode_id was cached when the app called get_series_info
provider_id = _episode_provider_cache.get(sid)
if provider_id:
prov_result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
provider = prov_result.scalar_one_or_none()
if provider:
client = XtreamClient(provider.base_url, provider.username, provider.password)
url = client.build_series_stream_url(sid, ext)
vod_session = vod_tracker.start(
user_id=str(user.id), username=user.username,
channel_id=0, channel_name=f"Episode {sid}",
provider_account_id=provider_id,
provider_name=provider.name,
stream_type="series",
stream_url=url,
user_max_connections=user.max_connections,
)
log_id = await _create_log(user.id, user.username, 0, f"Episode {sid}", "series", sid, client_ip)
return await _proxy_vod(url, request, vod_session=vod_session, log_id=log_id)
# Third: check if it's a cached Jellyfin episode (fake_id → direct-play URL)
jelly_url = _jellyfin_episode_cache.get(sid)
if jelly_url:
vod_session = vod_tracker.start(
user_id=str(user.id), username=user.username,
channel_id=0, channel_name=f"Episode {sid}",
provider_account_id=0,
provider_name="Jellyfin",
stream_type="series",
stream_url=jelly_url,
user_max_connections=user.max_connections,
)
log_id = await _create_log(user.id, user.username, 0, f"Episode {sid}", "series", sid, client_ip)
return await _proxy_vod(jelly_url, request, vod_session=vod_session, log_id=log_id)
raise HTTPException(404, "Episode not found — open the series detail first to load episode info")
async def _proxy_vod(
upstream_url: str,
request: Request,
vod_session: VodSession | None = None,
log_id: int | None = None,
) -> StreamingResponse:
"""Transparent proxy for VOD/series — forwards status, Content-Type, and range headers."""
req_headers = {}
if "Range" in request.headers:
req_headers["Range"] = request.headers["Range"]
timeout = aiohttp.ClientTimeout(connect=10, sock_read=300)
http_session = aiohttp.ClientSession(timeout=timeout)
if vod_session:
vod_session._http_session = http_session
try:
resp = await http_session.get(upstream_url, headers=req_headers, ssl=False)
except Exception as e:
await http_session.close()
if vod_session:
vod_tracker.end(vod_session.session_id)
raise HTTPException(502, f"Provider unreachable: {e}")
content_type = resp.headers.get("Content-Type", "application/octet-stream")
forward_headers: dict[str, str] = {"Cache-Control": "no-cache"}
for h in ("Content-Length", "Content-Range", "Accept-Ranges"):
if h in resp.headers:
forward_headers[h] = resp.headers[h]
log_started = time.time()
async def generate():
prev_bytes = 0
prev_time = time.time()
bytes_sent = 0
try:
async for chunk in resp.content.iter_chunked(65536):
if vod_session and vod_session.should_stop:
break
bytes_sent += len(chunk)
if vod_session:
now = time.time()
vod_session.bytes_pumped += len(chunk)
vod_session.last_active = now
if now - prev_time >= 2.0:
bps = (vod_session.bytes_pumped - prev_bytes) / (now - prev_time)
vod_session.bps_down = bps
vod_session.bps_up = bps
prev_bytes = vod_session.bytes_pumped
prev_time = now
yield chunk
finally:
resp.release()
await http_session.close()
if vod_session:
vod_session.running = False
vod_tracker.end(vod_session.session_id)
if log_id:
asyncio.create_task(_finish_log(log_id, bytes_sent, log_started))
return StreamingResponse(
generate(),
status_code=resp.status,
media_type=content_type,
headers=forward_headers,
)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
async def _user_info(user: User, password: str, base: str, db: AsyncSession) -> dict:
exp = int(user.expiry_date.timestamp()) if user.expiry_date else 4102444800 # 2100-01-01
return {
"user_info": {
"username": user.username,
"password": password,
"message": "KiraStream",
"auth": 1,
"status": "Active" if user.is_active else "Disabled",
"exp_date": str(exp),
"is_trial": "0",
"active_cons": "0",
"created_at": str(int(user.created_at.timestamp())),
"max_connections": str(user.max_connections),
"allowed_output_formats": ["ts", "m3u8", "rtmp"],
},
"server_info": {
"url": base,
"port": "80",
"https_port": "443",
"server_protocol": "http",
"rtmp_port": "1935",
"timezone": "Europe/Madrid",
"timestamp_now": int(time.time()),
"time_now": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
},
}
async def _get_custom_cats(db: AsyncSession, ctype: ContentType, user_id: int) -> list[dict]:
allowed_ids = await _get_user_custom_cat_ids(db, user_id)
q = (
select(CustomCategory)
.where(CustomCategory.type == ctype, CustomCategory.is_visible == True) # noqa: E712
.order_by(CustomCategory.position, CustomCategory.id)
)
if allowed_ids is not None:
q = q.where(CustomCategory.id.in_(allowed_ids))
result = await db.execute(q)
return [
{"category_id": f"{CC_PREFIX}{c.id}", "category_name": c.name, "parent_id": 0}
for c in result.scalars().all()
]
async def _get_live_categories(user: User, db: AsyncSession) -> list[dict]:
cats = await get_user_categories(db, user, ContentType.live)
result = [{"category_id": str(c.id), "category_name": c.name, "parent_id": 0} for c in cats]
result += await _get_custom_cats(db, ContentType.live, user.id)
return result
async def _get_categories(user: User, db: AsyncSession, ctype: ContentType) -> list[dict]:
cats = await get_user_categories(db, user, ctype)
result = [{"category_id": str(c.id), "category_name": c.name, "parent_id": 0} for c in cats]
result += await _get_custom_cats(db, ctype, user.id)
return result
async def _get_live_streams(user: User, db: AsyncSession, category_id: str | None, base: str) -> list[dict]:
if category_id and category_id.startswith(CC_PREFIX):
cc_id = int(category_id[len(CC_PREFIX):])
channels = await _get_custom_category_channels(db, cc_id)
else:
cat_id = int(category_id) if category_id else None
channels = await get_user_channels(db, user, ContentType.live, cat_id)
return [
{
"num": i + 1,
"name": ch.name,
"stream_type": "live",
"stream_id": int(ch.stream_id_at_provider) if ch.stream_id_at_provider.isdigit() else ch.id,
"stream_icon": ch.tvg_logo or "",
"epg_channel_id": ch.tvg_id or "",
"added": "0",
"category_id": str(ch.category_id) if ch.category_id else "0",
"custom_sid": "",
"tv_archive": 0,
"direct_source": "",
"tv_archive_duration": 0,
}
for i, ch in enumerate(channels)
]
async def _get_channels(user: User, db: AsyncSession, ctype: ContentType, category_id: str | None, base: str) -> list[dict]:
if category_id and category_id.startswith(CC_PREFIX):
cc_id = int(category_id[len(CC_PREFIX):])
channels = await _get_custom_category_channels(db, cc_id)
else:
cat_id = int(category_id) if category_id else None
channels = await get_user_channels(db, user, ctype, cat_id)
if ctype == ContentType.movie:
return [
{
"num": i + 1,
"name": ch.name,
"stream_type": "movie",
"stream_id": int(ch.stream_id_at_provider) if ch.stream_id_at_provider.isdigit() else ch.id,
"stream_icon": ch.tvg_logo or "",
"added": "0",
"category_id": str(ch.category_id) if ch.category_id else "0",
"category_ids": [str(ch.category_id) if ch.category_id else "0"],
"container_extension": "mkv",
}
for i, ch in enumerate(channels)
]
# Series: must return series_id (not stream_id) so apps can call get_series_info
return [
{
"num": i + 1,
"name": ch.name,
"series_id": int(ch.stream_id_at_provider) if ch.stream_id_at_provider.isdigit() else ch.id,
"stream_id": int(ch.stream_id_at_provider) if ch.stream_id_at_provider.isdigit() else ch.id,
"cover": ch.tvg_logo or "",
"stream_icon": ch.tvg_logo or "",
"plot": "",
"cast": "",
"director": "",
"genre": "",
"release_date": "",
"last_modified": "0",
"rating": "",
"rating_5based": 0,
"backdrop_path": [],
"youtube_trailer": "",
"episode_run_time": "",
"added": "0",
"category_id": str(ch.category_id) if ch.category_id else "0",
"category_ids": [str(ch.category_id) if ch.category_id else "0"],
"container_extension": "mkv",
}
for i, ch in enumerate(channels)
]
async def _get_series_info(series_id: str, user: User, db: AsyncSession) -> dict:
result = await db.execute(
select(Channel)
.options(selectinload(Channel.provider_maps))
.where(Channel.stream_id_at_provider == series_id, Channel.type == ContentType.series)
)
ch = result.scalar_one_or_none()
# Fallback: Jellyfin series have non-numeric stream_id_at_provider so the app
# receives ch.id (DB int) as series_id — look it up by primary key.
if not ch:
try:
ch_id = int(series_id)
fb = await db.execute(
select(Channel)
.options(selectinload(Channel.provider_maps))
.where(Channel.id == ch_id, Channel.type == ContentType.series)
)
ch = fb.scalar_one_or_none()
except (ValueError, TypeError):
pass
if not ch:
return {"info": {}, "episodes": {}}
# Check if this channel belongs to a Jellyfin config
ji_result = await db.execute(
select(JellyfinItem).where(JellyfinItem.channel_id == ch.id)
)
ji = ji_result.scalar_one_or_none()
if ji:
cfg_result = await db.execute(
select(JellyfinConfig).where(JellyfinConfig.id == ji.jellyfin_config_id)
)
cfg = cfg_result.scalar_one_or_none()
if cfg:
return await _get_jellyfin_series_info(ch, ji, cfg)
if not ch.provider_maps:
return {"info": {}, "episodes": {}}
pmap = ch.provider_maps[0]
prov_result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == pmap.provider_account_id))
provider = prov_result.scalar_one_or_none()
if not provider:
return {"info": {}, "episodes": {}}
client = XtreamClient(provider.base_url, provider.username, provider.password)
try:
info = await client.get_series_info(series_id)
# Cache episode_id → provider_id so series_stream can route the playback request
for season_eps in (info.get("episodes") or {}).values():
if isinstance(season_eps, list):
for ep in season_eps:
ep_id = str(ep.get("id", ""))
if ep_id:
_episode_provider_cache[ep_id] = provider.id
# Ensure cover is populated (some providers return it here, some in the list)
if info.get("info") is not None and not info["info"].get("cover"):
info["info"]["cover"] = ch.tvg_logo or ""
return info
except Exception as e:
logger.warning(f"Series info fetch failed for series_id={series_id}: {e}")
return {
"info": {"name": ch.name, "cover": ch.tvg_logo or "", "plot": "", "cast": "", "director": "", "genre": "", "rating": ""},
"episodes": {},
}
async def _get_jellyfin_series_info(ch: Channel, ji: JellyfinItem, cfg: JellyfinConfig) -> dict:
"""Build get_series_info response from Jellyfin seasons/episodes."""
from ..core.jellyfin_client import JellyfinClient as _JClient
client = _JClient(cfg.url, cfg.api_key)
episodes_by_season: dict[str, list[dict]] = {}
try:
seasons = await client.get_seasons(ji.jellyfin_item_id)
for season in seasons:
season_id = season.get("Id", "")
season_num = str(season.get("IndexNumber", 1))
eps = await client.get_episodes(ji.jellyfin_item_id, season_id)
ep_list = []
for ep_num, ep in enumerate(eps, 1):
ep_jf_id = ep.get("Id", "")
if not ep_jf_id:
continue
# Stable numeric ID derived from the Jellyfin episode ID
fake_id = abs(hash(ep_jf_id)) % (10 ** 9)
stream_url = client.build_stream_url(ep_jf_id)
_jellyfin_episode_cache[str(fake_id)] = stream_url
ep_list.append({
"id": fake_id,
"episode_num": ep.get("IndexNumber", ep_num),
"title": ep.get("Name", f"Episodio {ep_num}"),
"container_extension": "mkv",
"info": {
"plot": ep.get("Overview", ""),
"duration_secs": 0,
"rating": "",
"name": ep.get("Name", ""),
"air_date": "",
},
})
if ep_list:
episodes_by_season[season_num] = ep_list
except Exception as e:
logger.warning(f"Jellyfin seasons fetch failed for {ji.jellyfin_item_id}: {e}")
return {
"info": {
"name": ch.name,
"cover": ch.tvg_logo or "",
"plot": ji.plot or "",
"cast": "",
"director": "",
"genre": ji.genres or "",
"release_date": str(ji.year) if ji.year else "",
"rating": ji.rating or "",
"backdrop_path": ch.tvg_logo or "",
"youtube_trailer": "",
"episode_run_time": "",
},
"episodes": episodes_by_season,
}
async def _get_vod_info(vod_id: str, user: User, db: AsyncSession) -> dict:
result = await db.execute(
select(Channel)
.options(selectinload(Channel.provider_maps))
.where(Channel.stream_id_at_provider == vod_id, Channel.type == ContentType.movie)
)
ch = result.scalar_one_or_none()
# Fallback: Jellyfin movies return ch.id as vod_id (non-numeric stream_id_at_provider)
if not ch:
try:
ch_id = int(vod_id)
fb = await db.execute(
select(Channel)
.options(selectinload(Channel.provider_maps))
.where(Channel.id == ch_id, Channel.type == ContentType.movie)
)
ch = fb.scalar_one_or_none()
except (ValueError, TypeError):
pass
if not ch:
return {"info": {}, "movie_data": {}}
# If this is a Jellyfin movie, return its metadata from JellyfinItem
ji_result = await db.execute(select(JellyfinItem).where(JellyfinItem.channel_id == ch.id))
ji = ji_result.scalar_one_or_none()
if ji:
return {
"info": {
"name": ch.name,
"cover": ch.tvg_logo or "",
"plot": ji.plot or "",
"cast": "",
"director": "",
"genre": ji.genres or "",
"release_date": str(ji.year) if ji.year else "",
"rating": ji.rating or "",
"duration_secs": 0,
"backdrop_path": ch.tvg_logo or "",
"youtube_trailer": "",
"tmdb_id": "",
},
"movie_data": {
"stream_id": str(ch.id),
"name": ch.name,
"added": "0",
"category_id": str(ch.category_id) if ch.category_id else "0",
"container_extension": "mkv",
"custom_sid": "",
"direct_source": "",
},
}
if ch.provider_maps:
pmap = ch.provider_maps[0]
prov_result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == pmap.provider_account_id))
provider = prov_result.scalar_one_or_none()
if provider:
client = XtreamClient(provider.base_url, provider.username, provider.password)
try:
return await client.get_vod_info(vod_id)
except Exception as e:
logger.warning(f"VOD info fetch failed for vod_id={vod_id}: {e}")
return {
"info": {"name": ch.name, "cover": ch.tvg_logo or "", "plot": "", "cast": "", "director": "", "genre": "", "release_date": "", "rating": ""},
"movie_data": {"stream_id": ch.stream_id_at_provider, "container_extension": "mkv"},
}
+34
View File
@@ -0,0 +1,34 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
database_url: str = "postgresql+asyncpg://kirastream:kirastream_secret_2024@localhost/kirastream"
redis_url: str = "redis://localhost:6379/0"
secret_key: str = "kirastream-super-secret-change-in-production-2024"
algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 * 7 # 7 days
admin_username: str = "admin"
admin_password: str = "kiraadmin"
stream_chunk_size: int = 65536 # 64 KB chunks
stream_queue_maxsize: int = 512
stream_connect_timeout: int = 10
stream_read_timeout: int = 30
epg_refresh_hours: int = 12
m3u_cache_minutes: int = 30
log_level: str = "INFO"
class Config:
env_file = "/opt/kirastream/.env"
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
View File
+78
View File
@@ -0,0 +1,78 @@
"""Resolve channel catalog for a given user based on their assigned categories."""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..models.user import User, UserCatalogEntry, UserProviderPreference
from ..models.channel import Channel, Category, ChannelProviderMap, ContentType
from ..models.provider import ProviderAccount
async def get_user_categories(db: AsyncSession, user: User, content_type: ContentType | None = None) -> list[Category]:
q = (
select(Category)
.join(UserCatalogEntry, UserCatalogEntry.category_id == Category.id)
.where(UserCatalogEntry.user_id == user.id)
)
if content_type:
q = q.where(Category.type == content_type)
result = await db.execute(q)
return list(result.scalars().all())
async def get_user_channels(
db: AsyncSession,
user: User,
content_type: ContentType | None = None,
category_id: int | None = None,
) -> list[Channel]:
category_ids_q = (
select(UserCatalogEntry.category_id)
.where(UserCatalogEntry.user_id == user.id)
)
q = (
select(Channel)
.options(selectinload(Channel.provider_maps), selectinload(Channel.category))
.where(Channel.category_id.in_(category_ids_q))
.where(Channel.is_active == True) # noqa: E712
)
if content_type:
q = q.where(Channel.type == content_type)
if category_id:
q = q.where(Channel.category_id == category_id)
result = await db.execute(q)
return list(result.scalars().all())
async def get_channel_provider_maps(db: AsyncSession, channel_id: int) -> list[dict]:
q = (
select(ChannelProviderMap, ProviderAccount.name.label("provider_name"))
.join(ProviderAccount, ProviderAccount.id == ChannelProviderMap.provider_account_id)
.where(ChannelProviderMap.channel_id == channel_id)
)
result = await db.execute(q)
rows = result.all()
return [
{
"provider_account_id": row.ChannelProviderMap.provider_account_id,
"stream_url": row.ChannelProviderMap.stream_url,
"provider_name": row.provider_name or f"#{row.ChannelProviderMap.provider_account_id}",
}
for row in rows
]
async def can_user_access_channel(db: AsyncSession, user: User, channel_id: int) -> bool:
channels = await get_user_channels(db, user)
return any(ch.id == channel_id for ch in channels)
async def get_user_preferred_provider_ids(db: AsyncSession, user: User) -> list[int]:
result = await db.execute(
select(UserProviderPreference.provider_account_id)
.where(UserProviderPreference.user_id == user.id)
.order_by(UserProviderPreference.priority)
)
return list(result.scalars().all())
+99
View File
@@ -0,0 +1,99 @@
"""EPG manager: fetch XMLTV sources, merge by tvg-id, serve filtered by user catalog."""
import asyncio
import gzip
import logging
import time
from datetime import datetime, timezone
from io import BytesIO
from lxml import etree
import aiohttp
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from ..models.epg import EpgSource
from ..config import settings
logger = logging.getLogger(__name__)
_merged_epg: bytes = b""
_last_update: float = 0.0
_update_lock = asyncio.Lock()
async def refresh_epg(db: AsyncSession) -> None:
global _merged_epg, _last_update
async with _update_lock:
result = await db.execute(select(EpgSource).where(EpgSource.is_active == True)) # noqa: E712
sources = result.scalars().all()
if not sources:
logger.info("No active EPG sources configured")
return
root = etree.Element("tv")
timeout = aiohttp.ClientTimeout(total=60)
async with aiohttp.ClientSession(timeout=timeout) as session:
for source in sources:
try:
data = await _fetch_xmltv(session, source.url)
tree = etree.fromstring(data)
for elem in tree:
root.append(elem)
logger.info(f"EPG loaded from {source.name}: {len(tree)} entries")
except Exception as e:
logger.error(f"EPG fetch failed for {source.name}: {e}")
_merged_epg = etree.tostring(root, xml_declaration=True, encoding="UTF-8")
_last_update = time.time()
from sqlalchemy import update
await db.execute(
update(EpgSource).where(EpgSource.is_active == True).values(last_fetched_at=datetime.now(timezone.utc)) # noqa: E712
)
await db.commit()
logger.info(f"EPG merge complete, {len(_merged_epg)} bytes")
async def _fetch_xmltv(session: aiohttp.ClientSession, url: str) -> bytes:
async with session.get(url, ssl=False) as resp:
resp.raise_for_status()
data = await resp.read()
if data[:2] == b"\x1f\x8b":
data = gzip.decompress(data)
return data
def get_merged_epg() -> bytes:
return _merged_epg
def filter_epg_for_channels(tvg_ids: set[str]) -> bytes:
"""Return EPG XML filtered to only contain channels/programs for given tvg-ids."""
if not _merged_epg:
return b'<?xml version="1.0" encoding="UTF-8"?><tv></tv>'
try:
tree = etree.fromstring(_merged_epg)
root = etree.Element("tv")
for elem in tree:
tag = elem.tag
if tag == "channel":
if elem.get("id") in tvg_ids:
root.append(elem)
elif tag == "programme":
if elem.get("channel") in tvg_ids:
root.append(elem)
return etree.tostring(root, xml_declaration=True, encoding="UTF-8")
except Exception as e:
logger.error(f"EPG filter error: {e}")
return _merged_epg
async def epg_background_task(get_db_func) -> None:
"""Periodic EPG refresh task — runs every N hours."""
while True:
try:
async for db in get_db_func():
await refresh_epg(db)
except Exception as e:
logger.error(f"EPG background refresh error: {e}")
await asyncio.sleep(settings.epg_refresh_hours * 3600)
+115
View File
@@ -0,0 +1,115 @@
"""Background health checker for provider URLs."""
import asyncio
import logging
import time
from datetime import datetime, timezone
import aiohttp
from sqlalchemy import select
from ..database import AsyncSessionLocal
from ..models.provider import ProviderAccount, ProviderUrl
logger = logging.getLogger(__name__)
CHECK_INTERVAL = 300 # 5 minutes
async def _check_url(base_url: str, username: str, password: str) -> tuple[str, int | None]:
url = f"{base_url.rstrip('/')}/player_api.php"
params = {"username": username, "password": password, "action": "user_info"}
start = time.time()
try:
timeout = aiohttp.ClientTimeout(total=8)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, params=params, ssl=False) as resp:
elapsed_ms = int((time.time() - start) * 1000)
if resp.status == 200:
try:
data = await resp.json(content_type=None)
if "user_info" in data:
return "ok", elapsed_ms
except Exception:
pass
return "error", elapsed_ms
except asyncio.TimeoutError:
return "timeout", None
except Exception:
return "error", None
async def check_provider_urls(provider_account_id: int) -> list[dict]:
"""Check all URLs for a specific provider. Returns list of result dicts."""
async with AsyncSessionLocal() as db:
result = await db.execute(
select(ProviderUrl.id, ProviderUrl.url, ProviderAccount.username, ProviderAccount.password)
.join(ProviderAccount)
.where(
ProviderUrl.provider_account_id == provider_account_id,
ProviderUrl.is_active == True, # noqa: E712
)
)
rows = result.all()
if not rows:
return []
tasks = [_check_url(url, username, password) for _, url, username, password in rows]
results = await asyncio.gather(*tasks, return_exceptions=True)
now = datetime.now(timezone.utc)
output = []
async with AsyncSessionLocal() as db:
for (url_id, url, _, _), check_result in zip(rows, results):
if isinstance(check_result, Exception):
status, response_ms = "error", None
else:
status, response_ms = check_result
pu = await db.get(ProviderUrl, url_id)
if pu:
pu.status = status
pu.response_ms = response_ms
pu.last_checked_at = now
output.append({"id": url_id, "url": url, "status": status, "response_ms": response_ms})
await db.commit()
return output
async def health_check_loop() -> None:
"""Runs forever, checking all active provider URLs every CHECK_INTERVAL seconds."""
logger.info("Provider URL health checker started")
while True:
await asyncio.sleep(CHECK_INTERVAL)
try:
async with AsyncSessionLocal() as db:
result = await db.execute(
select(ProviderUrl.id, ProviderUrl.url, ProviderAccount.username, ProviderAccount.password)
.join(ProviderAccount)
.where(ProviderUrl.is_active == True) # noqa: E712
)
rows = result.all()
if not rows:
continue
tasks = [_check_url(url, username, password) for _, url, username, password in rows]
check_results = await asyncio.gather(*tasks, return_exceptions=True)
now = datetime.now(timezone.utc)
async with AsyncSessionLocal() as db:
for (url_id, _, _, _), check_result in zip(rows, check_results):
if isinstance(check_result, Exception):
status, response_ms = "error", None
else:
status, response_ms = check_result
pu = await db.get(ProviderUrl, url_id)
if pu:
pu.status = status
pu.response_ms = response_ms
pu.last_checked_at = now
await db.commit()
logger.info(f"Health check done: {len(rows)} URLs checked")
except Exception as e:
logger.error(f"Health check loop error: {e}")
+120
View File
@@ -0,0 +1,120 @@
"""Async Jellyfin API client — discovery, libraries, items, streaming."""
import logging
import aiohttp
logger = logging.getLogger(__name__)
_SHORT = aiohttp.ClientTimeout(total=10)
_MEDIUM = aiohttp.ClientTimeout(total=30)
_LONG = aiohttp.ClientTimeout(total=60)
class JellyfinClient:
def __init__(self, url: str, api_key: str):
self.url = url.rstrip("/")
self.api_key = api_key
@property
def _headers(self) -> dict:
return {"X-Emby-Token": self.api_key, "Accept": "application/json"}
# ------------------------------------------------------------------
# Discovery
# ------------------------------------------------------------------
async def test_connection(self) -> dict:
async with aiohttp.ClientSession() as s:
async with s.get(
f"{self.url}/System/Info/Public", timeout=_SHORT
) as r:
r.raise_for_status()
return await r.json()
async def get_libraries(self) -> list[dict]:
async with aiohttp.ClientSession() as s:
async with s.get(
f"{self.url}/Library/VirtualFolders",
headers=self._headers,
timeout=_SHORT,
) as r:
r.raise_for_status()
data = await r.json()
return data if isinstance(data, list) else []
# ------------------------------------------------------------------
# Items (movies / series)
# ------------------------------------------------------------------
async def get_items(self, library_id: str, item_type: str) -> list[dict]:
"""Fetch all items of a given type from one library (paginated)."""
all_items: list[dict] = []
start = 0
limit = 200
while True:
params = {
"ParentId": library_id,
"IncludeItemTypes": item_type,
"Recursive": "true",
"Fields": "Overview,Genres,OfficialRating,ProductionYear",
"Limit": str(limit),
"StartIndex": str(start),
}
async with aiohttp.ClientSession() as s:
async with s.get(
f"{self.url}/Items",
params=params,
headers=self._headers,
timeout=_LONG,
) as r:
r.raise_for_status()
data = await r.json()
batch = data.get("Items", [])
all_items.extend(batch)
total = data.get("TotalRecordCount", 0)
if len(all_items) >= total or not batch:
break
start += len(batch)
return all_items
# ------------------------------------------------------------------
# Series structure (seasons + episodes)
# ------------------------------------------------------------------
async def get_seasons(self, series_id: str) -> list[dict]:
async with aiohttp.ClientSession() as s:
async with s.get(
f"{self.url}/Shows/{series_id}/Seasons",
params={"Fields": "Overview"},
headers=self._headers,
timeout=_MEDIUM,
) as r:
r.raise_for_status()
return (await r.json()).get("Items", [])
async def get_episodes(self, series_id: str, season_id: str) -> list[dict]:
async with aiohttp.ClientSession() as s:
async with s.get(
f"{self.url}/Shows/{series_id}/Episodes",
params={"SeasonId": season_id, "Fields": "Overview"},
headers=self._headers,
timeout=_MEDIUM,
) as r:
r.raise_for_status()
return (await r.json()).get("Items", [])
# ------------------------------------------------------------------
# URL builders
# ------------------------------------------------------------------
def build_stream_url(self, item_id: str) -> str:
return (
f"{self.url}/Videos/{item_id}/stream"
f"?static=true&api_key={self.api_key}&Container=mkv"
)
def build_image_url(self, item_id: str, max_width: int = 400) -> str:
return (
f"{self.url}/Items/{item_id}/Images/Primary"
f"?api_key={self.api_key}&MaxWidth={max_width}"
)
+314
View File
@@ -0,0 +1,314 @@
"""ProviderPool: manages provider account slots and live stream allocation."""
import asyncio
import json
import logging
import time
from typing import AsyncIterator
import redis.asyncio as aioredis
from ..config import settings
from .restream import BroadcastGroup, ClientHandle
logger = logging.getLogger(__name__)
ACTIVE_STREAM_KEY = "ks:stream:{channel_id}"
PROVIDER_SLOT_KEY = "ks:slot:{account_id}"
USER_CONN_KEY = "ks:user_conns:{user_id}"
MONITORING_KEY = "ks:monitoring"
class NoSlotsAvailableError(Exception):
pass
class MaxConnectionsExceededError(Exception):
pass
class ProviderPool:
"""
Singleton managing all active BroadcastGroups and provider slot allocation.
Uses Redis for cross-process state and in-memory dict for the asyncio objects.
"""
def __init__(self):
self._groups: dict[int, BroadcastGroup] = {}
self._lock = asyncio.Lock()
self._redis: aioredis.Redis | None = None
async def init(self) -> None:
self._redis = aioredis.from_url(settings.redis_url, decode_responses=True)
await self._redis.ping()
# Clear any stale keys left from a previous process crash or restart
stale = await self._redis.keys("ks:*")
if stale:
await self._redis.delete(*stale)
logger.info(f"ProviderPool cleared {len(stale)} stale Redis keys on startup")
logger.info("ProviderPool initialized, Redis connected")
async def close(self) -> None:
if self._redis:
await self._redis.aclose()
# -------------------------------------------------------------------------
# Public interface
# -------------------------------------------------------------------------
async def acquire(
self,
channel_id: int,
stream_url_resolver, # async callable(provider_account_id) -> str
provider_maps: list[dict], # [{"provider_account_id": X, "stream_url": Y, "provider_name": Z}, ...]
user_id: str,
user_max_conns: int,
is_priority: bool = False,
preferred_provider_ids: list[int] | None = None,
username: str = "",
channel_name: str = "",
) -> ClientHandle:
await self._check_user_connections(user_id, user_max_conns)
async with self._lock:
# Case 1: stream already active → attach to existing group
if channel_id in self._groups:
group = self._groups[channel_id]
handle = await group.add_client(user_id, username=username, is_priority=is_priority)
await self._redis.hincrby(ACTIVE_STREAM_KEY.format(channel_id=channel_id), "clients", 1)
await self._redis.sadd(USER_CONN_KEY.format(user_id=user_id), handle.client_id)
await self._publish_monitoring()
logger.info(f"User {user_id} attached to existing stream ch={channel_id}")
return handle
# Case 2: find a free slot (preferred providers sorted first)
sorted_maps = self._sort_by_preference(provider_maps, preferred_provider_ids)
account_id, stream_url = await self._find_free_slot(sorted_maps)
if account_id is None:
if not is_priority:
raise NoSlotsAvailableError(f"No free provider slots for channel {channel_id}")
# Case 3: priority eviction — bump a non-priority stream
account_id, stream_url, victim_group = await self._find_eviction_candidate(sorted_maps)
if account_id is None:
raise NoSlotsAvailableError(f"No evictable non-priority streams for channel {channel_id}")
# Clean Redis for the victim stream inside the lock
victim_channel_id = victim_group.channel_id
victim_account_id = victim_group.provider_account_id
self._groups.pop(victim_channel_id, None)
await self._redis.delete(ACTIVE_STREAM_KEY.format(channel_id=victim_channel_id))
await self._redis.delete(PROVIDER_SLOT_KEY.format(account_id=victim_account_id))
# Stop victim group outside lock (disconnect all its clients via SENTINEL)
asyncio.create_task(victim_group.stop())
logger.info(
f"Priority eviction: stopped ch={victim_channel_id} "
f"(provider_account={victim_account_id}) for priority user {user_id}"
)
# Reserve slot
await self._redis.set(PROVIDER_SLOT_KEY.format(account_id=account_id), channel_id)
provider_name = next(
(pm.get("provider_name", "") for pm in provider_maps if pm["provider_account_id"] == account_id), ""
)
stream_urls = await self._build_stream_urls(account_id, stream_url)
group = BroadcastGroup(
channel_id=channel_id,
stream_urls=stream_urls,
provider_account_id=account_id,
channel_name=channel_name,
provider_name=provider_name,
)
self._groups[channel_id] = group
handle = await group.add_client(user_id, username=username, is_priority=is_priority)
await self._redis.hset(
ACTIVE_STREAM_KEY.format(channel_id=channel_id),
mapping={
"provider_account_id": account_id,
"stream_url": stream_url,
"clients": 1,
"started_at": time.time(),
},
)
await self._redis.sadd(USER_CONN_KEY.format(user_id=user_id), handle.client_id)
await group.start()
await self._publish_monitoring()
logger.info(f"New stream started ch={channel_id} provider_account={account_id}")
return handle
async def release(self, channel_id: int, client_id: str, user_id: str) -> None:
group_to_stop: BroadcastGroup | None = None
stopped_account_id: int | None = None
async with self._lock:
group = self._groups.get(channel_id)
if not group:
return
remaining = await group.remove_client(client_id)
await self._redis.srem(USER_CONN_KEY.format(user_id=user_id), client_id)
if remaining == 0:
# Remove from registry and clean Redis *while holding the lock*,
# but defer group.stop() (which awaits task cancellation) until
# after the lock is released to avoid blocking the pool.
self._groups.pop(channel_id, None)
group_to_stop = group
stopped_account_id = group.provider_account_id
await self._redis.delete(ACTIVE_STREAM_KEY.format(channel_id=channel_id))
await self._redis.delete(PROVIDER_SLOT_KEY.format(account_id=stopped_account_id))
else:
await self._redis.hset(
ACTIVE_STREAM_KEY.format(channel_id=channel_id), "clients", remaining
)
await self._publish_monitoring()
if group_to_stop is not None:
await group_to_stop.stop()
logger.info(f"Stream stopped ch={channel_id}, slot freed: provider_account={stopped_account_id}")
async def kill_client(self, client_id: str) -> bool:
"""Admin: force-disconnect a specific client by sending SENTINEL to their queue."""
async with self._lock:
for group in self._groups.values():
if client_id in group._clients:
return await group.force_disconnect_client(client_id)
return False
def get_active_streams(self) -> list[dict]:
return [g.stats() for g in self._groups.values()]
async def get_user_connection_count(self, user_id: str) -> int:
return await self._redis.scard(USER_CONN_KEY.format(user_id=user_id))
# -------------------------------------------------------------------------
# Internal helpers
# -------------------------------------------------------------------------
async def _check_user_connections(self, user_id: str, max_conns: int) -> None:
# Cross-check Redis set against in-memory groups to auto-heal stale entries
redis_conns = await self._redis.smembers(USER_CONN_KEY.format(user_id=user_id))
if redis_conns:
active = {cid for g in self._groups.values() for cid in g.client_ids}
stale = redis_conns - active
if stale:
await self._redis.srem(USER_CONN_KEY.format(user_id=user_id), *stale)
logger.warning(f"Auto-cleaned {len(stale)} stale conn(s) for user {user_id}")
redis_conns -= stale
count = len(redis_conns)
if count >= max_conns:
# Determine if this is a zapping scenario (same device switching channels quickly)
# or a genuine multi-device connection attempt.
# Zapping: the existing connection is very recent (< 10 s) — the TV app opens the
# new stream before the HTTP response of the old one closes. In that case we evict
# silently so the channel switch is seamless.
# Multi-device: the existing connection is older — keep it alive and raise so the
# caller can show a "blocked" stream on the new device instead.
now = time.time()
groups_snapshot = list(self._groups.values())
oldest_age = 0.0
for group in groups_snapshot:
for cid, handle in group._clients.items():
if cid in redis_conns:
oldest_age = max(oldest_age, now - handle.connected_at)
if oldest_age < 10.0:
# Zapping — evict silently
logger.info(f"User {user_id} zapping ({oldest_age:.1f}s old conn) — evicting old stream")
for client_id in list(redis_conns):
for group in groups_snapshot:
if client_id in group._clients:
await group.force_disconnect_client(client_id)
break
await self._redis.srem(USER_CONN_KEY.format(user_id=user_id), client_id)
else:
# Multi-device — reject so caller can show blocked image
raise MaxConnectionsExceededError(
f"User {user_id} already has {count}/{max_conns} connections"
)
def _sort_by_preference(
self, provider_maps: list[dict], preferred_ids: list[int] | None
) -> list[dict]:
if not preferred_ids:
return provider_maps
pref_set = set(preferred_ids)
preferred = [pm for pm in provider_maps if pm["provider_account_id"] in pref_set]
rest = [pm for pm in provider_maps if pm["provider_account_id"] not in pref_set]
return preferred + rest
async def _build_stream_urls(self, account_id: int, primary_stream_url: str) -> list[str]:
"""Build list of stream URLs for failover: one per active ProviderUrl, substituting base URL."""
from urllib.parse import urlparse, urlunparse
from ..models.provider import ProviderUrl
from ..database import AsyncSessionLocal
from sqlalchemy import select
parsed = urlparse(primary_stream_url)
async with AsyncSessionLocal() as db:
result = await db.execute(
select(ProviderUrl)
.where(
ProviderUrl.provider_account_id == account_id,
ProviderUrl.is_active == True, # noqa: E712
)
.order_by(ProviderUrl.priority)
)
provider_urls = result.scalars().all()
if not provider_urls:
return [primary_stream_url]
urls = []
for pu in provider_urls:
alt = urlparse(pu.url.rstrip('/'))
reconstructed = urlunparse((
alt.scheme or parsed.scheme,
alt.netloc,
parsed.path,
parsed.params,
parsed.query,
parsed.fragment,
))
urls.append(reconstructed)
return urls
async def _find_free_slot(self, provider_maps: list[dict]) -> tuple[int | None, str | None]:
for pm in provider_maps:
account_id = pm["provider_account_id"]
slot_key = PROVIDER_SLOT_KEY.format(account_id=account_id)
busy = await self._redis.exists(slot_key)
if not busy:
return account_id, pm["stream_url"]
return None, None
async def _find_eviction_candidate(
self, provider_maps: list[dict]
) -> tuple[int | None, str | None, BroadcastGroup | None]:
"""Find a stream with only non-priority clients that uses a slot we need."""
available = {pm["provider_account_id"]: pm["stream_url"] for pm in provider_maps}
candidate: BroadcastGroup | None = None
for group in self._groups.values():
if group.provider_account_id in available and group.all_non_priority:
if candidate is None or group.client_count < candidate.client_count:
candidate = group
if candidate is None:
return None, None, None
return candidate.provider_account_id, available[candidate.provider_account_id], candidate
async def _publish_monitoring(self) -> None:
try:
data = json.dumps([g.stats() for g in self._groups.values()])
await self._redis.publish(MONITORING_KEY, data)
except Exception:
pass
pool = ProviderPool()
+112
View File
@@ -0,0 +1,112 @@
"""FFprobe-based stream metadata — resolution, FPS, codecs, bitrate."""
import asyncio
import json
import logging
import time
logger = logging.getLogger(__name__)
# Simple in-process cache: url → (info_dict, fetched_at)
_cache: dict[str, tuple[dict, float]] = {}
_CACHE_TTL = 3600 # 1 hour — re-probe if URL expires or stream restarts
# Locks per URL to avoid parallel probes for the same stream
_locks: dict[str, asyncio.Lock] = {}
async def probe_stream(url: str) -> dict:
"""
Run ffprobe against *url* and return a dict with video/audio metadata.
Returns {} on failure (non-fatal — the stream still works).
Results are cached for CACHE_TTL seconds.
"""
now = time.time()
cached = _cache.get(url)
if cached and now - cached[1] < _CACHE_TTL:
return cached[0]
if url not in _locks:
_locks[url] = asyncio.Lock()
async with _locks[url]:
# Re-check after acquiring lock (another task may have already probed)
cached = _cache.get(url)
if cached and now - cached[1] < _CACHE_TTL:
return cached[0]
info = await _run_ffprobe(url)
_cache[url] = (info, time.time())
return info
async def _run_ffprobe(url: str) -> dict:
try:
proc = await asyncio.create_subprocess_exec(
"ffprobe",
"-v", "quiet",
"-print_format", "json",
"-show_streams",
"-show_format",
"-analyzeduration", "3000000", # 3 s — fast enough for live streams
"-probesize", "1000000", # 1 MB
url,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
try:
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=20)
except asyncio.TimeoutError:
try:
proc.kill()
except ProcessLookupError:
pass
logger.debug(f"ffprobe timed out for {url}")
return {}
data = json.loads(stdout)
except Exception as e:
logger.debug(f"ffprobe failed for {url}: {e}")
return {}
info: dict = {}
for stream in data.get("streams", []):
ctype = stream.get("codec_type", "")
if ctype == "video" and "video_codec" not in info:
info["video_codec"] = stream.get("codec_name", "")
info["video_profile"] = stream.get("profile", "")
info["width"] = stream.get("width", 0)
info["height"] = stream.get("height", 0)
# FPS: prefer avg_frame_rate, fallback r_frame_rate
for fps_field in ("avg_frame_rate", "r_frame_rate"):
fps_str = stream.get(fps_field, "")
if fps_str and "/" in fps_str:
try:
num, den = fps_str.split("/")
fps = round(int(num) / int(den), 3) if int(den) else 0
if 1 < fps < 200: # sanity check
info["fps"] = round(fps, 2)
break
except (ValueError, ZeroDivisionError):
pass
elif ctype == "audio" and "audio_codec" not in info:
info["audio_codec"] = stream.get("codec_name", "")
info["audio_channels"] = stream.get("channels", 0)
info["audio_sample_rate"] = int(stream.get("sample_rate", 0) or 0)
fmt = data.get("format", {})
try:
br = int(fmt.get("bit_rate", 0) or 0)
if br > 0:
info["bitrate_bps"] = br
except (ValueError, TypeError):
pass
logger.debug(f"Probed {url}: {info}")
return info
def invalidate(url: str) -> None:
"""Evict a URL from the cache (call when stream URL changes)."""
_cache.pop(url, None)
+610
View File
@@ -0,0 +1,610 @@
"""BroadcastGroup: fan-out a single provider stream to N simultaneous clients.
Recovery model
--------------
The pump loop runs indefinitely while self._running is True. Each iteration
spawns an ffmpeg process in copy (remux) mode. ffmpeg owns the upstream
HTTP connection. When the provider closes the session (typically every
~2 minutes) ffmpeg exits and the outer loop spawns a fresh process — the
provider delivers from the current live edge so timestamps are always
forward-moving and the player never sees a backward seek (rewind).
If ffmpeg itself exits (unrecoverable error or stall beyond its own timeout)
the outer loop drains all client queues, backs off briefly, and spawns a
fresh ffmpeg process — again without disconnecting clients.
SENTINEL is only pushed when stop() is called or the pump truly gives up.
Stall detection
---------------
ffmpeg's -timeout option (socket-level read timeout) handles provider stalls.
A safety-net asyncio.wait_for wraps each stdout read in case ffmpeg hangs
without producing output or exiting.
A/V sync
--------
ffmpeg re-muxes the incoming MPEG-TS into a fresh output stream. On every
reconnect the output PTS/DTS sequence is continuous regardless of provider-
side timestamp resets, eliminating audio desync entirely. No video or audio
is re-encoded — quality is bit-for-bit identical to the source.
"""
import asyncio
import logging
import os
import shutil
import time
import uuid
from dataclasses import dataclass, field
from typing import AsyncIterator
from ..config import settings
logger = logging.getLogger(__name__)
CHUNK_SIZE = settings.stream_chunk_size
QUEUE_MAX = settings.stream_queue_maxsize
STALL_TIMEOUT = 10.0 # seconds — passed to ffmpeg -timeout (in µs below)
MAX_RETRIES = 999 # effectively infinite; pool stops the group when 0 clients
_BACKOFF_CAP = 5.0 # max seconds between outer retries
_SENTINEL = object() # signals end-of-stream to client queues
_FFMPEG = shutil.which("ffmpeg") or "ffmpeg"
# ffmpeg stderr patterns that indicate input-side A/V issues (demuxer warnings).
# These don't affect output quality with -use_wallclock_as_timestamps but are
# counted as a health metric to surface in the dashboard.
_AV_WARN_PATTERNS = (
"timestamp discontinuity",
"Packet corrupt",
"PES packet size mismatch",
"DTS, out of order",
)
# Health check: data-age thresholds
_HEALTH_WARN_SECS = 12.0 # > 12 s without data → warning (normal reconnects are 1-4 s)
_HEALTH_ERROR_SECS = 25.0 # > 25 s → error, trigger correction
def _url_netloc(url: str) -> str:
from urllib.parse import urlparse
try:
return urlparse(url).netloc
except Exception:
return url
@dataclass
class ClientHandle:
client_id: str
queue: asyncio.Queue = field(default_factory=lambda: asyncio.Queue(maxsize=QUEUE_MAX))
connected_at: float = field(default_factory=time.time)
is_priority: bool = False
username: str = ""
_overflow_count: int = 0
_overflow_since: float | None = None
async def read(self) -> AsyncIterator[bytes]:
while True:
chunk = await self.queue.get()
if chunk is _SENTINEL:
break
yield chunk
class BroadcastGroup:
"""Streams one provider URL and distributes chunks to all registered clients."""
def __init__(
self,
channel_id: int,
stream_urls: list[str],
provider_account_id: int,
channel_name: str = "",
provider_name: str = "",
# kept for backward compat in callers that still pass stream_url as kwarg
stream_url: str | None = None,
):
self.channel_id = channel_id
self.channel_name = channel_name
# Support old callers that pass a single stream_url
if stream_urls:
self._stream_urls = stream_urls
elif stream_url:
self._stream_urls = [stream_url]
else:
self._stream_urls = []
self.stream_url = self._stream_urls[0] if self._stream_urls else ""
self._active_url: str = self.stream_url
self.provider_account_id = provider_account_id
self.provider_name = provider_name
self.started_at = time.time()
self._clients: dict[str, ClientHandle] = {}
self._lock = asyncio.Lock()
self._task: asyncio.Task | None = None
self._running = False
# Throughput tracking
self._bytes_pumped: int = 0
self._bytes_prev: int = 0
self._bps_time: float = time.time()
self._bps_down: float = 0.0
# Health / metadata
self.stream_info: dict = {}
self._reconnect_count: int = 0
self._status: str = "connecting"
self._last_data_at: float = time.time()
# ffmpeg process tracking
self._ffmpeg_pid: int | None = None
self._ffmpeg_start_time: float | None = None # wall time when ffmpeg started
self._ffmpeg_cpu: float = 0.0
self._cpu_task: asyncio.Task | None = None
# A/V health tracking
self._av_health: str = "ok" # ok / warning / error
self._av_desync_count: int = 0 # cumulative stderr A/V-warning events
self._corrections: int = 0 # how many times health monitor killed stuck ffmpeg
self._health_task: asyncio.Task | None = None
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
@property
def client_count(self) -> int:
return len(self._clients)
@property
def client_ids(self) -> list[str]:
return list(self._clients.keys())
@property
def all_non_priority(self) -> bool:
return all(not h.is_priority for h in self._clients.values())
async def start(self) -> None:
self._running = True
self._task = asyncio.create_task(
self._pump_with_retry(), name=f"pump-ch{self.channel_id}"
)
self._cpu_task = asyncio.create_task(
self._monitor_cpu(), name=f"cpu-ch{self.channel_id}"
)
self._health_task = asyncio.create_task(
self._health_check_loop(), name=f"health-ch{self.channel_id}"
)
asyncio.create_task(self._probe_info(), name=f"probe-ch{self.channel_id}")
async def add_client(self, user_id: str, username: str = "", is_priority: bool = False) -> ClientHandle:
client_id = f"{user_id}-{uuid.uuid4().hex[:8]}"
handle = ClientHandle(client_id=client_id, is_priority=is_priority, username=username)
async with self._lock:
self._clients[client_id] = handle
logger.info(
f"Client {client_id} ({username}) joined channel {self.channel_id} "
f"(total={len(self._clients)})"
)
return handle
async def force_disconnect_client(self, client_id: str) -> bool:
async with self._lock:
handle = self._clients.get(client_id)
if handle is None:
return False
_drain_queue(handle.queue)
try:
handle.queue.put_nowait(_SENTINEL)
except asyncio.QueueFull:
pass
return True
async def remove_client(self, client_id: str) -> int:
async with self._lock:
self._clients.pop(client_id, None)
remaining = len(self._clients)
logger.info(f"Client {client_id} left channel {self.channel_id} (remaining={remaining})")
return remaining
async def stop(self) -> None:
self._running = False
self._status = "stopped"
for task in (self._cpu_task, self._health_task):
if task and not task.done():
task.cancel()
self._cpu_task = self._health_task = None
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
async with self._lock:
for handle in self._clients.values():
try:
handle.queue.put_nowait(_SENTINEL)
except asyncio.QueueFull:
pass
def stats(self) -> dict:
n = self.client_count or 1
return {
"stream_type": "live",
"channel_id": self.channel_id,
"channel_name": self.channel_name or f"Canal #{self.channel_id}",
"provider_account_id": self.provider_account_id,
"provider_name": self.provider_name,
"client_count": self.client_count,
"clients": [
{
"client_id": h.client_id,
"username": h.username or h.client_id.rsplit("-", 1)[0],
}
for h in self._clients.values()
],
"started_at": self.started_at,
"bytes_pumped": self._bytes_pumped,
"bps_down": self._bps_down,
"bps_up": self._bps_down * n,
"running": self._running,
"status": self._status,
"reconnect_count": self._reconnect_count,
"last_data_ago": round(time.time() - self._last_data_at, 1),
"stream_info": self.stream_info,
# ffmpeg process info
"ffmpeg_pid": self._ffmpeg_pid,
"ffmpeg_cpu_pct": round(self._ffmpeg_cpu, 2),
# multi-domain failover info
"active_url_domain": _url_netloc(self._active_url),
"url_count": len(self._stream_urls),
# A/V health
"av_health": self._av_health,
"av_desync_count": self._av_desync_count,
"corrections": self._corrections,
"buffer_pct": self._buffer_pct(),
"buffer_server_bytes": self._buffer_server_bytes(),
"buffer_server_secs": self._buffer_server_secs(),
"buffer_capacity": QUEUE_MAX * CHUNK_SIZE,
}
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
async def _monitor_cpu(self) -> None:
"""Update self._ffmpeg_cpu every 3 s using cumulative average since start.
Delta-based sampling fails for low-CPU processes (copy-mode ffmpeg uses
<1% CPU) because the tick delta in 2 s is 0-1 integer ticks, making the
result always 0.0%. Cumulative average ((total_cpu_ticks / clk_tck) /
elapsed_wall) gives a stable, accurate reading — the same method ps uses.
"""
clk_tck = float(os.sysconf(os.sysconf_names.get("SC_CLK_TCK", 2)) or 100)
while self._running:
await asyncio.sleep(3)
pid = self._ffmpeg_pid
start_time = self._ffmpeg_start_time
if pid is None or start_time is None:
self._ffmpeg_cpu = 0.0
continue
try:
with open(f"/proc/{pid}/stat") as f:
parts = f.read().split()
cpu_ticks = int(parts[13]) + int(parts[14]) # utime + stime
elapsed = time.time() - start_time
if elapsed > 0:
self._ffmpeg_cpu = cpu_ticks / clk_tck / elapsed * 100
except (FileNotFoundError, IndexError, ValueError, OSError):
self._ffmpeg_cpu = 0.0
def _buffer_pct(self) -> float:
"""Return queue fill % of the slowest (most backlogged) client (0–100)."""
if not self._clients:
return 0.0
max_fill = max(h.queue.qsize() for h in self._clients.values())
return round(max_fill / QUEUE_MAX * 100, 1)
def _buffer_server_bytes(self) -> int:
"""Bytes buffered for the least-buffered client (first to run dry on provider drop)."""
if not self._clients:
return 0
return min(h.queue.qsize() for h in self._clients.values()) * CHUNK_SIZE
def _buffer_server_secs(self) -> float:
"""Estimated seconds of coverage at current bitrate."""
if not self._clients or self._bps_down <= 0:
return 0.0
return round(self._buffer_server_bytes() / self._bps_down, 1)
async def _health_check_loop(self) -> None:
"""Update _av_health every 5 s based on data-flow age.
If ffmpeg is running but producing no output for _HEALTH_ERROR_SECS,
kill it so _pump_with_retry can restart it immediately — this is the
auto-correction the caller sees as a 'corrections' counter tick.
"""
consecutive_error = 0
while self._running:
await asyncio.sleep(5)
if not self._running:
break
age = time.time() - self._last_data_at
if age < _HEALTH_WARN_SECS:
self._av_health = "ok"
consecutive_error = 0
elif age < _HEALTH_ERROR_SECS:
self._av_health = "warning"
consecutive_error = 0
else:
self._av_health = "error"
consecutive_error += 1
# After 2 consecutive error checks (≈10 s) kill a stuck ffmpeg
# so _pump_with_retry can restart it without waiting for the
# 25-second safety-net timeout in _pump_once.
if consecutive_error >= 2 and self._ffmpeg_pid is not None:
import signal as _sig
try:
os.kill(self._ffmpeg_pid, _sig.SIGTERM)
self._corrections += 1
logger.warning(
f"[ch{self.channel_id}] Health correction #{self._corrections}: "
f"killed stuck ffmpeg pid={self._ffmpeg_pid} "
f"(no data for {age:.1f}s)"
)
except (ProcessLookupError, PermissionError):
pass
consecutive_error = 0 # reset so we don't spam kills
async def _log_stream_event(
self,
event_type: str,
domain: str,
error_message: str,
attempt: int,
) -> None:
"""Persist a stream failure/recovery event to the database (fire-and-forget)."""
try:
from ..database import AsyncSessionLocal
from ..models.stream_event import StreamEvent
async with AsyncSessionLocal() as db:
ev = StreamEvent(
channel_id=self.channel_id,
channel_name=self.channel_name or f"Canal #{self.channel_id}",
provider_name=self.provider_name,
event_type=event_type,
domain=domain,
error_message=error_message[:500] if error_message else None,
attempt_number=attempt,
)
db.add(ev)
await db.commit()
except Exception as exc:
logger.debug(f"[ch{self.channel_id}] Failed to persist stream event: {exc}")
async def _probe_info(self) -> None:
from .probe import probe_stream
try:
info = await probe_stream(self.stream_url)
self.stream_info.update(info)
except Exception as e:
logger.debug(f"probe_info failed for ch{self.channel_id}: {e}")
async def _pump_with_retry(self) -> None:
consecutive = 0
base_wait = 0.5
url_idx = 0
n = len(self._stream_urls) or 1
try:
while self._running:
current_url = self._stream_urls[url_idx % n]
self._active_url = current_url
pump_start = time.time()
try:
await self._pump_once(current_url)
url_idx = 0 # reset to primary after successful pump
break
except asyncio.CancelledError:
raise
except Exception as e:
if not self._running:
break
pump_duration = time.time() - pump_start
# Recovery detection: had failures before but this attempt ran stably
# for >30s — the stream was healthy and dropped again (e.g. provider
# session timeout). Log recovery, then treat next failure as fresh drop.
if consecutive > 1 and pump_duration > 30:
asyncio.create_task(self._log_stream_event(
"recuperado",
_url_netloc(current_url),
f"Estable {int(pump_duration)}s antes de caer de nuevo",
consecutive,
))
consecutive = 0 # reset so next failure is logged as a fresh "caida"
consecutive += 1
self._reconnect_count += 1
next_idx = (url_idx + 1) % n
cycle_done = (consecutive % n == 0)
# Log the first drop of each failure sequence
if consecutive == 1:
asyncio.create_task(self._log_stream_event(
"caida",
_url_netloc(current_url),
str(e),
1,
))
if n > 1:
logger.warning(
f"[ch{self.channel_id}] Domain {_url_netloc(current_url)} failed "
f"(attempt {consecutive}), trying {_url_netloc(self._stream_urls[next_idx])}: {e}"
)
else:
logger.warning(
f"[ch{self.channel_id}] Upstream error (attempt {consecutive}): {e}"
)
url_idx = next_idx
self._status = "reconnecting"
if cycle_done:
# All domains tried — back off before next cycle.
# Do NOT drain client queues: the buffered data (up to 32 MB) covers
# the reconnect window so players never see a black screen.
wait = min(base_wait * (2 ** (consecutive // n - 1)), _BACKOFF_CAP)
logger.warning(f"[ch{self.channel_id}] All {n} domain(s) failed, retrying in {wait:.1f}s")
await asyncio.sleep(wait)
# else: try next domain immediately (no sleep)
finally:
self._status = "stopped"
self._running = False
async with self._lock:
for handle in self._clients.values():
_drain_queue(handle.queue)
try:
handle.queue.put_nowait(_SENTINEL)
except asyncio.QueueFull:
pass
async def _pump_once(self, url: str | None = None) -> None:
"""
Spawn ffmpeg in copy (remux) mode and pump its stdout to all client
queues until ffmpeg exits or self._running goes False.
Reconnection is intentionally NOT delegated to ffmpeg (-reconnect_streamed
is omitted). When the provider closes the TCP session (typically every
~2 minutes), ffmpeg exits cleanly and _pump_with_retry immediately spawns
a fresh process that opens a brand-new HTTP connection. The provider then
delivers the stream from the current live edge — timestamps are always
forward-moving. If ffmpeg were to reconnect internally it would often
receive a few seconds of already-seen data (the provider rewinds to the
last keyframe boundary), causing a visible backward seek in the player.
"""
stream_url = url if url is not None else self.stream_url
cmd = [
_FFMPEG,
"-hide_banner", "-loglevel", "warning",
"-timeout", str(int(STALL_TIMEOUT * 1_000_000)),
"-i", stream_url,
"-c", "copy",
"-f", "mpegts",
"pipe:1",
]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self._status = "ok"
self._last_data_at = time.time()
self._ffmpeg_pid = proc.pid
self._ffmpeg_start_time = time.time()
logger.info(
f"[ch{self.channel_id}] ffmpeg started pid={proc.pid} "
f"(reconnects so far: {self._reconnect_count})"
)
# Drain stderr in background so the pipe never fills and blocks ffmpeg.
stderr_task = asyncio.create_task(self._log_ffmpeg_stderr(proc))
try:
while self._running:
try:
chunk = await asyncio.wait_for(
proc.stdout.read(CHUNK_SIZE),
timeout=STALL_TIMEOUT + 15, # safety net beyond ffmpeg's own timeout
)
except asyncio.TimeoutError:
raise RuntimeError(
f"ffmpeg produced no output for {STALL_TIMEOUT + 15:.0f}s"
)
if not chunk:
# ffmpeg exited cleanly or with error
rc = proc.returncode
raise RuntimeError(f"ffmpeg exited (rc={rc})")
# Throughput accounting
self._bytes_pumped += len(chunk)
self._last_data_at = time.time()
now = self._last_data_at
if now - self._bps_time >= 2.0:
self._bps_down = (self._bytes_pumped - self._bytes_prev) / (now - self._bps_time)
self._bytes_prev = self._bytes_pumped
self._bps_time = now
await self._dispatch(chunk, now)
finally:
self._ffmpeg_pid = None
self._ffmpeg_start_time = None
self._ffmpeg_cpu = 0.0
stderr_task.cancel()
try:
proc.kill()
except ProcessLookupError:
pass
try:
await asyncio.wait_for(proc.wait(), timeout=5)
except asyncio.TimeoutError:
pass
async def _log_ffmpeg_stderr(self, proc: asyncio.subprocess.Process) -> None:
"""Read ffmpeg stderr lines, log them and count A/V-quality events."""
try:
async for line in proc.stderr:
text = line.decode(errors="replace").rstrip()
if text:
logger.warning(f"[ch{self.channel_id}] ffmpeg: {text}")
if any(p in text for p in _AV_WARN_PATTERNS):
self._av_desync_count += 1
except asyncio.CancelledError:
pass
except Exception:
pass
async def _dispatch(self, chunk: bytes, now: float) -> None:
"""Push chunk to every client queue; evict clients that have been full for >30 s."""
async with self._lock:
dead: list[str] = []
for cid, handle in self._clients.items():
try:
handle.queue.put_nowait(chunk)
handle._overflow_count = 0
handle._overflow_since = None
except asyncio.QueueFull:
handle._overflow_count += 1
if handle._overflow_since is None:
handle._overflow_since = now
elif now - handle._overflow_since > 30:
dead.append(cid)
for cid in dead:
logger.warning(
f"[ch{self.channel_id}] Evicting unresponsive client {cid} "
f"after 30 s of sustained queue overflow"
)
dropped = self._clients.pop(cid, None)
if dropped is not None:
_drain_queue(dropped.queue)
try:
dropped.queue.put_nowait(_SENTINEL)
except asyncio.QueueFull:
pass
def _drain_queue(q: asyncio.Queue) -> None:
"""Empty a queue non-blockingly."""
while not q.empty():
try:
q.get_nowait()
except asyncio.QueueEmpty:
break
+164
View File
@@ -0,0 +1,164 @@
"""VodTracker: lightweight session tracking for VOD and Series streams."""
import asyncio
import logging
import time
import uuid
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
STALE_TIMEOUT = 10 # seconds of no new bytes before a session is considered dead
@dataclass
class VodSession:
session_id: str
user_id: str
username: str
channel_id: int
channel_name: str
provider_account_id: int
provider_name: str
stream_type: str # "movie" or "series"
stream_url: str
started_at: float = field(default_factory=time.time)
last_active: float = field(default_factory=time.time)
bytes_pumped: int = 0
bps_down: float = 0.0 # download speed from provider
bps_up: float = 0.0 # upload speed to client (same as down for VOD, 1 client)
running: bool = True
_stop: asyncio.Event = field(default_factory=asyncio.Event)
# Set by _proxy_vod so kill() can immediately abort the upstream connection
_http_session: object = field(default=None, repr=False)
@property
def should_stop(self) -> bool:
return self._stop.is_set()
def kill(self) -> None:
self._stop.set()
# Close the upstream aiohttp session immediately so iter_chunked() raises
# and the generator's finally block runs without waiting for the next chunk.
http = self._http_session
if http is not None:
try:
http.connector.close()
except Exception:
pass
def to_dict(self) -> dict:
return {
"stream_type": self.stream_type,
"channel_id": self.channel_id,
"channel_name": self.channel_name,
"provider_account_id": self.provider_account_id,
"provider_name": self.provider_name,
"client_count": 1,
"clients": [{"client_id": self.session_id, "username": self.username}],
"started_at": self.started_at,
"bytes_pumped": self.bytes_pumped,
"bps_down": self.bps_down,
"bps_up": self.bps_up,
"running": self.running,
}
class VodTracker:
def __init__(self) -> None:
self._sessions: dict[str, VodSession] = {}
self._user_channel_map: dict[tuple[str, int], str] = {}
self._cleanup_task: asyncio.Task | None = None
def _ensure_cleanup_running(self) -> None:
"""Start the background staleness sweep if not already running."""
if self._cleanup_task is None or self._cleanup_task.done():
try:
loop = asyncio.get_running_loop()
self._cleanup_task = loop.create_task(self._cleanup_loop(), name="vod-cleanup")
except RuntimeError:
pass # no event loop yet — task will start on first session
async def _cleanup_loop(self) -> None:
"""Every 5 s, remove sessions that have received no new bytes for STALE_TIMEOUT s."""
while True:
await asyncio.sleep(5)
now = time.time()
stale = [
s for s in list(self._sessions.values())
if now - s.last_active > STALE_TIMEOUT
]
for s in stale:
logger.info(
f"Removing stale VOD session {s.session_id} "
f"(user={s.user_id}, ch={s.channel_id}, "
f"idle={now - s.last_active:.0f}s)"
)
s.kill()
self._sessions.pop(s.session_id, None)
key = (s.user_id, s.channel_id)
if self._user_channel_map.get(key) == s.session_id:
self._user_channel_map.pop(key, None)
def start(
self,
user_id: str,
username: str,
channel_id: int,
channel_name: str,
provider_account_id: int,
provider_name: str,
stream_type: str,
stream_url: str,
user_max_connections: int = 1,
) -> VodSession:
self._ensure_cleanup_running()
# Kill ALL existing VOD sessions for this user — one active VOD per user.
# Handles both probe-zombies (same channel) and movie switching (different channel).
for s in list(self._sessions.values()):
if s.user_id == user_id:
self._sessions.pop(s.session_id, None)
old_key = (s.user_id, s.channel_id)
if self._user_channel_map.get(old_key) == s.session_id:
self._user_channel_map.pop(old_key, None)
s.kill()
logger.info(
f"Killed VOD session {s.session_id} (ch={s.channel_id}) "
f"for user={user_id} — new session starting"
)
session_id = f"vod-{uuid.uuid4().hex[:8]}"
session = VodSession(
session_id=session_id,
user_id=user_id,
username=username,
channel_id=channel_id,
channel_name=channel_name,
provider_account_id=provider_account_id,
provider_name=provider_name,
stream_type=stream_type,
stream_url=stream_url,
)
self._sessions[session_id] = session
self._user_channel_map[(user_id, channel_id)] = session_id
return session
def end(self, session_id: str) -> None:
session = self._sessions.pop(session_id, None)
if session:
key = (session.user_id, session.channel_id)
if self._user_channel_map.get(key) == session_id:
self._user_channel_map.pop(key, None)
def kill(self, session_id: str) -> bool:
s = self._sessions.get(session_id)
if s:
s.kill()
return True
return False
def get_all(self) -> list[dict]:
return [s.to_dict() for s in self._sessions.values()]
vod_tracker = VodTracker()
+87
View File
@@ -0,0 +1,87 @@
"""Client for consuming Xtream Codes API from provider."""
import aiohttp
import asyncio
from typing import Any
import logging
logger = logging.getLogger(__name__)
class XtreamClient:
def __init__(self, base_url: str, username: str, password: str):
self.base_url = base_url.rstrip("/")
self.username = username
self.password = password
def _api_url(self) -> str:
return f"{self.base_url}/player_api.php"
async def _get(self, params: dict) -> Any:
params = {"username": self.username, "password": self.password, **params}
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(self._api_url(), params=params, ssl=False) as resp:
resp.raise_for_status()
return await resp.json(content_type=None)
async def get_user_info(self) -> dict:
return await self._get({})
async def get_live_categories(self) -> list[dict]:
return await self._get({"action": "get_live_categories"})
async def get_live_streams(self, category_id: str | None = None) -> list[dict]:
params = {"action": "get_live_streams"}
if category_id:
params["category_id"] = category_id
return await self._get(params)
async def get_vod_categories(self) -> list[dict]:
return await self._get({"action": "get_vod_categories"})
async def get_vod_streams(self, category_id: str | None = None) -> list[dict]:
params = {"action": "get_vod_streams"}
if category_id:
params["category_id"] = category_id
return await self._get(params)
async def get_series_categories(self) -> list[dict]:
return await self._get({"action": "get_series_categories"})
async def get_series(self, category_id: str | None = None) -> list[dict]:
params = {"action": "get_series"}
if category_id:
params["category_id"] = category_id
return await self._get(params)
async def get_series_info(self, series_id: str) -> dict:
return await self._get({"action": "get_series_info", "series_id": series_id})
async def get_vod_info(self, vod_id: str) -> dict:
return await self._get({"action": "get_vod_info", "vod_id": vod_id})
async def get_epg(self, stream_id: str | None = None) -> dict:
params = {"action": "get_short_epg"}
if stream_id:
params["stream_id"] = stream_id
return await self._get(params)
def build_live_stream_url(self, stream_id: str, ext: str = "ts") -> str:
return f"{self.base_url}/{self.username}/{self.password}/{stream_id}.{ext}"
def build_vod_stream_url(self, stream_id: str, ext: str = "mkv") -> str:
return f"{self.base_url}/movie/{self.username}/{self.password}/{stream_id}.{ext}"
def build_series_stream_url(self, stream_id: str, ext: str = "mkv") -> str:
return f"{self.base_url}/series/{self.username}/{self.password}/{stream_id}.{ext}"
def get_xmltv_url(self) -> str:
return f"{self.base_url}/xmltv.php?username={self.username}&password={self.password}"
async def test_connection(self) -> bool:
try:
info = await self.get_user_info()
return info.get("user_info", {}).get("status") == "Active"
except Exception as e:
logger.warning(f"Provider connection test failed: {e}")
return False
+42
View File
@@ -0,0 +1,42 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import text
from .config import settings
engine = create_async_engine(
settings.database_url,
echo=False,
pool_size=20,
max_overflow=10,
pool_pre_ping=True,
)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
class Base(DeclarativeBase):
pass
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
async def init_db():
from .models import provider, channel, user, epg, custom_category, log, notification, jellyfin, stream_event # noqa: ensure all models imported
from .models.provider import ProviderUrl # noqa: ensure ProviderUrl is registered
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Migrate: create provider_urls entries from existing base_url if not already done
await conn.execute(text("""
INSERT INTO provider_urls (provider_account_id, url, priority, is_active, status)
SELECT id, base_url, 0, true, 'unknown'
FROM provider_accounts
WHERE id NOT IN (SELECT DISTINCT provider_account_id FROM provider_urls WHERE provider_account_id IS NOT NULL)
ON CONFLICT DO NOTHING
"""))
await conn.commit()
+143
View File
@@ -0,0 +1,143 @@
"""KiraStream — IPTV Management Server."""
import asyncio
import logging
import sys
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.security import OAuth2PasswordRequestForm
import json
from .config import settings
from .database import init_db, AsyncSessionLocal
from .core.pool import pool
from .core.epg_manager import epg_background_task
from .core.health_checker import health_check_loop
from .database import get_db
logging.basicConfig(
level=getattr(logging, settings.log_level),
format="%(asctime)s %(levelname)s %(name)s — %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
logger = logging.getLogger(__name__)
async def _auto_sync_loop() -> None:
"""Check every 30 minutes if any provider is due for auto-sync."""
from sqlalchemy import select
from .models.provider import ProviderAccount
from .api.admin.providers import _run_sync, _sync_status
await asyncio.sleep(60) # delay first check so startup completes
while True:
try:
async with AsyncSessionLocal() as db:
result = await db.execute(
select(ProviderAccount).where(ProviderAccount.auto_sync_hours > 0, ProviderAccount.is_active == True) # noqa: E712
)
providers = result.scalars().all()
now = asyncio.get_event_loop().time()
import time as _time
wall_now = _time.time()
for p in providers:
interval_secs = p.auto_sync_hours * 3600
last_sync_ts = p.last_sync_at.timestamp() if p.last_sync_at else 0
if wall_now - last_sync_ts >= interval_secs:
if _sync_status.get(p.id, {}).get("status") == "running":
continue
logger.info(f"Auto-sync triggered for provider {p.id} ({p.name})")
cats = json.loads(p.last_sync_categories) if p.last_sync_categories else {}
asyncio.create_task(_run_sync(
p.id, p.base_url, p.username, p.password,
selected_live=set(cats["live"]) if cats.get("live") is not None else None,
selected_movie=set(cats["movie"]) if cats.get("movie") is not None else None,
selected_series=set(cats["series"]) if cats.get("series") is not None else None,
))
except Exception as e:
logger.error(f"Auto-sync loop error: {e}")
await asyncio.sleep(1800) # check every 30 minutes
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("KiraStream starting up...")
await init_db()
await pool.init()
epg_task = asyncio.create_task(epg_background_task(get_db))
auto_sync_task = asyncio.create_task(_auto_sync_loop())
health_task = asyncio.create_task(health_check_loop())
logger.info("KiraStream ready")
yield
logger.info("KiraStream shutting down...")
await pool.close()
epg_task.cancel()
auto_sync_task.cancel()
health_task.cancel()
for t in (epg_task, auto_sync_task, health_task):
try:
await t
except asyncio.CancelledError:
pass
app = FastAPI(
title="KiraStream",
description="Professional IPTV Management with Xtream Codes",
version="1.0.0",
lifespan=lifespan,
docs_url="/api/docs",
redoc_url="/api/redoc",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- Admin auth ---
from .api.auth import login, Token # noqa: E402
@app.post("/api/admin/auth/token", response_model=Token, tags=["admin"])
async def admin_login(form_data: OAuth2PasswordRequestForm = Depends()):
return await login(form_data)
# --- Admin routers ---
from .api.admin.providers import router as providers_router # noqa: E402
from .api.admin.users import router as users_router # noqa: E402
from .api.admin.catalogs import router as catalogs_router # noqa: E402
from .api.admin.epg_admin import router as epg_admin_router # noqa: E402
from .api.admin.monitoring import router as monitoring_router # noqa: E402
from .api.admin.custom_categories import router as custom_cats_router # noqa: E402
from .api.admin.logs import router as logs_router # noqa: E402
from .api.admin.settings import router as settings_router # noqa: E402
from .api.admin.jellyfin_admin import router as jellyfin_router # noqa: E402
app.include_router(providers_router, prefix="/api/admin")
app.include_router(users_router, prefix="/api/admin")
app.include_router(catalogs_router, prefix="/api/admin")
app.include_router(epg_admin_router, prefix="/api/admin")
app.include_router(monitoring_router, prefix="/api/admin")
app.include_router(custom_cats_router, prefix="/api/admin")
app.include_router(logs_router, prefix="/api/admin")
app.include_router(settings_router, prefix="/api/admin")
app.include_router(jellyfin_router, prefix="/api/admin")
# --- Xtream Codes compatible API (public) ---
from .api.xtream import router as xtream_router # noqa: E402
app.include_router(xtream_router)
@app.get("/health")
async def health():
return {"status": "ok", "service": "kirastream"}
@app.get("/")
async def root():
return JSONResponse({"message": "KiraStream IPTV Server", "docs": "/api/docs"})
+15
View File
@@ -0,0 +1,15 @@
from .provider import ProviderAccount
from .channel import Category, Channel, ChannelProviderMap, ContentType
from .user import User, UserCatalogEntry
from .epg import EpgSource
__all__ = [
"ProviderAccount",
"Category",
"Channel",
"ChannelProviderMap",
"ContentType",
"User",
"UserCatalogEntry",
"EpgSource",
]
+61
View File
@@ -0,0 +1,61 @@
import enum
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Enum, UniqueConstraint, func
from sqlalchemy.orm import relationship
from ..database import Base
class ContentType(str, enum.Enum):
live = "live"
movie = "movie"
series = "series"
class Category(Base):
__tablename__ = "categories"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(300), nullable=False)
type = Column(Enum(ContentType), nullable=False)
provider_category_id = Column(String(100), nullable=True)
provider_account_id = Column(Integer, ForeignKey("provider_accounts.id", ondelete="CASCADE"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
channels = relationship("Channel", back_populates="category")
catalog_entries = relationship("UserCatalogEntry", back_populates="category", cascade="all, delete-orphan")
__table_args__ = (
UniqueConstraint("provider_category_id", "provider_account_id", "type", name="uq_category_provider"),
)
class Channel(Base):
__tablename__ = "channels"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(500), nullable=False)
tvg_id = Column(String(200), nullable=True, index=True)
tvg_logo = Column(String(1000), nullable=True)
stream_id_at_provider = Column(String(100), nullable=False)
category_id = Column(Integer, ForeignKey("categories.id", ondelete="SET NULL"), nullable=True)
type = Column(Enum(ContentType), nullable=False, default=ContentType.live)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
category = relationship("Category", back_populates="channels")
provider_maps = relationship("ChannelProviderMap", back_populates="channel", cascade="all, delete-orphan")
class ChannelProviderMap(Base):
__tablename__ = "channel_provider_map"
id = Column(Integer, primary_key=True, index=True)
channel_id = Column(Integer, ForeignKey("channels.id", ondelete="CASCADE"), nullable=False)
provider_account_id = Column(Integer, ForeignKey("provider_accounts.id", ondelete="CASCADE"), nullable=False)
stream_url = Column(String(2000), nullable=False)
channel = relationship("Channel", back_populates="provider_maps")
provider_account = relationship("ProviderAccount", back_populates="channel_maps")
__table_args__ = (
UniqueConstraint("channel_id", "provider_account_id", name="uq_channel_provider"),
)
+29
View File
@@ -0,0 +1,29 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Enum, ForeignKey, func
from sqlalchemy.orm import relationship
from ..database import Base
from .channel import ContentType
class CustomCategory(Base):
__tablename__ = "custom_categories"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False)
type = Column(Enum(ContentType), nullable=False)
position = Column(Integer, default=0, nullable=False)
is_visible = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
items = relationship("CustomCategoryItem", back_populates="category", cascade="all, delete-orphan")
class CustomCategoryItem(Base):
__tablename__ = "custom_category_items"
id = Column(Integer, primary_key=True, index=True)
custom_category_id = Column(Integer, ForeignKey("custom_categories.id"), nullable=False)
channel_id = Column(Integer, ForeignKey("channels.id"), nullable=False)
position = Column(Integer, default=0, nullable=False)
category = relationship("CustomCategory", back_populates="items")
channel = relationship("Channel")
+13
View File
@@ -0,0 +1,13 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, func
from ..database import Base
class EpgSource(Base):
__tablename__ = "epg_sources"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False)
url = Column(String(2000), nullable=False)
last_fetched_at = Column(DateTime(timezone=True), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
+47
View File
@@ -0,0 +1,47 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, func
from sqlalchemy.orm import relationship
from ..database import Base
class JellyfinConfig(Base):
__tablename__ = "jellyfin_configs"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False)
url = Column(String(500), nullable=False)
api_key = Column(String(300), nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
movies_library_id = Column(String(100), nullable=True)
series_library_id = Column(String(100), nullable=True)
movies_category_name = Column(String(200), default="Películas KiraTV", nullable=False)
series_category_name = Column(String(200), default="Series KiraTV", nullable=False)
last_sync_at = Column(DateTime(timezone=True), nullable=True)
# Dummy ProviderAccount auto-created to satisfy ChannelProviderMap FK
provider_account_id = Column(
Integer,
ForeignKey("provider_accounts.id", ondelete="SET NULL"),
nullable=True,
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
items = relationship("JellyfinItem", back_populates="config", cascade="all, delete-orphan")
class JellyfinItem(Base):
__tablename__ = "jellyfin_items"
id = Column(Integer, primary_key=True, index=True)
jellyfin_config_id = Column(
Integer, ForeignKey("jellyfin_configs.id", ondelete="CASCADE"), nullable=False
)
channel_id = Column(
Integer, ForeignKey("channels.id", ondelete="CASCADE"), nullable=True
)
jellyfin_item_id = Column(String(100), nullable=False, index=True) # Jellyfin hex ID
item_type = Column(String(20), nullable=False) # "movie" | "series"
plot = Column(Text, nullable=True)
year = Column(Integer, nullable=True)
genres = Column(String(500), nullable=True)
rating = Column(String(20), nullable=True)
config = relationship("JellyfinConfig", back_populates="items")
+19
View File
@@ -0,0 +1,19 @@
from sqlalchemy import Column, Integer, BigInteger, String, DateTime, func
from ..database import Base
class ConnectionLog(Base):
__tablename__ = "connection_logs"
id = Column(BigInteger, primary_key=True, index=True)
user_id = Column(Integer, nullable=False, index=True)
username = Column(String(200), nullable=False, index=True)
channel_id = Column(Integer, nullable=True)
channel_name = Column(String(500), nullable=True)
content_type = Column(String(20), nullable=True) # live, movie, series
stream_id = Column(String(100), nullable=True)
started_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
ended_at = Column(DateTime(timezone=True), nullable=True)
duration_seconds = Column(Integer, nullable=True)
bytes_transferred = Column(BigInteger, default=0, nullable=False)
client_ip = Column(String(100), nullable=True)
+16
View File
@@ -0,0 +1,16 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, UniqueConstraint, func
from ..database import Base
class NotificationDismissal(Base):
__tablename__ = "notification_dismissals"
id = Column(Integer, primary_key=True, index=True)
provider_id = Column(Integer, ForeignKey("provider_accounts.id", ondelete="CASCADE"), nullable=False)
category_id_str = Column(String(100), nullable=False)
category_type = Column(String(20), nullable=False)
dismissed_at = Column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
UniqueConstraint("provider_id", "category_id_str", "category_type", name="uq_dismissal"),
)
+41
View File
@@ -0,0 +1,41 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, func
from sqlalchemy.orm import relationship
from ..database import Base
class ProviderAccount(Base):
__tablename__ = "provider_accounts"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
base_url = Column(String(500), nullable=False)
username = Column(String(200), nullable=False)
password = Column(String(200), nullable=False)
max_connections = Column(Integer, default=1, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
last_sync_at = Column(DateTime(timezone=True), nullable=True)
expiry_date = Column(DateTime(timezone=True), nullable=True)
# 0 = disabled; 6/12/24 = sync every N hours automatically
auto_sync_hours = Column(Integer, default=0, nullable=False)
# JSON: {"live": ["cat_id", ...], "movie": [...], "series": [...]}
last_sync_categories = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
channel_maps = relationship("ChannelProviderMap", back_populates="provider_account", cascade="all, delete-orphan")
extra_urls = relationship("ProviderUrl", back_populates="provider_account", cascade="all, delete-orphan", order_by="ProviderUrl.priority")
class ProviderUrl(Base):
__tablename__ = "provider_urls"
id = Column(Integer, primary_key=True, index=True)
provider_account_id = Column(Integer, ForeignKey("provider_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
url = Column(String(500), nullable=False)
priority = Column(Integer, default=0, nullable=False) # 0=primary, higher=lower priority
is_active = Column(Boolean, default=True, nullable=False)
status = Column(String(20), default="unknown", nullable=False) # unknown|ok|error|timeout
response_ms = Column(Integer, nullable=True)
last_checked_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
provider_account = relationship("ProviderAccount", back_populates="extra_urls")
+16
View File
@@ -0,0 +1,16 @@
from sqlalchemy import Column, BigInteger, Integer, String, Text, DateTime, func
from ..database import Base
class StreamEvent(Base):
__tablename__ = "stream_events"
id = Column(BigInteger, primary_key=True, index=True)
channel_id = Column(Integer, nullable=True, index=True)
channel_name = Column(String(500), nullable=True)
provider_name = Column(String(200), nullable=True)
event_type = Column(String(50), nullable=False, index=True) # caida | recuperado
domain = Column(String(300), nullable=True)
error_message = Column(Text, nullable=True)
attempt_number = Column(Integer, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
+66
View File
@@ -0,0 +1,66 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint, func
from sqlalchemy.orm import relationship
import bcrypt as _bcrypt
from ..database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(100), unique=True, nullable=False, index=True)
password_hash = Column(String(200), nullable=False)
max_connections = Column(Integer, default=1, nullable=False)
expiry_date = Column(DateTime(timezone=True), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
is_priority = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
catalog_entries = relationship("UserCatalogEntry", back_populates="user", cascade="all, delete-orphan")
provider_preferences = relationship("UserProviderPreference", back_populates="user", cascade="all, delete-orphan", order_by="UserProviderPreference.priority")
def set_password(self, password: str) -> None:
hashed = _bcrypt.hashpw(password.encode(), _bcrypt.gensalt())
self.password_hash = hashed.decode()
def verify_password(self, password: str) -> bool:
return _bcrypt.checkpw(password.encode(), self.password_hash.encode())
class UserCatalogEntry(Base):
__tablename__ = "user_catalog_entries"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
category_id = Column(Integer, ForeignKey("categories.id", ondelete="CASCADE"), nullable=False)
user = relationship("User", back_populates="catalog_entries")
category = relationship("Category", back_populates="catalog_entries")
class UserCustomCatalogEntry(Base):
__tablename__ = "user_custom_catalog_entries"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
custom_category_id = Column(Integer, ForeignKey("custom_categories.id", ondelete="CASCADE"), nullable=False)
__table_args__ = (
UniqueConstraint("user_id", "custom_category_id", name="uq_user_custom_cat"),
)
class UserProviderPreference(Base):
__tablename__ = "user_provider_preferences"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
provider_account_id = Column(Integer, ForeignKey("provider_accounts.id", ondelete="CASCADE"), nullable=False)
priority = Column(Integer, default=0, nullable=False)
user = relationship("User", back_populates="provider_preferences")
provider_account = relationship("ProviderAccount")
__table_args__ = (
UniqueConstraint("user_id", "provider_account_id", name="uq_user_provider_pref"),
)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KiraStream Admin</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script type="module" crossorigin src="/panel/assets/index-H1dlHjcY.js"></script>
<link rel="stylesheet" crossorigin href="/panel/assets/index-Bf2TNXpE.css">
</head>
<body>
<div id="root"></div>
</body>
</html>