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.
121 lines
4.3 KiB
Python
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}"
|
|
)
|