390 lines
14 KiB
Python
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,
|
|
}
|