3c432473e5
Backend: - FastAPI app with 17 REST endpoints covering dashboard, monitoring, media index, file browser, and jobs - Reuses existing clients/domain/services unchanged - pydantic-settings config, dependency injection, CORS setup - Auto-generated OpenAPI docs at /docs Frontend: - Vite + React + TypeScript SPA - @tanstack/react-query for data fetching with polling - ag-grid-react for media table and file browser - recharts for monitoring charts - Tailwind CSS styling - 4 pages: Dashboard, Monitoring, Media, File Browser - Typed API client matching all backend endpoints Also: - docs/MIGRATION_PLAN.md with full architecture plan - Updated .gitignore for both subprojects - Streamlit app preserved for now (can coexist)
168 lines
6.3 KiB
Python
168 lines
6.3 KiB
Python
"""Jellyfin HTTP API client.
|
|
|
|
This module is deliberately independent from Streamlit. It wraps only the API
|
|
calls the app currently needs and returns plain Python dictionaries/lists so a
|
|
future FastAPI/React frontend can reuse the same client.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import requests
|
|
|
|
|
|
# Jellyfin validates Fields against its ItemFields enum. Keep this list to
|
|
# documented/commonly supported optional fields; invalid names cause 400s.
|
|
DEFAULT_FIELDS = ",".join(
|
|
[
|
|
"DateCreated",
|
|
"Genres",
|
|
"MediaSources",
|
|
"Overview",
|
|
"Path",
|
|
"People",
|
|
"PremiereDate",
|
|
"ProviderIds",
|
|
"Tags",
|
|
]
|
|
)
|
|
|
|
|
|
class JellyfinClient:
|
|
"""Small wrapper around the Jellyfin/Emby-compatible HTTP API."""
|
|
|
|
def __init__(self, base_url: str, api_key: str, timeout: int = 30):
|
|
if not base_url:
|
|
raise ValueError("Jellyfin URL is required")
|
|
if not api_key:
|
|
raise ValueError("Jellyfin API key is required")
|
|
|
|
# Use the server root, not the web UI path. Users often paste
|
|
# https://host/web; API endpoints live at https://host/...
|
|
self.base_url = base_url.rstrip("/")
|
|
if self.base_url.endswith("/web"):
|
|
self.base_url = self.base_url[:-4]
|
|
self.api_key = api_key
|
|
self.timeout = timeout
|
|
self.session = requests.Session()
|
|
self.session.headers.update(
|
|
{
|
|
"X-Emby-Token": api_key,
|
|
"Accept": "application/json",
|
|
"X-Emby-Authorization": 'MediaBrowser Client="MediaLibraryViewer", Device="Streamlit", DeviceId="streamlit", Version="0.1"',
|
|
}
|
|
)
|
|
|
|
def get(self, path: str, **params: Any) -> dict[str, Any]:
|
|
"""GET a Jellyfin endpoint and include useful response text on errors."""
|
|
clean_params = {k: v for k, v in params.items() if v is not None and v != ""}
|
|
response = self.session.get(
|
|
f"{self.base_url}{path}", params=clean_params, timeout=self.timeout
|
|
)
|
|
try:
|
|
response.raise_for_status()
|
|
except requests.HTTPError as exc:
|
|
detail = response.text[:500]
|
|
raise requests.HTTPError(
|
|
f"{response.status_code} for {response.url}: {detail}",
|
|
response=response,
|
|
) from exc
|
|
return response.json()
|
|
|
|
def users(self) -> list[dict[str, Any]]:
|
|
"""List users visible to this API key.
|
|
|
|
Jellyfin API keys are server-level tokens, not user session tokens, so
|
|
/Users/Me often fails with API-key auth. The user id selected here is
|
|
then used for user-scoped library endpoints.
|
|
"""
|
|
return self.get("/Users")
|
|
|
|
def libraries(self, user_id: str) -> list[dict[str, Any]]:
|
|
"""Return top-level library views visible to the selected Jellyfin user."""
|
|
return self.get(f"/Users/{user_id}/Views").get("Items", [])
|
|
|
|
def items(
|
|
self,
|
|
user_id: str,
|
|
parent_id: str | None = None,
|
|
start_index: int = 0,
|
|
limit: int = 50,
|
|
search: str | None = None,
|
|
include_item_types: str | None = None,
|
|
recursive: bool = True,
|
|
sort_by: str = "SortName",
|
|
sort_order: str = "Ascending",
|
|
) -> dict[str, Any]:
|
|
"""Return a paginated item list for a user/library.
|
|
|
|
This is used by both the visual library browser and the media-index
|
|
builder. Keep arguments close to Jellyfin's own query parameters so the
|
|
service layer can request server-side pagination and basic sorting.
|
|
"""
|
|
return self.get(
|
|
f"/Users/{user_id}/Items",
|
|
ParentId=parent_id,
|
|
StartIndex=start_index,
|
|
Limit=limit,
|
|
SearchTerm=search,
|
|
IncludeItemTypes=include_item_types,
|
|
Recursive=str(recursive).lower(),
|
|
Fields=DEFAULT_FIELDS,
|
|
SortBy=sort_by,
|
|
SortOrder=sort_order,
|
|
)
|
|
|
|
def item_count(self, user_id: str, include_item_types: str, parent_id: str | None = None) -> int:
|
|
"""Return a count using Jellyfin's TotalRecordCount without fetching rows."""
|
|
response = self.get(
|
|
f"/Users/{user_id}/Items",
|
|
ParentId=parent_id,
|
|
Recursive="true",
|
|
IncludeItemTypes=include_item_types,
|
|
Limit=0,
|
|
)
|
|
return int(response.get("TotalRecordCount", 0))
|
|
|
|
def media_counts(self, user_id: str) -> dict[str, int]:
|
|
"""Return dashboard-level counts for the main media types."""
|
|
return {
|
|
"movies": self.item_count(user_id, "Movie"),
|
|
"series": self.item_count(user_id, "Series"),
|
|
"episodes": self.item_count(user_id, "Episode"),
|
|
}
|
|
|
|
def library_item_counts(self, user_id: str, libraries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
"""Return per-library item counts broken down by type for the dashboard."""
|
|
results = []
|
|
for lib in libraries:
|
|
lib_id = lib.get("Id")
|
|
lib_name = lib.get("Name", "Unknown")
|
|
lib_type = lib.get("CollectionType", "")
|
|
if not lib_id:
|
|
continue
|
|
movies = self.item_count(user_id, "Movie", parent_id=lib_id)
|
|
series = self.item_count(user_id, "Series", parent_id=lib_id)
|
|
episodes = self.item_count(user_id, "Episode", parent_id=lib_id)
|
|
total = self.item_count(user_id, "Movie,Episode,Video,Audio,Series", parent_id=lib_id)
|
|
results.append({
|
|
"library": lib_name,
|
|
"type": lib_type,
|
|
"movies": movies,
|
|
"series": series,
|
|
"episodes": episodes,
|
|
"total": total,
|
|
})
|
|
return results
|
|
|
|
def active_sessions(self, active_within_seconds: int = 300) -> list[dict[str, Any]]:
|
|
"""Return currently active sessions that have a now-playing item."""
|
|
payload = self.get("/Sessions", ActiveWithinSeconds=active_within_seconds)
|
|
sessions = payload if isinstance(payload, list) else []
|
|
return [session for session in sessions if session.get("NowPlayingItem")]
|
|
|
|
def image_url(self, item_id: str, image_type: str = "Primary") -> str:
|
|
"""Build an authenticated image URL suitable for st.image/browser use."""
|
|
return f"{self.base_url}/Items/{item_id}/Images/{image_type}?api_key={self.api_key}"
|