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