Start Magent beta overhaul
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
from ..models import NormalizedState, TriageRecommendation, TriageResult, Snapshot
|
||||
|
||||
|
||||
def triage_snapshot(snapshot: Snapshot) -> TriageResult:
|
||||
recommendations = []
|
||||
root_cause = "unknown"
|
||||
summary = "No clear blocker detected yet."
|
||||
confidence = 0.2
|
||||
|
||||
if snapshot.state == NormalizedState.requested:
|
||||
root_cause = "approval"
|
||||
summary = "The request is waiting for approval in Seerr."
|
||||
recommendations.append(
|
||||
TriageRecommendation(
|
||||
action_id="wait_for_approval",
|
||||
title="Ask an admin to approve the request",
|
||||
reason="Seerr has not marked this request as approved.",
|
||||
risk="low",
|
||||
)
|
||||
)
|
||||
confidence = 0.6
|
||||
|
||||
if snapshot.state == NormalizedState.needs_add:
|
||||
root_cause = "not_added"
|
||||
summary = "The request is approved but not added to Sonarr/Radarr yet."
|
||||
recommendations.append(
|
||||
TriageRecommendation(
|
||||
action_id="readd_to_arr",
|
||||
title="Push to Sonarr/Radarr",
|
||||
reason="Sonarr/Radarr has not created the entry for this request.",
|
||||
risk="medium",
|
||||
)
|
||||
)
|
||||
confidence = 0.7
|
||||
|
||||
if snapshot.state == NormalizedState.added_to_arr:
|
||||
root_cause = "search"
|
||||
summary = "The item is in Sonarr/Radarr but has not been downloaded yet."
|
||||
recommendations.append(
|
||||
TriageRecommendation(
|
||||
action_id="search",
|
||||
title="Re-run search",
|
||||
reason="A fresh search can locate new releases.",
|
||||
risk="low",
|
||||
)
|
||||
)
|
||||
confidence = 0.55
|
||||
|
||||
if not recommendations:
|
||||
recommendations.append(
|
||||
TriageRecommendation(
|
||||
action_id="diagnostics",
|
||||
title="Generate diagnostics bundle",
|
||||
reason="Collect service status and recent errors for review.",
|
||||
risk="low",
|
||||
)
|
||||
)
|
||||
|
||||
return TriageResult(
|
||||
summary=summary,
|
||||
confidence=confidence,
|
||||
root_cause=root_cause,
|
||||
recommendations=recommendations,
|
||||
)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,226 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, Response, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
|
||||
from .config import settings
|
||||
from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
|
||||
from .network_security import request_trusts_forwarded_headers
|
||||
from .security import TokenError, safe_decode_token, verify_password
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
|
||||
|
||||
|
||||
def _is_expired(expires_at: str | None) -> bool:
|
||||
if not isinstance(expires_at, str) or not expires_at.strip():
|
||||
return False
|
||||
candidate = expires_at.strip()
|
||||
if candidate.endswith("Z"):
|
||||
candidate = candidate[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(candidate)
|
||||
except ValueError:
|
||||
return False
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed <= datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _extract_client_ip(request: Request) -> str:
|
||||
direct_host = request.client.host if request.client else None
|
||||
if request_trusts_forwarded_headers(direct_host):
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
||||
if parts:
|
||||
return parts[0]
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
if direct_host:
|
||||
return direct_host
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _cookie_settings() -> dict[str, Any]:
|
||||
samesite = str(settings.auth_cookie_samesite or "lax").strip().lower()
|
||||
if samesite not in {"lax", "strict", "none"}:
|
||||
samesite = "lax"
|
||||
return {
|
||||
"secure": bool(settings.auth_cookie_secure),
|
||||
"httponly": True,
|
||||
"samesite": samesite,
|
||||
"domain": settings.auth_cookie_domain or None,
|
||||
"path": "/",
|
||||
}
|
||||
|
||||
|
||||
def _state_cookie_settings() -> dict[str, Any]:
|
||||
cookie = _cookie_settings()
|
||||
cookie["httponly"] = False
|
||||
return cookie
|
||||
|
||||
|
||||
def set_auth_cookies(response: Response, token: str) -> None:
|
||||
max_age = max(60, int(settings.jwt_exp_minutes or 720) * 60)
|
||||
response.set_cookie(
|
||||
settings.auth_cookie_name,
|
||||
token,
|
||||
max_age=max_age,
|
||||
**_cookie_settings(),
|
||||
)
|
||||
response.set_cookie(
|
||||
settings.auth_state_cookie_name,
|
||||
"1",
|
||||
max_age=max_age,
|
||||
**_state_cookie_settings(),
|
||||
)
|
||||
|
||||
|
||||
def clear_auth_cookies(response: Response) -> None:
|
||||
response.delete_cookie(settings.auth_cookie_name, path="/", domain=settings.auth_cookie_domain or None)
|
||||
response.delete_cookie(
|
||||
settings.auth_state_cookie_name,
|
||||
path="/",
|
||||
domain=settings.auth_cookie_domain or None,
|
||||
)
|
||||
|
||||
|
||||
def _extract_access_token(request: Request, oauth_token: Optional[str]) -> Optional[str]:
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
return auth_header.split(" ", 1)[1].strip()
|
||||
if oauth_token:
|
||||
return oauth_token
|
||||
cookie_token = request.cookies.get(settings.auth_cookie_name)
|
||||
if isinstance(cookie_token, str) and cookie_token.strip():
|
||||
return cookie_token.strip()
|
||||
return None
|
||||
|
||||
|
||||
def resolve_user_auth_provider(user: Optional[Dict[str, Any]]) -> str:
|
||||
if not isinstance(user, dict):
|
||||
return "local"
|
||||
provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
|
||||
if provider != "local":
|
||||
return provider
|
||||
password_hash = user.get("password_hash")
|
||||
if isinstance(password_hash, str) and password_hash:
|
||||
if verify_password("jellyfin-user", password_hash):
|
||||
return "jellyfin"
|
||||
if verify_password("jellyseerr-user", password_hash):
|
||||
return "jellyseerr"
|
||||
return provider
|
||||
|
||||
|
||||
def normalize_user_auth_provider(user: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
if not isinstance(user, dict):
|
||||
return {}
|
||||
resolved_provider = resolve_user_auth_provider(user)
|
||||
stored_provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
|
||||
if resolved_provider != stored_provider:
|
||||
username = str(user.get("username") or "").strip()
|
||||
if username:
|
||||
set_user_auth_provider(username, resolved_provider)
|
||||
refreshed_user = get_user_by_username(username)
|
||||
if refreshed_user:
|
||||
user = refreshed_user
|
||||
normalized = dict(user)
|
||||
normalized["auth_provider"] = resolved_provider
|
||||
normalized["password_change_supported"] = resolved_provider in {"local", "jellyfin"}
|
||||
normalized["password_provider"] = (
|
||||
resolved_provider if resolved_provider in {"local", "jellyfin"} else None
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _load_current_user_from_token(
|
||||
token: str,
|
||||
request: Optional[Request] = None,
|
||||
allowed_token_types: Optional[set[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
payload = safe_decode_token(token)
|
||||
except TokenError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
|
||||
token_type = str(payload.get("typ") or "access").strip().lower()
|
||||
if allowed_token_types and token_type not in allowed_token_types:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
|
||||
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token subject")
|
||||
|
||||
user = get_user_by_username(username)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
if user.get("is_blocked"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
|
||||
if _is_expired(user.get("expires_at")):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
|
||||
|
||||
user = normalize_user_auth_provider(user)
|
||||
|
||||
if request is not None:
|
||||
ip = _extract_client_ip(request)
|
||||
user_agent = request.headers.get("user-agent", "unknown")
|
||||
upsert_user_activity(user["username"], ip, user_agent)
|
||||
|
||||
return {
|
||||
"username": user["username"],
|
||||
"email": user.get("email"),
|
||||
"role": user["role"],
|
||||
"auth_provider": user.get("auth_provider", "local"),
|
||||
"jellyseerr_user_id": user.get("jellyseerr_user_id"),
|
||||
"auto_search_enabled": bool(user.get("auto_search_enabled", True)),
|
||||
"invite_management_enabled": bool(user.get("invite_management_enabled", False)),
|
||||
"profile_id": user.get("profile_id"),
|
||||
"expires_at": user.get("expires_at"),
|
||||
"is_expired": bool(user.get("is_expired", False)),
|
||||
"password_change_supported": bool(user.get("password_change_supported", False)),
|
||||
"password_provider": user.get("password_provider"),
|
||||
}
|
||||
|
||||
|
||||
def get_current_user(
|
||||
request: Request,
|
||||
token: Optional[str] = Depends(oauth2_scheme),
|
||||
) -> Dict[str, Any]:
|
||||
resolved_token = _extract_access_token(request, token)
|
||||
if not resolved_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
||||
return _load_current_user_from_token(resolved_token, request)
|
||||
|
||||
|
||||
def get_current_user_event_stream(
|
||||
request: Request,
|
||||
token: Optional[str] = Depends(oauth2_scheme),
|
||||
) -> Dict[str, Any]:
|
||||
"""EventSource cannot send Authorization headers, so allow a short-lived stream token via query."""
|
||||
resolved_token = _extract_access_token(request, token)
|
||||
stream_query_token = request.query_params.get("stream_token")
|
||||
if resolved_token:
|
||||
# Allow standard bearer tokens for non-browser EventSource clients.
|
||||
return _load_current_user_from_token(resolved_token, None)
|
||||
if not stream_query_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
||||
return _load_current_user_from_token(
|
||||
str(stream_query_token),
|
||||
None,
|
||||
allowed_token_types={"sse"},
|
||||
)
|
||||
|
||||
|
||||
def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
if user.get("role") != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return user
|
||||
|
||||
|
||||
def require_admin_event_stream(
|
||||
user: Dict[str, Any] = Depends(get_current_user_event_stream),
|
||||
) -> Dict[str, Any]:
|
||||
if user.get("role") != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return user
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,108 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import logging
|
||||
import time
|
||||
import httpx
|
||||
|
||||
from ..logging_config import sanitize_headers, sanitize_value
|
||||
|
||||
|
||||
class ApiClient:
|
||||
def __init__(self, base_url: Optional[str], api_key: Optional[str] = None):
|
||||
self.base_url = base_url.rstrip("/") if base_url else None
|
||||
self.api_key = api_key
|
||||
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
|
||||
|
||||
def configured(self) -> bool:
|
||||
return bool(self.base_url)
|
||||
|
||||
def headers(self) -> Dict[str, str]:
|
||||
return {"X-Api-Key": self.api_key} if self.api_key else {}
|
||||
|
||||
def _response_summary(self, response: Optional[httpx.Response]) -> Optional[Any]:
|
||||
if response is None:
|
||||
return None
|
||||
try:
|
||||
payload = sanitize_value(response.json())
|
||||
except ValueError:
|
||||
payload = sanitize_value(response.text)
|
||||
if isinstance(payload, str) and len(payload) > 500:
|
||||
return f"{payload[:500]}..."
|
||||
return payload
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Any]:
|
||||
if not self.base_url:
|
||||
self.logger.warning("client request skipped method=%s path=%s reason=not-configured", method, path)
|
||||
return None
|
||||
url = f"{self.base_url}{path}"
|
||||
started_at = time.perf_counter()
|
||||
self.logger.debug(
|
||||
"outbound request started method=%s url=%s params=%s payload=%s headers=%s",
|
||||
method,
|
||||
url,
|
||||
sanitize_value(params),
|
||||
sanitize_value(payload),
|
||||
sanitize_headers(self.headers()),
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
headers=self.headers(),
|
||||
params=params,
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
self.logger.debug(
|
||||
"outbound request completed method=%s url=%s status=%s duration_ms=%s",
|
||||
method,
|
||||
url,
|
||||
response.status_code,
|
||||
duration_ms,
|
||||
)
|
||||
if not response.content:
|
||||
return None
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
response = exc.response
|
||||
status = response.status_code if response is not None else "unknown"
|
||||
log_fn = self.logger.error if isinstance(status, int) and status >= 500 else self.logger.warning
|
||||
log_fn(
|
||||
"outbound request returned error method=%s url=%s status=%s duration_ms=%s response=%s",
|
||||
method,
|
||||
url,
|
||||
status,
|
||||
duration_ms,
|
||||
self._response_summary(response),
|
||||
)
|
||||
raise
|
||||
except Exception:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
self.logger.exception(
|
||||
"outbound request failed method=%s url=%s duration_ms=%s",
|
||||
method,
|
||||
url,
|
||||
duration_ms,
|
||||
)
|
||||
raise
|
||||
|
||||
async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
return await self._request("GET", path, params=params)
|
||||
|
||||
async def post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
return await self._request("POST", path, payload=payload)
|
||||
|
||||
async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
return await self._request("PUT", path, payload=payload)
|
||||
|
||||
async def delete(self, path: str) -> Optional[Any]:
|
||||
return await self._request("DELETE", path)
|
||||
@@ -0,0 +1,201 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import httpx
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class JellyfinClient(ApiClient):
|
||||
def __init__(self, base_url: Optional[str], api_key: Optional[str]):
|
||||
super().__init__(base_url, api_key)
|
||||
|
||||
def configured(self) -> bool:
|
||||
return bool(self.base_url and self.api_key)
|
||||
|
||||
def _emby_headers(self) -> Dict[str, str]:
|
||||
return {"X-Emby-Token": self.api_key} if self.api_key else {}
|
||||
|
||||
@staticmethod
|
||||
def _extract_user_id(payload: Any) -> Optional[str]:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
candidate = payload.get("User") if isinstance(payload.get("User"), dict) else payload
|
||||
if not isinstance(candidate, dict):
|
||||
return None
|
||||
for key in ("Id", "id", "UserId", "userId"):
|
||||
value = candidate.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, (str, int)):
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
async def get_users(self) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
url = f"{self.base_url}/Users"
|
||||
headers = self._emby_headers()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_user(self, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
url = f"{self.base_url}/Users/{user_id}"
|
||||
headers = self._emby_headers()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def find_user_by_name(self, username: str) -> Optional[Dict[str, Any]]:
|
||||
users = await self.get_users()
|
||||
if not isinstance(users, list):
|
||||
return None
|
||||
target = username.strip().lower()
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
name = str(user.get("Name") or "").strip().lower()
|
||||
if name and name == target:
|
||||
return user
|
||||
return None
|
||||
|
||||
async def authenticate_by_name(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
url = f"{self.base_url}/Users/AuthenticateByName"
|
||||
headers = self._emby_headers()
|
||||
payload = {"Username": username, "Pw": password}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def create_user(self, username: str) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
url = f"{self.base_url}/Users/New"
|
||||
headers = self._emby_headers()
|
||||
payload = {"Name": username}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
if not response.content:
|
||||
return None
|
||||
return response.json()
|
||||
|
||||
async def set_user_password(self, user_id: str, password: str) -> None:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
headers = self._emby_headers()
|
||||
payloads = [
|
||||
{"CurrentPw": "", "NewPw": password},
|
||||
{"CurrentPwd": "", "NewPw": password},
|
||||
{"CurrentPw": "", "NewPw": password, "ResetPassword": False},
|
||||
{"CurrentPwd": "", "NewPw": password, "ResetPassword": False},
|
||||
{"NewPw": password, "ResetPassword": False},
|
||||
]
|
||||
paths = [
|
||||
f"/Users/{user_id}/Password",
|
||||
f"/Users/{user_id}/EasyPassword",
|
||||
]
|
||||
last_error: Exception | None = None
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
for path in paths:
|
||||
url = f"{self.base_url}{path}"
|
||||
for payload in payloads:
|
||||
try:
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
return
|
||||
except httpx.HTTPStatusError as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
if last_error:
|
||||
raise last_error
|
||||
|
||||
async def set_user_disabled(self, user_id: str, disabled: bool = True) -> None:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
user = await self.get_user(user_id)
|
||||
if not isinstance(user, dict):
|
||||
raise RuntimeError("Jellyfin user details not available")
|
||||
policy = user.get("Policy") if isinstance(user.get("Policy"), dict) else {}
|
||||
payload = {**policy, "IsDisabled": bool(disabled)}
|
||||
url = f"{self.base_url}/Users/{user_id}/Policy"
|
||||
headers = self._emby_headers()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
async def delete_user(self, user_id: str) -> None:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
url = f"{self.base_url}/Users/{user_id}"
|
||||
headers = self._emby_headers()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.delete(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
async def create_user_with_password(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
created = await self.create_user(username)
|
||||
user_id = self._extract_user_id(created)
|
||||
if not user_id:
|
||||
users = await self.get_users()
|
||||
if isinstance(users, list):
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
name = str(user.get("Name") or "").strip()
|
||||
if name.lower() == username.strip().lower():
|
||||
created = user
|
||||
user_id = self._extract_user_id(user)
|
||||
break
|
||||
if not user_id:
|
||||
raise RuntimeError("Jellyfin user created but user ID was not returned")
|
||||
await self.set_user_password(user_id, password)
|
||||
return created
|
||||
|
||||
async def search_items(
|
||||
self, term: str, item_types: Optional[list[str]] = None, limit: int = 20
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
url = f"{self.base_url}/Items"
|
||||
params = {
|
||||
"SearchTerm": term,
|
||||
"IncludeItemTypes": ",".join(item_types or []),
|
||||
"Recursive": "true",
|
||||
"Limit": limit,
|
||||
}
|
||||
headers = self._emby_headers()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_system_info(self) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
url = f"{self.base_url}/System/Info"
|
||||
headers = self._emby_headers()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def refresh_library(self, recursive: bool = True) -> None:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
url = f"{self.base_url}/Library/Refresh"
|
||||
headers = self._emby_headers()
|
||||
params = {"Recursive": "true" if recursive else "false"}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
@@ -0,0 +1,79 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import httpx
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class JellyseerrClient(ApiClient):
|
||||
async def get_status(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v1/status")
|
||||
|
||||
async def get_request(self, request_id: str) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(f"/api/v1/request/{request_id}")
|
||||
|
||||
async def get_recent_requests(self, take: int = 10, skip: int = 0) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(
|
||||
"/api/v1/request",
|
||||
params={
|
||||
"take": take,
|
||||
"skip": skip,
|
||||
},
|
||||
)
|
||||
|
||||
async def get_movie(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(f"/api/v1/movie/{tmdb_id}")
|
||||
|
||||
async def get_tv(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(f"/api/v1/tv/{tmdb_id}")
|
||||
|
||||
async def search(self, query: str, page: int = 1) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(
|
||||
"/api/v1/search",
|
||||
params={
|
||||
"query": query,
|
||||
"page": page,
|
||||
},
|
||||
)
|
||||
|
||||
async def create_request(
|
||||
self,
|
||||
*,
|
||||
media_type: str,
|
||||
media_id: int,
|
||||
seasons: Optional[list[int]] = None,
|
||||
is_4k: Optional[bool] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
payload: Dict[str, Any] = {
|
||||
"mediaType": media_type,
|
||||
"mediaId": media_id,
|
||||
}
|
||||
if isinstance(seasons, list) and seasons:
|
||||
payload["seasons"] = seasons
|
||||
if isinstance(is_4k, bool):
|
||||
payload["is4k"] = is_4k
|
||||
return await self.post("/api/v1/request", payload=payload)
|
||||
|
||||
async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(
|
||||
"/api/v1/user",
|
||||
params={
|
||||
"take": take,
|
||||
"skip": skip,
|
||||
},
|
||||
)
|
||||
|
||||
async def get_user(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(f"/api/v1/user/{user_id}")
|
||||
|
||||
async def delete_user(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.delete(f"/api/v1/user/{user_id}")
|
||||
|
||||
async def login_local(self, email: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
payload = {"email": email, "password": password}
|
||||
try:
|
||||
return await self.post("/api/v1/auth/local", payload=payload)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
# Backward compatibility for older Seerr/Overseerr deployments
|
||||
# that still expose /auth/login instead of /auth/local.
|
||||
if exc.response is not None and exc.response.status_code in {404, 405}:
|
||||
return await self.post("/api/v1/auth/login", payload=payload)
|
||||
raise
|
||||
@@ -0,0 +1,10 @@
|
||||
from typing import Any, Dict, Optional
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class ProwlarrClient(ApiClient):
|
||||
async def get_health(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v1/health")
|
||||
|
||||
async def search(self, query: str) -> Optional[Any]:
|
||||
return await self.get("/api/v1/search", params={"query": query})
|
||||
@@ -0,0 +1,108 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import httpx
|
||||
import logging
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class QBittorrentClient(ApiClient):
|
||||
def __init__(self, base_url: Optional[str], username: Optional[str], password: Optional[str]):
|
||||
super().__init__(base_url, None)
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def configured(self) -> bool:
|
||||
return bool(self.base_url and self.username and self.password)
|
||||
|
||||
async def _login(self, client: httpx.AsyncClient) -> None:
|
||||
if not self.base_url or not self.username or not self.password:
|
||||
raise RuntimeError("qBittorrent not configured")
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/v2/auth/login",
|
||||
data={"username": self.username, "password": self.password},
|
||||
headers={"Referer": self.base_url},
|
||||
)
|
||||
response.raise_for_status()
|
||||
text = response.text.strip().lower()
|
||||
has_session_cookie = any(name.upper().startswith("QBT_SID") for name in client.cookies.keys())
|
||||
if text not in {"ok.", ""} or (text == "" and not has_session_cookie):
|
||||
raise RuntimeError("qBittorrent login failed")
|
||||
|
||||
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
return response.text.strip()
|
||||
|
||||
async def _post_form(self, path: str, data: Dict[str, Any]) -> None:
|
||||
if not self.base_url:
|
||||
return None
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.post(f"{self.base_url}{path}", data=data)
|
||||
response.raise_for_status()
|
||||
|
||||
async def is_webui_reachable(self) -> bool:
|
||||
if not self.base_url:
|
||||
return False
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
||||
response = await client.get(self.base_url)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
|
||||
async def get_torrents(self) -> Optional[Any]:
|
||||
return await self._get("/api/v2/torrents/info")
|
||||
|
||||
async def get_torrents_by_hashes(self, hashes: str) -> Optional[Any]:
|
||||
return await self._get("/api/v2/torrents/info", params={"hashes": hashes})
|
||||
|
||||
async def get_torrents_by_category(self, category: str) -> Optional[Any]:
|
||||
return await self._get("/api/v2/torrents/info", params={"category": category})
|
||||
|
||||
async def get_torrents_by_tag(self, tag: str) -> Optional[Any]:
|
||||
return await self._get("/api/v2/torrents/info", params={"tag": tag})
|
||||
|
||||
async def get_app_version(self) -> Optional[Any]:
|
||||
return await self._get_text("/api/v2/app/version")
|
||||
|
||||
async def resume_torrents(self, hashes: str) -> None:
|
||||
try:
|
||||
await self._post_form("/api/v2/torrents/resume", data={"hashes": hashes})
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response is not None and exc.response.status_code == 404:
|
||||
await self._post_form("/api/v2/torrents/start", data={"hashes": hashes})
|
||||
return
|
||||
raise
|
||||
|
||||
async def add_torrent_url(
|
||||
self, url: str, category: Optional[str] = None, tags: Optional[str] = None
|
||||
) -> None:
|
||||
url_host = None
|
||||
if isinstance(url, str) and "://" in url:
|
||||
url_host = url.split("://", 1)[-1].split("/", 1)[0]
|
||||
self.logger.warning(
|
||||
"qBittorrent add_torrent_url invoked: category=%s host=%s",
|
||||
category,
|
||||
url_host or "unknown",
|
||||
)
|
||||
data: Dict[str, Any] = {"urls": url}
|
||||
if category:
|
||||
data["category"] = category
|
||||
if tags:
|
||||
data["tags"] = tags
|
||||
await self._post_form("/api/v2/torrents/add", data=data)
|
||||
@@ -0,0 +1,63 @@
|
||||
from typing import Any, Dict, Optional
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class RadarrClient(ApiClient):
|
||||
async def get_system_status(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/system/status")
|
||||
|
||||
async def get_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/movie", params={"tmdbId": tmdb_id})
|
||||
|
||||
async def get_movie(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(f"/api/v3/movie/{movie_id}")
|
||||
|
||||
async def get_movies(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/movie")
|
||||
|
||||
async def get_root_folders(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/rootfolder")
|
||||
|
||||
async def get_quality_profiles(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/qualityprofile")
|
||||
|
||||
async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/queue", params={"movieId": movie_id})
|
||||
|
||||
async def get_indexers(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/indexer")
|
||||
|
||||
async def search(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/command", payload={"name": "MoviesSearch", "movieIds": [movie_id]})
|
||||
|
||||
async def add_movie(
|
||||
self,
|
||||
tmdb_id: int,
|
||||
quality_profile_id: int,
|
||||
root_folder: str,
|
||||
monitored: bool = True,
|
||||
search_for_movie: bool = True,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
payload = {
|
||||
"tmdbId": tmdb_id,
|
||||
"qualityProfileId": quality_profile_id,
|
||||
"rootFolderPath": root_folder,
|
||||
"monitored": monitored,
|
||||
"addOptions": {"searchForMovie": search_for_movie},
|
||||
}
|
||||
return await self.post("/api/v3/movie", payload=payload)
|
||||
|
||||
async def update_movie(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return await self.put("/api/v3/movie", payload=payload)
|
||||
|
||||
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
|
||||
|
||||
async def push_release(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/release/push", payload=payload)
|
||||
|
||||
async def download_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.post(
|
||||
"/api/v3/command",
|
||||
payload={"name": "DownloadRelease", "guid": guid, "indexerId": indexer_id},
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
from typing import Any, Dict, Optional
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class SonarrClient(ApiClient):
|
||||
async def get_system_status(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/system/status")
|
||||
|
||||
async def get_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/series", params={"tvdbId": tvdb_id})
|
||||
|
||||
async def get_series(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(f"/api/v3/series/{series_id}")
|
||||
|
||||
async def get_root_folders(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/rootfolder")
|
||||
|
||||
async def get_quality_profiles(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/qualityprofile")
|
||||
|
||||
async def get_queue(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/queue", params={"seriesId": series_id})
|
||||
|
||||
async def get_indexers(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/indexer")
|
||||
|
||||
async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/episode", params={"seriesId": series_id})
|
||||
|
||||
async def search(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/command", payload={"name": "SeriesSearch", "seriesId": series_id})
|
||||
|
||||
async def search_episodes(self, episode_ids: list[int]) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/command", payload={"name": "EpisodeSearch", "episodeIds": episode_ids})
|
||||
|
||||
async def add_series(
|
||||
self,
|
||||
tvdb_id: int,
|
||||
quality_profile_id: int,
|
||||
root_folder: str,
|
||||
monitored: bool = True,
|
||||
title: Optional[str] = None,
|
||||
search_missing: bool = True,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
payload = {
|
||||
"tvdbId": tvdb_id,
|
||||
"qualityProfileId": quality_profile_id,
|
||||
"rootFolderPath": root_folder,
|
||||
"monitored": monitored,
|
||||
"seasonFolder": True,
|
||||
"addOptions": {"searchForMissingEpisodes": search_missing},
|
||||
}
|
||||
if title:
|
||||
payload["title"] = title
|
||||
return await self.post("/api/v3/series", payload=payload)
|
||||
|
||||
async def update_series(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return await self.put("/api/v3/series", payload=payload)
|
||||
|
||||
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
|
||||
|
||||
async def push_release(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/release/push", payload=payload)
|
||||
|
||||
async def download_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.post(
|
||||
"/api/v3/command",
|
||||
payload={"name": "DownloadRelease", "guid": guid, "indexerId": indexer_id},
|
||||
)
|
||||
@@ -0,0 +1,322 @@
|
||||
from typing import Optional
|
||||
from pydantic import AliasChoices, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from .build_info import BUILD_NUMBER, CHANGELOG
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="")
|
||||
app_name: str = "Magent"
|
||||
cors_allow_origin: str = "http://localhost:3000"
|
||||
sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH"))
|
||||
sqlite_journal_mode: str = Field(
|
||||
default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE")
|
||||
)
|
||||
jwt_secret: str = Field(default="", validation_alias=AliasChoices("JWT_SECRET"))
|
||||
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
|
||||
api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
|
||||
auth_rate_limit_window_seconds: int = Field(
|
||||
default=60, validation_alias=AliasChoices("AUTH_RATE_LIMIT_WINDOW_SECONDS")
|
||||
)
|
||||
auth_rate_limit_max_attempts_ip: int = Field(
|
||||
default=15, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_IP")
|
||||
)
|
||||
auth_rate_limit_max_attempts_user: int = Field(
|
||||
default=5, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_USER")
|
||||
)
|
||||
password_reset_rate_limit_window_seconds: int = Field(
|
||||
default=300, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_WINDOW_SECONDS")
|
||||
)
|
||||
password_reset_rate_limit_max_attempts_ip: int = Field(
|
||||
default=6, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IP")
|
||||
)
|
||||
password_reset_rate_limit_max_attempts_identifier: int = Field(
|
||||
default=3, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IDENTIFIER")
|
||||
)
|
||||
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
|
||||
admin_password: str = Field(default="", validation_alias=AliasChoices("ADMIN_PASSWORD"))
|
||||
auth_cookie_name: str = Field(
|
||||
default="magent_auth", validation_alias=AliasChoices("AUTH_COOKIE_NAME")
|
||||
)
|
||||
auth_cookie_secure: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("AUTH_COOKIE_SECURE")
|
||||
)
|
||||
auth_cookie_samesite: str = Field(
|
||||
default="lax", validation_alias=AliasChoices("AUTH_COOKIE_SAMESITE")
|
||||
)
|
||||
auth_cookie_domain: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("AUTH_COOKIE_DOMAIN")
|
||||
)
|
||||
auth_state_cookie_name: str = Field(
|
||||
default="magent_logged_in", validation_alias=AliasChoices("AUTH_STATE_COOKIE_NAME")
|
||||
)
|
||||
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
|
||||
log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE"))
|
||||
log_file_max_bytes: int = Field(
|
||||
default=20_000_000, validation_alias=AliasChoices("LOG_FILE_MAX_BYTES")
|
||||
)
|
||||
log_file_backup_count: int = Field(
|
||||
default=10, validation_alias=AliasChoices("LOG_FILE_BACKUP_COUNT")
|
||||
)
|
||||
log_http_client_level: str = Field(
|
||||
default="INFO", validation_alias=AliasChoices("LOG_HTTP_CLIENT_LEVEL")
|
||||
)
|
||||
log_background_sync_level: str = Field(
|
||||
default="INFO", validation_alias=AliasChoices("LOG_BACKGROUND_SYNC_LEVEL")
|
||||
)
|
||||
requests_sync_ttl_minutes: int = Field(
|
||||
default=1440, validation_alias=AliasChoices("REQUESTS_SYNC_TTL_MINUTES")
|
||||
)
|
||||
requests_poll_interval_seconds: int = Field(
|
||||
default=300, validation_alias=AliasChoices("REQUESTS_POLL_INTERVAL_SECONDS")
|
||||
)
|
||||
requests_delta_sync_interval_minutes: int = Field(
|
||||
default=5, validation_alias=AliasChoices("REQUESTS_DELTA_SYNC_INTERVAL_MINUTES")
|
||||
)
|
||||
requests_full_sync_time: str = Field(
|
||||
default="00:00", validation_alias=AliasChoices("REQUESTS_FULL_SYNC_TIME")
|
||||
)
|
||||
requests_cleanup_time: str = Field(
|
||||
default="02:00", validation_alias=AliasChoices("REQUESTS_CLEANUP_TIME")
|
||||
)
|
||||
requests_cleanup_days: int = Field(
|
||||
default=90, validation_alias=AliasChoices("REQUESTS_CLEANUP_DAYS")
|
||||
)
|
||||
requests_data_source: str = Field(
|
||||
default="prefer_cache", validation_alias=AliasChoices("REQUESTS_DATA_SOURCE")
|
||||
)
|
||||
artwork_cache_mode: str = Field(
|
||||
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
|
||||
)
|
||||
site_build_number: Optional[str] = Field(default=BUILD_NUMBER)
|
||||
site_banner_enabled: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("SITE_BANNER_ENABLED")
|
||||
)
|
||||
site_banner_message: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("SITE_BANNER_MESSAGE")
|
||||
)
|
||||
site_banner_tone: str = Field(
|
||||
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
|
||||
)
|
||||
site_login_show_jellyfin_login: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_JELLYFIN_LOGIN")
|
||||
)
|
||||
site_login_show_local_login: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_LOCAL_LOGIN")
|
||||
)
|
||||
site_login_show_forgot_password: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_FORGOT_PASSWORD")
|
||||
)
|
||||
site_login_show_signup_link: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_SIGNUP_LINK")
|
||||
)
|
||||
site_nav_show_requests: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("SITE_NAV_SHOW_REQUESTS")
|
||||
)
|
||||
site_changelog: Optional[str] = Field(default=CHANGELOG)
|
||||
|
||||
magent_application_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_APPLICATION_URL")
|
||||
)
|
||||
magent_application_port: int = Field(
|
||||
default=3000, validation_alias=AliasChoices("MAGENT_APPLICATION_PORT")
|
||||
)
|
||||
magent_api_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_API_URL")
|
||||
)
|
||||
magent_api_port: int = Field(
|
||||
default=8000, validation_alias=AliasChoices("MAGENT_API_PORT")
|
||||
)
|
||||
magent_bind_host: str = Field(
|
||||
default="0.0.0.0", validation_alias=AliasChoices("MAGENT_BIND_HOST")
|
||||
)
|
||||
magent_proxy_enabled: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("MAGENT_PROXY_ENABLED")
|
||||
)
|
||||
magent_proxy_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_PROXY_BASE_URL")
|
||||
)
|
||||
magent_proxy_trust_forwarded_headers: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("MAGENT_PROXY_TRUST_FORWARDED_HEADERS")
|
||||
)
|
||||
magent_proxy_trusted_proxies: str = Field(
|
||||
default="127.0.0.1,::1",
|
||||
validation_alias=AliasChoices("MAGENT_PROXY_TRUSTED_PROXIES"),
|
||||
)
|
||||
magent_proxy_forwarded_prefix: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_PROXY_FORWARDED_PREFIX")
|
||||
)
|
||||
magent_ssl_bind_enabled: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("MAGENT_SSL_BIND_ENABLED")
|
||||
)
|
||||
magent_ssl_certificate_path: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PATH")
|
||||
)
|
||||
magent_ssl_private_key_path: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PATH")
|
||||
)
|
||||
magent_ssl_certificate_pem: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PEM")
|
||||
)
|
||||
magent_ssl_private_key_pem: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PEM")
|
||||
)
|
||||
|
||||
magent_notify_enabled: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_ENABLED")
|
||||
)
|
||||
magent_notify_email_enabled: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_ENABLED")
|
||||
)
|
||||
magent_notify_email_smtp_host: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_HOST")
|
||||
)
|
||||
magent_notify_email_smtp_port: int = Field(
|
||||
default=587, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PORT")
|
||||
)
|
||||
magent_notify_email_smtp_username: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_USERNAME")
|
||||
)
|
||||
magent_notify_email_smtp_password: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PASSWORD")
|
||||
)
|
||||
magent_notify_email_from_address: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_ADDRESS")
|
||||
)
|
||||
magent_notify_email_from_name: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_NAME")
|
||||
)
|
||||
magent_notify_email_use_tls: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_TLS")
|
||||
)
|
||||
magent_notify_email_use_ssl: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_SSL")
|
||||
)
|
||||
|
||||
magent_notify_discord_enabled: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_ENABLED")
|
||||
)
|
||||
magent_notify_discord_webhook_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_WEBHOOK_URL")
|
||||
)
|
||||
|
||||
magent_notify_telegram_enabled: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_ENABLED")
|
||||
)
|
||||
magent_notify_telegram_bot_token: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_BOT_TOKEN")
|
||||
)
|
||||
magent_notify_telegram_chat_id: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_CHAT_ID")
|
||||
)
|
||||
|
||||
magent_notify_push_enabled: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_ENABLED")
|
||||
)
|
||||
magent_notify_push_provider: Optional[str] = Field(
|
||||
default="ntfy", validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_PROVIDER")
|
||||
)
|
||||
magent_notify_push_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_BASE_URL")
|
||||
)
|
||||
magent_notify_push_topic: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOPIC")
|
||||
)
|
||||
magent_notify_push_token: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOKEN")
|
||||
)
|
||||
magent_notify_push_user_key: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_USER_KEY")
|
||||
)
|
||||
magent_notify_push_device: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_DEVICE")
|
||||
)
|
||||
|
||||
magent_notify_webhook_enabled: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_ENABLED")
|
||||
)
|
||||
magent_notify_webhook_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_URL")
|
||||
)
|
||||
magent_allow_private_notification_targets: bool = Field(
|
||||
default=False,
|
||||
validation_alias=AliasChoices("MAGENT_ALLOW_PRIVATE_NOTIFICATION_TARGETS"),
|
||||
)
|
||||
|
||||
jellyseerr_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")
|
||||
)
|
||||
jellyseerr_api_key: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("JELLYSEERR_API_KEY", "JELLYSEERR_KEY")
|
||||
)
|
||||
jellyfin_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("JELLYFIN_URL", "JELLYFIN_BASE_URL")
|
||||
)
|
||||
jellyfin_api_key: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("JELLYFIN_API_KEY", "JELLYFIN_KEY")
|
||||
)
|
||||
jellyfin_public_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("JELLYFIN_PUBLIC_URL")
|
||||
)
|
||||
jellyfin_sync_to_arr: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("JELLYFIN_SYNC_TO_ARR")
|
||||
)
|
||||
|
||||
sonarr_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("SONARR_URL", "SONARR_BASE_URL")
|
||||
)
|
||||
sonarr_api_key: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("SONARR_API_KEY", "SONARR_KEY")
|
||||
)
|
||||
sonarr_quality_profile_id: Optional[int] = Field(
|
||||
default=None, validation_alias=AliasChoices("SONARR_QUALITY_PROFILE_ID")
|
||||
)
|
||||
sonarr_root_folder: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("SONARR_ROOT_FOLDER")
|
||||
)
|
||||
sonarr_qbittorrent_category: Optional[str] = Field(
|
||||
default="sonarr",
|
||||
validation_alias=AliasChoices("SONARR_QBITTORRENT_CATEGORY"),
|
||||
)
|
||||
|
||||
radarr_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("RADARR_URL", "RADARR_BASE_URL")
|
||||
)
|
||||
radarr_api_key: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("RADARR_API_KEY", "RADARR_KEY")
|
||||
)
|
||||
radarr_quality_profile_id: Optional[int] = Field(
|
||||
default=None, validation_alias=AliasChoices("RADARR_QUALITY_PROFILE_ID")
|
||||
)
|
||||
radarr_root_folder: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("RADARR_ROOT_FOLDER")
|
||||
)
|
||||
radarr_qbittorrent_category: Optional[str] = Field(
|
||||
default="radarr",
|
||||
validation_alias=AliasChoices("RADARR_QBITTORRENT_CATEGORY"),
|
||||
)
|
||||
|
||||
prowlarr_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL")
|
||||
)
|
||||
prowlarr_api_key: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("PROWLARR_API_KEY", "PROWLARR_KEY")
|
||||
)
|
||||
|
||||
qbittorrent_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("QBIT_URL", "QBITTORRENT_URL", "QBITTORRENT_BASE_URL")
|
||||
)
|
||||
qbittorrent_username: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("QBIT_USERNAME", "QBITTORRENT_USERNAME")
|
||||
)
|
||||
qbittorrent_password: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("QBIT_PASSWORD", "QBITTORRENT_PASSWORD")
|
||||
)
|
||||
|
||||
discord_webhook_url: Optional[str] = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
+3758
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Any, Mapping, Optional
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
REQUEST_ID_CONTEXT: contextvars.ContextVar[str] = contextvars.ContextVar(
|
||||
"magent_request_id", default="-"
|
||||
)
|
||||
|
||||
_SENSITIVE_KEYWORDS = (
|
||||
"api_key",
|
||||
"authorization",
|
||||
"cert",
|
||||
"cookie",
|
||||
"jwt",
|
||||
"key",
|
||||
"pass",
|
||||
"password",
|
||||
"pem",
|
||||
"private",
|
||||
"secret",
|
||||
"session",
|
||||
"signature",
|
||||
"token",
|
||||
)
|
||||
_MAX_BODY_BYTES = 4096
|
||||
|
||||
|
||||
class RequestContextFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.request_id = REQUEST_ID_CONTEXT.get("-")
|
||||
return True
|
||||
|
||||
|
||||
def bind_request_id(request_id: str) -> contextvars.Token[str]:
|
||||
return REQUEST_ID_CONTEXT.set(request_id or "-")
|
||||
|
||||
|
||||
def reset_request_id(token: contextvars.Token[str]) -> None:
|
||||
REQUEST_ID_CONTEXT.reset(token)
|
||||
|
||||
|
||||
def current_request_id() -> str:
|
||||
return REQUEST_ID_CONTEXT.get("-")
|
||||
|
||||
|
||||
def _is_sensitive_key(key: str) -> bool:
|
||||
lowered = key.strip().lower()
|
||||
return any(marker in lowered for marker in _SENSITIVE_KEYWORDS)
|
||||
|
||||
|
||||
def _redact_scalar(value: Any) -> Any:
|
||||
if value is None or isinstance(value, (int, float, bool)):
|
||||
return value
|
||||
text = str(value)
|
||||
if len(text) <= 4:
|
||||
return "***"
|
||||
return f"{text[:2]}***{text[-2:]}"
|
||||
|
||||
|
||||
def sanitize_value(value: Any, *, key_hint: Optional[str] = None, depth: int = 0) -> Any:
|
||||
if key_hint and _is_sensitive_key(key_hint):
|
||||
return _redact_scalar(value)
|
||||
if value is None or isinstance(value, (bool, int, float)):
|
||||
return value
|
||||
if isinstance(value, bytes):
|
||||
return f"<bytes:{len(value)}>"
|
||||
if isinstance(value, str):
|
||||
return value if len(value) <= 512 else f"{value[:509]}..."
|
||||
if depth >= 3:
|
||||
return f"<{type(value).__name__}>"
|
||||
if isinstance(value, Mapping):
|
||||
return {
|
||||
str(key): sanitize_value(item, key_hint=str(key), depth=depth + 1)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [sanitize_value(item, depth=depth + 1) for item in list(value)[:20]]
|
||||
if hasattr(value, "model_dump"):
|
||||
try:
|
||||
return sanitize_value(value.model_dump(), depth=depth + 1)
|
||||
except Exception:
|
||||
return f"<{type(value).__name__}>"
|
||||
return str(value)
|
||||
|
||||
|
||||
def sanitize_headers(headers: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
str(key).lower(): sanitize_value(value, key_hint=str(key))
|
||||
for key, value in headers.items()
|
||||
}
|
||||
|
||||
|
||||
def summarize_http_body(body: bytes, content_type: Optional[str]) -> Any:
|
||||
if not body:
|
||||
return None
|
||||
normalized = (content_type or "").split(";")[0].strip().lower()
|
||||
if normalized == "application/json":
|
||||
preview = body[:_MAX_BODY_BYTES]
|
||||
try:
|
||||
payload = json.loads(preview.decode("utf-8"))
|
||||
summary = sanitize_value(payload)
|
||||
if len(body) > _MAX_BODY_BYTES:
|
||||
return {"truncated": True, "bytes": len(body), "payload": summary}
|
||||
return summary
|
||||
except Exception:
|
||||
pass
|
||||
if normalized == "application/x-www-form-urlencoded":
|
||||
try:
|
||||
parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True)
|
||||
compact = {
|
||||
key: value[0] if len(value) == 1 else value
|
||||
for key, value in parsed.items()
|
||||
}
|
||||
return sanitize_value(compact)
|
||||
except Exception:
|
||||
pass
|
||||
if normalized.startswith("multipart/"):
|
||||
return {"content_type": normalized, "bytes": len(body)}
|
||||
preview = body[: min(len(body), 256)].decode("utf-8", errors="replace")
|
||||
return {
|
||||
"content_type": normalized or "unknown",
|
||||
"bytes": len(body),
|
||||
"preview": preview if len(body) <= 256 else f"{preview}...",
|
||||
}
|
||||
|
||||
|
||||
def _coerce_level(level_name: Optional[str], fallback: int) -> int:
|
||||
if not level_name:
|
||||
return fallback
|
||||
return getattr(logging, str(level_name).upper(), fallback)
|
||||
|
||||
|
||||
def configure_logging(
|
||||
log_level: Optional[str],
|
||||
log_file: Optional[str],
|
||||
*,
|
||||
log_file_max_bytes: int = 20_000_000,
|
||||
log_file_backup_count: int = 10,
|
||||
log_http_client_level: Optional[str] = "INFO",
|
||||
log_background_sync_level: Optional[str] = "INFO",
|
||||
) -> None:
|
||||
level_name = (log_level or "INFO").upper()
|
||||
level = getattr(logging, level_name, logging.INFO)
|
||||
|
||||
handlers: list[logging.Handler] = []
|
||||
stream_handler = logging.StreamHandler()
|
||||
handlers.append(stream_handler)
|
||||
|
||||
if log_file:
|
||||
log_path = log_file
|
||||
if not os.path.isabs(log_path):
|
||||
log_path = os.path.join(os.getcwd(), log_path)
|
||||
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
||||
file_handler = RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=max(1_000_000, int(log_file_max_bytes or 20_000_000)),
|
||||
backupCount=max(1, int(log_file_backup_count or 10)),
|
||||
encoding="utf-8",
|
||||
)
|
||||
handlers.append(file_handler)
|
||||
|
||||
context_filter = RequestContextFilter()
|
||||
formatter = logging.Formatter(
|
||||
fmt="%(asctime)s | %(levelname)s | %(name)s | request_id=%(request_id)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
for handler in handlers:
|
||||
handler.addFilter(context_filter)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
root = logging.getLogger()
|
||||
for handler in list(root.handlers):
|
||||
root.removeHandler(handler)
|
||||
for handler in handlers:
|
||||
root.addHandler(handler)
|
||||
root.setLevel(level)
|
||||
|
||||
logging.getLogger("uvicorn").setLevel(level)
|
||||
logging.getLogger("uvicorn.error").setLevel(level)
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
http_client_level = _coerce_level(log_http_client_level, logging.DEBUG)
|
||||
background_sync_level = _coerce_level(log_background_sync_level, logging.INFO)
|
||||
logging.getLogger("app.clients.base").setLevel(http_client_level)
|
||||
logging.getLogger("app.routers.requests").setLevel(background_sync_level)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING if level > logging.DEBUG else logging.INFO)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
@@ -0,0 +1,246 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .config import settings
|
||||
from .db import has_admin_user, init_db
|
||||
from .routers.requests import (
|
||||
router as requests_router,
|
||||
startup_warmup_requests_cache,
|
||||
run_requests_delta_loop,
|
||||
run_daily_requests_full_sync,
|
||||
run_daily_db_cleanup,
|
||||
)
|
||||
from .routers.auth import router as auth_router
|
||||
from .routers.admin import router as admin_router, events_router as admin_events_router
|
||||
from .routers.images import router as images_router
|
||||
from .routers.branding import router as branding_router
|
||||
from .routers.status import router as status_router
|
||||
from .routers.feedback import router as feedback_router
|
||||
from .routers.site import router as site_router
|
||||
from .routers.events import router as events_router
|
||||
from .routers.portal import router as portal_router
|
||||
from .services.jellyfin_sync import run_daily_jellyfin_sync
|
||||
from .logging_config import (
|
||||
bind_request_id,
|
||||
configure_logging,
|
||||
reset_request_id,
|
||||
sanitize_headers,
|
||||
sanitize_value,
|
||||
summarize_http_body,
|
||||
)
|
||||
from .runtime import get_runtime_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_background_tasks: list[asyncio.Task[None]] = []
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
docs_url="/docs" if settings.api_docs_enabled else None,
|
||||
redoc_url=None,
|
||||
openapi_url="/openapi.json" if settings.api_docs_enabled else None,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[settings.cors_allow_origin],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def log_requests_and_add_security_headers(request: Request, call_next):
|
||||
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
|
||||
token = bind_request_id(request_id)
|
||||
request.state.request_id = request_id
|
||||
started_at = time.perf_counter()
|
||||
body = await request.body()
|
||||
body_summary = summarize_http_body(body, request.headers.get("content-type"))
|
||||
|
||||
async def receive() -> dict:
|
||||
return {"type": "http.request", "body": body, "more_body": False}
|
||||
|
||||
request._receive = receive
|
||||
logger.info(
|
||||
"request started method=%s path=%s query=%s client=%s headers=%s body=%s",
|
||||
request.method,
|
||||
request.url.path,
|
||||
sanitize_value(dict(request.query_params)),
|
||||
request.client.host if request.client else "-",
|
||||
sanitize_headers(
|
||||
{
|
||||
key: value
|
||||
for key, value in request.headers.items()
|
||||
if key.lower()
|
||||
in {
|
||||
"content-type",
|
||||
"content-length",
|
||||
"user-agent",
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-proto",
|
||||
"x-request-id",
|
||||
}
|
||||
}
|
||||
),
|
||||
body_summary,
|
||||
)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
logger.exception(
|
||||
"request failed method=%s path=%s duration_ms=%s",
|
||||
request.method,
|
||||
request.url.path,
|
||||
duration_ms,
|
||||
)
|
||||
reset_request_id(token)
|
||||
raise
|
||||
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
response.headers.setdefault("X-Request-ID", request_id)
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||
response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
||||
# Keep API responses non-executable and non-embeddable by default.
|
||||
if request.url.path not in {"/docs", "/redoc"} and not request.url.path.startswith("/openapi"):
|
||||
response.headers.setdefault(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'",
|
||||
)
|
||||
logger.info(
|
||||
"request completed method=%s path=%s status=%s duration_ms=%s response_headers=%s",
|
||||
request.method,
|
||||
request.url.path,
|
||||
response.status_code,
|
||||
duration_ms,
|
||||
sanitize_headers(
|
||||
{
|
||||
key: value
|
||||
for key, value in response.headers.items()
|
||||
if key.lower() in {"content-type", "content-length", "x-request-id"}
|
||||
}
|
||||
),
|
||||
)
|
||||
reset_request_id(token)
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
async def _run_background_task(
|
||||
name: str, coroutine_factory: Callable[[], Awaitable[None]]
|
||||
) -> None:
|
||||
token = bind_request_id(f"task-{name}")
|
||||
logger.info("background task started task=%s", name)
|
||||
try:
|
||||
await coroutine_factory()
|
||||
logger.warning("background task exited task=%s", name)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("background task cancelled task=%s", name)
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("background task crashed task=%s", name)
|
||||
raise
|
||||
finally:
|
||||
reset_request_id(token)
|
||||
|
||||
|
||||
def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable[None]]) -> None:
|
||||
task = asyncio.create_task(
|
||||
_run_background_task(name, coroutine_factory), name=f"magent:{name}"
|
||||
)
|
||||
_background_tasks.append(task)
|
||||
|
||||
|
||||
def _log_security_configuration_warnings() -> None:
|
||||
jwt_secret = str(settings.jwt_secret or "").strip()
|
||||
if not jwt_secret or jwt_secret == "change-me":
|
||||
logger.warning(
|
||||
"security configuration warning: JWT_SECRET is unset or still set to the default value"
|
||||
)
|
||||
admin_password = str(settings.admin_password or "")
|
||||
if not admin_password or admin_password == "adminadmin":
|
||||
logger.warning(
|
||||
"security configuration warning: ADMIN_PASSWORD is unset or still set to the bootstrap default"
|
||||
)
|
||||
if bool(settings.api_docs_enabled):
|
||||
logger.warning(
|
||||
"security configuration warning: API docs are enabled; disable API_DOCS_ENABLED outside controlled environments"
|
||||
)
|
||||
|
||||
|
||||
def _enforce_secure_startup_configuration() -> None:
|
||||
jwt_secret = str(settings.jwt_secret or "").strip()
|
||||
if not jwt_secret or jwt_secret == "change-me":
|
||||
raise RuntimeError("JWT_SECRET must be set to a strong, non-default value before startup.")
|
||||
admin_password = str(settings.admin_password or "")
|
||||
if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
|
||||
raise RuntimeError(
|
||||
"A secure ADMIN_PASSWORD is required on first startup until an admin account exists."
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup() -> None:
|
||||
configure_logging(
|
||||
settings.log_level,
|
||||
settings.log_file,
|
||||
log_file_max_bytes=settings.log_file_max_bytes,
|
||||
log_file_backup_count=settings.log_file_backup_count,
|
||||
log_http_client_level=settings.log_http_client_level,
|
||||
log_background_sync_level=settings.log_background_sync_level,
|
||||
)
|
||||
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
|
||||
_log_security_configuration_warnings()
|
||||
init_db()
|
||||
_enforce_secure_startup_configuration()
|
||||
runtime = get_runtime_settings()
|
||||
configure_logging(
|
||||
runtime.log_level,
|
||||
runtime.log_file,
|
||||
log_file_max_bytes=runtime.log_file_max_bytes,
|
||||
log_file_backup_count=runtime.log_file_backup_count,
|
||||
log_http_client_level=runtime.log_http_client_level,
|
||||
log_background_sync_level=runtime.log_background_sync_level,
|
||||
)
|
||||
logger.info(
|
||||
"runtime settings applied log_level=%s log_file=%s log_file_max_bytes=%s log_file_backup_count=%s log_http_client_level=%s log_background_sync_level=%s request_source=%s",
|
||||
runtime.log_level,
|
||||
runtime.log_file,
|
||||
runtime.log_file_max_bytes,
|
||||
runtime.log_file_backup_count,
|
||||
runtime.log_http_client_level,
|
||||
runtime.log_background_sync_level,
|
||||
runtime.requests_data_source,
|
||||
)
|
||||
_launch_background_task("jellyfin-sync", run_daily_jellyfin_sync)
|
||||
_launch_background_task("requests-warmup", startup_warmup_requests_cache)
|
||||
_launch_background_task("requests-delta-loop", run_requests_delta_loop)
|
||||
_launch_background_task("requests-full-sync", run_daily_requests_full_sync)
|
||||
_launch_background_task("db-cleanup", run_daily_db_cleanup)
|
||||
logger.info("startup complete")
|
||||
|
||||
|
||||
app.include_router(requests_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(admin_events_router)
|
||||
app.include_router(images_router)
|
||||
app.include_router(branding_router)
|
||||
app.include_router(status_router)
|
||||
app.include_router(feedback_router)
|
||||
app.include_router(site_router)
|
||||
app.include_router(events_router)
|
||||
app.include_router(portal_router)
|
||||
@@ -0,0 +1,67 @@
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RequestType(str, Enum):
|
||||
movie = "movie"
|
||||
tv = "tv"
|
||||
unknown = "unknown"
|
||||
|
||||
|
||||
class NormalizedState(str, Enum):
|
||||
requested = "REQUESTED"
|
||||
approved = "APPROVED"
|
||||
needs_add = "NEEDS_ADD"
|
||||
added_to_arr = "ADDED_TO_ARR"
|
||||
searching = "SEARCHING"
|
||||
grabbed = "GRABBED"
|
||||
downloading = "DOWNLOADING"
|
||||
importing = "IMPORTING"
|
||||
completed = "COMPLETED"
|
||||
failed = "FAILED"
|
||||
available = "AVAILABLE"
|
||||
unknown = "UNKNOWN"
|
||||
|
||||
|
||||
class TimelineHop(BaseModel):
|
||||
service: str
|
||||
status: str
|
||||
details: Dict[str, Any] = Field(default_factory=dict)
|
||||
timestamp: Optional[str] = None
|
||||
|
||||
|
||||
class ActionOption(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
risk: str
|
||||
description: Optional[str] = None
|
||||
requires_confirmation: bool = True
|
||||
|
||||
|
||||
class Snapshot(BaseModel):
|
||||
request_id: str
|
||||
title: str
|
||||
year: Optional[int] = None
|
||||
request_type: RequestType = RequestType.unknown
|
||||
state: NormalizedState = NormalizedState.unknown
|
||||
state_reason: Optional[str] = None
|
||||
timeline: List[TimelineHop] = Field(default_factory=list)
|
||||
actions: List[ActionOption] = Field(default_factory=list)
|
||||
artwork: Dict[str, Any] = Field(default_factory=dict)
|
||||
presentation: Dict[str, Any] = Field(default_factory=dict)
|
||||
raw: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TriageRecommendation(BaseModel):
|
||||
action_id: str
|
||||
title: str
|
||||
reason: str
|
||||
risk: str
|
||||
|
||||
|
||||
class TriageResult(BaseModel):
|
||||
summary: str
|
||||
confidence: float
|
||||
root_cause: str
|
||||
recommendations: List[TriageRecommendation]
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from functools import lru_cache
|
||||
from typing import Iterable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .config import settings
|
||||
|
||||
_METADATA_HOSTS = {
|
||||
"169.254.169.254",
|
||||
"metadata.google.internal",
|
||||
"metadata.azure.internal",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_text(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _split_csv(value: object) -> list[str]:
|
||||
raw = _normalize_text(value)
|
||||
if not raw:
|
||||
return []
|
||||
return [part.strip() for part in raw.split(",") if part.strip()]
|
||||
|
||||
|
||||
def _ip_is_sensitive(ip_obj: ipaddress._BaseAddress) -> bool:
|
||||
return bool(
|
||||
ip_obj.is_loopback
|
||||
or ip_obj.is_link_local
|
||||
or ip_obj.is_multicast
|
||||
or ip_obj.is_unspecified
|
||||
or ip_obj.is_reserved
|
||||
or ip_obj.is_private
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def _resolve_host_ips(host: str) -> tuple[ipaddress._BaseAddress, ...]:
|
||||
resolved: list[ipaddress._BaseAddress] = []
|
||||
for family, _, _, _, sockaddr in socket.getaddrinfo(host, None):
|
||||
if family == socket.AF_INET:
|
||||
resolved.append(ipaddress.ip_address(sockaddr[0]))
|
||||
elif family == socket.AF_INET6:
|
||||
resolved.append(ipaddress.ip_address(sockaddr[0]))
|
||||
return tuple(resolved)
|
||||
|
||||
|
||||
def _is_trusted_proxy_host(host: str, trusted_proxies: Iterable[str]) -> bool:
|
||||
candidate = _normalize_text(host)
|
||||
if not candidate:
|
||||
return False
|
||||
try:
|
||||
host_ip = ipaddress.ip_address(candidate)
|
||||
except ValueError:
|
||||
return candidate.lower() in {entry.lower() for entry in trusted_proxies}
|
||||
|
||||
for entry in trusted_proxies:
|
||||
raw = _normalize_text(entry)
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
if "/" in raw:
|
||||
if host_ip in ipaddress.ip_network(raw, strict=False):
|
||||
return True
|
||||
elif host_ip == ipaddress.ip_address(raw):
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def request_trusts_forwarded_headers(client_host: str | None) -> bool:
|
||||
if not settings.magent_proxy_enabled or not settings.magent_proxy_trust_forwarded_headers:
|
||||
return False
|
||||
trusted = _split_csv(settings.magent_proxy_trusted_proxies)
|
||||
if not trusted:
|
||||
return False
|
||||
return _is_trusted_proxy_host(client_host or "", trusted)
|
||||
|
||||
|
||||
def validate_notification_target_url(
|
||||
url: str,
|
||||
*,
|
||||
allow_private: bool | None = None,
|
||||
) -> str:
|
||||
raw = _normalize_text(url)
|
||||
if not raw:
|
||||
raise ValueError("URL cannot be empty.")
|
||||
|
||||
parsed = urlparse(raw)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
raise ValueError("URL must use http:// or https://.")
|
||||
if parsed.username or parsed.password:
|
||||
raise ValueError("URL must not embed credentials.")
|
||||
hostname = _normalize_text(parsed.hostname).lower()
|
||||
if not hostname:
|
||||
raise ValueError("URL must include a valid host.")
|
||||
|
||||
allow_private_targets = (
|
||||
settings.magent_allow_private_notification_targets
|
||||
if allow_private is None
|
||||
else bool(allow_private)
|
||||
)
|
||||
if hostname in _METADATA_HOSTS:
|
||||
raise ValueError("Metadata service targets are not allowed.")
|
||||
if hostname == "localhost" and not allow_private_targets:
|
||||
raise ValueError("Local notification targets are not allowed.")
|
||||
|
||||
try:
|
||||
host_ip = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
host_ip = None
|
||||
|
||||
if host_ip is not None:
|
||||
if _ip_is_sensitive(host_ip) and not allow_private_targets:
|
||||
raise ValueError("Private or local notification targets are not allowed.")
|
||||
return raw
|
||||
|
||||
try:
|
||||
resolved_ips = _resolve_host_ips(hostname)
|
||||
except socket.gaierror as exc:
|
||||
raise ValueError("Host could not be resolved.") from exc
|
||||
if not resolved_ips:
|
||||
raise ValueError("Host could not be resolved.")
|
||||
if not allow_private_targets and any(_ip_is_sensitive(ip_obj) for ip_obj in resolved_ips):
|
||||
raise ValueError("Private or local notification targets are not allowed.")
|
||||
return raw
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
import os
|
||||
from io import BytesIO
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||
from fastapi.responses import FileResponse
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
router = APIRouter(prefix="/branding", tags=["branding"])
|
||||
|
||||
_BRANDING_DIR = os.path.join(os.getcwd(), "data", "branding")
|
||||
_LOGO_PATH = os.path.join(_BRANDING_DIR, "logo.png")
|
||||
_FAVICON_PATH = os.path.join(_BRANDING_DIR, "favicon.ico")
|
||||
_BUNDLED_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "assets", "branding"))
|
||||
_BUNDLED_LOGO_PATH = os.path.join(_BUNDLED_DIR, "logo.png")
|
||||
_BUNDLED_FAVICON_PATH = os.path.join(_BUNDLED_DIR, "favicon.ico")
|
||||
_BRANDING_SOURCE = os.getenv("BRANDING_SOURCE", "bundled").lower()
|
||||
|
||||
|
||||
def _ensure_branding_dir() -> None:
|
||||
os.makedirs(_BRANDING_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def _resize_image(image: Image.Image, max_size: int = 300) -> Image.Image:
|
||||
image = image.convert("RGBA")
|
||||
image.thumbnail((max_size, max_size))
|
||||
return image
|
||||
|
||||
|
||||
def _load_font(size: int) -> ImageFont.ImageFont:
|
||||
candidates = [
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
]
|
||||
for path in candidates:
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
return ImageFont.truetype(path, size)
|
||||
except OSError:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def _ensure_default_branding() -> None:
|
||||
if os.path.exists(_LOGO_PATH) and os.path.exists(_FAVICON_PATH):
|
||||
return
|
||||
_ensure_branding_dir()
|
||||
if not os.path.exists(_LOGO_PATH) and os.path.exists(_BUNDLED_LOGO_PATH):
|
||||
try:
|
||||
with open(_BUNDLED_LOGO_PATH, "rb") as source, open(_LOGO_PATH, "wb") as target:
|
||||
target.write(source.read())
|
||||
except OSError:
|
||||
pass
|
||||
if not os.path.exists(_FAVICON_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
|
||||
try:
|
||||
with open(_BUNDLED_FAVICON_PATH, "rb") as source, open(_FAVICON_PATH, "wb") as target:
|
||||
target.write(source.read())
|
||||
except OSError:
|
||||
pass
|
||||
if not os.path.exists(_LOGO_PATH):
|
||||
image = Image.new("RGBA", (300, 300), (12, 18, 28, 255))
|
||||
draw = ImageDraw.Draw(image)
|
||||
font = _load_font(160)
|
||||
text = "M"
|
||||
box = draw.textbbox((0, 0), text, font=font)
|
||||
text_w = box[2] - box[0]
|
||||
text_h = box[3] - box[1]
|
||||
draw.text(
|
||||
((300 - text_w) / 2, (300 - text_h) / 2 - 6),
|
||||
text,
|
||||
font=font,
|
||||
fill=(255, 255, 255, 255),
|
||||
)
|
||||
image.save(_LOGO_PATH, format="PNG")
|
||||
if not os.path.exists(_FAVICON_PATH):
|
||||
favicon = Image.open(_LOGO_PATH).copy()
|
||||
favicon.thumbnail((64, 64))
|
||||
try:
|
||||
favicon.save(_FAVICON_PATH, format="ICO", sizes=[(32, 32), (64, 64)])
|
||||
except OSError:
|
||||
favicon.save(_FAVICON_PATH, format="ICO")
|
||||
|
||||
|
||||
def _resolve_branding_paths() -> tuple[str, str]:
|
||||
if _BRANDING_SOURCE == "data":
|
||||
_ensure_default_branding()
|
||||
return _LOGO_PATH, _FAVICON_PATH
|
||||
if os.path.exists(_BUNDLED_LOGO_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
|
||||
return _BUNDLED_LOGO_PATH, _BUNDLED_FAVICON_PATH
|
||||
_ensure_default_branding()
|
||||
return _LOGO_PATH, _FAVICON_PATH
|
||||
|
||||
|
||||
@router.get("/logo.png")
|
||||
async def branding_logo() -> FileResponse:
|
||||
logo_path, _ = _resolve_branding_paths()
|
||||
if not os.path.exists(logo_path):
|
||||
raise HTTPException(status_code=404, detail="Logo not found")
|
||||
headers = {"Cache-Control": "no-store"}
|
||||
return FileResponse(logo_path, media_type="image/png", headers=headers)
|
||||
|
||||
|
||||
@router.get("/favicon.ico")
|
||||
async def branding_favicon() -> FileResponse:
|
||||
_, favicon_path = _resolve_branding_paths()
|
||||
if not os.path.exists(favicon_path):
|
||||
raise HTTPException(status_code=404, detail="Favicon not found")
|
||||
headers = {"Cache-Control": "no-store"}
|
||||
return FileResponse(favicon_path, media_type="image/x-icon", headers=headers)
|
||||
|
||||
|
||||
async def save_branding_image(file: UploadFile) -> Dict[str, Any]:
|
||||
if not file.content_type or not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="Please upload an image file.")
|
||||
content = await file.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="Uploaded file is empty.")
|
||||
try:
|
||||
image = Image.open(BytesIO(content))
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=400, detail="Image file could not be read.") from exc
|
||||
|
||||
_ensure_branding_dir()
|
||||
image = _resize_image(image, 300)
|
||||
image.save(_LOGO_PATH, format="PNG")
|
||||
|
||||
favicon = image.copy()
|
||||
favicon.thumbnail((64, 64))
|
||||
try:
|
||||
favicon.save(_FAVICON_PATH, format="ICO", sizes=[(32, 32), (64, 64)])
|
||||
except OSError:
|
||||
favicon.save(_FAVICON_PATH, format="ICO")
|
||||
|
||||
return {"status": "ok", "width": image.width, "height": image.height}
|
||||
@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from ..auth import get_current_user_event_stream
|
||||
from . import requests as requests_router
|
||||
from .status import services_status
|
||||
|
||||
router = APIRouter(prefix="/events", tags=["events"])
|
||||
|
||||
|
||||
def _sse_json(payload: Dict[str, Any]) -> str:
|
||||
return f"data: {json.dumps(payload, ensure_ascii=True, separators=(',', ':'), default=str)}\n\n"
|
||||
|
||||
|
||||
def _jsonable(value: Any) -> Any:
|
||||
if hasattr(value, "model_dump"):
|
||||
try:
|
||||
return value.model_dump(mode="json")
|
||||
except TypeError:
|
||||
return value.model_dump()
|
||||
if hasattr(value, "dict"):
|
||||
try:
|
||||
return value.dict()
|
||||
except TypeError:
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def _request_history_brief(entries: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
items: list[dict[str, Any]] = []
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"request_id": entry.get("request_id"),
|
||||
"state": entry.get("state"),
|
||||
"state_reason": entry.get("state_reason"),
|
||||
"created_at": entry.get("created_at"),
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def _request_actions_brief(entries: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
items: list[dict[str, Any]] = []
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"request_id": entry.get("request_id"),
|
||||
"action_id": entry.get("action_id"),
|
||||
"label": entry.get("label"),
|
||||
"status": entry.get("status"),
|
||||
"message": entry.get("message"),
|
||||
"created_at": entry.get("created_at"),
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def events_stream(
|
||||
request: Request,
|
||||
recent_days: int = 90,
|
||||
recent_stage: str = "all",
|
||||
user: Dict[str, Any] = Depends(get_current_user_event_stream),
|
||||
) -> StreamingResponse:
|
||||
recent_days = max(0, min(int(recent_days or 90), 3650))
|
||||
recent_take = 50 if user.get("role") == "admin" else 6
|
||||
|
||||
async def event_generator():
|
||||
yield "retry: 2000\n\n"
|
||||
last_recent_signature: Optional[str] = None
|
||||
last_services_signature: Optional[str] = None
|
||||
next_recent_at = 0.0
|
||||
next_services_at = 0.0
|
||||
heartbeat_counter = 0
|
||||
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
|
||||
now = time.monotonic()
|
||||
sent_any = False
|
||||
|
||||
if now >= next_recent_at:
|
||||
next_recent_at = now + 15.0
|
||||
try:
|
||||
recent_payload = await requests_router.recent_requests(
|
||||
take=recent_take,
|
||||
skip=0,
|
||||
days=recent_days,
|
||||
stage=recent_stage,
|
||||
user=user,
|
||||
)
|
||||
results = recent_payload.get("results") if isinstance(recent_payload, dict) else []
|
||||
payload = {
|
||||
"type": "home_recent",
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"days": recent_days,
|
||||
"stage": recent_stage,
|
||||
"results": results if isinstance(results, list) else [],
|
||||
}
|
||||
except Exception as exc:
|
||||
payload = {
|
||||
"type": "home_recent",
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"days": recent_days,
|
||||
"stage": recent_stage,
|
||||
"error": str(exc),
|
||||
}
|
||||
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
|
||||
if signature != last_recent_signature:
|
||||
last_recent_signature = signature
|
||||
yield _sse_json(payload)
|
||||
sent_any = True
|
||||
|
||||
if now >= next_services_at:
|
||||
next_services_at = now + 30.0
|
||||
try:
|
||||
status_payload = await services_status()
|
||||
payload = {
|
||||
"type": "home_services",
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status_payload,
|
||||
}
|
||||
except Exception as exc:
|
||||
payload = {
|
||||
"type": "home_services",
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"error": str(exc),
|
||||
}
|
||||
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
|
||||
if signature != last_services_signature:
|
||||
last_services_signature = signature
|
||||
yield _sse_json(payload)
|
||||
sent_any = True
|
||||
|
||||
if sent_any:
|
||||
heartbeat_counter = 0
|
||||
else:
|
||||
heartbeat_counter += 1
|
||||
if heartbeat_counter >= 15:
|
||||
yield ": ping\n\n"
|
||||
heartbeat_counter = 0
|
||||
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
headers = {
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
|
||||
|
||||
|
||||
@router.get("/requests/{request_id}/stream")
|
||||
async def request_events_stream(
|
||||
request_id: str,
|
||||
request: Request,
|
||||
user: Dict[str, Any] = Depends(get_current_user_event_stream),
|
||||
) -> StreamingResponse:
|
||||
request_id = str(request_id).strip()
|
||||
if not request_id:
|
||||
raise HTTPException(status_code=400, detail="Missing request id")
|
||||
|
||||
async def event_generator():
|
||||
yield "retry: 2000\n\n"
|
||||
last_signature: Optional[str] = None
|
||||
next_refresh_at = 0.0
|
||||
heartbeat_counter = 0
|
||||
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
|
||||
now = time.monotonic()
|
||||
sent_any = False
|
||||
|
||||
if now >= next_refresh_at:
|
||||
next_refresh_at = now + 2.0
|
||||
try:
|
||||
snapshot = await requests_router.get_snapshot(request_id=request_id, user=user)
|
||||
history_payload = await requests_router.request_history(
|
||||
request_id=request_id, limit=5, user=user
|
||||
)
|
||||
actions_payload = await requests_router.request_actions(
|
||||
request_id=request_id, limit=5, user=user
|
||||
)
|
||||
payload = {
|
||||
"type": "request_live",
|
||||
"request_id": request_id,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"snapshot": _jsonable(snapshot),
|
||||
"history": _request_history_brief(
|
||||
history_payload.get("snapshots", []) if isinstance(history_payload, dict) else []
|
||||
),
|
||||
"actions": _request_actions_brief(
|
||||
actions_payload.get("actions", []) if isinstance(actions_payload, dict) else []
|
||||
),
|
||||
}
|
||||
except HTTPException as exc:
|
||||
payload = {
|
||||
"type": "request_live",
|
||||
"request_id": request_id,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"error": str(exc.detail),
|
||||
"status_code": int(exc.status_code),
|
||||
}
|
||||
except Exception as exc:
|
||||
payload = {
|
||||
"type": "request_live",
|
||||
"request_id": request_id,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
|
||||
if signature != last_signature:
|
||||
last_signature = signature
|
||||
yield _sse_json(payload)
|
||||
sent_any = True
|
||||
|
||||
if sent_any:
|
||||
heartbeat_counter = 0
|
||||
else:
|
||||
heartbeat_counter += 1
|
||||
if heartbeat_counter >= 15:
|
||||
yield ": ping\n\n"
|
||||
heartbeat_counter = 0
|
||||
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
headers = {
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
|
||||
@@ -0,0 +1,46 @@
|
||||
from typing import Any, Dict
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..network_security import validate_notification_target_url
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(get_current_user)])
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict:
|
||||
runtime = get_runtime_settings()
|
||||
webhook_url = (
|
||||
getattr(runtime, "magent_notify_discord_webhook_url", None)
|
||||
or runtime.discord_webhook_url
|
||||
)
|
||||
if not webhook_url:
|
||||
raise HTTPException(status_code=400, detail="Discord webhook not configured")
|
||||
try:
|
||||
webhook_url = validate_notification_target_url(webhook_url)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
feedback_type = str(payload.get("type") or "").strip().lower()
|
||||
if feedback_type not in {"bug", "feature"}:
|
||||
raise HTTPException(status_code=400, detail="Invalid feedback type")
|
||||
|
||||
message = str(payload.get("message") or "").strip()
|
||||
if not message:
|
||||
raise HTTPException(status_code=400, detail="Message is required")
|
||||
if len(message) > 2000:
|
||||
raise HTTPException(status_code=400, detail="Message is too long")
|
||||
|
||||
username = user.get("username") or "unknown"
|
||||
content = f"**{feedback_type.title()}** from **{username}**\n{message}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(webhook_url, json={"content": content})
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,100 @@
|
||||
import os
|
||||
import re
|
||||
import mimetypes
|
||||
import logging
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Response
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
import httpx
|
||||
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
router = APIRouter(prefix="/images", tags=["images"])
|
||||
|
||||
_TMDB_BASE = "https://image.tmdb.org/t/p"
|
||||
_ALLOWED_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"}
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_filename(path: str) -> str:
|
||||
trimmed = path.strip("/")
|
||||
trimmed = trimmed.replace("/", "_")
|
||||
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", trimmed)
|
||||
return safe or "image"
|
||||
|
||||
def tmdb_cache_path(path: str, size: str) -> Optional[str]:
|
||||
if not path or "://" in path or ".." in path:
|
||||
return None
|
||||
if not path.startswith("/"):
|
||||
path = f"/{path}"
|
||||
if size not in _ALLOWED_SIZES:
|
||||
return None
|
||||
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
|
||||
return os.path.join(cache_dir, _safe_filename(path))
|
||||
|
||||
|
||||
def is_tmdb_cached(path: str, size: str) -> bool:
|
||||
file_path = tmdb_cache_path(path, size)
|
||||
return bool(file_path and os.path.exists(file_path))
|
||||
|
||||
|
||||
async def cache_tmdb_image(path: str, size: str = "w342") -> bool:
|
||||
if not path or "://" in path or ".." in path:
|
||||
return False
|
||||
|
||||
runtime = get_runtime_settings()
|
||||
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
|
||||
if cache_mode != "cache":
|
||||
return False
|
||||
|
||||
file_path = tmdb_cache_path(path, size)
|
||||
if not file_path:
|
||||
return False
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
if os.path.exists(file_path):
|
||||
return True
|
||||
|
||||
url = f"{_TMDB_BASE}/{size}{path}"
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
content = response.content
|
||||
with open(file_path, "wb") as handle:
|
||||
handle.write(content)
|
||||
return True
|
||||
|
||||
|
||||
@router.get("/tmdb")
|
||||
async def tmdb_image(path: str, size: str = "w342"):
|
||||
if not path or "://" in path or ".." in path:
|
||||
raise HTTPException(status_code=400, detail="Invalid image path")
|
||||
if not path.startswith("/"):
|
||||
path = f"/{path}"
|
||||
if size not in _ALLOWED_SIZES:
|
||||
raise HTTPException(status_code=400, detail="Invalid size")
|
||||
|
||||
runtime = get_runtime_settings()
|
||||
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
|
||||
url = f"{_TMDB_BASE}/{size}{path}"
|
||||
if cache_mode != "cache":
|
||||
return RedirectResponse(url=url)
|
||||
|
||||
file_path = tmdb_cache_path(path, size)
|
||||
if not file_path:
|
||||
raise HTTPException(status_code=400, detail="Invalid image path")
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
headers = {"Cache-Control": "public, max-age=86400"}
|
||||
if os.path.exists(file_path):
|
||||
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
|
||||
return FileResponse(file_path, media_type=media_type, headers=headers)
|
||||
|
||||
try:
|
||||
await cache_tmdb_image(path, size)
|
||||
if os.path.exists(file_path):
|
||||
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
|
||||
return FileResponse(file_path, media_type=media_type, headers=headers)
|
||||
logger.warning("TMDB cache miss after fetch: path=%s size=%s", path, size)
|
||||
except (httpx.HTTPError, OSError) as exc:
|
||||
logger.warning("TMDB cache failed: path=%s size=%s error=%s", path, size, exc)
|
||||
|
||||
return RedirectResponse(url=url)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..build_info import BUILD_NUMBER, CHANGELOG
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
router = APIRouter(prefix="/site", tags=["site"])
|
||||
|
||||
_BANNER_TONES = {"info", "warning", "error", "maintenance"}
|
||||
|
||||
|
||||
def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
banner_message = (runtime.site_banner_message or "").strip()
|
||||
tone = (runtime.site_banner_tone or "info").strip().lower()
|
||||
if tone not in _BANNER_TONES:
|
||||
tone = "info"
|
||||
info = {
|
||||
"buildNumber": (runtime.site_build_number or BUILD_NUMBER or "").strip(),
|
||||
"banner": {
|
||||
"enabled": bool(runtime.site_banner_enabled and banner_message),
|
||||
"message": banner_message,
|
||||
"tone": tone,
|
||||
},
|
||||
"login": {
|
||||
"showJellyfinLogin": bool(runtime.site_login_show_jellyfin_login),
|
||||
"showLocalLogin": bool(runtime.site_login_show_local_login),
|
||||
"showForgotPassword": bool(runtime.site_login_show_forgot_password),
|
||||
"showSignupLink": bool(runtime.site_login_show_signup_link),
|
||||
},
|
||||
"navigation": {
|
||||
"showRequests": bool(runtime.site_nav_show_requests),
|
||||
},
|
||||
}
|
||||
if include_changelog:
|
||||
info["changelog"] = (CHANGELOG or "").strip()
|
||||
return info
|
||||
|
||||
|
||||
@router.get("/public")
|
||||
async def site_public() -> Dict[str, Any]:
|
||||
return _build_site_info(False)
|
||||
|
||||
|
||||
@router.get("/info")
|
||||
async def site_info(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
return _build_site_info(True)
|
||||
@@ -0,0 +1,164 @@
|
||||
from typing import Any, Dict
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..runtime import get_runtime_settings
|
||||
from ..clients.jellyseerr import JellyseerrClient
|
||||
from ..clients.sonarr import SonarrClient
|
||||
from ..clients.radarr import RadarrClient
|
||||
from ..clients.prowlarr import ProwlarrClient
|
||||
from ..clients.qbittorrent import QBittorrentClient
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
|
||||
router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(get_current_user)])
|
||||
|
||||
|
||||
async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
|
||||
if not configured:
|
||||
return {"name": name, "status": "not_configured"}
|
||||
try:
|
||||
result = await func()
|
||||
return {"name": name, "status": "up", "detail": result}
|
||||
except httpx.HTTPError as exc:
|
||||
return {"name": name, "status": "down", "message": str(exc)}
|
||||
except Exception as exc:
|
||||
return {"name": name, "status": "down", "message": str(exc)}
|
||||
|
||||
|
||||
async def _check_qbittorrent(qbittorrent: QBittorrentClient) -> Dict[str, Any]:
|
||||
if not qbittorrent.base_url:
|
||||
return {"name": "qBittorrent", "status": "not_configured"}
|
||||
if not qbittorrent.username or not qbittorrent.password:
|
||||
reachable = await qbittorrent.is_webui_reachable()
|
||||
return {
|
||||
"name": "qBittorrent",
|
||||
"status": "degraded" if reachable else "not_configured",
|
||||
"message": "qBittorrent credentials are incomplete" if reachable else "qBittorrent is not fully configured",
|
||||
}
|
||||
try:
|
||||
result = await qbittorrent.get_app_version()
|
||||
return {"name": "qBittorrent", "status": "up", "detail": result}
|
||||
except RuntimeError as exc:
|
||||
if "login failed" in str(exc).lower():
|
||||
reachable = await qbittorrent.is_webui_reachable()
|
||||
if reachable:
|
||||
return {
|
||||
"name": "qBittorrent",
|
||||
"status": "degraded",
|
||||
"message": "qBittorrent is reachable but the saved credentials were rejected",
|
||||
}
|
||||
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
|
||||
except httpx.HTTPError as exc:
|
||||
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
|
||||
except Exception as exc:
|
||||
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
|
||||
|
||||
|
||||
@router.get("/services")
|
||||
async def services_status() -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
||||
qbittorrent = QBittorrentClient(
|
||||
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
|
||||
)
|
||||
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
|
||||
services = []
|
||||
services.append(
|
||||
await _check(
|
||||
"Seerr",
|
||||
jellyseerr.configured(),
|
||||
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
|
||||
)
|
||||
)
|
||||
services.append(
|
||||
await _check(
|
||||
"Sonarr",
|
||||
sonarr.configured(),
|
||||
sonarr.get_system_status,
|
||||
)
|
||||
)
|
||||
services.append(
|
||||
await _check(
|
||||
"Radarr",
|
||||
radarr.configured(),
|
||||
radarr.get_system_status,
|
||||
)
|
||||
)
|
||||
prowlarr_status = await _check(
|
||||
"Prowlarr",
|
||||
prowlarr.configured(),
|
||||
prowlarr.get_health,
|
||||
)
|
||||
if prowlarr_status.get("status") == "up":
|
||||
health = prowlarr_status.get("detail")
|
||||
if isinstance(health, list) and health:
|
||||
prowlarr_status["status"] = "degraded"
|
||||
prowlarr_status["message"] = "Health warnings"
|
||||
services.append(prowlarr_status)
|
||||
services.append(await _check_qbittorrent(qbittorrent))
|
||||
services.append(
|
||||
await _check(
|
||||
"Jellyfin",
|
||||
jellyfin.configured(),
|
||||
jellyfin.get_system_info,
|
||||
)
|
||||
)
|
||||
|
||||
overall = "up"
|
||||
if any(s.get("status") == "down" for s in services):
|
||||
overall = "down"
|
||||
elif any(s.get("status") in {"degraded", "not_configured"} for s in services):
|
||||
overall = "degraded"
|
||||
|
||||
return {"overall": overall, "services": services}
|
||||
|
||||
|
||||
@router.post("/services/{service}/test")
|
||||
async def test_service(service: str) -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
||||
qbittorrent = QBittorrentClient(
|
||||
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
|
||||
)
|
||||
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
|
||||
service_key = service.strip().lower()
|
||||
checks = {
|
||||
"seerr": (
|
||||
"Seerr",
|
||||
jellyseerr.configured(),
|
||||
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
|
||||
),
|
||||
"jellyseerr": (
|
||||
"Seerr",
|
||||
jellyseerr.configured(),
|
||||
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
|
||||
),
|
||||
"sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
|
||||
"radarr": ("Radarr", radarr.configured(), radarr.get_system_status),
|
||||
"prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
|
||||
"jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
|
||||
}
|
||||
|
||||
if service_key == "qbittorrent":
|
||||
return await _check_qbittorrent(qbittorrent)
|
||||
|
||||
if service_key not in checks:
|
||||
raise HTTPException(status_code=404, detail="Unknown service")
|
||||
|
||||
name, configured, func = checks[service_key]
|
||||
result = await _check(name, configured, func)
|
||||
if name == "Prowlarr" and result.get("status") == "up":
|
||||
health = result.get("detail")
|
||||
if isinstance(health, list) and health:
|
||||
result["status"] = "degraded"
|
||||
result["message"] = "Health warnings"
|
||||
return result
|
||||
@@ -0,0 +1,67 @@
|
||||
from .config import settings
|
||||
from .db import get_settings_overrides
|
||||
|
||||
_INT_FIELDS = {
|
||||
"magent_application_port",
|
||||
"magent_api_port",
|
||||
"auth_rate_limit_window_seconds",
|
||||
"auth_rate_limit_max_attempts_ip",
|
||||
"auth_rate_limit_max_attempts_user",
|
||||
"password_reset_rate_limit_window_seconds",
|
||||
"password_reset_rate_limit_max_attempts_ip",
|
||||
"password_reset_rate_limit_max_attempts_identifier",
|
||||
"sonarr_quality_profile_id",
|
||||
"radarr_quality_profile_id",
|
||||
"jwt_exp_minutes",
|
||||
"log_file_max_bytes",
|
||||
"log_file_backup_count",
|
||||
"requests_sync_ttl_minutes",
|
||||
"requests_poll_interval_seconds",
|
||||
"requests_delta_sync_interval_minutes",
|
||||
"requests_cleanup_days",
|
||||
"magent_notify_email_smtp_port",
|
||||
}
|
||||
_BOOL_FIELDS = {
|
||||
"magent_proxy_enabled",
|
||||
"magent_proxy_trust_forwarded_headers",
|
||||
"magent_ssl_bind_enabled",
|
||||
"magent_notify_enabled",
|
||||
"magent_notify_email_enabled",
|
||||
"magent_notify_email_use_tls",
|
||||
"magent_notify_email_use_ssl",
|
||||
"magent_notify_discord_enabled",
|
||||
"magent_notify_telegram_enabled",
|
||||
"magent_notify_push_enabled",
|
||||
"magent_notify_webhook_enabled",
|
||||
"jellyfin_sync_to_arr",
|
||||
"site_banner_enabled",
|
||||
"site_login_show_jellyfin_login",
|
||||
"site_login_show_local_login",
|
||||
"site_login_show_forgot_password",
|
||||
"site_login_show_signup_link",
|
||||
"site_nav_show_requests",
|
||||
}
|
||||
_SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"}
|
||||
|
||||
|
||||
def get_runtime_settings():
|
||||
overrides = get_settings_overrides()
|
||||
update = {}
|
||||
for key, value in overrides.items():
|
||||
if value is None:
|
||||
continue
|
||||
if key in _SKIP_OVERRIDE_FIELDS:
|
||||
continue
|
||||
if key in _INT_FIELDS:
|
||||
try:
|
||||
update[key] = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
elif key in _BOOL_FIELDS:
|
||||
if isinstance(value, bool):
|
||||
update[key] = value
|
||||
else:
|
||||
update[key] = str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||
else:
|
||||
update[key] = value
|
||||
return settings.model_copy(update=update)
|
||||
@@ -0,0 +1,73 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from passlib.context import CryptContext
|
||||
import jwt
|
||||
from jwt import InvalidTokenError
|
||||
|
||||
from .config import settings
|
||||
|
||||
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||
_ALGORITHM = "HS256"
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
PASSWORD_POLICY_MESSAGE = f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return _pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return _pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def validate_password_policy(password: str) -> str:
|
||||
candidate = password.strip()
|
||||
if len(candidate) < MIN_PASSWORD_LENGTH:
|
||||
raise ValueError(PASSWORD_POLICY_MESSAGE)
|
||||
return candidate
|
||||
|
||||
|
||||
def _create_token(
|
||||
subject: str,
|
||||
role: str,
|
||||
*,
|
||||
expires_at: datetime,
|
||||
token_type: str = "access",
|
||||
) -> str:
|
||||
payload: Dict[str, Any] = {
|
||||
"sub": subject,
|
||||
"role": role,
|
||||
"typ": token_type,
|
||||
"exp": expires_at,
|
||||
}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
|
||||
|
||||
def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str:
|
||||
if not settings.jwt_secret:
|
||||
raise ValueError("JWT_SECRET is not configured")
|
||||
minutes = expires_minutes or settings.jwt_exp_minutes
|
||||
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
||||
return _create_token(subject, role, expires_at=expires, token_type="access")
|
||||
|
||||
|
||||
def create_stream_token(subject: str, role: str, expires_seconds: int = 120) -> str:
|
||||
expires = datetime.now(timezone.utc) + timedelta(seconds=max(30, int(expires_seconds or 120)))
|
||||
return _create_token(subject, role, expires_at=expires, token_type="sse")
|
||||
|
||||
|
||||
def decode_token(token: str) -> Dict[str, Any]:
|
||||
if not settings.jwt_secret:
|
||||
raise ValueError("JWT_SECRET is not configured")
|
||||
return jwt.decode(token, settings.jwt_secret, algorithms=[_ALGORITHM])
|
||||
|
||||
|
||||
class TokenError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def safe_decode_token(token: str) -> Dict[str, Any]:
|
||||
try:
|
||||
return decode_token(token)
|
||||
except InvalidTokenError as exc:
|
||||
raise TokenError("Invalid token") from exc
|
||||
@@ -0,0 +1,735 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from time import perf_counter
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..clients.jellyseerr import JellyseerrClient
|
||||
from ..clients.prowlarr import ProwlarrClient
|
||||
from ..clients.qbittorrent import QBittorrentClient
|
||||
from ..clients.radarr import RadarrClient
|
||||
from ..clients.sonarr import SonarrClient
|
||||
from ..config import settings as env_settings
|
||||
from ..db import get_database_diagnostics
|
||||
from ..network_security import validate_notification_target_url
|
||||
from ..runtime import get_runtime_settings
|
||||
from .invite_email import send_test_email, smtp_email_config_ready, smtp_email_delivery_warning
|
||||
|
||||
|
||||
DiagnosticRunner = Callable[[], Awaitable[Dict[str, Any]]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiagnosticCheck:
|
||||
key: str
|
||||
label: str
|
||||
category: str
|
||||
description: str
|
||||
live_safe: bool
|
||||
configured: bool
|
||||
config_detail: str
|
||||
target: Optional[str]
|
||||
runner: DiagnosticRunner
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _clean_text(value: Any, fallback: str = "") -> str:
|
||||
if value is None:
|
||||
return fallback
|
||||
if isinstance(value, str):
|
||||
trimmed = value.strip()
|
||||
return trimmed if trimmed else fallback
|
||||
return str(value)
|
||||
|
||||
|
||||
def _url_target(url: Optional[str]) -> Optional[str]:
|
||||
raw = _clean_text(url)
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
parsed = urlparse(raw)
|
||||
except Exception:
|
||||
return raw
|
||||
host = parsed.hostname or parsed.netloc or raw
|
||||
if parsed.port:
|
||||
host = f"{host}:{parsed.port}"
|
||||
return host
|
||||
|
||||
|
||||
def _host_port_target(host: Optional[str], port: Optional[int]) -> Optional[str]:
|
||||
resolved_host = _clean_text(host)
|
||||
if not resolved_host:
|
||||
return None
|
||||
if port is None:
|
||||
return resolved_host
|
||||
return f"{resolved_host}:{port}"
|
||||
|
||||
|
||||
def _http_error_detail(exc: Exception) -> str:
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
response = exc.response
|
||||
body = ""
|
||||
try:
|
||||
body = response.text.strip()
|
||||
except Exception:
|
||||
body = ""
|
||||
if body:
|
||||
return f"HTTP {response.status_code}: {body}"
|
||||
return f"HTTP {response.status_code}"
|
||||
return str(exc)
|
||||
|
||||
|
||||
def _config_status(detail: str) -> str:
|
||||
lowered = detail.lower()
|
||||
if "disabled" in lowered:
|
||||
return "disabled"
|
||||
return "not_configured"
|
||||
|
||||
|
||||
def _discord_config_ready(runtime) -> tuple[bool, str]:
|
||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_discord_enabled:
|
||||
return False, "Discord notifications are disabled."
|
||||
webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
|
||||
if webhook_url:
|
||||
try:
|
||||
validate_notification_target_url(webhook_url)
|
||||
except ValueError as exc:
|
||||
return False, str(exc)
|
||||
return True, "ok"
|
||||
return False, "Discord webhook URL is required."
|
||||
|
||||
|
||||
def _telegram_config_ready(runtime) -> tuple[bool, str]:
|
||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_telegram_enabled:
|
||||
return False, "Telegram notifications are disabled."
|
||||
if _clean_text(runtime.magent_notify_telegram_bot_token) and _clean_text(runtime.magent_notify_telegram_chat_id):
|
||||
return True, "ok"
|
||||
return False, "Telegram bot token and chat ID are required."
|
||||
|
||||
|
||||
def _webhook_config_ready(runtime) -> tuple[bool, str]:
|
||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_webhook_enabled:
|
||||
return False, "Generic webhook notifications are disabled."
|
||||
webhook_url = _clean_text(runtime.magent_notify_webhook_url)
|
||||
if webhook_url:
|
||||
try:
|
||||
validate_notification_target_url(webhook_url)
|
||||
except ValueError as exc:
|
||||
return False, str(exc)
|
||||
return True, "ok"
|
||||
return False, "Generic webhook URL is required."
|
||||
|
||||
|
||||
def _push_config_ready(runtime) -> tuple[bool, str]:
|
||||
if not runtime.magent_notify_enabled or not runtime.magent_notify_push_enabled:
|
||||
return False, "Push notifications are disabled."
|
||||
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
||||
if provider == "ntfy":
|
||||
push_url = _clean_text(runtime.magent_notify_push_base_url)
|
||||
if push_url and _clean_text(runtime.magent_notify_push_topic):
|
||||
try:
|
||||
validate_notification_target_url(push_url)
|
||||
except ValueError as exc:
|
||||
return False, str(exc)
|
||||
return True, "ok"
|
||||
return False, "ntfy requires a base URL and topic."
|
||||
if provider == "gotify":
|
||||
push_url = _clean_text(runtime.magent_notify_push_base_url)
|
||||
if push_url and _clean_text(runtime.magent_notify_push_token):
|
||||
try:
|
||||
validate_notification_target_url(push_url)
|
||||
except ValueError as exc:
|
||||
return False, str(exc)
|
||||
return True, "ok"
|
||||
return False, "Gotify requires a base URL and app token."
|
||||
if provider == "pushover":
|
||||
if _clean_text(runtime.magent_notify_push_token) and _clean_text(runtime.magent_notify_push_user_key):
|
||||
return True, "ok"
|
||||
return False, "Pushover requires an application token and user key."
|
||||
if provider == "webhook":
|
||||
push_url = _clean_text(runtime.magent_notify_push_base_url)
|
||||
if push_url:
|
||||
try:
|
||||
validate_notification_target_url(push_url)
|
||||
except ValueError as exc:
|
||||
return False, str(exc)
|
||||
return True, "ok"
|
||||
return False, "Webhook relay requires a target URL."
|
||||
if provider == "telegram":
|
||||
return _telegram_config_ready(runtime)
|
||||
if provider == "discord":
|
||||
return _discord_config_ready(runtime)
|
||||
return False, f"Unsupported push provider: {provider or 'unknown'}"
|
||||
|
||||
|
||||
def _summary_from_results(results: Sequence[Dict[str, Any]]) -> Dict[str, int]:
|
||||
summary = {
|
||||
"total": len(results),
|
||||
"up": 0,
|
||||
"down": 0,
|
||||
"degraded": 0,
|
||||
"not_configured": 0,
|
||||
"disabled": 0,
|
||||
}
|
||||
for result in results:
|
||||
status = str(result.get("status") or "").strip().lower()
|
||||
if status in summary:
|
||||
summary[status] += 1
|
||||
return summary
|
||||
|
||||
|
||||
async def _run_http_json_get(
|
||||
url: str,
|
||||
*,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return {"response": payload}
|
||||
|
||||
|
||||
async def _run_http_text_get(url: str) -> Dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
body = response.text
|
||||
return {"response": body, "message": f"HTTP {response.status_code}"}
|
||||
|
||||
|
||||
async def _run_http_post(
|
||||
url: str,
|
||||
*,
|
||||
json_payload: Optional[Dict[str, Any]] = None,
|
||||
data_payload: Any = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
validate_notification_target_url(url)
|
||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
||||
response = await client.post(url, json=json_payload, data=data_payload, params=params, headers=headers)
|
||||
response.raise_for_status()
|
||||
if not response.content:
|
||||
return {"message": f"HTTP {response.status_code}"}
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if "application/json" in content_type.lower():
|
||||
try:
|
||||
return {"response": response.json(), "message": f"HTTP {response.status_code}"}
|
||||
except Exception:
|
||||
pass
|
||||
return {"response": response.text.strip(), "message": f"HTTP {response.status_code}"}
|
||||
|
||||
|
||||
async def _run_database_check() -> Dict[str, Any]:
|
||||
detail = await asyncio.to_thread(get_database_diagnostics)
|
||||
integrity = _clean_text(detail.get("integrity_check"), "unknown")
|
||||
requests_cached = detail.get("row_counts", {}).get("requests_cache", 0) if isinstance(detail, dict) else 0
|
||||
wal_size_bytes = detail.get("wal_size_bytes", 0) if isinstance(detail, dict) else 0
|
||||
wal_size_megabytes = round((float(wal_size_bytes or 0) / (1024 * 1024)), 2)
|
||||
status = "up" if integrity == "ok" else "degraded"
|
||||
return {
|
||||
"status": status,
|
||||
"message": f"SQLite {integrity} · {requests_cached} cached requests · WAL {wal_size_megabytes:.2f} MB",
|
||||
"detail": detail,
|
||||
}
|
||||
|
||||
|
||||
async def _run_magent_api_check(runtime) -> Dict[str, Any]:
|
||||
base_url = _clean_text(runtime.magent_api_url) or f"http://127.0.0.1:{int(runtime.magent_api_port or 8000)}"
|
||||
result = await _run_http_json_get(f"{base_url.rstrip('/')}/health")
|
||||
payload = result.get("response")
|
||||
build_number = payload.get("build") if isinstance(payload, dict) else None
|
||||
message = "Health endpoint responded"
|
||||
if build_number:
|
||||
message = f"Health endpoint responded (build {build_number})"
|
||||
return {"message": message, "detail": payload}
|
||||
|
||||
|
||||
async def _run_magent_web_check(runtime) -> Dict[str, Any]:
|
||||
base_url = _clean_text(runtime.magent_application_url) or f"http://127.0.0.1:{int(runtime.magent_application_port or 3000)}"
|
||||
result = await _run_http_text_get(base_url.rstrip("/"))
|
||||
body = result.get("response")
|
||||
if isinstance(body, str) and "<html" in body.lower():
|
||||
return {"message": "Application page responded", "detail": "html"}
|
||||
return {"status": "degraded", "message": "Application responded with unexpected content"}
|
||||
|
||||
|
||||
async def _run_seerr_check(runtime) -> Dict[str, Any]:
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
payload = await client.get_status()
|
||||
version = payload.get("version") if isinstance(payload, dict) else None
|
||||
message = "Seerr responded"
|
||||
if version:
|
||||
message = f"Seerr version {version}"
|
||||
return {"message": message, "detail": payload}
|
||||
|
||||
|
||||
async def _run_sonarr_check(runtime) -> Dict[str, Any]:
|
||||
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||
payload = await client.get_system_status()
|
||||
version = payload.get("version") if isinstance(payload, dict) else None
|
||||
message = "Sonarr responded"
|
||||
if version:
|
||||
message = f"Sonarr version {version}"
|
||||
return {"message": message, "detail": payload}
|
||||
|
||||
|
||||
async def _run_radarr_check(runtime) -> Dict[str, Any]:
|
||||
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||
payload = await client.get_system_status()
|
||||
version = payload.get("version") if isinstance(payload, dict) else None
|
||||
message = "Radarr responded"
|
||||
if version:
|
||||
message = f"Radarr version {version}"
|
||||
return {"message": message, "detail": payload}
|
||||
|
||||
|
||||
async def _run_prowlarr_check(runtime) -> Dict[str, Any]:
|
||||
client = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
|
||||
payload = await client.get_health()
|
||||
if isinstance(payload, list) and payload:
|
||||
return {
|
||||
"status": "degraded",
|
||||
"message": f"Prowlarr health warnings: {len(payload)}",
|
||||
"detail": payload,
|
||||
}
|
||||
return {"message": "Prowlarr reported healthy", "detail": payload}
|
||||
|
||||
|
||||
async def _run_qbittorrent_check(runtime) -> Dict[str, Any]:
|
||||
client = QBittorrentClient(
|
||||
runtime.qbittorrent_base_url,
|
||||
runtime.qbittorrent_username,
|
||||
runtime.qbittorrent_password,
|
||||
)
|
||||
version = await client.get_app_version()
|
||||
message = "qBittorrent responded"
|
||||
if isinstance(version, str) and version:
|
||||
message = f"qBittorrent version {version}"
|
||||
return {"message": message, "detail": version}
|
||||
|
||||
|
||||
async def _run_jellyfin_check(runtime) -> Dict[str, Any]:
|
||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
payload = await client.get_system_info()
|
||||
version = payload.get("Version") if isinstance(payload, dict) else None
|
||||
message = "Jellyfin responded"
|
||||
if version:
|
||||
message = f"Jellyfin version {version}"
|
||||
return {"message": message, "detail": payload}
|
||||
|
||||
|
||||
async def _run_email_check(recipient_email: Optional[str] = None) -> Dict[str, Any]:
|
||||
result = await send_test_email(recipient_email=recipient_email)
|
||||
recipient = _clean_text(result.get("recipient_email"), "configured recipient")
|
||||
warning = _clean_text(result.get("warning"))
|
||||
if warning:
|
||||
return {
|
||||
"status": "degraded",
|
||||
"message": f"SMTP relay accepted a test for {recipient}, but delivery is not guaranteed.",
|
||||
"detail": result,
|
||||
}
|
||||
return {"message": f"Test email sent to {recipient}", "detail": result}
|
||||
|
||||
|
||||
async def _run_discord_check(runtime) -> Dict[str, Any]:
|
||||
webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
|
||||
payload = {
|
||||
"content": f"{env_settings.app_name} diagnostics ping\nBuild {env_settings.site_build_number or 'unknown'}",
|
||||
}
|
||||
result = await _run_http_post(webhook_url, json_payload=payload)
|
||||
return {"message": "Discord webhook accepted ping", "detail": result.get("response")}
|
||||
|
||||
|
||||
async def _run_telegram_check(runtime) -> Dict[str, Any]:
|
||||
bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
|
||||
chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
|
||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||
payload = {
|
||||
"chat_id": chat_id,
|
||||
"text": f"{env_settings.app_name} diagnostics ping\nBuild {env_settings.site_build_number or 'unknown'}",
|
||||
}
|
||||
result = await _run_http_post(url, json_payload=payload)
|
||||
return {"message": "Telegram ping accepted", "detail": result.get("response")}
|
||||
|
||||
|
||||
async def _run_webhook_check(runtime) -> Dict[str, Any]:
|
||||
webhook_url = _clean_text(runtime.magent_notify_webhook_url)
|
||||
payload = {
|
||||
"type": "diagnostics.ping",
|
||||
"application": env_settings.app_name,
|
||||
"build": env_settings.site_build_number,
|
||||
"checked_at": _now_iso(),
|
||||
}
|
||||
result = await _run_http_post(webhook_url, json_payload=payload)
|
||||
return {"message": "Webhook accepted ping", "detail": result.get("response")}
|
||||
|
||||
|
||||
async def _run_push_check(runtime) -> Dict[str, Any]:
|
||||
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
||||
message = f"{env_settings.app_name} diagnostics ping"
|
||||
build_suffix = f"Build {env_settings.site_build_number or 'unknown'}"
|
||||
|
||||
if provider == "ntfy":
|
||||
base_url = _clean_text(runtime.magent_notify_push_base_url)
|
||||
topic = _clean_text(runtime.magent_notify_push_topic)
|
||||
result = await _run_http_post(
|
||||
f"{base_url.rstrip('/')}/{topic}",
|
||||
data_payload=f"{message}\n{build_suffix}",
|
||||
headers={"Content-Type": "text/plain; charset=utf-8"},
|
||||
)
|
||||
return {"message": "ntfy push accepted", "detail": result.get("response")}
|
||||
|
||||
if provider == "gotify":
|
||||
base_url = _clean_text(runtime.magent_notify_push_base_url)
|
||||
token = _clean_text(runtime.magent_notify_push_token)
|
||||
result = await _run_http_post(
|
||||
f"{base_url.rstrip('/')}/message",
|
||||
json_payload={"title": env_settings.app_name, "message": build_suffix, "priority": 5},
|
||||
params={"token": token},
|
||||
)
|
||||
return {"message": "Gotify push accepted", "detail": result.get("response")}
|
||||
|
||||
if provider == "pushover":
|
||||
token = _clean_text(runtime.magent_notify_push_token)
|
||||
user_key = _clean_text(runtime.magent_notify_push_user_key)
|
||||
device = _clean_text(runtime.magent_notify_push_device)
|
||||
payload = {
|
||||
"token": token,
|
||||
"user": user_key,
|
||||
"message": f"{message}\n{build_suffix}",
|
||||
"title": env_settings.app_name,
|
||||
}
|
||||
if device:
|
||||
payload["device"] = device
|
||||
result = await _run_http_post("https://api.pushover.net/1/messages.json", data_payload=payload)
|
||||
return {"message": "Pushover push accepted", "detail": result.get("response")}
|
||||
|
||||
if provider == "webhook":
|
||||
base_url = _clean_text(runtime.magent_notify_push_base_url)
|
||||
payload = {
|
||||
"type": "diagnostics.push",
|
||||
"application": env_settings.app_name,
|
||||
"build": env_settings.site_build_number,
|
||||
"checked_at": _now_iso(),
|
||||
}
|
||||
result = await _run_http_post(base_url, json_payload=payload)
|
||||
return {"message": "Push webhook accepted", "detail": result.get("response")}
|
||||
|
||||
if provider == "telegram":
|
||||
return await _run_telegram_check(runtime)
|
||||
|
||||
if provider == "discord":
|
||||
return await _run_discord_check(runtime)
|
||||
|
||||
raise RuntimeError(f"Unsupported push provider: {provider}")
|
||||
|
||||
|
||||
def _build_diagnostic_checks(recipient_email: Optional[str] = None) -> List[DiagnosticCheck]:
|
||||
runtime = get_runtime_settings()
|
||||
seerr_target = _url_target(runtime.jellyseerr_base_url)
|
||||
jellyfin_target = _url_target(runtime.jellyfin_base_url)
|
||||
sonarr_target = _url_target(runtime.sonarr_base_url)
|
||||
radarr_target = _url_target(runtime.radarr_base_url)
|
||||
prowlarr_target = _url_target(runtime.prowlarr_base_url)
|
||||
qbittorrent_target = _url_target(runtime.qbittorrent_base_url)
|
||||
application_target = _url_target(runtime.magent_application_url) or _host_port_target("127.0.0.1", runtime.magent_application_port)
|
||||
api_target = _url_target(runtime.magent_api_url) or _host_port_target("127.0.0.1", runtime.magent_api_port)
|
||||
smtp_target = _host_port_target(runtime.magent_notify_email_smtp_host, runtime.magent_notify_email_smtp_port)
|
||||
discord_target = _url_target(runtime.magent_notify_discord_webhook_url) or _url_target(runtime.discord_webhook_url)
|
||||
telegram_target = "api.telegram.org" if _clean_text(runtime.magent_notify_telegram_bot_token) else None
|
||||
webhook_target = _url_target(runtime.magent_notify_webhook_url)
|
||||
|
||||
push_provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
||||
push_target = None
|
||||
if push_provider == "pushover":
|
||||
push_target = "api.pushover.net"
|
||||
elif push_provider == "telegram":
|
||||
push_target = telegram_target or "api.telegram.org"
|
||||
elif push_provider == "discord":
|
||||
push_target = discord_target or "discord.com"
|
||||
else:
|
||||
push_target = _url_target(runtime.magent_notify_push_base_url)
|
||||
|
||||
email_ready, email_detail = smtp_email_config_ready()
|
||||
email_warning = smtp_email_delivery_warning()
|
||||
discord_ready, discord_detail = _discord_config_ready(runtime)
|
||||
telegram_ready, telegram_detail = _telegram_config_ready(runtime)
|
||||
push_ready, push_detail = _push_config_ready(runtime)
|
||||
webhook_ready, webhook_detail = _webhook_config_ready(runtime)
|
||||
|
||||
checks = [
|
||||
DiagnosticCheck(
|
||||
key="magent-web",
|
||||
label="Magent application",
|
||||
category="Application",
|
||||
description="Checks that the frontend application URL is responding.",
|
||||
live_safe=True,
|
||||
configured=True,
|
||||
config_detail="ok",
|
||||
target=application_target,
|
||||
runner=lambda runtime=runtime: _run_magent_web_check(runtime),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="magent-api",
|
||||
label="Magent API",
|
||||
category="Application",
|
||||
description="Checks the Magent API health endpoint.",
|
||||
live_safe=True,
|
||||
configured=True,
|
||||
config_detail="ok",
|
||||
target=api_target,
|
||||
runner=lambda runtime=runtime: _run_magent_api_check(runtime),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="database",
|
||||
label="SQLite database",
|
||||
category="Application",
|
||||
description="Runs SQLite integrity_check against the current Magent database.",
|
||||
live_safe=True,
|
||||
configured=True,
|
||||
config_detail="ok",
|
||||
target="sqlite",
|
||||
runner=_run_database_check,
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="seerr",
|
||||
label="Seerr",
|
||||
category="Media services",
|
||||
description="Checks Seerr API reachability and version.",
|
||||
live_safe=True,
|
||||
configured=bool(runtime.jellyseerr_base_url and runtime.jellyseerr_api_key),
|
||||
config_detail="Seerr URL and API key are required.",
|
||||
target=seerr_target,
|
||||
runner=lambda runtime=runtime: _run_seerr_check(runtime),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="jellyfin",
|
||||
label="Jellyfin",
|
||||
category="Media services",
|
||||
description="Checks Jellyfin system info with the configured API key.",
|
||||
live_safe=True,
|
||||
configured=bool(runtime.jellyfin_base_url and runtime.jellyfin_api_key),
|
||||
config_detail="Jellyfin URL and API key are required.",
|
||||
target=jellyfin_target,
|
||||
runner=lambda runtime=runtime: _run_jellyfin_check(runtime),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="sonarr",
|
||||
label="Sonarr",
|
||||
category="Media services",
|
||||
description="Checks Sonarr system status with the configured API key.",
|
||||
live_safe=True,
|
||||
configured=bool(runtime.sonarr_base_url and runtime.sonarr_api_key),
|
||||
config_detail="Sonarr URL and API key are required.",
|
||||
target=sonarr_target,
|
||||
runner=lambda runtime=runtime: _run_sonarr_check(runtime),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="radarr",
|
||||
label="Radarr",
|
||||
category="Media services",
|
||||
description="Checks Radarr system status with the configured API key.",
|
||||
live_safe=True,
|
||||
configured=bool(runtime.radarr_base_url and runtime.radarr_api_key),
|
||||
config_detail="Radarr URL and API key are required.",
|
||||
target=radarr_target,
|
||||
runner=lambda runtime=runtime: _run_radarr_check(runtime),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="prowlarr",
|
||||
label="Prowlarr",
|
||||
category="Media services",
|
||||
description="Checks Prowlarr health and flags warnings as degraded.",
|
||||
live_safe=True,
|
||||
configured=bool(runtime.prowlarr_base_url and runtime.prowlarr_api_key),
|
||||
config_detail="Prowlarr URL and API key are required.",
|
||||
target=prowlarr_target,
|
||||
runner=lambda runtime=runtime: _run_prowlarr_check(runtime),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="qbittorrent",
|
||||
label="qBittorrent",
|
||||
category="Media services",
|
||||
description="Checks qBittorrent login and app version.",
|
||||
live_safe=True,
|
||||
configured=bool(
|
||||
runtime.qbittorrent_base_url and runtime.qbittorrent_username and runtime.qbittorrent_password
|
||||
),
|
||||
config_detail="qBittorrent URL, username, and password are required.",
|
||||
target=qbittorrent_target,
|
||||
runner=lambda runtime=runtime: _run_qbittorrent_check(runtime),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="email",
|
||||
label="SMTP email",
|
||||
category="Notifications",
|
||||
description="Sends a live test email using the configured SMTP provider.",
|
||||
live_safe=False,
|
||||
configured=email_ready,
|
||||
config_detail=email_warning or email_detail,
|
||||
target=smtp_target,
|
||||
runner=lambda recipient_email=recipient_email: _run_email_check(recipient_email),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="discord",
|
||||
label="Discord webhook",
|
||||
category="Notifications",
|
||||
description="Posts a live test message to the configured Discord webhook.",
|
||||
live_safe=False,
|
||||
configured=discord_ready,
|
||||
config_detail=discord_detail,
|
||||
target=discord_target,
|
||||
runner=lambda runtime=runtime: _run_discord_check(runtime),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="telegram",
|
||||
label="Telegram",
|
||||
category="Notifications",
|
||||
description="Sends a live test message to the configured Telegram chat.",
|
||||
live_safe=False,
|
||||
configured=telegram_ready,
|
||||
config_detail=telegram_detail,
|
||||
target=telegram_target,
|
||||
runner=lambda runtime=runtime: _run_telegram_check(runtime),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="push",
|
||||
label="Push/mobile provider",
|
||||
category="Notifications",
|
||||
description="Sends a live test message through the configured push provider.",
|
||||
live_safe=False,
|
||||
configured=push_ready,
|
||||
config_detail=push_detail,
|
||||
target=push_target,
|
||||
runner=lambda runtime=runtime: _run_push_check(runtime),
|
||||
),
|
||||
DiagnosticCheck(
|
||||
key="webhook",
|
||||
label="Generic webhook",
|
||||
category="Notifications",
|
||||
description="Posts a live test payload to the configured generic webhook.",
|
||||
live_safe=False,
|
||||
configured=webhook_ready,
|
||||
config_detail=webhook_detail,
|
||||
target=webhook_target,
|
||||
runner=lambda runtime=runtime: _run_webhook_check(runtime),
|
||||
),
|
||||
]
|
||||
return checks
|
||||
|
||||
|
||||
async def _execute_check(check: DiagnosticCheck) -> Dict[str, Any]:
|
||||
if not check.configured:
|
||||
return {
|
||||
"key": check.key,
|
||||
"label": check.label,
|
||||
"category": check.category,
|
||||
"description": check.description,
|
||||
"target": check.target,
|
||||
"live_safe": check.live_safe,
|
||||
"configured": False,
|
||||
"status": _config_status(check.config_detail),
|
||||
"message": check.config_detail,
|
||||
"checked_at": _now_iso(),
|
||||
"duration_ms": 0,
|
||||
}
|
||||
|
||||
started = perf_counter()
|
||||
checked_at = _now_iso()
|
||||
try:
|
||||
payload = await check.runner()
|
||||
status = _clean_text(payload.get("status"), "up")
|
||||
message = _clean_text(payload.get("message"), "Check passed")
|
||||
detail = payload.get("detail")
|
||||
return {
|
||||
"key": check.key,
|
||||
"label": check.label,
|
||||
"category": check.category,
|
||||
"description": check.description,
|
||||
"target": check.target,
|
||||
"live_safe": check.live_safe,
|
||||
"configured": True,
|
||||
"status": status,
|
||||
"message": message,
|
||||
"detail": detail,
|
||||
"checked_at": checked_at,
|
||||
"duration_ms": round((perf_counter() - started) * 1000, 1),
|
||||
}
|
||||
except httpx.HTTPError as exc:
|
||||
return {
|
||||
"key": check.key,
|
||||
"label": check.label,
|
||||
"category": check.category,
|
||||
"description": check.description,
|
||||
"target": check.target,
|
||||
"live_safe": check.live_safe,
|
||||
"configured": True,
|
||||
"status": "down",
|
||||
"message": _http_error_detail(exc),
|
||||
"checked_at": checked_at,
|
||||
"duration_ms": round((perf_counter() - started) * 1000, 1),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"key": check.key,
|
||||
"label": check.label,
|
||||
"category": check.category,
|
||||
"description": check.description,
|
||||
"target": check.target,
|
||||
"live_safe": check.live_safe,
|
||||
"configured": True,
|
||||
"status": "down",
|
||||
"message": str(exc),
|
||||
"checked_at": checked_at,
|
||||
"duration_ms": round((perf_counter() - started) * 1000, 1),
|
||||
}
|
||||
|
||||
|
||||
def get_diagnostics_catalog() -> Dict[str, Any]:
|
||||
checks = _build_diagnostic_checks()
|
||||
items = []
|
||||
for check in checks:
|
||||
items.append(
|
||||
{
|
||||
"key": check.key,
|
||||
"label": check.label,
|
||||
"category": check.category,
|
||||
"description": check.description,
|
||||
"live_safe": check.live_safe,
|
||||
"target": check.target,
|
||||
"configured": check.configured,
|
||||
"config_status": "configured" if check.configured else _config_status(check.config_detail),
|
||||
"config_detail": "Ready to test." if check.configured else check.config_detail,
|
||||
}
|
||||
)
|
||||
categories = sorted({item["category"] for item in items})
|
||||
return {
|
||||
"checks": items,
|
||||
"categories": categories,
|
||||
"generated_at": _now_iso(),
|
||||
}
|
||||
|
||||
|
||||
async def run_diagnostics(keys: Optional[Sequence[str]] = None, recipient_email: Optional[str] = None) -> Dict[str, Any]:
|
||||
checks = _build_diagnostic_checks(recipient_email=recipient_email)
|
||||
selected = {str(key).strip().lower() for key in (keys or []) if str(key).strip()}
|
||||
if selected:
|
||||
checks = [check for check in checks if check.key.lower() in selected]
|
||||
results = await asyncio.gather(*(_execute_check(check) for check in checks))
|
||||
return {
|
||||
"results": results,
|
||||
"summary": _summary_from_results(results),
|
||||
"checked_at": _now_iso(),
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..db import (
|
||||
create_user_if_missing,
|
||||
get_user_by_username,
|
||||
set_user_email,
|
||||
set_user_auth_provider,
|
||||
set_user_jellyseerr_id,
|
||||
)
|
||||
from ..runtime import get_runtime_settings
|
||||
from .user_cache import (
|
||||
build_jellyseerr_candidate_map,
|
||||
extract_jellyseerr_user_email,
|
||||
find_matching_jellyseerr_user,
|
||||
get_cached_jellyseerr_users,
|
||||
match_jellyseerr_user_id,
|
||||
save_jellyfin_users_cache,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def sync_jellyfin_users() -> int:
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
if not client.configured():
|
||||
raise HTTPException(status_code=400, detail="Jellyfin not configured")
|
||||
users = await client.get_users()
|
||||
if not isinstance(users, list):
|
||||
return 0
|
||||
save_jellyfin_users_cache(users)
|
||||
# Jellyfin is the canonical source for local user objects; Seerr IDs are
|
||||
# matched as enrichment when possible.
|
||||
jellyseerr_users = get_cached_jellyseerr_users()
|
||||
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
|
||||
imported = 0
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
name = user.get("Name")
|
||||
if not name:
|
||||
continue
|
||||
matched_id = match_jellyseerr_user_id(name, candidate_map) if candidate_map else None
|
||||
matched_seerr_user = find_matching_jellyseerr_user(name, jellyseerr_users or [])
|
||||
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
|
||||
created = create_user_if_missing(
|
||||
name,
|
||||
"jellyfin-user",
|
||||
role="user",
|
||||
email=matched_email,
|
||||
auth_provider="jellyfin",
|
||||
jellyseerr_user_id=matched_id,
|
||||
)
|
||||
if created:
|
||||
imported += 1
|
||||
else:
|
||||
existing = get_user_by_username(name)
|
||||
if (
|
||||
existing
|
||||
and str(existing.get("role") or "user").strip().lower() != "admin"
|
||||
and str(existing.get("auth_provider") or "local").strip().lower() != "jellyfin"
|
||||
):
|
||||
set_user_auth_provider(name, "jellyfin")
|
||||
if matched_id is not None:
|
||||
set_user_jellyseerr_id(name, matched_id)
|
||||
if matched_email:
|
||||
set_user_email(name, matched_email)
|
||||
return imported
|
||||
|
||||
|
||||
async def run_daily_jellyfin_sync() -> None:
|
||||
while True:
|
||||
delay = _seconds_until_midnight()
|
||||
await _sleep_seconds(delay)
|
||||
try:
|
||||
imported = await sync_jellyfin_users()
|
||||
logger.info("Jellyfin daily sync complete: imported=%s", imported)
|
||||
except HTTPException as exc:
|
||||
logger.warning("Jellyfin daily sync skipped: %s", exc.detail)
|
||||
except Exception:
|
||||
logger.exception("Jellyfin daily sync failed")
|
||||
|
||||
|
||||
def _seconds_until_midnight() -> float:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
now = datetime.now()
|
||||
next_midnight = (now + timedelta(days=1)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
return max((next_midnight - now).total_seconds(), 0.0)
|
||||
|
||||
|
||||
async def _sleep_seconds(delay: float) -> None:
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(delay)
|
||||
@@ -0,0 +1,280 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import settings as env_settings
|
||||
from ..db import get_setting
|
||||
from ..network_security import validate_notification_target_url
|
||||
from ..runtime import get_runtime_settings
|
||||
from .invite_email import send_generic_email
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _clean_text(value: Any, fallback: str = "") -> str:
|
||||
if value is None:
|
||||
return fallback
|
||||
if isinstance(value, str):
|
||||
trimmed = value.strip()
|
||||
return trimmed if trimmed else fallback
|
||||
return str(value)
|
||||
|
||||
|
||||
def _split_emails(value: str) -> list[str]:
|
||||
if not value:
|
||||
return []
|
||||
parts = [entry.strip() for entry in value.replace(";", ",").split(",")]
|
||||
return [entry for entry in parts if entry and "@" in entry]
|
||||
|
||||
|
||||
def _resolve_app_url() -> str:
|
||||
runtime = get_runtime_settings()
|
||||
for candidate in (
|
||||
runtime.magent_application_url,
|
||||
runtime.magent_proxy_base_url,
|
||||
env_settings.cors_allow_origin,
|
||||
):
|
||||
normalized = _clean_text(candidate)
|
||||
if normalized:
|
||||
return normalized.rstrip("/")
|
||||
port = int(getattr(runtime, "magent_application_port", 3000) or 3000)
|
||||
return f"http://localhost:{port}"
|
||||
|
||||
|
||||
def _portal_item_url(item_id: int) -> str:
|
||||
return f"{_resolve_app_url()}/portal?item={item_id}"
|
||||
|
||||
|
||||
async def _http_post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
validate_notification_target_url(url)
|
||||
async with httpx.AsyncClient(timeout=12.0) as client:
|
||||
response = await client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
body = response.text
|
||||
return {"status_code": response.status_code, "body": body}
|
||||
|
||||
|
||||
async def _send_discord(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
webhook = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(
|
||||
runtime.discord_webhook_url
|
||||
)
|
||||
if not webhook:
|
||||
return {"status": "skipped", "detail": "Discord webhook not configured."}
|
||||
data = {
|
||||
"content": f"**{title}**\n{message}",
|
||||
"embeds": [
|
||||
{
|
||||
"title": title,
|
||||
"description": message,
|
||||
"fields": [
|
||||
{"name": "Type", "value": _clean_text(payload.get("kind"), "unknown"), "inline": True},
|
||||
{"name": "Status", "value": _clean_text(payload.get("status"), "unknown"), "inline": True},
|
||||
{"name": "Priority", "value": _clean_text(payload.get("priority"), "normal"), "inline": True},
|
||||
],
|
||||
"url": _clean_text(payload.get("item_url")),
|
||||
}
|
||||
],
|
||||
}
|
||||
result = await _http_post_json(webhook, data)
|
||||
return {"status": "ok", "detail": f"Discord accepted ({result['status_code']})."}
|
||||
|
||||
|
||||
async def _send_telegram(title: str, message: str) -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
|
||||
chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
|
||||
if not bot_token or not chat_id:
|
||||
return {"status": "skipped", "detail": "Telegram is not configured."}
|
||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||
payload = {"chat_id": chat_id, "text": f"{title}\n\n{message}", "disable_web_page_preview": True}
|
||||
result = await _http_post_json(url, payload)
|
||||
return {"status": "ok", "detail": f"Telegram accepted ({result['status_code']})."}
|
||||
|
||||
|
||||
async def _send_webhook(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
webhook = _clean_text(runtime.magent_notify_webhook_url)
|
||||
if not webhook:
|
||||
return {"status": "skipped", "detail": "Generic webhook is not configured."}
|
||||
result = await _http_post_json(webhook, payload)
|
||||
return {"status": "ok", "detail": f"Webhook accepted ({result['status_code']})."}
|
||||
|
||||
|
||||
async def _send_push(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
||||
base_url = _clean_text(runtime.magent_notify_push_base_url)
|
||||
token = _clean_text(runtime.magent_notify_push_token)
|
||||
topic = _clean_text(runtime.magent_notify_push_topic)
|
||||
if provider == "ntfy":
|
||||
if not base_url or not topic:
|
||||
return {"status": "skipped", "detail": "ntfy needs base URL and topic."}
|
||||
validate_notification_target_url(base_url)
|
||||
url = f"{base_url.rstrip('/')}/{quote(topic)}"
|
||||
headers = {"Title": title, "Tags": "magent,portal"}
|
||||
async with httpx.AsyncClient(timeout=12.0) as client:
|
||||
response = await client.post(url, content=message.encode("utf-8"), headers=headers)
|
||||
response.raise_for_status()
|
||||
return {"status": "ok", "detail": f"ntfy accepted ({response.status_code})."}
|
||||
if provider == "gotify":
|
||||
if not base_url or not token:
|
||||
return {"status": "skipped", "detail": "Gotify needs base URL and token."}
|
||||
validate_notification_target_url(base_url)
|
||||
url = f"{base_url.rstrip('/')}/message?token={quote(token)}"
|
||||
body = {"title": title, "message": message, "priority": 5, "extras": {"client::display": {"contentType": "text/plain"}}}
|
||||
result = await _http_post_json(url, body)
|
||||
return {"status": "ok", "detail": f"Gotify accepted ({result['status_code']})."}
|
||||
if provider == "pushover":
|
||||
user_key = _clean_text(runtime.magent_notify_push_user_key)
|
||||
if not token or not user_key:
|
||||
return {"status": "skipped", "detail": "Pushover needs token and user key."}
|
||||
form = {"token": token, "user": user_key, "title": title, "message": message}
|
||||
async with httpx.AsyncClient(timeout=12.0) as client:
|
||||
response = await client.post("https://api.pushover.net/1/messages.json", data=form)
|
||||
response.raise_for_status()
|
||||
return {"status": "ok", "detail": f"Pushover accepted ({response.status_code})."}
|
||||
if provider == "discord":
|
||||
return await _send_discord(title, message, payload)
|
||||
if provider == "telegram":
|
||||
return await _send_telegram(title, message)
|
||||
if provider == "webhook":
|
||||
return await _send_webhook(payload)
|
||||
return {"status": "skipped", "detail": f"Unsupported push provider '{provider}'."}
|
||||
|
||||
|
||||
async def _send_email(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
recipients = _split_emails(_clean_text(get_setting("portal_notification_recipients")))
|
||||
fallback = _clean_text(runtime.magent_notify_email_from_address)
|
||||
if fallback and fallback not in recipients:
|
||||
recipients.append(fallback)
|
||||
if not recipients:
|
||||
return {"status": "skipped", "detail": "No portal notification recipient is configured."}
|
||||
|
||||
body_text = (
|
||||
f"{title}\n\n"
|
||||
f"{message}\n\n"
|
||||
f"Kind: {_clean_text(payload.get('kind'))}\n"
|
||||
f"Status: {_clean_text(payload.get('status'))}\n"
|
||||
f"Priority: {_clean_text(payload.get('priority'))}\n"
|
||||
f"Requested by: {_clean_text(payload.get('requested_by'))}\n"
|
||||
f"Open: {_clean_text(payload.get('item_url'))}\n"
|
||||
)
|
||||
body_html = (
|
||||
"<div style=\"font-family:Segoe UI,Arial,sans-serif; color:#132033;\">"
|
||||
f"<h2 style=\"margin:0 0 12px;\">{title}</h2>"
|
||||
f"<p style=\"margin:0 0 16px; line-height:1.7;\">{message}</p>"
|
||||
"<table style=\"border-collapse:collapse; width:100%; margin:0 0 16px;\">"
|
||||
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Kind</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('kind'))}</td></tr>"
|
||||
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Status</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('status'))}</td></tr>"
|
||||
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Priority</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('priority'))}</td></tr>"
|
||||
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Requested by</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('requested_by'))}</td></tr>"
|
||||
"</table>"
|
||||
f"<a href=\"{_clean_text(payload.get('item_url'))}\" style=\"display:inline-block; padding:10px 16px; border-radius:999px; background:#1c6bff; color:#fff; text-decoration:none; font-weight:700;\">Open portal item</a>"
|
||||
"</div>"
|
||||
)
|
||||
deliveries: list[Dict[str, Any]] = []
|
||||
for recipient in recipients:
|
||||
try:
|
||||
result = await send_generic_email(
|
||||
recipient_email=recipient,
|
||||
subject=title,
|
||||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
)
|
||||
deliveries.append({"recipient": recipient, "status": "ok", **result})
|
||||
except Exception as exc:
|
||||
deliveries.append({"recipient": recipient, "status": "error", "detail": str(exc)})
|
||||
successful = [entry for entry in deliveries if entry.get("status") == "ok"]
|
||||
if successful:
|
||||
return {"status": "ok", "detail": f"Email sent to {len(successful)} recipient(s).", "deliveries": deliveries}
|
||||
return {"status": "error", "detail": "Email delivery failed for all recipients.", "deliveries": deliveries}
|
||||
|
||||
|
||||
async def send_portal_notification(
|
||||
*,
|
||||
event_type: str,
|
||||
item: Dict[str, Any],
|
||||
actor_username: str,
|
||||
actor_role: str,
|
||||
note: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.magent_notify_enabled:
|
||||
return {"status": "skipped", "detail": "Notifications are disabled.", "channels": {}}
|
||||
|
||||
item_id = int(item.get("id") or 0)
|
||||
title = f"{env_settings.app_name} portal update: {item.get('title') or f'Item #{item_id}'}"
|
||||
message_lines = [
|
||||
f"Event: {event_type}",
|
||||
f"Actor: {actor_username} ({actor_role})",
|
||||
f"Item #{item_id} is now '{_clean_text(item.get('status'), 'unknown')}'.",
|
||||
]
|
||||
if note:
|
||||
message_lines.append(f"Note: {note}")
|
||||
message_lines.append(f"Open: {_portal_item_url(item_id)}")
|
||||
message = "\n".join(message_lines)
|
||||
payload = {
|
||||
"type": "portal.notification",
|
||||
"event": event_type,
|
||||
"item_id": item_id,
|
||||
"item_url": _portal_item_url(item_id),
|
||||
"kind": _clean_text(item.get("kind")),
|
||||
"status": _clean_text(item.get("status")),
|
||||
"priority": _clean_text(item.get("priority")),
|
||||
"requested_by": _clean_text(item.get("created_by_username")),
|
||||
"actor_username": actor_username,
|
||||
"actor_role": actor_role,
|
||||
"note": note or "",
|
||||
}
|
||||
|
||||
channels: Dict[str, Dict[str, Any]] = {}
|
||||
if runtime.magent_notify_discord_enabled:
|
||||
try:
|
||||
channels["discord"] = await _send_discord(title, message, payload)
|
||||
except Exception as exc:
|
||||
channels["discord"] = {"status": "error", "detail": str(exc)}
|
||||
if runtime.magent_notify_telegram_enabled:
|
||||
try:
|
||||
channels["telegram"] = await _send_telegram(title, message)
|
||||
except Exception as exc:
|
||||
channels["telegram"] = {"status": "error", "detail": str(exc)}
|
||||
if runtime.magent_notify_webhook_enabled:
|
||||
try:
|
||||
channels["webhook"] = await _send_webhook(payload)
|
||||
except Exception as exc:
|
||||
channels["webhook"] = {"status": "error", "detail": str(exc)}
|
||||
if runtime.magent_notify_push_enabled:
|
||||
try:
|
||||
channels["push"] = await _send_push(title, message, payload)
|
||||
except Exception as exc:
|
||||
channels["push"] = {"status": "error", "detail": str(exc)}
|
||||
if runtime.magent_notify_email_enabled:
|
||||
try:
|
||||
channels["email"] = await _send_email(title, message, payload)
|
||||
except Exception as exc:
|
||||
channels["email"] = {"status": "error", "detail": str(exc)}
|
||||
|
||||
successful = [name for name, value in channels.items() if value.get("status") == "ok"]
|
||||
failed = [name for name, value in channels.items() if value.get("status") == "error"]
|
||||
skipped = [name for name, value in channels.items() if value.get("status") == "skipped"]
|
||||
logger.info(
|
||||
"portal notification event=%s item_id=%s successful=%s failed=%s skipped=%s",
|
||||
event_type,
|
||||
item_id,
|
||||
successful,
|
||||
failed,
|
||||
skipped,
|
||||
)
|
||||
overall = "ok" if successful and not failed else "error" if failed and not successful else "partial"
|
||||
if not channels:
|
||||
overall = "skipped"
|
||||
return {"status": overall, "channels": channels}
|
||||
@@ -0,0 +1,333 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ..auth import normalize_user_auth_provider, resolve_user_auth_provider
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..clients.jellyseerr import JellyseerrClient
|
||||
from ..db import (
|
||||
create_password_reset_token,
|
||||
delete_expired_password_reset_tokens,
|
||||
get_password_reset_token,
|
||||
get_user_by_jellyseerr_id,
|
||||
get_user_by_username,
|
||||
get_users_by_username_ci,
|
||||
mark_password_reset_token_used,
|
||||
set_user_auth_provider,
|
||||
set_user_password,
|
||||
sync_jellyfin_password_state,
|
||||
)
|
||||
from ..runtime import get_runtime_settings
|
||||
from .invite_email import send_password_reset_email
|
||||
from .user_cache import get_cached_jellyseerr_users, save_jellyseerr_users_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PASSWORD_RESET_TOKEN_TTL_MINUTES = 30
|
||||
|
||||
|
||||
class PasswordResetUnavailableError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_handles(value: object) -> list[str]:
|
||||
if not isinstance(value, str):
|
||||
return []
|
||||
normalized = value.strip().lower()
|
||||
if not normalized:
|
||||
return []
|
||||
handles = [normalized]
|
||||
if "@" in normalized:
|
||||
handles.append(normalized.split("@", 1)[0])
|
||||
return list(dict.fromkeys(handles))
|
||||
|
||||
|
||||
def _pick_preferred_user(users: list[dict], requested_identifier: str) -> dict | None:
|
||||
if not users:
|
||||
return None
|
||||
requested = str(requested_identifier or "").strip().lower()
|
||||
|
||||
def _rank(user: dict) -> tuple[int, int, int, int]:
|
||||
provider = str(user.get("auth_provider") or "local").strip().lower()
|
||||
role = str(user.get("role") or "user").strip().lower()
|
||||
username = str(user.get("username") or "").strip().lower()
|
||||
return (
|
||||
0 if role == "admin" else 1,
|
||||
0 if isinstance(user.get("jellyseerr_user_id"), int) else 1,
|
||||
0 if provider == "jellyfin" else (1 if provider == "local" else 2),
|
||||
0 if username == requested else 1,
|
||||
)
|
||||
|
||||
return sorted(users, key=_rank)[0]
|
||||
|
||||
|
||||
def _find_matching_seerr_user(identifier: str, users: list[dict]) -> dict | None:
|
||||
target_handles = set(_normalize_handles(identifier))
|
||||
if not target_handles:
|
||||
return None
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
for key in ("username", "email"):
|
||||
value = user.get(key)
|
||||
if target_handles.intersection(_normalize_handles(value)):
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch_all_seerr_users() -> list[dict]:
|
||||
cached = get_cached_jellyseerr_users()
|
||||
if cached is not None:
|
||||
return cached
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if not client.configured():
|
||||
return []
|
||||
users: list[dict] = []
|
||||
take = 100
|
||||
skip = 0
|
||||
while True:
|
||||
payload = await client.get_users(take=take, skip=skip)
|
||||
if not payload:
|
||||
break
|
||||
if isinstance(payload, list):
|
||||
batch = payload
|
||||
elif isinstance(payload, dict):
|
||||
batch = payload.get("results") or payload.get("users") or payload.get("data") or payload.get("items")
|
||||
else:
|
||||
batch = None
|
||||
if not isinstance(batch, list) or not batch:
|
||||
break
|
||||
users.extend([user for user in batch if isinstance(user, dict)])
|
||||
if len(batch) < take:
|
||||
break
|
||||
skip += take
|
||||
if users:
|
||||
return save_jellyseerr_users_cache(users)
|
||||
return users
|
||||
|
||||
|
||||
def _resolve_seerr_user_email(seerr_user: Optional[dict], local_user: Optional[dict]) -> Optional[str]:
|
||||
if isinstance(local_user, dict):
|
||||
stored_email = str(local_user.get("email") or "").strip()
|
||||
if "@" in stored_email:
|
||||
return stored_email
|
||||
username = str(local_user.get("username") or "").strip()
|
||||
if "@" in username:
|
||||
return username
|
||||
if isinstance(seerr_user, dict):
|
||||
email = str(seerr_user.get("email") or "").strip()
|
||||
if "@" in email:
|
||||
return email
|
||||
return None
|
||||
|
||||
|
||||
async def _resolve_reset_target(identifier: str) -> Optional[Dict[str, Any]]:
|
||||
normalized_identifier = str(identifier or "").strip()
|
||||
if not normalized_identifier:
|
||||
return None
|
||||
|
||||
local_user = normalize_user_auth_provider(
|
||||
_pick_preferred_user(get_users_by_username_ci(normalized_identifier), normalized_identifier)
|
||||
)
|
||||
seerr_users: list[dict] | None = None
|
||||
seerr_user: dict | None = None
|
||||
|
||||
if isinstance(local_user, dict) and isinstance(local_user.get("jellyseerr_user_id"), int):
|
||||
seerr_users = await _fetch_all_seerr_users()
|
||||
seerr_user = next(
|
||||
(
|
||||
user
|
||||
for user in seerr_users
|
||||
if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if not local_user:
|
||||
seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
|
||||
seerr_user = _find_matching_seerr_user(normalized_identifier, seerr_users)
|
||||
if seerr_user:
|
||||
seerr_user_id = seerr_user.get("id") or seerr_user.get("userId") or seerr_user.get("Id")
|
||||
try:
|
||||
seerr_user_id = int(seerr_user_id) if seerr_user_id is not None else None
|
||||
except (TypeError, ValueError):
|
||||
seerr_user_id = None
|
||||
if seerr_user_id is not None:
|
||||
local_user = normalize_user_auth_provider(get_user_by_jellyseerr_id(seerr_user_id))
|
||||
if not local_user:
|
||||
for candidate in (seerr_user.get("email"), seerr_user.get("username")):
|
||||
if not isinstance(candidate, str) or not candidate.strip():
|
||||
continue
|
||||
local_user = normalize_user_auth_provider(
|
||||
_pick_preferred_user(get_users_by_username_ci(candidate), candidate)
|
||||
)
|
||||
if local_user:
|
||||
break
|
||||
|
||||
if not local_user:
|
||||
return None
|
||||
|
||||
auth_provider = resolve_user_auth_provider(local_user)
|
||||
username = str(local_user.get("username") or "").strip()
|
||||
recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
|
||||
if not recipient_email:
|
||||
seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
|
||||
if isinstance(local_user.get("jellyseerr_user_id"), int):
|
||||
seerr_user = next(
|
||||
(
|
||||
user
|
||||
for user in seerr_users
|
||||
if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not seerr_user:
|
||||
seerr_user = _find_matching_seerr_user(username, seerr_users)
|
||||
recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
|
||||
if not recipient_email:
|
||||
return None
|
||||
|
||||
if auth_provider == "jellyseerr":
|
||||
runtime = get_runtime_settings()
|
||||
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
if jellyfin_client.configured():
|
||||
try:
|
||||
jellyfin_user = await jellyfin_client.find_user_by_name(username)
|
||||
except Exception:
|
||||
jellyfin_user = None
|
||||
if isinstance(jellyfin_user, dict):
|
||||
auth_provider = "jellyfin"
|
||||
|
||||
if auth_provider not in {"local", "jellyfin"}:
|
||||
return None
|
||||
|
||||
return {
|
||||
"username": username,
|
||||
"recipient_email": recipient_email,
|
||||
"auth_provider": auth_provider,
|
||||
}
|
||||
|
||||
|
||||
def _token_record_is_usable(record: Optional[dict]) -> bool:
|
||||
if not isinstance(record, dict):
|
||||
return False
|
||||
if record.get("is_used"):
|
||||
return False
|
||||
if record.get("is_expired"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _mask_email(email: str) -> str:
|
||||
candidate = str(email or "").strip()
|
||||
if "@" not in candidate:
|
||||
return "valid reset link"
|
||||
local_part, domain = candidate.split("@", 1)
|
||||
if not local_part:
|
||||
return f"***@{domain}"
|
||||
if len(local_part) == 1:
|
||||
return f"{local_part}***@{domain}"
|
||||
return f"{local_part[0]}***{local_part[-1]}@{domain}"
|
||||
|
||||
|
||||
async def request_password_reset(
|
||||
identifier: str,
|
||||
*,
|
||||
requested_by_ip: Optional[str] = None,
|
||||
requested_user_agent: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
delete_expired_password_reset_tokens()
|
||||
target = await _resolve_reset_target(identifier)
|
||||
if not target:
|
||||
logger.info("password reset requested with no eligible match identifier=%s", identifier.strip().lower()[:256])
|
||||
return {"status": "ok", "issued": False}
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=PASSWORD_RESET_TOKEN_TTL_MINUTES)).isoformat()
|
||||
create_password_reset_token(
|
||||
token,
|
||||
target["username"],
|
||||
target["recipient_email"],
|
||||
target["auth_provider"],
|
||||
expires_at,
|
||||
requested_by_ip=requested_by_ip,
|
||||
requested_user_agent=requested_user_agent,
|
||||
)
|
||||
await send_password_reset_email(
|
||||
recipient_email=target["recipient_email"],
|
||||
username=target["username"],
|
||||
token=token,
|
||||
expires_at=expires_at,
|
||||
auth_provider=target["auth_provider"],
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"issued": True,
|
||||
"username": target["username"],
|
||||
"recipient_email": target["recipient_email"],
|
||||
"auth_provider": target["auth_provider"],
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
|
||||
def verify_password_reset_token(token: str) -> Dict[str, Any]:
|
||||
delete_expired_password_reset_tokens()
|
||||
record = get_password_reset_token(token)
|
||||
if not _token_record_is_usable(record):
|
||||
raise ValueError("Password reset link is invalid or has expired.")
|
||||
return {
|
||||
"status": "ok",
|
||||
"recipient_hint": _mask_email(str(record.get("recipient_email") or "")),
|
||||
"auth_provider": record.get("auth_provider"),
|
||||
"expires_at": record.get("expires_at"),
|
||||
}
|
||||
|
||||
|
||||
async def apply_password_reset(token: str, new_password: str) -> Dict[str, Any]:
|
||||
delete_expired_password_reset_tokens()
|
||||
record = get_password_reset_token(token)
|
||||
if not _token_record_is_usable(record):
|
||||
raise ValueError("Password reset link is invalid or has expired.")
|
||||
|
||||
username = str(record.get("username") or "").strip()
|
||||
if not username:
|
||||
raise ValueError("Password reset link is invalid or has expired.")
|
||||
|
||||
stored_user = normalize_user_auth_provider(get_user_by_username(username))
|
||||
if not stored_user:
|
||||
raise ValueError("Password reset link is invalid or has expired.")
|
||||
|
||||
auth_provider = resolve_user_auth_provider(stored_user)
|
||||
if auth_provider == "jellyseerr":
|
||||
auth_provider = "jellyfin"
|
||||
|
||||
if auth_provider == "local":
|
||||
set_user_password(username, new_password)
|
||||
if str(stored_user.get("auth_provider") or "").strip().lower() != "local":
|
||||
set_user_auth_provider(username, "local")
|
||||
mark_password_reset_token_used(token)
|
||||
logger.info("password reset applied username=%s provider=local", username)
|
||||
return {"status": "ok", "provider": "local", "username": username}
|
||||
|
||||
if auth_provider == "jellyfin":
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
if not client.configured():
|
||||
raise PasswordResetUnavailableError("Jellyfin is not configured for password reset.")
|
||||
jellyfin_user = await client.find_user_by_name(username)
|
||||
user_id = client._extract_user_id(jellyfin_user)
|
||||
if not user_id:
|
||||
raise ValueError("Password reset link is invalid or has expired.")
|
||||
await client.set_user_password(user_id, new_password)
|
||||
sync_jellyfin_password_state(username, new_password)
|
||||
if str(stored_user.get("auth_provider") or "").strip().lower() != "jellyfin":
|
||||
set_user_auth_provider(username, "jellyfin")
|
||||
mark_password_reset_token_used(token)
|
||||
logger.info("password reset applied username=%s provider=jellyfin", username)
|
||||
return {"status": "ok", "provider": "jellyfin", "username": username}
|
||||
|
||||
raise ValueError("Password reset is not available for this sign-in provider.")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,185 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..db import get_setting, set_setting, delete_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
JELLYSEERR_CACHE_KEY = "jellyseerr_users_cache"
|
||||
JELLYSEERR_CACHE_AT_KEY = "jellyseerr_users_cached_at"
|
||||
JELLYFIN_CACHE_KEY = "jellyfin_users_cache"
|
||||
JELLYFIN_CACHE_AT_KEY = "jellyfin_users_cached_at"
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _parse_iso(value: Optional[str]) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
def _cache_is_fresh(cached_at: Optional[str], max_age_minutes: int) -> bool:
|
||||
parsed = _parse_iso(cached_at)
|
||||
if not parsed:
|
||||
return False
|
||||
age = datetime.now(timezone.utc) - parsed
|
||||
return age <= timedelta(minutes=max_age_minutes)
|
||||
|
||||
|
||||
def _load_cached_users(
|
||||
cache_key: str, cache_at_key: str, max_age_minutes: int
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
cached_at = get_setting(cache_at_key)
|
||||
if not _cache_is_fresh(cached_at, max_age_minutes):
|
||||
return None
|
||||
raw = get_setting(cache_key)
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return None
|
||||
if isinstance(data, list):
|
||||
return [item for item in data if isinstance(item, dict)]
|
||||
return None
|
||||
|
||||
|
||||
def _save_cached_users(cache_key: str, cache_at_key: str, users: List[Dict[str, Any]]) -> None:
|
||||
payload = json.dumps(users, ensure_ascii=True)
|
||||
set_setting(cache_key, payload)
|
||||
set_setting(cache_at_key, _now_iso())
|
||||
|
||||
|
||||
def _normalized_handles(value: Any) -> List[str]:
|
||||
if not isinstance(value, str):
|
||||
return []
|
||||
normalized = value.strip().lower()
|
||||
if not normalized:
|
||||
return []
|
||||
handles = [normalized]
|
||||
if "@" in normalized:
|
||||
handles.append(normalized.split("@", 1)[0])
|
||||
return list(dict.fromkeys(handles))
|
||||
|
||||
|
||||
def build_jellyseerr_candidate_map(users: List[Dict[str, Any]]) -> Dict[str, int]:
|
||||
candidate_to_id: Dict[str, int] = {}
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
user_id = user.get("id") or user.get("userId") or user.get("Id")
|
||||
try:
|
||||
user_id = int(user_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
for key in ("username", "email", "displayName", "name"):
|
||||
for handle in _normalized_handles(user.get(key)):
|
||||
candidate_to_id.setdefault(handle, user_id)
|
||||
return candidate_to_id
|
||||
|
||||
|
||||
def find_matching_jellyseerr_user(
|
||||
identifier: str, users: List[Dict[str, Any]]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
target_handles = set(_normalized_handles(identifier))
|
||||
if not target_handles:
|
||||
return None
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
for key in ("username", "email", "displayName", "name"):
|
||||
if target_handles.intersection(_normalized_handles(user.get(key))):
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
def extract_jellyseerr_user_email(user: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
if not isinstance(user, dict):
|
||||
return None
|
||||
value = user.get("email")
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
candidate = value.strip()
|
||||
if not candidate or "@" not in candidate:
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def match_jellyseerr_user_id(
|
||||
username: str, candidate_map: Dict[str, int]
|
||||
) -> Optional[int]:
|
||||
for handle in _normalized_handles(username):
|
||||
matched = candidate_map.get(handle)
|
||||
if matched is not None:
|
||||
return matched
|
||||
return None
|
||||
|
||||
|
||||
def save_jellyseerr_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
normalized.append(
|
||||
{
|
||||
"id": user.get("id") or user.get("userId") or user.get("Id"),
|
||||
"email": user.get("email"),
|
||||
"username": user.get("username"),
|
||||
"displayName": user.get("displayName"),
|
||||
"name": user.get("name"),
|
||||
}
|
||||
)
|
||||
_save_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, normalized)
|
||||
logger.debug("Cached Seerr users: %s", len(normalized))
|
||||
return normalized
|
||||
|
||||
|
||||
def get_cached_jellyseerr_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
|
||||
return _load_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, max_age_minutes)
|
||||
|
||||
|
||||
def save_jellyfin_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
for user in users:
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
normalized.append(
|
||||
{
|
||||
"id": user.get("Id"),
|
||||
"name": user.get("Name"),
|
||||
"hasPassword": user.get("HasPassword"),
|
||||
"lastLoginDate": user.get("LastLoginDate"),
|
||||
}
|
||||
)
|
||||
_save_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, normalized)
|
||||
logger.debug("Cached Jellyfin users: %s", len(normalized))
|
||||
return normalized
|
||||
|
||||
|
||||
def get_cached_jellyfin_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
|
||||
return _load_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, max_age_minutes)
|
||||
|
||||
|
||||
def clear_user_import_caches() -> Dict[str, int]:
|
||||
cleared = 0
|
||||
for key in (
|
||||
JELLYSEERR_CACHE_KEY,
|
||||
JELLYSEERR_CACHE_AT_KEY,
|
||||
JELLYFIN_CACHE_KEY,
|
||||
JELLYFIN_CACHE_AT_KEY,
|
||||
):
|
||||
delete_setting(key)
|
||||
cleared += 1
|
||||
logger.debug("Cleared user import cache keys: %s", cleared)
|
||||
return {"settingsKeysCleared": cleared}
|
||||
Reference in New Issue
Block a user