b969b7e5af
- 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>
91 lines
3.0 KiB
Python
91 lines
3.0 KiB
Python
"""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())
|