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