Phase 2: Docker and OIDC auth

This commit is contained in:
2026-05-04 13:50:53 +02:00
parent 47baee854b
commit 4226628d5a
71 changed files with 9722 additions and 1347 deletions
@@ -1,7 +1,8 @@
"""Dashboard router — media counts, per-library breakdown, now-playing."""
"""Dashboard router — media counts, per-library breakdown, activity."""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends
@@ -9,6 +10,8 @@ from fastapi import APIRouter, Depends
from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
from media_library_viewer_api.clients.jellyfin import JellyfinClient
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@@ -18,7 +21,9 @@ def get_counts(
user_id: str = Depends(get_user_id),
) -> dict[str, int]:
"""Return total movie/series/episode counts."""
return client.media_counts(user_id)
counts = client.media_counts(user_id)
logger.info("Dashboard counts user_id=%s counts=%s", user_id, counts)
return counts
@router.get("/libraries")
@@ -28,26 +33,31 @@ def get_library_counts(
) -> list[dict[str, Any]]:
"""Return per-library item counts broken down by type."""
libraries = client.libraries(user_id)
logger.info("Dashboard libraries user_id=%s count=%s", user_id, len(libraries))
return client.library_item_counts(user_id, libraries)
@router.get("/now-playing")
def get_now_playing(
client: JellyfinClient = Depends(get_jellyfin_client),
) -> list[dict[str, Any]]:
"""Return currently active playback sessions with transcode info."""
sessions = client.active_sessions()
results = []
def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Normalize Jellyfin sessions into dashboard activity rows."""
results: list[dict[str, Any]] = []
for session in sessions:
item = session.get("NowPlayingItem") or {}
play_state = session.get("PlayState") or {}
transcoding = session.get("TranscodingInfo") or {}
has_item = bool(item)
series = item.get("SeriesName") or ""
title = f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown")
title = (
f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown")
) if has_item else "(idle)"
if not has_item:
state_label = "idle"
else:
state_label = "paused" if play_state.get("IsPaused") else "playing"
is_transcoding = bool(transcoding)
transcode_type = []
transcode_type: list[str] = []
if is_transcoding:
if transcoding.get("IsVideoDirect") is False:
transcode_type.append("video")
@@ -59,11 +69,32 @@ def get_now_playing(
results.append({
"user": session.get("UserName") or "Unknown",
"title": title,
"type": item.get("Type", ""),
"state": "paused" if play_state.get("IsPaused") else "playing",
"type": item.get("Type", "") if has_item else "",
"state": state_label,
"transcoding": "yes" if is_transcoding else "no",
"transcoding_type": ", ".join(transcode_type),
"device": session.get("DeviceName") or session.get("Client") or "",
"session_id": session.get("Id") or "",
})
return results
@router.get("/activity")
def get_activity(
client: JellyfinClient = Depends(get_jellyfin_client),
) -> list[dict[str, Any]]:
"""Return activity rows for active sessions (playing and idle/logged-in)."""
sessions = client.sessions()
rows = _map_sessions_to_activity_rows(sessions)
state_rank = {"playing": 0, "paused": 1, "idle": 2}
rows.sort(key=lambda r: (state_rank.get(str(r.get("state")), 9), str(r.get("user", "")).lower()))
logger.info("Dashboard activity sessions=%s rows=%s", len(sessions), len(rows))
return rows
@router.get("/now-playing")
def get_now_playing(
client: JellyfinClient = Depends(get_jellyfin_client),
) -> list[dict[str, Any]]:
"""Backward-compatible alias; returns full activity rows."""
return get_activity(client)
@@ -3,15 +3,18 @@
from __future__ import annotations
import json
import logging
from typing import Any
from fastapi import APIRouter, Depends, Query, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from media_library_viewer_api.dependencies import get_ssh_client
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import get_ssh_client
from media_library_viewer_api.path_utils import resolve_remote_media_path
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/files", tags=["files"])
@@ -23,8 +26,10 @@ def list_directory(
"""List a remote directory."""
result = ssh.list_dir(path)
if result.exit_status != 0:
logger.warning("Files list failed path=%s exit_status=%s", path, result.exit_status)
raise HTTPException(status_code=400, detail=result.stderr or result.stdout or "Failed to list directory")
entries = json.loads(result.stdout)
logger.info("Files list path=%s entries=%s", path, len(entries))
return {
"path": path,
"entries": entries,
@@ -41,7 +46,9 @@ def get_ffprobe(
try:
data = ssh.ffprobe_json(path)
except RuntimeError as exc:
logger.warning("Files ffprobe failed path=%s error=%s", path, exc)
raise HTTPException(status_code=400, detail=str(exc))
logger.info("Files ffprobe path=%s", path)
return data
@@ -53,7 +60,9 @@ def get_stat(
"""Run stat on a remote path."""
result = ssh.stat_path(path)
if result.exit_status != 0:
logger.warning("Files stat failed path=%s exit_status=%s", path, result.exit_status)
raise HTTPException(status_code=400, detail=result.stderr or result.stdout or "stat failed")
logger.info("Files stat path=%s", path)
return {"path": path, "output": result.stdout}
@@ -64,4 +73,5 @@ def resolve_path(
"""Resolve a Jellyfin path to its SSH-visible equivalent."""
settings = get_settings()
resolved = resolve_remote_media_path(path, settings.media_root, settings.path_prefix)
logger.info("Files resolve path original=%s resolved=%s", path, resolved)
return {"original": path, "resolved": resolved}
@@ -2,15 +2,18 @@
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from media_library_viewer_api.dependencies import get_ssh_client
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.dependencies import get_ssh_client
from media_library_viewer_api.jobs import JOB_TEMPLATES, run_job
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
@@ -22,7 +25,7 @@ class RunJobRequest(BaseModel):
@router.get("/templates")
def get_templates() -> list[dict[str, str]]:
"""Return available job templates."""
return [
templates = [
{
"key": key,
"name": template.name,
@@ -30,6 +33,8 @@ def get_templates() -> list[dict[str, str]]:
}
for key, template in JOB_TEMPLATES.items()
]
logger.info("Jobs templates requested count=%s", len(templates))
return templates
@router.post("/run")
@@ -39,9 +44,12 @@ def post_run_job(
) -> dict[str, Any]:
"""Run a job template on a remote path."""
if request.job_key not in JOB_TEMPLATES:
logger.warning("Unknown job requested key=%s path=%s", request.job_key, request.path)
raise HTTPException(status_code=400, detail=f"Unknown job key: {request.job_key}")
logger.info("Running job key=%s path=%s", request.job_key, request.path)
result = run_job(ssh, request.job_key, request.path)
logger.info("Job finished key=%s exit_status=%s path=%s", request.job_key, result.exit_status, request.path)
return {
"job_key": request.job_key,
"path": request.path,
@@ -1,45 +1,262 @@
"""Media router — index status, build, and query."""
"""Media router — index status, build, stop, and query."""
from __future__ import annotations
import logging
import os
import signal
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query, status
from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.services.media_index import MediaIndex, build_media_index
from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
from media_library_viewer_api.services.media_index import MediaIndex
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/media", tags=["media"])
_build_lock = threading.Lock()
def get_media_index() -> MediaIndex:
return MediaIndex()
@router.get("/status")
def get_index_status(index: MediaIndex = Depends(get_media_index)) -> dict[str, Any]:
"""Return media index status (exists, count, last updated, build duration)."""
def _set_build_metadata(index: MediaIndex, state: dict[str, Any]) -> None:
for key, value in state.items():
index.set_metadata(key, "" if value is None else value)
def _staging_db_path(index: MediaIndex) -> Path:
return index.db_path.with_name(f"{index.db_path.stem}.building{index.db_path.suffix}")
def _pid_is_alive(pid: int | None) -> bool:
if not pid:
return False
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
def _clean_stale_build_state(index: MediaIndex) -> Any:
status = index.status()
if status.build_running and not _pid_is_alive(status.build_pid):
logger.warning("Detected stale media build state pid=%s", status.build_pid)
_set_build_metadata(
index,
{
"build_running": False,
"build_stage": "stale",
"build_message": "Previous media index build stopped unexpectedly",
"build_cancel_requested": False,
"build_pid": "",
"build_error": "Worker process is no longer running",
},
)
status = index.status()
return status
def _serialize_status(status: Any) -> dict[str, Any]:
return {
"exists": status.exists,
"item_count": status.item_count,
"updated_at": status.updated_at,
"updated_at_label": status.updated_at_label,
"build_duration_seconds": status.build_duration_seconds,
"build_running": status.build_running,
"build_stage": status.build_stage,
"build_message": status.build_message,
"build_progress": status.build_progress,
"build_items_processed": status.build_items_processed,
"build_items_total": status.build_items_total,
"build_current_library": status.build_current_library,
"build_library_index": status.build_library_index,
"build_libraries_total": status.build_libraries_total,
"build_library_progress": status.build_library_progress,
"build_library_items_processed": status.build_library_items_processed,
"build_library_items_total": status.build_library_items_total,
"build_elapsed_seconds": status.build_elapsed_seconds,
"build_eta_seconds": status.build_eta_seconds,
"build_library_elapsed_seconds": status.build_library_elapsed_seconds,
"build_library_eta_seconds": status.build_library_eta_seconds,
"build_cancel_requested": status.build_cancel_requested,
"build_pid": status.build_pid,
"build_error": status.build_error,
}
@router.post("/build")
def _worker_command(final_db_path: Path, staging_db_path: Path) -> list[str]:
return [
sys.executable,
"-m",
"media_library_viewer_api.workers.media_index_worker",
"--index-path",
str(final_db_path),
"--staging-path",
str(staging_db_path),
]
def _start_worker(index: MediaIndex) -> subprocess.Popen[bytes]:
staging_path = _staging_db_path(index)
staging_path.unlink(missing_ok=True)
return subprocess.Popen(
_worker_command(index.db_path, staging_path),
start_new_session=True,
env=os.environ.copy(),
)
@router.get("/status")
def get_index_status(index: MediaIndex = Depends(get_media_index)) -> dict[str, Any]:
"""Return media index status (exists, count, build progress, and errors)."""
with _build_lock:
media_status = _clean_stale_build_state(index)
logger.info("Media status requested running=%s stage=%s", media_status.build_running, media_status.build_stage)
return _serialize_status(media_status)
@router.post("/build", status_code=status.HTTP_202_ACCEPTED)
def post_build_index(
client: JellyfinClient = Depends(get_jellyfin_client),
user_id: str = Depends(get_user_id),
index: MediaIndex = Depends(get_media_index),
) -> dict[str, Any]:
"""Rebuild the media index from Jellyfin."""
libraries = client.libraries(user_id)
count = build_media_index(client, user_id, libraries, index)
return {"indexed_items": count}
"""Start a media index build in a subprocess worker."""
with _build_lock:
current_status = _clean_stale_build_state(index)
if current_status.build_running and _pid_is_alive(current_status.build_pid):
logger.warning("Media build already running pid=%s", current_status.build_pid)
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Media index build already in progress")
libraries = client.libraries(user_id)
logger.info("Starting media index build user_id=%s libraries=%s", user_id, len(libraries))
process = _start_worker(index)
_set_build_metadata(
index,
{
"build_running": True,
"build_stage": "queued",
"build_message": "Media index build queued",
"build_progress": None,
"build_items_processed": 0,
"build_items_total": 0,
"build_current_library": "",
"build_library_index": 0,
"build_libraries_total": len(libraries),
"build_library_progress": None,
"build_library_items_processed": 0,
"build_library_items_total": 0,
"build_elapsed_seconds": None,
"build_eta_seconds": None,
"build_library_elapsed_seconds": None,
"build_library_eta_seconds": None,
"build_cancel_requested": False,
"build_pid": process.pid,
"build_error": "",
},
)
media_status = index.status()
logger.info("Media index build started pid=%s", process.pid)
return {"status": "started", **_serialize_status(media_status)}
@router.post("/stop", status_code=status.HTTP_202_ACCEPTED)
def stop_build(index: MediaIndex = Depends(get_media_index)) -> dict[str, Any]:
"""Request cooperative cancellation of a running media index build."""
with _build_lock:
media_status = _clean_stale_build_state(index)
if not media_status.build_running:
logger.warning("Stop requested but no media build is running")
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="No media index build is running")
logger.info("Cooperative stop requested for media build pid=%s", media_status.build_pid)
_set_build_metadata(
index,
{
"build_running": True,
"build_stage": "canceling",
"build_message": "Stopping media index build...",
"build_cancel_requested": True,
"build_error": "",
},
)
media_status = index.status()
logger.info("Media stop requested acknowledged")
return {"status": "stop_requested", **_serialize_status(media_status)}
@router.post("/force-stop", status_code=status.HTTP_202_ACCEPTED)
def force_stop_build(index: MediaIndex = Depends(get_media_index)) -> dict[str, Any]:
"""Terminate the media index worker process if it is stuck."""
with _build_lock:
media_status = _clean_stale_build_state(index)
if not media_status.build_running:
logger.warning("Force stop requested but no media build is running")
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="No media index build is running")
pid = media_status.build_pid
if not pid or not _pid_is_alive(pid):
logger.warning("Force stop requested but build worker is not alive pid=%s", pid)
_set_build_metadata(
index,
{
"build_running": False,
"build_stage": "stale",
"build_message": "Media index worker is not running",
"build_cancel_requested": False,
"build_pid": "",
"build_error": "Worker process is not running",
},
)
media_status = index.status()
return {"status": "already_stopped", **_serialize_status(media_status)}
logger.info("Force stopping media build pid=%s", pid)
try:
os.killpg(pid, signal.SIGTERM)
except ProcessLookupError:
pass
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
deadline = time.time() + 3.0
while time.time() < deadline and _pid_is_alive(pid):
time.sleep(0.1)
if _pid_is_alive(pid):
try:
os.killpg(pid, signal.SIGKILL)
except ProcessLookupError:
pass
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
_set_build_metadata(
index,
{
"build_running": False,
"build_stage": "force-stopped",
"build_message": "Media index build force stopped",
"build_cancel_requested": False,
"build_pid": "",
"build_error": "",
},
)
media_status = index.status()
return {"status": "force_stopped", **_serialize_status(media_status)}
@router.get("/query")
@@ -57,13 +274,25 @@ def query_media(
index: MediaIndex = Depends(get_media_index),
) -> dict[str, Any]:
"""Query the media index with filters, sorting, and pagination."""
# If no library IDs provided, use all libraries
# If no library IDs provided, use all libraries.
library_ids = [lid.strip() for lid in libraries.split(",") if lid.strip()] if libraries else None
if not library_ids:
all_libs = client.libraries(user_id)
library_ids = [lib["Id"] for lib in all_libs]
media_types = [t.strip() for t in types.split(",") if t.strip()]
logger.info(
"Media query user_id=%s libraries=%s types=%s search=%s hdr=%s sort=%s/%s limit=%s offset=%s",
user_id,
len(library_ids or []),
",".join(media_types),
search or "<none>",
hdr_filter,
sort_key,
sort_order,
limit,
offset,
)
rows, total = index.query(
library_ids=library_ids,
@@ -76,6 +305,7 @@ def query_media(
offset=offset,
)
logger.info("Media query returned total=%s rows=%s", total, len(rows))
return {
"items": rows,
"total": total,
@@ -2,13 +2,16 @@
from __future__ import annotations
import logging
import time
from typing import Any
from fastapi import APIRouter, Depends
from media_library_viewer_api.dependencies import get_ssh_client
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.dependencies import get_ssh_client
logger = logging.getLogger(__name__)
from media_library_viewer_api.clients.resources import (
disk_space,
read_resource_metrics,
@@ -26,19 +29,26 @@ router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
@router.get("/status")
def get_status(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
"""Return collector running status."""
return {"status": resource_collector_status(ssh)}
status = resource_collector_status(ssh)
logger.info("Monitoring status requested: %s", status)
return {"status": status}
@router.get("/metrics")
def get_metrics(
max_lines: int = 1000,
last_seconds: int = 3600,
max_lines: int = 70_000,
last_seconds: int | None = None,
ssh: RemoteSSHClient = Depends(get_ssh_client),
) -> dict[str, Any]:
"""Return resource metric samples from the remote collector."""
rows = read_resource_metrics(ssh, max_lines=max_lines)
cutoff_ts = time.time() - last_seconds
filtered = [row for row in rows if float(row.get("ts", 0)) >= cutoff_ts]
if last_seconds is None:
filtered = rows
cutoff_ts = 0.0
else:
cutoff_ts = time.time() - last_seconds
filtered = [row for row in rows if float(row.get("ts", 0)) >= cutoff_ts]
logger.info("Monitoring metrics requested total=%s filtered=%s last_seconds=%s", len(rows), len(filtered), last_seconds)
return {
"samples": filtered,
"total_samples": len(rows),
@@ -52,6 +62,7 @@ def get_disk_space(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str,
"""Return disk space for the configured media root."""
settings = get_settings()
path = settings.media_root or "/"
logger.info("Monitoring disk requested path=%s", path)
return disk_space(ssh, path)
@@ -59,6 +70,7 @@ def get_disk_space(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str,
def post_start(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
"""Start the remote resource collector."""
message = start_resource_collector(ssh)
logger.info("Monitoring collector start result: %s", message)
return {"message": message}
@@ -66,6 +78,7 @@ def post_start(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]
def post_stop(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
"""Stop the remote resource collector."""
message = stop_resource_collector(ssh)
logger.info("Monitoring collector stop result: %s", message)
return {"message": message}
@@ -73,10 +86,13 @@ def post_stop(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
def post_restart(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
"""Restart the remote resource collector."""
message = restart_resource_collector(ssh)
logger.info("Monitoring collector restart result: %s", message)
return {"message": message}
@router.get("/diagnostics")
def get_diagnostics(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
"""Return collector debug info for troubleshooting."""
return {"diagnostics": resource_collector_debug_info(ssh)}
diagnostics = resource_collector_debug_info(ssh)
logger.info("Monitoring diagnostics requested")
return {"diagnostics": diagnostics}
@@ -0,0 +1,394 @@
"""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, test_smtp_connection, 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/test-smtp")
def test_user_message_smtp() -> dict[str, Any]:
"""Test the configured SMTP connection without sending an email."""
settings = get_settings()
return test_smtp_connection(settings)
@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,
}