Add FastAPI backend and React frontend subprojects
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)
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
"""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}"
|
||||
Reference in New Issue
Block a user