Files
manage/backend/src/media_library_viewer_api/routers/users_impl.py
T

390 lines
14 KiB
Python

"""Users router — Jellyfin list plus optional Jellyseerr enrichment."""
from __future__ import annotations
import json
import logging
from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import (
get_jellyfin_client,
get_jellyseerr_client,
get_mail_queue,
)
from media_library_viewer_api.services.mailer import EmailAttachment, validate_smtp_settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/users", tags=["users"])
_PERMISSION_FLAGS = [
(2, "admin"),
(4, "manage_settings"),
(8, "manage_users"),
(16, "manage_requests"),
(32, "request"),
(64, "vote"),
(128, "auto_approve"),
(256, "auto_approve_movie"),
(512, "auto_approve_tv"),
(1024, "request_4k"),
(2048, "request_4k_movie"),
(4096, "request_4k_tv"),
(8192, "request_advanced"),
(16384, "request_view"),
(32768, "auto_approve_4k"),
(65536, "auto_approve_4k_movie"),
(131072, "auto_approve_4k_tv"),
(262144, "request_movie"),
(524288, "request_tv"),
(1048576, "manage_issues"),
(2097152, "view_issues"),
]
_USER_TYPES = {
1: "plex",
2: "local",
3: "jellyfin",
4: "emby",
}
def _safe_int(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def _permission_labels(permissions: int) -> list[str]:
labels = [label for bit, label in _PERMISSION_FLAGS if permissions & bit]
return labels or ["none"]
def _role_label(permissions: int) -> str:
if permissions & 2:
return "admin"
if permissions & (4 | 8 | 16):
return "manager"
if permissions & (32 | 64 | 128):
return "requester"
return "user"
def _account_type(user_type: Any) -> str:
return _USER_TYPES.get(_safe_int(user_type), "unknown")
def _merge_users(
jellyfin_users: list[dict[str, Any]],
jellyseerr_jellyfin_users: list[dict[str, Any]] | None,
jellyseerr_users: list[dict[str, Any]] | None,
jellyseerr_client: JellyseerrClient | None,
) -> dict[str, Any]:
def _normalize(value: Any) -> str:
return str(value or "").strip().lower()
def _looks_like_email(value: Any) -> bool:
text = str(value or "").strip()
return bool(text and "@" in text and " " not in text)
def _pick_source_and_value(candidates: list[tuple[str, Any]]) -> tuple[str, str]:
for source, value in candidates:
if _looks_like_email(value):
return source, str(value).strip()
return "", ""
def _first_value(candidates: list[tuple[str, Any]]) -> tuple[str, str]:
for source, value in candidates:
text = str(value or "").strip()
if text:
return source, text
return "", ""
def _source_summary(name_source: str, email_source: str, avatar_source: str, access_source: str) -> str:
return ", ".join(
[
f"name={name_source or 'none'}",
f"email={email_source or 'none'}",
f"avatar={avatar_source or 'none'}",
f"access={access_source or 'none'}",
]
)
def _lookup_keys(item: dict[str, Any]) -> list[str]:
return [
_normalize(item.get("id")),
_normalize(item.get("Id")),
_normalize(item.get("userId")),
_normalize(item.get("user_id")),
_normalize(item.get("jellyfinUserId")),
_normalize(item.get("jellyfin_user_id")),
_normalize(item.get("jellyfinUsername")),
_normalize(item.get("jellyfin_username")),
_normalize(item.get("username")),
_normalize(item.get("displayName")),
_normalize(item.get("display_name")),
]
linked_by_jellyfin_id: dict[str, dict[str, Any]] = {}
for item in jellyseerr_jellyfin_users or []:
for key in (
item.get("id"),
item.get("Id"),
item.get("userId"),
item.get("user_id"),
item.get("jellyfinUserId"),
item.get("jellyfin_user_id"),
):
normalized = _normalize(key)
if normalized:
linked_by_jellyfin_id[normalized] = item
seerr_by_key: dict[str, dict[str, Any]] = {}
for item in jellyseerr_users or []:
for key in _lookup_keys(item):
if key:
seerr_by_key[key] = item
items: list[dict[str, Any]] = []
enriched_count = 0
for user in jellyfin_users:
jellyfin_id = str(user.get("Id") or user.get("id") or "")
jellyfin_name = str(user.get("Name") or user.get("name") or "")
jf_link = linked_by_jellyfin_id.get(_normalize(jellyfin_id))
seerr_user = None
for candidate in [
jellyfin_name,
(jf_link or {}).get("jellyfinUsername"),
(jf_link or {}).get("jellyfin_username"),
(jf_link or {}).get("username"),
(jf_link or {}).get("displayName"),
(jf_link or {}).get("display_name"),
]:
seerr_user = seerr_by_key.get(_normalize(candidate))
if seerr_user:
break
email_source, email = _pick_source_and_value(
[
("jellyseerr:user", (seerr_user or {}).get("email")),
("jellyseerr:jellyfin", (jf_link or {}).get("email")),
]
)
avatar_source, avatar = _first_value(
[
("jellyseerr:user", (seerr_user or {}).get("avatar")),
("jellyseerr:jellyfin", (jf_link or {}).get("thumb")),
("jellyseerr:jellyfin", (jf_link or {}).get("avatar")),
]
)
if avatar and jellyseerr_client:
avatar = jellyseerr_client.absolute_url(avatar)
permissions = _safe_int((seerr_user or {}).get("permissions"))
user_type = _safe_int((seerr_user or {}).get("userType") or (seerr_user or {}).get("user_type"))
role = _role_label(permissions)
access_source = "jellyseerr:user" if seerr_user else ""
name_source = "jellyfin"
summary = _source_summary(name_source, email_source, avatar_source, access_source)
if seerr_user or jf_link:
enriched_count += 1
items.append(
{
"jellyfin_id": jellyfin_id,
"username": jellyfin_name,
"display_name": jellyfin_name,
"email": email,
"email_source": email_source,
"avatar": avatar,
"avatar_source": avatar_source,
"contactable": bool(email),
"source": summary,
"source_summary": summary,
"name_source": name_source,
"access_source": access_source,
"jellyseerr_user_id": _safe_int((seerr_user or {}).get("id") or (seerr_user or {}).get("userId"))
or None,
"jellyseerr_username": str(
(seerr_user or {}).get("username") or (seerr_user or {}).get("jellyfinUsername") or ""
),
"user_type": user_type or None,
"user_type_label": _account_type(user_type),
"role": role,
"permissions": permissions,
"permissions_label": ", ".join(_permission_labels(permissions)),
"request_count": _safe_int((seerr_user or {}).get("requestCount")) or None,
}
)
logger.info(
"Users merged jellyfin=%s jellyseerr_jellyfin=%s jellyseerr_users=%s enriched=%s",
len(jellyfin_users),
len(jellyseerr_jellyfin_users or []),
len(jellyseerr_users or []),
enriched_count,
)
return {
"items": items,
"total": len(items),
"jellyseerr_configured": jellyseerr_client is not None,
"jellyseerr_available": bool(jellyseerr_users or jellyseerr_jellyfin_users),
"jellyseerr_jellyfin_user_count": len(jellyseerr_jellyfin_users or []),
"jellyseerr_user_count": len(jellyseerr_users or []),
"enriched_count": enriched_count,
}
@router.get("")
def get_users(
jellyfin: JellyfinClient = Depends(get_jellyfin_client),
jellyseerr: JellyseerrClient | None = Depends(get_jellyseerr_client),
) -> dict[str, Any]:
"""Return the known users, enriched with Jellyseerr data when available."""
jellyfin_users = jellyfin.users()
logger.info("Users endpoint fetched %s Jellyfin users", len(jellyfin_users))
jellyseerr_jellyfin_users: list[dict[str, Any]] | None = None
jellyseerr_users: list[dict[str, Any]] | None = None
jellyseerr_error = ""
if jellyseerr:
try:
jellyseerr_jellyfin_users = jellyseerr.jellyfin_users()
except Exception as exc: # pragma: no cover - network fallback
logger.exception("Jellyseerr Jellyfin-linked user fetch failed")
jellyseerr_error = f"Jellyseerr Jellyfin users fetch failed: {exc}"
try:
jellyseerr_users = jellyseerr.users()
except Exception as exc: # pragma: no cover - network fallback
logger.exception("Jellyseerr user list fetch failed")
jellyseerr_error = (
f"{jellyseerr_error}; " if jellyseerr_error else ""
) + f"Jellyseerr user list fetch failed: {exc}"
result = _merge_users(jellyfin_users, jellyseerr_jellyfin_users, jellyseerr_users, jellyseerr)
result["jellyseerr_error"] = jellyseerr_error
logger.info(
"Users response total=%s configured=%s available=%s enriched=%s error=%s",
result["total"],
result["jellyseerr_configured"],
result["jellyseerr_available"],
result["enriched_count"],
bool(jellyseerr_error),
)
return result
@router.get("/message/status")
def get_user_message_status(mail_queue=Depends(get_mail_queue)) -> dict[str, Any]:
"""Return the current background email queue status."""
return mail_queue.status()
@router.post("/message", status_code=status.HTTP_202_ACCEPTED)
async def post_user_message(
recipient_ids: str = Form(...),
subject: str = Form(...),
html_body: str = Form(""),
text_body: str = Form(""),
attachments: list[UploadFile] | None = File(default=None),
jellyfin: JellyfinClient = Depends(get_jellyfin_client),
jellyseerr: JellyseerrClient | None = Depends(get_jellyseerr_client),
mail_queue=Depends(get_mail_queue),
) -> dict[str, Any]:
"""Queue a single email to the selected users without blocking the API."""
try:
requested_ids = json.loads(recipient_ids)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail=f"recipient_ids must be valid JSON: {exc}") from exc
if not isinstance(requested_ids, list):
raise HTTPException(status_code=400, detail="recipient_ids must be a JSON list")
cleaned_ids = [str(item).strip() for item in requested_ids if str(item).strip()]
if not cleaned_ids:
raise HTTPException(status_code=400, detail="At least one recipient is required")
subject = subject.strip()
if not subject:
raise HTTPException(status_code=400, detail="Subject is required")
directory = get_users(jellyfin=jellyfin, jellyseerr=jellyseerr)
users_by_id = {str(item.get("jellyfin_id") or ""): item for item in directory.get("items", [])}
recipients: list[str] = []
recipient_labels: list[str] = []
skipped: list[dict[str, str]] = []
for user_id in cleaned_ids:
item = users_by_id.get(user_id)
if not item:
skipped.append({"jellyfin_id": user_id, "reason": "not found"})
continue
email = str(item.get("email") or "").strip()
if not email:
skipped.append({"jellyfin_id": user_id, "reason": "missing email"})
continue
recipients.append(email)
recipient_labels.append(f"{item.get('display_name') or item.get('username') or user_id} <{email}>")
if not recipients:
raise HTTPException(status_code=400, detail="No selected users have a deliverable email address")
settings = get_settings()
validate_smtp_settings(settings)
queue_status = mail_queue.status()
if not queue_status["worker_running"]:
raise HTTPException(status_code=503, detail="Email queue worker is not running")
attachment_payloads: list[EmailAttachment] = []
for upload in attachments or []:
data = await upload.read()
if not data:
continue
attachment_payloads.append(
EmailAttachment(
filename=upload.filename or "attachment",
content_type=upload.content_type or "application/octet-stream",
data=data,
)
)
request_id = mail_queue.enqueue(
settings=settings,
recipients=recipients,
subject=subject,
html_body=html_body,
text_body=text_body,
attachments=attachment_payloads,
)
from_address = (
str(getattr(settings, "smtp_from_address", "") or "").strip()
or str(getattr(settings, "smtp_username", "") or "").strip()
)
logger.info(
"Users message queued request_id=%s subject=%s recipients=%s attachments=%s skipped=%s",
request_id,
subject,
len(recipients),
len(attachment_payloads),
len(skipped),
)
return {
"status": "queued",
"request_id": request_id,
"from_address": from_address,
"recipient_count": len(recipients),
"attachment_count": len(attachment_payloads),
"subject": subject,
"recipient_labels": recipient_labels,
"skipped": skipped,
}