Phase 2: Docker and OIDC auth
This commit is contained in:
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user