feat: add Apprise sidecar user/admin notifications

This commit is contained in:
2026-02-25 22:54:18 +13:00
parent d045dd0b07
commit 1fe4a44eb5
9 changed files with 748 additions and 4 deletions

View File

@@ -34,6 +34,7 @@ from ..db import (
update_request_cache_title,
repair_request_cache_titles,
delete_non_admin_users,
get_user_notification_settings,
)
from ..runtime import get_runtime_settings
from ..clients.sonarr import SonarrClient
@@ -49,6 +50,7 @@ from ..services.user_cache import (
save_jellyfin_users_cache,
save_jellyseerr_users_cache,
)
from ..services.notifications import send_apprise_notification
import logging
from ..logging_config import configure_logging
from ..routers import requests as requests_router
@@ -64,6 +66,7 @@ SENSITIVE_KEYS = {
"radarr_api_key",
"prowlarr_api_key",
"qbittorrent_password",
"apprise_api_key",
}
URL_SETTING_KEYS = {
@@ -74,6 +77,7 @@ URL_SETTING_KEYS = {
"radarr_base_url",
"prowlarr_base_url",
"qbittorrent_base_url",
"apprise_base_url",
}
SETTING_KEYS: List[str] = [
@@ -101,6 +105,8 @@ SETTING_KEYS: List[str] = [
"qbittorrent_password",
"log_level",
"log_file",
"apprise_base_url",
"apprise_api_key",
"requests_sync_ttl_minutes",
"requests_poll_interval_seconds",
"requests_delta_sync_interval_minutes",
@@ -608,6 +614,72 @@ async def list_users() -> Dict[str, Any]:
users = [user for user in get_all_users() if user.get("role") == "admin" or user.get("auth_provider") == "jellyseerr"]
return {"users": users}
@router.get("/notifications/users")
async def list_notification_users() -> Dict[str, Any]:
users = get_all_users()
results: list[Dict[str, Any]] = []
for user in users:
username = user.get("username") or ""
settings = get_user_notification_settings(username)
results.append(
{
"username": username,
"role": user.get("role"),
"authProvider": user.get("auth_provider"),
"jellyseerrUserId": user.get("jellyseerr_user_id"),
"isBlocked": bool(user.get("is_blocked")),
"notifyEnabled": bool(settings.get("enabled")),
"notifyCount": len(settings.get("urls") or []),
}
)
return {"users": results}
@router.post("/notifications/send")
async def send_notifications(payload: Dict[str, Any]) -> Dict[str, Any]:
usernames = payload.get("usernames")
message = payload.get("message")
title = payload.get("title") or "Magent admin message"
if not isinstance(usernames, list) or not usernames:
raise HTTPException(status_code=400, detail="Select at least one user.")
if not isinstance(message, str) or not message.strip():
raise HTTPException(status_code=400, detail="Message cannot be empty.")
results: list[Dict[str, Any]] = []
counts = {"sent": 0, "skipped": 0, "failed": 0}
for raw_username in usernames:
if not isinstance(raw_username, str) or not raw_username.strip():
results.append({"username": str(raw_username), "status": "invalid"})
counts["failed"] += 1
continue
username = raw_username.strip()
user = get_user_by_username(username)
if not user:
results.append({"username": username, "status": "not_found"})
counts["failed"] += 1
continue
if user.get("is_blocked"):
results.append({"username": username, "status": "blocked"})
counts["skipped"] += 1
continue
settings = get_user_notification_settings(username)
if not settings.get("enabled"):
results.append({"username": username, "status": "disabled"})
counts["skipped"] += 1
continue
urls = settings.get("urls") or []
if not urls:
results.append({"username": username, "status": "no_targets"})
counts["skipped"] += 1
continue
ok = send_apprise_notification(urls, str(title).strip() or "Magent admin message", message.strip())
if ok:
results.append({"username": username, "status": "sent"})
counts["sent"] += 1
else:
results.append({"username": username, "status": "failed"})
counts["failed"] += 1
return {"results": results, **counts}
@router.get("/users/summary")
async def list_users_summary() -> Dict[str, Any]:
users = [user for user in get_all_users() if user.get("role") == "admin" or user.get("auth_provider") == "jellyseerr"]

View File

@@ -1,4 +1,5 @@
from datetime import datetime, timedelta, timezone
import asyncio
from fastapi import APIRouter, HTTPException, status, Depends
from fastapi.security import OAuth2PasswordRequestForm
@@ -16,6 +17,8 @@ from ..db import (
get_user_request_stats,
get_global_request_leader,
get_global_request_total,
get_user_notification_settings,
set_user_notification_settings,
)
from ..runtime import get_runtime_settings
from ..clients.jellyfin import JellyfinClient
@@ -28,6 +31,11 @@ from ..services.user_cache import (
match_jellyseerr_user_id,
save_jellyfin_users_cache,
)
from ..services.notifications import (
notify_admins_new_signup,
send_apprise_notification,
validate_apprise_urls,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -119,10 +127,14 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict) or not response.get("User"):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin")
created = create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin")
user = get_user_by_username(username)
if user and user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
if created:
asyncio.create_task(
asyncio.to_thread(notify_admins_new_signup, username, "jellyfin")
)
try:
users = await client.get_users()
if isinstance(users, list):
@@ -159,7 +171,7 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) ->
if not isinstance(response, dict):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials")
jellyseerr_user_id = _extract_jellyseerr_user_id(response)
create_user_if_missing(
created = create_user_if_missing(
form_data.username,
"jellyseerr-user",
role="user",
@@ -171,6 +183,10 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) ->
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
if jellyseerr_user_id is not None:
set_user_jellyseerr_id(form_data.username, jellyseerr_user_id)
if created:
asyncio.create_task(
asyncio.to_thread(notify_admins_new_signup, form_data.username, "jellyseerr")
)
token = create_access_token(form_data.username, "user")
set_last_login(form_data.username)
return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}}
@@ -207,6 +223,48 @@ async def profile(current_user: dict = Depends(get_current_user)) -> dict:
}
@router.get("/notifications")
async def get_notifications(current_user: dict = Depends(get_current_user)) -> dict:
settings = get_user_notification_settings(current_user.get("username") or "")
return settings
@router.put("/notifications")
async def update_notifications(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
enabled = bool(payload.get("enabled"))
urls_raw = payload.get("urls") or []
if isinstance(urls_raw, str):
urls = [line.strip() for line in urls_raw.splitlines() if line.strip()]
elif isinstance(urls_raw, list):
urls = [str(item).strip() for item in urls_raw if str(item).strip()]
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid URLs")
try:
validated = validate_apprise_urls(urls)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
set_user_notification_settings(current_user.get("username") or "", enabled, validated)
return {"status": "ok", "enabled": enabled, "urls": validated}
@router.post("/notifications/test")
async def test_notifications(current_user: dict = Depends(get_current_user)) -> dict:
settings = get_user_notification_settings(current_user.get("username") or "")
if not settings.get("enabled"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Notifications are disabled")
urls = settings.get("urls") or []
if not urls:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No Apprise URLs configured")
title = "Magent notification test"
body = f"Hello {current_user.get('username')}, your Apprise notifications are working."
sent = await asyncio.to_thread(send_apprise_notification, urls, title, body)
if not sent:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Notification failed")
return {"status": "ok"}
@router.post("/password")
async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
if current_user.get("auth_provider") != "local":