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,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", [])),
|
||||
}
|
||||
Reference in New Issue
Block a user