Phase 2: Docker and OIDC auth
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Jellyseerr HTTP API client.
|
||||
|
||||
Jellyseerr is optional. When configured, it can enrich the Jellyfin user list
|
||||
with email addresses, avatars, permissions, and request metadata.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JellyseerrClient:
|
||||
"""Small wrapper around the Jellyseerr REST API."""
|
||||
|
||||
def __init__(self, base_url: str, api_key: str, timeout: int = 30):
|
||||
if not base_url:
|
||||
raise ValueError("Jellyseerr URL is required")
|
||||
if not api_key:
|
||||
raise ValueError("Jellyseerr API key is required")
|
||||
|
||||
self.base_url = base_url.rstrip("/")
|
||||
if self.base_url.endswith("/api/v1"):
|
||||
self.base_url = self.base_url[:-7]
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
"X-Api-Key": api_key,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
def get(self, path: str, **params: Any) -> Any:
|
||||
"""GET a Jellyseerr 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("Jellyseerr GET %s params=%s", path, sorted(clean_params.keys()))
|
||||
response = self.session.get(
|
||||
f"{self.base_url}/api/v1{path}", params=clean_params, timeout=self.timeout
|
||||
)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
detail = response.text[:500]
|
||||
logger.warning(
|
||||
"Jellyseerr 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("Jellyseerr GET %s ok status=%s", path, response.status_code)
|
||||
return response.json()
|
||||
|
||||
def absolute_url(self, path: str | None) -> str:
|
||||
"""Return an absolute URL for Jellyseerr-relative assets."""
|
||||
if not path:
|
||||
return ""
|
||||
if path.startswith("http://") or path.startswith("https://"):
|
||||
return path
|
||||
if not path.startswith("/"):
|
||||
path = f"/{path}"
|
||||
return f"{self.base_url}{path}"
|
||||
|
||||
def jellyfin_users(self) -> list[dict[str, Any]]:
|
||||
"""Return Jellyfin-linked users known to Jellyseerr.
|
||||
|
||||
Jellyseerr has used both a top-level list payload and a wrapped
|
||||
`{ "users": [...] }` payload in different versions/docs, so accept
|
||||
either shape.
|
||||
"""
|
||||
payload = self.get("/settings/jellyfin/users")
|
||||
if isinstance(payload, list):
|
||||
users = [item for item in payload if isinstance(item, dict)]
|
||||
logger.info("Jellyseerr returned %s Jellyfin-linked users", len(users))
|
||||
return users
|
||||
if isinstance(payload, dict):
|
||||
users = payload.get("users")
|
||||
if isinstance(users, list):
|
||||
mapped = [item for item in users if isinstance(item, dict)]
|
||||
logger.info("Jellyseerr returned %s Jellyfin-linked users (wrapped payload)", len(mapped))
|
||||
return mapped
|
||||
logger.info("Jellyseerr returned no Jellyfin-linked users")
|
||||
return []
|
||||
|
||||
def users(self, page_size: int = 1000) -> list[dict[str, Any]]:
|
||||
"""Return Jellyseerr users via the paginated /user list endpoint.
|
||||
|
||||
Jellyseerr's list endpoint uses ``take`` and ``skip`` query params,
|
||||
not ``page``.
|
||||
"""
|
||||
results: list[dict[str, Any]] = []
|
||||
take = max(1, int(page_size))
|
||||
skip = 0
|
||||
total_results: int | None = None
|
||||
|
||||
while True:
|
||||
payload = self.get("/user", take=take, skip=skip)
|
||||
if not isinstance(payload, dict):
|
||||
return results
|
||||
|
||||
page_results = payload.get("results") or []
|
||||
page_items = [item for item in page_results if isinstance(item, dict)] if isinstance(page_results, list) else []
|
||||
results.extend(page_items)
|
||||
|
||||
page_info = payload.get("pageInfo") or {}
|
||||
if isinstance(page_info, dict):
|
||||
try:
|
||||
page_total = int(page_info.get("results") or 0)
|
||||
if page_total:
|
||||
total_results = page_total
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
logger.debug(
|
||||
"Jellyseerr user page skip=%s take=%s -> %s results (total=%s)",
|
||||
skip,
|
||||
take,
|
||||
len(page_items),
|
||||
total_results if total_results is not None else "unknown",
|
||||
)
|
||||
|
||||
if not page_items:
|
||||
break
|
||||
skip += len(page_items)
|
||||
if len(page_items) < take:
|
||||
break
|
||||
if total_results is not None and skip >= total_results:
|
||||
break
|
||||
|
||||
logger.info("Jellyseerr returned %s users", len(results))
|
||||
return results
|
||||
@@ -9,12 +9,15 @@ Lines. This module starts/stops the collector and reads those JSONL samples.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# POSIX shell script copied to the remote server by start_resource_collector().
|
||||
# Keep this script bash-free because many NAS/media servers have minimal shells.
|
||||
COLLECTOR_SCRIPT = r'''#!/bin/sh
|
||||
@@ -199,9 +202,14 @@ else
|
||||
echo "started pid=$(cat {shlex.quote(paths.pid_file)})"
|
||||
fi
|
||||
"""
|
||||
logger.info(
|
||||
"Starting remote resource collector interval=%ss retention=%ss max_lines=%s", interval_seconds, retention_seconds, max_lines
|
||||
)
|
||||
result = ssh.run(command, timeout=20)
|
||||
if result.exit_status != 0:
|
||||
logger.warning("Failed to start remote resource collector: %s", result.stderr or result.stdout)
|
||||
raise RuntimeError(result.stderr or result.stdout or "failed to start resource collector")
|
||||
logger.info("Remote resource collector start response: %s", result.stdout.strip())
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
@@ -221,9 +229,12 @@ else
|
||||
echo "not running"
|
||||
fi
|
||||
"""
|
||||
logger.info("Stopping remote resource collector")
|
||||
result = ssh.run(command, timeout=20)
|
||||
if result.exit_status != 0:
|
||||
logger.warning("Failed to stop remote resource collector: %s", result.stderr or result.stdout)
|
||||
raise RuntimeError(result.stderr or result.stdout or "failed to stop resource collector")
|
||||
logger.info("Remote resource collector stop response: %s", result.stdout.strip())
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
@@ -234,6 +245,7 @@ def restart_resource_collector(
|
||||
max_lines: int = 70_000,
|
||||
paths: ResourceMonitorPaths = ResourceMonitorPaths(),
|
||||
) -> str:
|
||||
logger.info("Restarting remote resource collector")
|
||||
stop_message = stop_resource_collector(ssh, paths)
|
||||
start_message = start_resource_collector(ssh, interval_seconds, retention_seconds, max_lines, paths)
|
||||
return f"{stop_message}\n{start_message}"
|
||||
@@ -250,8 +262,11 @@ fi
|
||||
"""
|
||||
result = ssh.run(command, timeout=10)
|
||||
if result.exit_status != 0:
|
||||
logger.warning("Failed to read collector status: %s", result.stderr or result.stdout)
|
||||
raise RuntimeError(result.stderr or result.stdout or "failed to check collector status")
|
||||
return result.stdout.strip()
|
||||
status = result.stdout.strip()
|
||||
logger.info("Resource collector status: %s", status)
|
||||
return status
|
||||
|
||||
|
||||
def resource_collector_debug_info(ssh: RemoteSSHClient, paths: ResourceMonitorPaths = ResourceMonitorPaths()) -> str:
|
||||
@@ -281,15 +296,20 @@ if [ -f {shlex.quote(paths.log_file)} ]; then tail -n 40 {shlex.quote(paths.log_
|
||||
echo "netdev_snapshot:"
|
||||
cat /proc/net/dev 2>&1 || true
|
||||
"""
|
||||
logger.info("Collecting resource collector diagnostics")
|
||||
result = ssh.run(command, timeout=20)
|
||||
return (result.stdout or "") + (result.stderr or "")
|
||||
output = (result.stdout or "") + (result.stderr or "")
|
||||
logger.debug("Resource collector diagnostics length=%s", len(output))
|
||||
return output
|
||||
|
||||
|
||||
def read_resource_metrics(ssh: RemoteSSHClient, max_lines: int = 1000, paths: ResourceMonitorPaths = ResourceMonitorPaths()) -> list[dict[str, Any]]:
|
||||
"""Read recent JSONL metric samples from the remote collector file."""
|
||||
command = f"test -f {shlex.quote(paths.metrics_file)} && tail -n {int(max_lines)} {shlex.quote(paths.metrics_file)} || true"
|
||||
logger.debug("Reading up to %s resource metric lines", max_lines)
|
||||
result = ssh.run(command, timeout=20)
|
||||
if result.exit_status != 0:
|
||||
logger.warning("Failed to read resource metrics: %s", result.stderr or result.stdout)
|
||||
raise RuntimeError(result.stderr or result.stdout or "failed to read resource metrics")
|
||||
rows = []
|
||||
for line in result.stdout.splitlines():
|
||||
@@ -310,7 +330,11 @@ def disk_space(ssh: RemoteSSHClient, path: str = "/") -> dict[str, Any]:
|
||||
+ shlex.quote(path or "/")
|
||||
+ " | awk 'NR==2 {printf \"{\\\"filesystem\\\":\\\"%s\\\",\\\"size\\\":%s,\\\"used\\\":%s,\\\"available\\\":%s,\\\"used_pct\\\":\\\"%s\\\",\\\"mount\\\":\\\"%s\\\"}\", $1,$2,$3,$4,$5,$6}'"
|
||||
)
|
||||
logger.debug("Reading disk space for path=%s", path)
|
||||
result = ssh.run(command, timeout=20)
|
||||
if result.exit_status != 0 or not result.stdout.strip():
|
||||
logger.warning("Failed to read disk space for %s: %s", path, result.stderr or result.stdout)
|
||||
raise RuntimeError(result.stderr or result.stdout or "failed to read disk space")
|
||||
return json.loads(result.stdout)
|
||||
data = json.loads(result.stdout)
|
||||
logger.info("Disk space path=%s mount=%s used_pct=%s", path, data.get("mount"), data.get("used_pct"))
|
||||
return data
|
||||
|
||||
@@ -10,6 +10,7 @@ commands are shell-quoted by callers. This is important for two reasons:
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import posixpath
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
@@ -17,6 +18,8 @@ from typing import Any
|
||||
|
||||
import paramiko
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
@@ -88,14 +91,25 @@ class RemoteSSHClient:
|
||||
"""
|
||||
client = self.connect()
|
||||
shell_command = f"/bin/sh -c {shlex.quote(command)}"
|
||||
logger.debug("SSH run host=%s timeout=%s command=%s", self.host, timeout or self.timeout, command)
|
||||
stdin, stdout, stderr = client.exec_command(shell_command, timeout=timeout or self.timeout)
|
||||
exit_status = stdout.channel.recv_exit_status()
|
||||
return CommandResult(
|
||||
result = CommandResult(
|
||||
command=command,
|
||||
exit_status=exit_status,
|
||||
stdout=stdout.read().decode(errors="replace"),
|
||||
stderr=stderr.read().decode(errors="replace"),
|
||||
)
|
||||
if result.exit_status == 0:
|
||||
logger.debug("SSH command ok host=%s exit_status=%s", self.host, result.exit_status)
|
||||
else:
|
||||
logger.warning(
|
||||
"SSH command failed host=%s exit_status=%s stderr=%s",
|
||||
self.host,
|
||||
result.exit_status,
|
||||
result.stderr.strip() or result.stdout.strip(),
|
||||
)
|
||||
return result
|
||||
|
||||
def list_dir(self, path: str) -> CommandResult:
|
||||
"""List one remote directory as JSON.
|
||||
@@ -123,12 +137,16 @@ class RemoteSSHClient:
|
||||
"print(json.dumps(rows))"
|
||||
)
|
||||
)
|
||||
return self.run(command)
|
||||
result = self.run(command)
|
||||
logger.info("SSH list_dir path=%s exit_status=%s", path, result.exit_status)
|
||||
return result
|
||||
|
||||
def stat_path(self, path: str) -> CommandResult:
|
||||
"""Run stat for a remote file or directory path."""
|
||||
quoted = shlex.quote(path)
|
||||
return self.run(f"stat --printf='%F\\n%s bytes\\n%y\\n%n\\n' {quoted}")
|
||||
result = self.run(f"stat --printf='%F\\n%s bytes\\n%y\\n%n\\n' {quoted}")
|
||||
logger.info("SSH stat path=%s exit_status=%s", path, result.exit_status)
|
||||
return result
|
||||
|
||||
def ffprobe_json(self, path: str) -> dict[str, Any]:
|
||||
"""Run ffprobe and parse JSON output for a remote media file."""
|
||||
@@ -137,6 +155,7 @@ class RemoteSSHClient:
|
||||
"ffprobe -v error -show_format -show_streams -print_format json " + quoted,
|
||||
timeout=60,
|
||||
)
|
||||
logger.info("SSH ffprobe path=%s exit_status=%s", path, result.exit_status)
|
||||
if result.exit_status != 0:
|
||||
raise RuntimeError(result.stderr or result.stdout or "ffprobe failed")
|
||||
return json.loads(result.stdout)
|
||||
|
||||
Reference in New Issue
Block a user