Files
KiraTV/backend/api/admin/jellyfin_admin.py
T
joaquin f5d6745ed0 Initial commit: KiraTV IPTV proxy server
Full-stack IPTV management system with Xtream Codes API compatibility,
multi-account provider fan-out, Jellyfin local library integration,
real-time dashboard, EPG support, and A/V-sync-safe stream recovery.
2026-05-17 23:22:05 +02:00

407 lines
14 KiB
Python

"""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", [])),
}