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