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