Files
KiraTV/backend/core/jellyfin_client.py
KiraStream b969b7e5af 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>
2026-05-19 14:43:31 +00:00

121 lines
4.3 KiB
Python

"""Async Jellyfin API client — discovery, libraries, items, streaming."""
import logging
import aiohttp
logger = logging.getLogger(__name__)
_SHORT = aiohttp.ClientTimeout(total=10)
_MEDIUM = aiohttp.ClientTimeout(total=30)
_LONG = aiohttp.ClientTimeout(total=60)
class JellyfinClient:
def __init__(self, url: str, api_key: str):
self.url = url.rstrip("/")
self.api_key = api_key
@property
def _headers(self) -> dict:
return {"X-Emby-Token": self.api_key, "Accept": "application/json"}
# ------------------------------------------------------------------
# Discovery
# ------------------------------------------------------------------
async def test_connection(self) -> dict:
async with aiohttp.ClientSession() as s:
async with s.get(
f"{self.url}/System/Info/Public", timeout=_SHORT
) as r:
r.raise_for_status()
return await r.json()
async def get_libraries(self) -> list[dict]:
async with aiohttp.ClientSession() as s:
async with s.get(
f"{self.url}/Library/VirtualFolders",
headers=self._headers,
timeout=_SHORT,
) as r:
r.raise_for_status()
data = await r.json()
return data if isinstance(data, list) else []
# ------------------------------------------------------------------
# Items (movies / series)
# ------------------------------------------------------------------
async def get_items(self, library_id: str, item_type: str) -> list[dict]:
"""Fetch all items of a given type from one library (paginated)."""
all_items: list[dict] = []
start = 0
limit = 200
while True:
params = {
"ParentId": library_id,
"IncludeItemTypes": item_type,
"Recursive": "true",
"Fields": "Overview,Genres,OfficialRating,ProductionYear",
"Limit": str(limit),
"StartIndex": str(start),
}
async with aiohttp.ClientSession() as s:
async with s.get(
f"{self.url}/Items",
params=params,
headers=self._headers,
timeout=_LONG,
) as r:
r.raise_for_status()
data = await r.json()
batch = data.get("Items", [])
all_items.extend(batch)
total = data.get("TotalRecordCount", 0)
if len(all_items) >= total or not batch:
break
start += len(batch)
return all_items
# ------------------------------------------------------------------
# Series structure (seasons + episodes)
# ------------------------------------------------------------------
async def get_seasons(self, series_id: str) -> list[dict]:
async with aiohttp.ClientSession() as s:
async with s.get(
f"{self.url}/Shows/{series_id}/Seasons",
params={"Fields": "Overview"},
headers=self._headers,
timeout=_MEDIUM,
) as r:
r.raise_for_status()
return (await r.json()).get("Items", [])
async def get_episodes(self, series_id: str, season_id: str) -> list[dict]:
async with aiohttp.ClientSession() as s:
async with s.get(
f"{self.url}/Shows/{series_id}/Episodes",
params={"SeasonId": season_id, "Fields": "Overview"},
headers=self._headers,
timeout=_MEDIUM,
) as r:
r.raise_for_status()
return (await r.json()).get("Items", [])
# ------------------------------------------------------------------
# URL builders
# ------------------------------------------------------------------
def build_stream_url(self, item_id: str) -> str:
return (
f"{self.url}/Videos/{item_id}/stream"
f"?static=true&api_key={self.api_key}&Container=mkv"
)
def build_image_url(self, item_id: str, max_width: int = 400) -> str:
return (
f"{self.url}/Items/{item_id}/Images/Primary"
f"?api_key={self.api_key}&MaxWidth={max_width}"
)