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}
|
||||
Reference in New Issue
Block a user