"""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, }