Rebase services-as-hub-ia onto mobile-responsive-parity

Combine both branches into a single coherent branch:
- Full mobile responsive parity (useIsMobile, MobileCardRow, SheetForm,
  .mobile-touch-target, mobile cards, SheetForm forms, 44px targets,
  dirty-state confirm, TablePagination, refetchIntervalInBackground).
- Full services-as-hub IA (data-driven nav, service-page tab skeleton,
  new service types, Authentik directory + messaging, named dashboards,
  legacy routes 404, Observability split, Jellyseerr absorbed).

Enhancement: service tabs now use mobile-parity primitives:
- MediaTab: MobileCardRow below md (title/size/HDR/library/year) +
  TablePagination; DataTable at md+ (desktop branch preserved).
- FilesTab: MobileCardRow below md (name/type/size/modified) +
  handleRowClick; DataTable at md+.
- ServicePage: SheetForm branch below md (open-on-mount, sticky header
  + save bar, cancel navigates back to /services, dirty-state guard).
- Dashboard: single-column + section anchors below md (from mobile-parity)
  + empty-state CTA (from services-hub).
- App.tsx: useIsMobile() replaces inline matchMedia (from mobile-parity)
  + data-driven useNavItems (from services-hub).
- Backup tables (BackupAlerts/Jobs/Runs) already have MobileCardRow from
  mobile-parity; JobsTab inherits mobile behavior through its sub-components.

