f5d6745ed0
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.
407 lines
14 KiB
Python
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", [])),
|
|
}
|