Phase 2: Docker and OIDC auth

This commit is contained in:
2026-05-04 13:50:53 +02:00
parent 47baee854b
commit 4226628d5a
71 changed files with 9722 additions and 1347 deletions
@@ -7,10 +7,13 @@ future FastAPI/React frontend can reuse the same client.
from __future__ import annotations
from typing import Any
import logging
from typing import Any, cast
import requests
logger = logging.getLogger(__name__)
# Jellyfin validates Fields against its ItemFields enum. Keep this list to
# documented/commonly supported optional fields; invalid names cause 400s.
@@ -54,9 +57,10 @@ class JellyfinClient:
}
)
def get(self, path: str, **params: Any) -> dict[str, Any]:
def get(self, path: str, **params: Any) -> 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 != ""}
logger.debug("Jellyfin GET %s params=%s", path, sorted(clean_params.keys()))
response = self.session.get(
f"{self.base_url}{path}", params=clean_params, timeout=self.timeout
)
@@ -64,10 +68,12 @@ class JellyfinClient:
response.raise_for_status()
except requests.HTTPError as exc:
detail = response.text[:500]
logger.warning("Jellyfin GET %s failed status=%s url=%s", path, response.status_code, response.url)
raise requests.HTTPError(
f"{response.status_code} for {response.url}: {detail}",
response=response,
) from exc
logger.debug("Jellyfin GET %s ok status=%s", path, response.status_code)
return response.json()
def users(self) -> list[dict[str, Any]]:
@@ -77,11 +83,15 @@ class JellyfinClient:
/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")
users = self.get("/Users")
logger.info("Jellyfin returned %s visible users", len(users))
return 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", [])
items = self.get(f"/Users/{user_id}/Views").get("Items", [])
logger.info("Jellyfin returned %s libraries for user_id=%s", len(items), user_id)
return items
def items(
self,
@@ -101,6 +111,17 @@ class JellyfinClient:
builder. Keep arguments close to Jellyfin's own query parameters so the
service layer can request server-side pagination and basic sorting.
"""
logger.debug(
"Jellyfin items user_id=%s parent_id=%s start=%s limit=%s types=%s search=%s sort=%s/%s",
user_id,
parent_id or "<root>",
start_index,
limit,
include_item_types or "<all>",
search or "<none>",
sort_by,
sort_order,
)
return self.get(
f"/Users/{user_id}/Items",
ParentId=parent_id,
@@ -123,7 +144,15 @@ class JellyfinClient:
IncludeItemTypes=include_item_types,
Limit=0,
)
return int(response.get("TotalRecordCount", 0))
count = int(response.get("TotalRecordCount", 0))
logger.debug(
"Jellyfin item count user_id=%s parent_id=%s types=%s count=%s",
user_id,
parent_id or "<root>",
include_item_types,
count,
)
return count
def media_counts(self, user_id: str) -> dict[str, int]:
"""Return dashboard-level counts for the main media types."""
@@ -156,11 +185,20 @@ class JellyfinClient:
})
return results
def sessions(self, active_within_seconds: int | None = None) -> list[dict[str, Any]]:
"""Return Jellyfin sessions (playing and idle/logged-in).
When ``active_within_seconds`` is None, no recency filter is sent and
Jellyfin decides which sessions to include.
"""
payload: Any = self.get("/Sessions", ActiveWithinSeconds=active_within_seconds)
return cast(list[dict[str, Any]], payload) if isinstance(payload, list) else []
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")]
"""Return sessions that currently have a now-playing item."""
sessions = [session for session in self.sessions(active_within_seconds) if session.get("NowPlayingItem")]
logger.info("Jellyfin active sessions within %ss: %s", active_within_seconds, len(sessions))
return sessions
def image_url(self, item_id: str, image_type: str = "Primary") -> str:
"""Build an authenticated image URL suitable for st.image/browser use."""