Conflict resolutions:
- Backend: entirely from services-hub (mobile didn't touch it).
- Deleted pages (Media/FileBrowser/Actions/Users/UsersPage/Applications/
  ObservabilityPage/BackupsPage + hooks/useUsers + tests): kept deleted
  (services-hub deleted them; content moved into service tabs).
- New service-tabs/*: from services-hub, enhanced with mobile patterns.
- App.tsx: services-hub's data-driven nav + mobile-parity's useIsMobile.
- Dashboard.tsx: merged (services-hub CTA + mobile-parity sections/anchors).
- ServicePage.tsx: services-hub's tab skeleton + mobile-parity's SheetForm.
- Primitives (useIsMobile/mobile-card/sheet-form/etc.): from mobile-parity.

117 frontend tests pass (mobile-parity's 122 - 5 deleted page tests +
services-hub's new tab/dashboard tests); 271 backend tests pass; lint/
build green both sides.
This commit is contained in:
Developer
2026-06-26 20:55:00 +00:00
parent b583d5a365
commit 01527ae4f0
75 changed files with 5343 additions and 4926 deletions
@@ -0,0 +1,115 @@
"""Authentik directory API client.
Authentik is the user-directory source (replacing the Jellyfin-backed Users
page). This client wraps the Authentik REST API for browsing the user directory
with pagination and search. OIDC authentication is unchanged — this client is
for the directory, not SSO.
"""
from __future__ import annotations
import logging
from typing import Any
import requests
logger = logging.getLogger(__name__)
class AuthentikClient:
"""Small wrapper around the Authentik core directory API."""
def __init__(self, base_url: str, api_token: str, timeout: float = 10.0):
if not base_url:
raise ValueError("Authentik base_url is required")
if not api_token:
raise ValueError("Authentik API token is required")
self.base_url = base_url.rstrip("/")
if self.base_url.endswith("/api/v3"):
self.base_url = self.base_url[:-7]
self.api_token = api_token
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update(
{
"Authorization": f"Bearer {api_token}",
"Accept": "application/json",
}
)
def get(self, path: str, **params: Any) -> Any:
"""GET an Authentik endpoint and include useful response text on errors."""
clean_params = {k: v for k, v in params.items() if v is not None and v != ""}
logger.debug("Authentik GET %s params=%s", path, sorted(clean_params.keys()))
response = self.session.get(
f"{self.base_url}/api/v3{path}",
params=clean_params,
timeout=self.timeout,
)
try:
response.raise_for_status()
except requests.HTTPError as exc:
detail = response.text[:500]
logger.warning(
"Authentik GET %s failed status=%s url=%s",
path,
response.status_code,
response.url,
)
raise requests.HTTPError(
f"{response.status_code} for {response.url}: {detail}",
response=response,
) from exc
logger.debug("Authentik GET %s ok status=%s", path, response.status_code)
return response.json()
def users(
self,
search: str | None = None,
page: int = 1,
page_size: int = 50,
) -> dict[str, Any]:
"""Return a normalized page of Authentik users.
Calls ``GET /api/v3/core/users/`` and normalizes the paginated
Authentik response into ``{items, total, page, page_size}``. Each item
is the raw Authentik user dict (pk, username, name, email, avatar, …)
so the frontend can pick the fields it needs.
"""
payload = self.get(
"/core/users/",
search=search,
page=page,
page_size=page_size,
)
if not isinstance(payload, dict):
logger.warning("Authentik users payload was not a dict: %s", type(payload).__name__)
return {"items": [], "total": 0, "page": page, "page_size": page_size}
results = payload.get("results")
items: list[dict[str, Any]] = (
[item for item in results if isinstance(item, dict)] if isinstance(results, list) else []
)
pagination = payload.get("pagination") or {}
total = 0
if isinstance(pagination, dict):
try:
total = int(pagination.get("count") or 0)
except (TypeError, ValueError):
total = 0
logger.info(
"Authentik users page=%s page_size=%s -> %s items (total=%s)",
page,
page_size,
len(items),
total,
)
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
}
@@ -18,7 +18,6 @@ from typing import Any
from fastapi import HTTPException, Request
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
from media_library_viewer_api.clients.local import LocalCommandClient
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.config import get_settings
@@ -178,22 +177,6 @@ def get_jellyfin_client(request: Request = None) -> JellyfinClient:
return _jellyfin_client_for(cache_key)
def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None:
"""Return a cached Jellyseerr client when configured, otherwise None."""
store = get_settings_store()
service_id = _request_jellyfin_service_id(request)
service = _service_record(store, "jellyseerr", service_id)
if service is None:
logger.info("Jellyseerr client not configured (no jellyseerr service)")
return None
base_url = str(service.get("config", {}).get("base_url") or "")
api_key = str(service.get("secrets", {}).get("api_key") or "")
if not base_url or not api_key:
logger.info("Jellyseerr service is missing base_url or api_key")
return None
return JellyseerrClient(base_url, api_key)
def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient:
"""Build a RemoteSSHClient from a machine config dict."""
store = store or get_settings_store()
@@ -0,0 +1,35 @@
"""Authentik service definition.
Authentik is the user-directory source (replacing the Jellyfin-backed Users
page). Its directory API is queried via :class:`AuthentikClient` and surfaced
on the Authentik service page (Users + Messaging tabs). OIDC authentication
is unchanged -- this service type is for the directory, not SSO.
"""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
)
class AuthentikConfig(ServiceConfigBase):
"""Non-secret Authentik connection config."""
base_url: ServiceBaseUrl
timeout_seconds: int = 10
DEFINITION = ServiceDefinition(
service_type="authentik",
name="Authentik",
description="User directory and identity provider integration.",
config_model=AuthentikConfig,
secret_fields=[
SecretField(key="api_token", label="API token", required=True),
],
widget_kinds=[],
)
@@ -0,0 +1,48 @@
"""Backups service definition.
Backups is modeled as a service type so it can be configured, named, and
multi-instanced like other services. Reports arrive via the existing REST
report endpoint; the ``ingestion_label`` disambiguates multi-instance
ingestion.
"""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
ServiceConfigBase,
ServiceDefinition,
WidgetConfigBase,
widget_kind,
)
class BackupsConfig(ServiceConfigBase):
"""Non-secret Backups connection config."""
ingestion_label: str = "default"
class BackupsSummaryWidgetConfig(WidgetConfigBase):
"""Backup dashboard summary (jobs, runs, alerts)."""
# No user-overridable fields; the widget reads the internal backup tables.
pass
DEFINITION = ServiceDefinition(
service_type="backups",
name="Backups",
description="Backup job monitoring, run history, and alerting.",
config_model=BackupsConfig,
secret_fields=[],
widget_kinds=[
widget_kind(
kind="summary",
name="Summary",
description="Backup job summary and active alerts.",
model_cls=BackupsSummaryWidgetConfig,
default_config={},
refresh_interval_ms=60_000,
),
],
)
@@ -13,11 +13,20 @@ from media_library_viewer_api.integrations.base import (
class JellyfinConfig(ServiceConfigBase):
"""Non-secret Jellyfin connection config."""
"""Non-secret Jellyfin connection config.
The optional ``jellyseerr_url`` / ``jellyseerr_api_key`` fields carry the
paired Jellyseerr companion config, absorbed from the former standalone
``jellyseerr`` service type (see OpenSpec change ``services-as-hub-ia``).
When both are set, the Jellyfin service page renders a Requests tab backed
by Jellyseerr.
"""
base_url: ServiceBaseUrl
user_id: str = ""
timeout_seconds: int = 10
jellyseerr_url: str = ""
jellyseerr_api_key: str = ""
class JellyfinActivityWidgetConfig(WidgetConfigBase):
@@ -1,33 +0,0 @@
"""Jellyseerr service definition.
Jellyseerr is a companion to Jellyfin (request management). It is modeled as its
own service type so multiple Jellyseerr instances are supported independently of
Jellyfin. It provides no dashboard widgets today.
"""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
)
class JellyseerrConfig(ServiceConfigBase):
"""Non-secret Jellyseerr connection config."""
base_url: ServiceBaseUrl
DEFINITION = ServiceDefinition(
service_type="jellyseerr",
name="Jellyseerr",
description="Request management companion to Jellyfin.",
config_model=JellyseerrConfig,
secret_fields=[
SecretField(key="api_key", label="API key", required=True),
],
widget_kinds=[],
)
@@ -7,10 +7,11 @@ There is no runtime plugin loading.
from __future__ import annotations
from media_library_viewer_api.integrations.alertmanager import DEFINITION as ALERTMANAGER
from media_library_viewer_api.integrations.authentik import DEFINITION as AUTHENTIK
from media_library_viewer_api.integrations.backups import DEFINITION as BACKUPS
from media_library_viewer_api.integrations.base import ServiceDefinition, WidgetKind
from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA
from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN
from media_library_viewer_api.integrations.jellyseerr import DEFINITION as JELLYSEERR
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
@@ -20,9 +21,10 @@ SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
PROMETHEUS.service_type: PROMETHEUS,
ALERTMANAGER.service_type: ALERTMANAGER,
JELLYFIN.service_type: JELLYFIN,
JELLYSEERR.service_type: JELLYSEERR,
NEXTCLOUD.service_type: NEXTCLOUD,
SSH_TASKS.service_type: SSH_TASKS,
BACKUPS.service_type: BACKUPS,
AUTHENTIK.service_type: AUTHENTIK,
}
+7 -2
View File
@@ -21,8 +21,12 @@ from media_library_viewer_api.observability import (
record_request,
set_current_request_id,
)
from media_library_viewer_api.routers import (
authentik_users as authentik_users_router,
)
from media_library_viewer_api.routers import backups as backups_router
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks, users
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks
from media_library_viewer_api.routers import dashboards as dashboards_router
from media_library_viewer_api.routers import services as services_router
from media_library_viewer_api.routers import widgets as widgets_router
from media_library_viewer_api.routers.settings import router as settings_router
@@ -136,12 +140,13 @@ app.include_router(monitoring.router)
app.include_router(media.router)
app.include_router(files.router)
app.include_router(jobs.router)
app.include_router(users.router)
app.include_router(tasks.router)
app.include_router(settings_router)
app.include_router(backups_router.router)
app.include_router(widgets_router.router)
app.include_router(dashboards_router.router)
app.include_router(services_router.router)
app.include_router(authentik_users_router.router)
@app.get("/api/health")
@@ -0,0 +1,29 @@
"""Pydantic models for the named-dashboards API."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class NamedDashboardInput(BaseModel):
"""Input for create/update of a named dashboard."""
id: str | None = None
label: str = Field(default="Dashboard")
slug: str | None = None
sort_order: int = 0
payload: dict[str, Any] = Field(default_factory=dict)
class NamedDashboard(BaseModel):
"""A named dashboard record."""
id: str
label: str
slug: str
sort_order: int
payload: dict[str, Any]
created_at: int
updated_at: int
@@ -0,0 +1,144 @@
"""Authentik directory + messaging router.
Resolves an ``authentik`` service instance from the registry, builds an
:class:`AuthentikClient` from its config + decrypted ``api_token`` secret, and
proxies paginated directory queries plus message-compose (email enqueue).
Graceful "not configured" / "unreachable" payloads (matching the monitoring
router's pattern) so the UI always renders.
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from media_library_viewer_api.clients.authentik import AuthentikClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import get_mail_queue, get_settings_store
from media_library_viewer_api.services.mail_queue import MailQueue
from media_library_viewer_api.services.mailer import validate_smtp_settings
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/services/authentik", tags=["authentik"])
class MessageRequest(BaseModel):
"""Compose-request body for the Authentik messaging endpoint."""
recipient_emails: list[str]
subject: str
html_body: str
def _resolve_service_record(
store: SettingsStore,
service_id: str | None = None,
) -> ServiceRecord | None:
"""Return the requested authentik instance, else the first enabled one.
Returns ``None`` when the instance does not exist / is the wrong type, or
when no enabled ``authentik`` instance is configured.
"""
service_type = "authentik"
if service_id:
row = store.get_service(service_id)
if not row or row.get("service_type") != service_type:
return None
if not row.get("enabled", True):
return None
return build_service_record(store, row)
for row in store.list_services(service_type):
if row.get("enabled", True):
return build_service_record(store, row)
return None
def _build_client(service: ServiceRecord) -> AuthentikClient:
base_url = str(service.config.get("base_url") or "").rstrip("/")
api_token = str(service.secrets.get("api_token") or "")
try:
timeout = float(service.config.get("timeout_seconds") or 10)
except (TypeError, ValueError):
timeout = 10.0
return AuthentikClient(base_url=base_url, api_token=api_token, timeout=timeout)
def _empty(error: str) -> dict[str, Any]:
return {"items": [], "total": 0, "page": 1, "page_size": 50, "error": error}
@router.get("/{service_id}/users")
def get_authentik_users(
service_id: str,
search: str | None = None,
page: int = 1,
page_size: int = 50,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Paginated Authentik user directory for a specific service instance."""
service = _resolve_service_record(store, service_id)
if service is None:
logger.info("Authentik users requested but no enabled authentik service for id=%s", service_id)
return _empty("Authentik service not configured")
try:
client = _build_client(service)
return client.users(search=search, page=page, page_size=page_size)
except Exception:
logger.exception("Authentik users query failed for service %s", service_id)
return _empty("Authentik is unreachable")
@router.get("/{service_id}/message/status")
def get_authentik_message_status(
service_id: str,
store: SettingsStore = Depends(get_settings_store),
mail_queue: MailQueue = Depends(get_mail_queue),
) -> dict[str, Any]:
"""Mail-queue status snapshot for the Authentik messaging tab."""
service = _resolve_service_record(store, service_id)
if service is None:
return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"}
return mail_queue.status()
@router.post("/{service_id}/message")
def post_authentik_message(
service_id: str,
body: MessageRequest,
store: SettingsStore = Depends(get_settings_store),
mail_queue: MailQueue = Depends(get_mail_queue),
) -> dict[str, Any]:
"""Enqueue an email to Authentik-sourced recipients via the mail queue."""
service = _resolve_service_record(store, service_id)
if service is None:
return {"status": "error", "error": "Authentik service not configured"}
recipients = [r.strip() for r in body.recipient_emails if r.strip()]
if not recipients:
return {"status": "error", "error": "No recipients with valid email addresses."}
settings = get_settings()
try:
validate_smtp_settings(settings)
except ValueError as exc:
return {"status": "error", "error": f"SMTP settings invalid: {exc}"}
request_id = mail_queue.enqueue(
settings=settings,
recipients=recipients,
subject=body.subject,
html_body=body.html_body,
)
logger.info("Authentik message enqueued for service %s (%d recipients)", service_id, len(recipients))
return {
"status": "queued",
"request_id": request_id,
"recipient_count": len(recipients),
}
@@ -15,7 +15,23 @@ from ..services.settings_store import SettingsStore, get_settings_store
router = APIRouter(prefix="/api/backups", tags=["backups"])
def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dict[str, Any]:
def _resolve_backup_service_id(store: SettingsStore, explicit: str | None = None) -> str:
"""Return the service_id for backup attribution.
First-wins: if no explicit service_id is given, pick the first enabled
``backups`` service instance (spec R6.1). Returns an empty string when
none is configured (backward-compatible with pre-service reports).
"""
if explicit:
return explicit
candidates = store.list_services("backups")
for svc in candidates:
if svc.get("enabled"):
return svc["id"]
return ""
def _get_or_create_job(store: SettingsStore, report: BackupReportRequest, service_id: str = "") -> dict[str, Any]:
job = store.get_backup_job_by_name(report.name)
if not job:
job = store.upsert_backup_job(
@@ -24,6 +40,7 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
"source": report.source,
"target": report.target,
"schedule_interval_seconds": report.schedule_interval_seconds,
"service_id": service_id,
}
)
elif report.schedule_interval_seconds:
@@ -34,6 +51,7 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
"source": report.source,
"target": report.target,
"schedule_interval_seconds": report.schedule_interval_seconds,
"service_id": service_id,
}
)
job = store.get_backup_job(job["id"])
@@ -43,10 +61,12 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
@router.post("/report")
def post_backup_report(
report: BackupReportRequest,
service_id: str | None = None,
store: SettingsStore = Depends(get_settings_store),
_auth: str = Depends(require_api_key),
) -> BackupRunResponse:
job = _get_or_create_job(store, report)
resolved_service_id = _resolve_backup_service_id(store, service_id)
job = _get_or_create_job(store, report, resolved_service_id)
# Check for duplicate (same job + started_at within 1s)
existing_runs = store.list_backup_runs(job_id=job["id"], limit=5)
@@ -88,10 +108,12 @@ def post_backup_report(
@router.post("/report/start")
def post_backup_start(
report: BackupReportRequest,
service_id: str | None = None,
store: SettingsStore = Depends(get_settings_store),
_auth: str = Depends(require_api_key),
) -> BackupRunResponse:
job = _get_or_create_job(store, report)
resolved_service_id = _resolve_backup_service_id(store, service_id)
job = _get_or_create_job(store, report, resolved_service_id)
run_data = {
"job_id": job["id"],
@@ -0,0 +1,53 @@
"""Named dashboards CRUD router."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.models.dashboards import NamedDashboard, NamedDashboardInput
from media_library_viewer_api.services.settings_store import SettingsStore
router = APIRouter(prefix="/api/dashboards", tags=["dashboards"])
@router.get("")
def list_dashboards(store: SettingsStore = Depends(get_settings_store)) -> list[NamedDashboard]:
rows = store.list_dashboards()
return [NamedDashboard(**row) for row in rows]
@router.get("/slug/{slug}")
def get_dashboard_by_slug(slug: str, store: SettingsStore = Depends(get_settings_store)) -> NamedDashboard:
row = store.get_dashboard_by_slug(slug)
if not row:
raise HTTPException(status_code=404, detail="Dashboard not found")
return NamedDashboard(**row)
@router.post("")
def create_dashboard(body: NamedDashboardInput, store: SettingsStore = Depends(get_settings_store)) -> NamedDashboard:
row = store.upsert_dashboard(body.model_dump())
return NamedDashboard(**row)
@router.put("/{dashboard_id}")
def update_dashboard(
dashboard_id: str,
body: NamedDashboardInput,
store: SettingsStore = Depends(get_settings_store),
) -> NamedDashboard:
if not store.get_dashboard(dashboard_id):
raise HTTPException(status_code=404, detail="Dashboard not found")
if body.id and body.id != dashboard_id:
raise HTTPException(status_code=400, detail="ID mismatch")
row = store.upsert_dashboard(body.model_dump(), dashboard_id)
return NamedDashboard(**row)
@router.delete("/{dashboard_id}")
def delete_dashboard(dashboard_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]:
if not store.get_dashboard(dashboard_id):
raise HTTPException(status_code=404, detail="Dashboard not found")
store.delete_dashboard(dashboard_id)
return {"status": "deleted"}
@@ -1 +0,0 @@
from .users_impl import * # noqa: F401,F403
@@ -1,389 +0,0 @@
"""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,
}
@@ -8,6 +8,7 @@ in the same UI.
from __future__ import annotations
import json
import logging
import sqlite3
import time
import uuid
@@ -19,6 +20,8 @@ import paramiko
from media_library_viewer_api.models.widgets import _validate_config_keys
logger = logging.getLogger(__name__)
DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
LOCAL_MACHINE_ID = "local"
DEFAULT_SERVICES = ["monitoring", "files"]
@@ -172,6 +175,9 @@ class SettingsStore:
created_at INTEGER NOT NULL
)
""")
backup_job_cols = {col[1] for col in conn.execute("PRAGMA table_info(backup_jobs)").fetchall()}
if "service_id" not in backup_job_cols:
conn.execute("ALTER TABLE backup_jobs ADD COLUMN service_id TEXT")
conn.execute("""
CREATE TABLE IF NOT EXISTS backup_runs (
id TEXT PRIMARY KEY,
@@ -244,6 +250,19 @@ class SettingsStore:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_service_task_runs_task ON service_task_runs(task_id, created_at DESC)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS named_dashboards (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
sort_order INTEGER NOT NULL DEFAULT 0,
payload_json TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
@staticmethod
def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]:
@@ -415,6 +434,75 @@ class SettingsStore:
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
if not row or int(row[0]) == 0:
self._seed_local_machine()
self._migrate_jellyseerr_into_jellyfin()
def _migrate_jellyseerr_into_jellyfin(self) -> None:
"""Absorb standalone ``jellyseerr`` services into their paired Jellyfin.
Idempotent: once no ``jellyseerr`` rows remain the method is a no-op.
Pairing policy: exactly-one Jellyfin merges; multiple picks the first
Jellyfin whose ``jellyseerr_url`` is still empty; no Jellyfin or all
paired -> drop with a logged warning.
"""
from media_library_viewer_api.services.secrets import decrypt_value
self.init_schema()
jellyseerr_rows: list[sqlite3.Row] = []
with self.connect() as conn:
jellyseerr_rows = conn.execute(
"SELECT * FROM services WHERE service_type = 'jellyseerr' ORDER BY name ASC"
).fetchall()
if not jellyseerr_rows:
return
jellyfin_rows = self.list_services("jellyfin")
for js_row in jellyseerr_rows:
js_config = json.loads(js_row["config_json"] or "{}")
js_secrets = json.loads(js_row["secrets_json"] or "{}")
js_url = str(js_config.get("base_url", "")).strip()
js_api_key = str(js_secrets.get("api_key", "")).strip()
# Decrypt the api_key (secrets are stored encrypted; config is plaintext).
if js_api_key:
try:
js_api_key = decrypt_value(js_api_key)
except Exception:
logger.warning("could not decrypt jellyseerr api_key for %r", js_row["name"])
js_api_key = ""
js_name = js_row["name"]
target = None
if len(jellyfin_rows) == 1:
target = jellyfin_rows[0]
elif len(jellyfin_rows) > 1:
for jf in jellyfin_rows:
if not str(jf["config"].get("jellyseerr_url", "")).strip():
target = jf
break
if target:
merged_config = dict(target["config"])
merged_config["jellyseerr_url"] = js_url
merged_config["jellyseerr_api_key"] = js_api_key
self.upsert_service(
{
"id": target["id"],
"service_type": "jellyfin",
"name": target["name"],
"config": merged_config,
"enabled": target["enabled"],
},
secret_values={"api_key": str(target["secrets"].get("api_key", ""))},
)
logger.info("migrated jellyseerr service %r into jellyfin service %r", js_name, target["name"])
else:
logger.warning(
"dropped unpaired jellyseerr service %r; reconfigure manually on the Jellyfin instance",
js_name,
)
with self.connect() as conn:
conn.execute("DELETE FROM services WHERE id = ?", (js_row["id"],))
conn.commit()
def list_machines(self) -> list[dict[str, Any]]:
self.init_schema()
@@ -892,6 +980,7 @@ class SettingsStore:
"source": row["source"],
"target": row["target"],
"schedule_interval_seconds": row["schedule_interval_seconds"],
"service_id": row["service_id"],
"created_at": row["created_at"],
}
@@ -910,12 +999,18 @@ class SettingsStore:
schedule_interval_seconds = (current or {}).get("schedule_interval_seconds")
if schedule_interval_seconds is not None:
schedule_interval_seconds = int(schedule_interval_seconds)
service_id = str(
payload.get("service_id")
if payload.get("service_id") is not None
else (current or {}).get("service_id", "") or ""
).strip()
return {
"id": job_id,
"name": name,
"source": source,
"target": target,
"schedule_interval_seconds": schedule_interval_seconds,
"service_id": service_id,
}
def get_backup_job_by_name(self, name: str) -> dict[str, Any] | None:
@@ -933,17 +1028,19 @@ class SettingsStore:
created_at = int(existing[0]) if existing else now
conn.execute(
"""
INSERT INTO backup_jobs (id, name, source, target, schedule_interval_seconds, created_at)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO backup_jobs (id, name, source, target, schedule_interval_seconds, service_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
source = excluded.source,
target = excluded.target,
schedule_interval_seconds = excluded.schedule_interval_seconds
schedule_interval_seconds = excluded.schedule_interval_seconds,
service_id = excluded.service_id
ON CONFLICT(name) DO UPDATE SET
source = excluded.source,
target = excluded.target,
schedule_interval_seconds = excluded.schedule_interval_seconds
schedule_interval_seconds = excluded.schedule_interval_seconds,
service_id = excluded.service_id
""",
(
job["id"],
@@ -951,6 +1048,7 @@ class SettingsStore:
job["source"],
job["target"],
job["schedule_interval_seconds"],
job["service_id"],
created_at,
),
)
@@ -1566,6 +1664,113 @@ class SettingsStore:
for row in rows
]
# ------------------------------------------------------------------
# Named dashboards
# ------------------------------------------------------------------
@staticmethod
def _slugify(label: str) -> str:
import re
slug = re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-")
return slug or "dashboard"
def _unique_slug(self, slug: str, exclude_id: str | None = None) -> str:
self.init_schema()
base = slug
suffix = 1
with self.connect() as conn:
while True:
row = conn.execute(
"SELECT id FROM named_dashboards WHERE slug = ? AND id != ?",
(slug, exclude_id or ""),
).fetchone()
if not row:
return slug
suffix += 1
slug = f"{base}-{suffix}"
def _row_to_dashboard(self, row: sqlite3.Row) -> dict[str, Any]:
return {
"id": row["id"],
"label": row["label"],
"slug": row["slug"],
"sort_order": row["sort_order"],
"payload": json.loads(row["payload_json"] or "{}"),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def list_dashboards(self) -> list[dict[str, Any]]:
self.init_schema()
with self.connect() as conn:
rows = conn.execute(
"SELECT * FROM named_dashboards ORDER BY sort_order ASC, label COLLATE NOCASE"
).fetchall()
return [self._row_to_dashboard(row) for row in rows]
def get_dashboard(self, dashboard_id: str | None) -> dict[str, Any] | None:
if not dashboard_id:
return None
self.init_schema()
with self.connect() as conn:
row = conn.execute("SELECT * FROM named_dashboards WHERE id = ?", (dashboard_id,)).fetchone()
return self._row_to_dashboard(row) if row else None
def get_dashboard_by_slug(self, slug: str | None) -> dict[str, Any] | None:
if not slug:
return None
self.init_schema()
with self.connect() as conn:
row = conn.execute("SELECT * FROM named_dashboards WHERE slug = ?", (slug,)).fetchone()
return self._row_to_dashboard(row) if row else None
def upsert_dashboard(self, payload: dict[str, Any], dashboard_id: str | None = None) -> dict[str, Any]:
self.init_schema()
current = self.get_dashboard(dashboard_id) if dashboard_id else None
dash_id = str(payload.get("id") or dashboard_id or uuid.uuid4().hex[:12]).strip()
label = str(payload.get("label") or (current or {}).get("label") or "Dashboard").strip()
slug = str(payload.get("slug") or "").strip() or self._slugify(label)
slug = self._unique_slug(slug, exclude_id=dash_id)
sort_order = payload.get("sort_order")
if sort_order is None:
sort_order = (current or {}).get("sort_order", 0)
sort_order = int(sort_order)
payload_data = payload.get("payload")
if payload_data is None:
payload_data = (current or {}).get("payload", {})
now = int(time.time())
with self.connect() as conn:
existing = conn.execute("SELECT created_at FROM named_dashboards WHERE id = ?", (dash_id,)).fetchone()
created_at = int(existing[0]) if existing else now
conn.execute(
"""
INSERT INTO named_dashboards (id, label, slug, sort_order, payload_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
label = excluded.label,
slug = excluded.slug,
sort_order = excluded.sort_order,
payload_json = excluded.payload_json,
updated_at = excluded.updated_at
""",
(
dash_id,
label,
slug,
sort_order,
json.dumps(payload_data),
created_at,
now,
),
)
return self.get_dashboard(dash_id) or {"id": dash_id, "label": label, "slug": slug}
def delete_dashboard(self, dashboard_id: str) -> None:
self.init_schema()
with self.connect() as conn:
conn.execute("DELETE FROM named_dashboards WHERE id = ?", (dashboard_id,))
_store: SettingsStore | None = None