refactor frontend and backend modules
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
"""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,
|
||||
}
|
||||
Reference in New Issue
Block a user