2190 lines
86 KiB
TypeScript
2190 lines
86 KiB
TypeScript
"use client";
|
||
|
||
import { useRouter } from "next/navigation";
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from "../lib/auth";
|
||
import SettingField from "./SettingField";
|
||
import SettingsRegion from "./SettingsRegion";
|
||
import { serviceStatusLabel } from "./configNavigation";
|
||
import AdminShell from "../ui/AdminShell";
|
||
|
||
type AdminSetting = {
|
||
key: string;
|
||
value: string | null;
|
||
isSet: boolean;
|
||
source: string;
|
||
sensitive: boolean;
|
||
};
|
||
|
||
type ServiceOptions = {
|
||
rootFolders: { id: number; path: string; label: string }[];
|
||
qualityProfiles: { id: number; name: string; label: string }[];
|
||
};
|
||
|
||
type ServiceStatus = {
|
||
name: string;
|
||
status: string;
|
||
message?: string;
|
||
};
|
||
|
||
type CacheRow = {
|
||
request_id: number;
|
||
title?: string | null;
|
||
media_type?: string | null;
|
||
status?: string | number | null;
|
||
updated_at?: string | null;
|
||
created_at?: string | null;
|
||
};
|
||
|
||
type ProgressState = {
|
||
status: string;
|
||
stored?: number;
|
||
processed?: number;
|
||
total?: number;
|
||
skip?: number;
|
||
only_missing?: boolean;
|
||
message?: string;
|
||
};
|
||
|
||
type ArtworkSummary = {
|
||
missing_artwork?: number;
|
||
cache_bytes?: number;
|
||
cache_files?: number;
|
||
total_requests?: number;
|
||
cache_mode?: string;
|
||
};
|
||
|
||
type ServiceTestResult = {
|
||
name?: string;
|
||
status?: string;
|
||
message?: string;
|
||
};
|
||
|
||
const SECTION_LABELS: Record<string, string> = {
|
||
magent: "Magent",
|
||
general: "Hosting & proxy",
|
||
notifications: "Email & notifications",
|
||
seerr: "Seerr",
|
||
jellyseerr: "Seerr",
|
||
jellyfin: "Jellyfin",
|
||
jellystat: "Jellystat",
|
||
artwork: "Artwork cache",
|
||
cache: "Request cache",
|
||
sonarr: "Sonarr",
|
||
radarr: "Radarr",
|
||
bazarr: "Bazarr",
|
||
prowlarr: "Prowlarr",
|
||
qbittorrent: "qBittorrent",
|
||
logs: "Activity log",
|
||
maintenance: "Recovery & cleanup",
|
||
requests: "Request updates",
|
||
"issue-workflow": "Issue follow-up",
|
||
site: "Site & sign-in",
|
||
};
|
||
|
||
const BOOL_SETTINGS = new Set([
|
||
"jellyfin_sync_to_arr",
|
||
"site_banner_enabled",
|
||
"site_login_show_jellyfin_login",
|
||
"site_login_show_local_login",
|
||
"site_login_show_forgot_password",
|
||
"site_login_show_signup_link",
|
||
"magent_proxy_enabled",
|
||
"magent_proxy_trust_forwarded_headers",
|
||
"magent_ssl_bind_enabled",
|
||
"magent_notify_enabled",
|
||
"magent_notify_email_enabled",
|
||
"magent_notify_email_use_tls",
|
||
"magent_notify_email_use_ssl",
|
||
"magent_notify_discord_enabled",
|
||
"magent_notify_telegram_enabled",
|
||
"magent_notify_push_enabled",
|
||
"magent_notify_webhook_enabled",
|
||
]);
|
||
const TEXTAREA_SETTINGS = new Set([
|
||
"site_banner_message",
|
||
"site_login_message",
|
||
"site_changelog",
|
||
"magent_ssl_certificate_pem",
|
||
"magent_ssl_private_key_pem",
|
||
]);
|
||
const NUMBER_SETTINGS = new Set([
|
||
"magent_application_port",
|
||
"magent_api_port",
|
||
"magent_notify_email_smtp_port",
|
||
"log_file_max_bytes",
|
||
"log_file_backup_count",
|
||
"requests_sync_ttl_minutes",
|
||
"requests_poll_interval_seconds",
|
||
"requests_stage_refresh_minutes",
|
||
"requests_delta_sync_interval_minutes",
|
||
"requests_cleanup_days",
|
||
"issue_confirmation_contact_attempts",
|
||
"issue_confirmation_interval_value",
|
||
]);
|
||
|
||
const SECTION_DESCRIPTIONS: Record<string, string> = {
|
||
magent: "Magent service settings. Runtime and notification controls are organized under General and Notifications.",
|
||
general: "Public addresses and advanced deployment settings.",
|
||
notifications: "Choose how Magent sends invitations, password resets and issue updates.",
|
||
seerr: "Connect Seerr where users submit content requests.",
|
||
jellyseerr: "Connect Seerr where users submit content requests.",
|
||
jellyfin: "Jellyfin connection, public playback links, user sync, and availability checks.",
|
||
jellystat: "Connect Jellystat so users can see their personal viewing stats in Magent.",
|
||
artwork: "Cache posters/backdrops and review artwork coverage.",
|
||
cache: "Manage saved requests cache and refresh behavior.",
|
||
sonarr: "Sonarr connection and the default profile and library location for TV requests.",
|
||
radarr: "Radarr connection and the default profile and library location for movie requests.",
|
||
bazarr: "Bazarr connection used to find and replace movie and episode subtitles.",
|
||
prowlarr: "Prowlarr connection used by Sonarr and Radarr for release searches.",
|
||
qbittorrent: "qBittorrent connection used for collector-owned download progress and diagnostics.",
|
||
requests: "Control how often requests are refreshed and cleaned up.",
|
||
"issue-workflow": "Control reporter confirmation, reminder timing, and automatic issue closure.",
|
||
logs: "Control log output and inspect recent application activity for troubleshooting.",
|
||
maintenance: "Repair cached data, clean historical records, and run recovery operations.",
|
||
site: "Announcements and sign-in options for your users.",
|
||
};
|
||
|
||
const SETTINGS_SECTION_MAP: Record<string, string | null> = {
|
||
magent: "magent",
|
||
general: "magent",
|
||
notifications: "magent",
|
||
seerr: "jellyseerr",
|
||
jellyseerr: "jellyseerr",
|
||
jellyfin: "jellyfin",
|
||
jellystat: "jellystat",
|
||
artwork: null,
|
||
sonarr: "sonarr",
|
||
radarr: "radarr",
|
||
bazarr: "bazarr",
|
||
prowlarr: "prowlarr",
|
||
qbittorrent: "qbittorrent",
|
||
requests: "requests",
|
||
"issue-workflow": "issue",
|
||
cache: null,
|
||
logs: "log",
|
||
maintenance: null,
|
||
site: "site",
|
||
};
|
||
|
||
const MAGENT_SECTION_GROUPS: Array<{
|
||
key: string;
|
||
title: string;
|
||
description: string;
|
||
keys: string[];
|
||
}> = [
|
||
{
|
||
key: "magent-runtime",
|
||
title: "Public addresses",
|
||
description: "Addresses used in links from Magent.",
|
||
keys: ["magent_application_url", "magent_api_url"],
|
||
},
|
||
{
|
||
key: "magent-binding",
|
||
title: "Deployment ports",
|
||
description:
|
||
"Stored preferences do not change Docker port mappings. Update the deployment and restart to change listening ports.",
|
||
keys: ["magent_application_port", "magent_api_port", "magent_bind_host"],
|
||
},
|
||
{
|
||
key: "magent-proxy",
|
||
title: "Proxy",
|
||
description: "Reverse proxy awareness and base URL handling when Magent sits behind Caddy/NGINX/Traefik.",
|
||
keys: [
|
||
"magent_proxy_enabled",
|
||
"magent_proxy_base_url",
|
||
"magent_proxy_trust_forwarded_headers",
|
||
"magent_proxy_forwarded_prefix",
|
||
],
|
||
},
|
||
{
|
||
key: "magent-ssl",
|
||
title: "Direct TLS settings",
|
||
description: "For direct hosting only. The beta deployment terminates HTTPS at its reverse proxy.",
|
||
keys: [
|
||
"magent_ssl_bind_enabled",
|
||
"magent_ssl_certificate_path",
|
||
"magent_ssl_private_key_path",
|
||
"magent_ssl_certificate_pem",
|
||
"magent_ssl_private_key_pem",
|
||
],
|
||
},
|
||
{
|
||
key: "magent-notify-core",
|
||
title: "Notifications",
|
||
description: "Global notification controls and provider-independent defaults used by Magent messaging features.",
|
||
keys: ["magent_notify_enabled"],
|
||
},
|
||
{
|
||
key: "magent-notify-email",
|
||
title: "Email",
|
||
description: "SMTP configuration for email notifications.",
|
||
keys: [
|
||
"magent_notify_email_enabled",
|
||
"magent_notify_email_smtp_host",
|
||
"magent_notify_email_smtp_port",
|
||
"magent_notify_email_smtp_username",
|
||
"magent_notify_email_smtp_password",
|
||
"magent_notify_email_from_address",
|
||
"magent_notify_email_from_name",
|
||
"magent_notify_email_use_tls",
|
||
"magent_notify_email_use_ssl",
|
||
],
|
||
},
|
||
{
|
||
key: "magent-notify-discord",
|
||
title: "Discord",
|
||
description: "Webhook settings for Discord notifications and feedback routing.",
|
||
keys: ["magent_notify_discord_enabled", "magent_notify_discord_webhook_url"],
|
||
},
|
||
{
|
||
key: "magent-notify-telegram",
|
||
title: "Telegram",
|
||
description: "Bot token and chat target for Telegram notifications.",
|
||
keys: ["magent_notify_telegram_enabled", "magent_notify_telegram_bot_token", "magent_notify_telegram_chat_id"],
|
||
},
|
||
{
|
||
key: "magent-notify-push",
|
||
title: "Push / Mobile",
|
||
description: "Generic push messaging configuration (ntfy, Gotify, Pushover, webhook-style push endpoints).",
|
||
keys: [
|
||
"magent_notify_push_enabled",
|
||
"magent_notify_push_provider",
|
||
"magent_notify_push_base_url",
|
||
"magent_notify_push_topic",
|
||
"magent_notify_push_token",
|
||
"magent_notify_push_user_key",
|
||
"magent_notify_push_device",
|
||
],
|
||
},
|
||
{
|
||
key: "magent-notify-webhook",
|
||
title: "Generic Webhook",
|
||
description: "Send notifications to a custom automation or integration endpoint.",
|
||
keys: ["magent_notify_webhook_enabled", "magent_notify_webhook_url"],
|
||
},
|
||
];
|
||
|
||
const MAGENT_GROUPS_BY_SECTION: Record<string, Set<string>> = {
|
||
general: new Set(["magent-runtime", "magent-binding", "magent-proxy", "magent-ssl"]),
|
||
notifications: new Set([
|
||
"magent-notify-core",
|
||
"magent-notify-email",
|
||
"magent-notify-discord",
|
||
"magent-notify-telegram",
|
||
"magent-notify-push",
|
||
"magent-notify-webhook",
|
||
]),
|
||
};
|
||
|
||
const SITE_SECTION_GROUPS: Array<{
|
||
key: string;
|
||
title: string;
|
||
description: string;
|
||
keys: string[];
|
||
}> = [
|
||
{
|
||
key: "site-banner",
|
||
title: "Site Banner",
|
||
description:
|
||
"Control the sitewide banner message, preset tone, surrounding background and border colours, and visibility.",
|
||
keys: [
|
||
"site_banner_enabled",
|
||
"site_banner_tone",
|
||
"site_banner_background_color",
|
||
"site_banner_border_color",
|
||
"site_banner_message",
|
||
],
|
||
},
|
||
{
|
||
key: "site-login",
|
||
title: "Login Page Behaviour",
|
||
description: "Control which sign-in and recovery options are shown on the logged-out login page.",
|
||
keys: [
|
||
"site_login_message",
|
||
"site_login_show_jellyfin_login",
|
||
"site_login_show_local_login",
|
||
"site_login_show_forgot_password",
|
||
"site_login_show_signup_link",
|
||
],
|
||
},
|
||
];
|
||
|
||
const STANDARD_SECTION_GROUPS: Record<
|
||
string,
|
||
Array<{ key: string; title: string; description: string; keys: string[] }>
|
||
> = {
|
||
seerr: [
|
||
{
|
||
key: "seerr-connection",
|
||
title: "Connection",
|
||
description: "The Seerr endpoint and API credential Magent uses for request discovery and status.",
|
||
keys: ["jellyseerr_base_url", "jellyseerr_api_key"],
|
||
},
|
||
],
|
||
jellyseerr: [
|
||
{
|
||
key: "seerr-connection",
|
||
title: "Connection",
|
||
description: "The Seerr endpoint and API credential Magent uses for request discovery and status.",
|
||
keys: ["jellyseerr_base_url", "jellyseerr_api_key"],
|
||
},
|
||
],
|
||
jellystat: [
|
||
{
|
||
key: "jellystat-connection",
|
||
title: "Connection",
|
||
description:
|
||
"Use the Jellystat instance connected to the same Jellyfin server as Magent. Create a Jellystat API key in its settings, then save and test the connection.",
|
||
keys: ["jellystat_base_url", "jellystat_api_key"],
|
||
},
|
||
],
|
||
jellyfin: [
|
||
{
|
||
key: "jellyfin-connection",
|
||
title: "Connection",
|
||
description: "Internal Jellyfin endpoint and administrator API credential used for lookups and user sync.",
|
||
keys: ["jellyfin_base_url", "jellyfin_api_key"],
|
||
},
|
||
{
|
||
key: "jellyfin-playback",
|
||
title: "Playback Links",
|
||
description: "Public address used when a viewer opens an available title from Magent.",
|
||
keys: ["jellyfin_public_url"],
|
||
},
|
||
{
|
||
key: "jellyfin-users",
|
||
title: "Library and User Sync",
|
||
description: "Control cross-service library reconciliation and manually import Jellyfin users.",
|
||
keys: ["jellyfin_sync_to_arr"],
|
||
},
|
||
],
|
||
sonarr: [
|
||
{
|
||
key: "sonarr-connection",
|
||
title: "Connection",
|
||
description: "Sonarr endpoint and API credential used for TV collection operations.",
|
||
keys: ["sonarr_base_url", "sonarr_api_key"],
|
||
},
|
||
{
|
||
key: "sonarr-library",
|
||
title: "TV Collection Defaults",
|
||
description: "Default quality profile and destination folder used for TV requests.",
|
||
keys: ["sonarr_quality_profile_id", "sonarr_root_folder"],
|
||
},
|
||
],
|
||
radarr: [
|
||
{
|
||
key: "radarr-connection",
|
||
title: "Connection",
|
||
description: "Radarr endpoint and API credential used for movie collection operations.",
|
||
keys: ["radarr_base_url", "radarr_api_key"],
|
||
},
|
||
{
|
||
key: "radarr-library",
|
||
title: "Movie Collection Defaults",
|
||
description: "Default quality profile and destination folder used for movie requests.",
|
||
keys: ["radarr_quality_profile_id", "radarr_root_folder"],
|
||
},
|
||
],
|
||
bazarr: [
|
||
{
|
||
key: "bazarr-connection",
|
||
title: "Connection",
|
||
description: "Bazarr endpoint, API credential, and default language used by subtitle issue repairs.",
|
||
keys: ["bazarr_base_url", "bazarr_api_key", "bazarr_default_language"],
|
||
},
|
||
],
|
||
prowlarr: [
|
||
{
|
||
key: "prowlarr-connection",
|
||
title: "Connection",
|
||
description: "Prowlarr endpoint and API credential used for indexer health and release discovery.",
|
||
keys: ["prowlarr_base_url", "prowlarr_api_key"],
|
||
},
|
||
],
|
||
qbittorrent: [
|
||
{
|
||
key: "qbittorrent-connection",
|
||
title: "Connection and Sign-in",
|
||
description: "qBittorrent Web UI endpoint and credentials used for live download progress and recovery.",
|
||
keys: ["qbittorrent_base_url", "qbittorrent_username", "qbittorrent_password"],
|
||
},
|
||
],
|
||
requests: [
|
||
{
|
||
key: "requests-advanced",
|
||
title: "Advanced scheduling",
|
||
description: "How frequently the background worker checks whether a full refresh is due.",
|
||
keys: ["requests_poll_interval_seconds", "requests_stage_refresh_minutes"],
|
||
},
|
||
{
|
||
key: "requests-sync",
|
||
title: "Synchronization Schedule",
|
||
description: "Control incremental checks and the scheduled full request-cache rebuild.",
|
||
keys: ["requests_delta_sync_interval_minutes", "requests_full_sync_time"],
|
||
},
|
||
{
|
||
key: "requests-retention",
|
||
title: "History Retention",
|
||
description: "Choose when old status history is cleaned up and how long it is retained.",
|
||
keys: ["requests_cleanup_time", "requests_cleanup_days"],
|
||
},
|
||
],
|
||
"issue-workflow": [
|
||
{
|
||
key: "issues-resolution-confirmation",
|
||
title: "Resolution confirmation",
|
||
description: "Choose how often Magent asks a reporter to confirm a fix before the issue is closed automatically.",
|
||
keys: [
|
||
"issue_confirmation_contact_attempts",
|
||
"issue_confirmation_interval_value",
|
||
"issue_confirmation_interval_unit",
|
||
],
|
||
},
|
||
],
|
||
logs: [
|
||
{
|
||
key: "logs-output",
|
||
title: "Log Output",
|
||
description: "Set the default application verbosity and the active log-file destination.",
|
||
keys: ["log_level", "log_format", "log_file"],
|
||
},
|
||
{
|
||
key: "logs-rotation",
|
||
title: "File Rotation",
|
||
description: "Limit log-file growth and choose how many historical files remain on disk.",
|
||
keys: ["log_file_max_bytes", "log_file_backup_count"],
|
||
},
|
||
{
|
||
key: "logs-components",
|
||
title: "Component Verbosity",
|
||
description: "Tune noisy outbound-service and scheduled-background messages independently.",
|
||
keys: ["log_http_client_level", "log_background_sync_level"],
|
||
},
|
||
],
|
||
};
|
||
|
||
const SETTING_LABEL_OVERRIDES: Record<string, string> = {
|
||
bazarr_base_url: "Bazarr base URL",
|
||
bazarr_api_key: "Bazarr API key",
|
||
bazarr_default_language: "Default subtitle language",
|
||
issue_confirmation_contact_attempts: "Confirmation emails before auto-close",
|
||
issue_confirmation_interval_value: "Confirmation interval",
|
||
issue_confirmation_interval_unit: "Interval unit",
|
||
jellyseerr_base_url: "Seerr base URL",
|
||
jellyseerr_api_key: "Seerr API key",
|
||
magent_application_url: "Application URL",
|
||
magent_application_port: "Application port",
|
||
magent_api_url: "API URL",
|
||
magent_api_port: "API port",
|
||
magent_bind_host: "Bind host",
|
||
magent_proxy_enabled: "Proxy support enabled",
|
||
magent_proxy_base_url: "Proxy base URL",
|
||
magent_proxy_trust_forwarded_headers: "Trust forwarded headers",
|
||
magent_proxy_forwarded_prefix: "Forwarded path prefix",
|
||
magent_ssl_bind_enabled: "Manual SSL bind enabled",
|
||
magent_ssl_certificate_path: "Certificate path",
|
||
magent_ssl_private_key_path: "Private key path",
|
||
magent_ssl_certificate_pem: "Certificate (PEM)",
|
||
magent_ssl_private_key_pem: "Private key (PEM)",
|
||
magent_notify_enabled: "Notifications enabled",
|
||
magent_notify_email_enabled: "Email notifications enabled",
|
||
magent_notify_email_smtp_host: "SMTP host",
|
||
magent_notify_email_smtp_port: "SMTP port",
|
||
magent_notify_email_smtp_username: "SMTP username",
|
||
magent_notify_email_smtp_password: "SMTP password",
|
||
magent_notify_email_from_address: "From email address",
|
||
magent_notify_email_from_name: "From display name",
|
||
magent_notify_email_use_tls: "Use STARTTLS",
|
||
magent_notify_email_use_ssl: "Use SSL/TLS (implicit)",
|
||
magent_notify_discord_enabled: "Discord notifications enabled",
|
||
magent_notify_discord_webhook_url: "Discord webhook URL",
|
||
magent_notify_telegram_enabled: "Telegram notifications enabled",
|
||
magent_notify_telegram_bot_token: "Telegram bot token",
|
||
magent_notify_telegram_chat_id: "Telegram chat ID",
|
||
magent_notify_push_enabled: "Push notifications enabled",
|
||
magent_notify_push_provider: "Push provider",
|
||
magent_notify_push_base_url: "Push provider/base URL",
|
||
magent_notify_push_topic: "Topic / channel",
|
||
magent_notify_push_token: "API token / password",
|
||
magent_notify_push_user_key: "User key / recipient key",
|
||
magent_notify_push_device: "Device / target",
|
||
magent_notify_webhook_enabled: "Generic webhook notifications enabled",
|
||
magent_notify_webhook_url: "Generic webhook URL",
|
||
jellyfin_base_url: "Internal server URL",
|
||
jellyfin_api_key: "Administrator API key",
|
||
jellystat_base_url: "Internal server URL",
|
||
jellystat_api_key: "Jellystat API key",
|
||
jellyfin_public_url: "Public playback URL",
|
||
jellyfin_sync_to_arr: "Reconcile Jellyfin with Sonarr and Radarr",
|
||
sonarr_base_url: "Sonarr server URL",
|
||
sonarr_api_key: "Sonarr API key",
|
||
sonarr_quality_profile_id: "Default TV quality profile",
|
||
sonarr_root_folder: "Default TV root folder",
|
||
radarr_base_url: "Radarr server URL",
|
||
radarr_api_key: "Radarr API key",
|
||
radarr_quality_profile_id: "Default movie quality profile",
|
||
radarr_root_folder: "Default movie root folder",
|
||
prowlarr_base_url: "Prowlarr server URL",
|
||
prowlarr_api_key: "Prowlarr API key",
|
||
qbittorrent_base_url: "Web UI URL",
|
||
qbittorrent_username: "Web UI username",
|
||
qbittorrent_password: "Web UI password",
|
||
requests_sync_ttl_minutes: "Request cache freshness (minutes)",
|
||
requests_stage_refresh_minutes: "Local stage refresh (minutes)",
|
||
requests_poll_interval_seconds: "Full-sync eligibility check (seconds)",
|
||
requests_delta_sync_interval_minutes: "Recent-change sync interval (minutes)",
|
||
requests_full_sync_time: "Daily full-sync time",
|
||
requests_cleanup_time: "Daily history cleanup time",
|
||
requests_cleanup_days: "History retention (days)",
|
||
requests_data_source: "Request read source",
|
||
artwork_cache_mode: "Artwork delivery mode",
|
||
log_level: "Application log level",
|
||
log_format: "Application log format",
|
||
log_file: "Active log file",
|
||
site_login_show_jellyfin_login: "Login page: Jellyfin sign-in",
|
||
site_login_message: "Logged-out login page message",
|
||
site_login_show_local_login: "Login page: local Magent sign-in",
|
||
site_login_show_forgot_password: "Login page: forgot password",
|
||
site_login_show_signup_link: "Login page: invite signup link",
|
||
site_nav_show_requests: "Top navigation: New Requests",
|
||
log_file_max_bytes: "Log file max size (bytes)",
|
||
log_file_backup_count: "Rotated log files to keep",
|
||
log_http_client_level: "Service HTTP log level",
|
||
log_background_sync_level: "Background sync log level",
|
||
};
|
||
|
||
const labelFromKey = (key: string) =>
|
||
SETTING_LABEL_OVERRIDES[key] ??
|
||
key
|
||
.replaceAll("_", " ")
|
||
.replace("jellyseerr", "Seerr")
|
||
.replace("base url", "URL")
|
||
.replace("api key", "API key")
|
||
.replace("quality profile id", "Quality profile ID")
|
||
.replace("root folder", "Root folder")
|
||
.replace("qbittorrent", "qBittorrent")
|
||
.replace("requests sync ttl minutes", "Saved request refresh TTL (minutes)")
|
||
.replace("requests poll interval seconds", "Full refresh check interval (seconds)")
|
||
.replace("requests delta sync interval minutes", "Delta sync interval (minutes)")
|
||
.replace("requests full sync time", "Daily full refresh time (24h)")
|
||
.replace("requests cleanup time", "Daily history cleanup time (24h)")
|
||
.replace("requests cleanup days", "History retention window (days)")
|
||
.replace("requests data source", "Request source (cache vs Seerr)")
|
||
.replace("jellyfin public url", "Jellyfin public URL")
|
||
.replace("jellyfin sync to arr", "Sync Jellyfin to Sonarr/Radarr")
|
||
.replace("artwork cache mode", "Artwork cache mode")
|
||
.replace("site build number", "Build number")
|
||
.replace("site banner enabled", "Sitewide banner enabled")
|
||
.replace("site banner message", "Sitewide banner message")
|
||
.replace("site banner tone", "Sitewide banner tone")
|
||
.replace("site banner background color", "Banner background colour")
|
||
.replace("site banner border color", "Banner border colour")
|
||
.replace("site login message", "Logged-out login page message")
|
||
.replace("site nav show requests", "Top navigation: New Requests")
|
||
.replace("site changelog", "Changelog text");
|
||
|
||
const formatBytes = (value?: number | null) => {
|
||
if (!value || value <= 0) return "0 B";
|
||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||
let size = value;
|
||
let unitIndex = 0;
|
||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||
size /= 1024;
|
||
unitIndex += 1;
|
||
}
|
||
const decimals = unitIndex === 0 || size >= 10 ? 0 : 1;
|
||
return `${size.toFixed(decimals)} ${units[unitIndex]}`;
|
||
};
|
||
|
||
type SettingsPageProps = {
|
||
section: string;
|
||
};
|
||
|
||
type SettingsSectionGroup = {
|
||
key: string;
|
||
title: string;
|
||
items: AdminSetting[];
|
||
description?: string;
|
||
};
|
||
|
||
type SectionFeedback = {
|
||
tone: "status" | "error";
|
||
message: string;
|
||
};
|
||
|
||
const SERVICE_TEST_ENDPOINTS: Record<string, string> = {
|
||
"seerr-connection": "seerr",
|
||
"jellyfin-connection": "jellyfin",
|
||
"jellystat-connection": "jellystat",
|
||
"sonarr-connection": "sonarr",
|
||
"radarr-connection": "radarr",
|
||
"bazarr-connection": "bazarr",
|
||
"prowlarr-connection": "prowlarr",
|
||
"qbittorrent-connection": "qbittorrent",
|
||
};
|
||
|
||
export default function SettingsPage({ section }: SettingsPageProps) {
|
||
const router = useRouter();
|
||
const [settings, setSettings] = useState<AdminSetting[]>([]);
|
||
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
||
const [status, setStatus] = useState<string | null>(null);
|
||
const [sectionFeedback, setSectionFeedback] = useState<Record<string, SectionFeedback>>({});
|
||
const [sectionSaving, setSectionSaving] = useState<Record<string, boolean>>({});
|
||
const [sectionTesting, setSectionTesting] = useState<Record<string, boolean>>({});
|
||
const [emailTestRecipient, setEmailTestRecipient] = useState("");
|
||
const [loading, setLoading] = useState(true);
|
||
const [sonarrOptions, setSonarrOptions] = useState<ServiceOptions | null>(null);
|
||
const [radarrOptions, setRadarrOptions] = useState<ServiceOptions | null>(null);
|
||
const [sonarrError, setSonarrError] = useState<string | null>(null);
|
||
const [radarrError, setRadarrError] = useState<string | null>(null);
|
||
const [jellyfinSyncStatus, setJellyfinSyncStatus] = useState<string | null>(null);
|
||
const [requestsSyncStatus, setRequestsSyncStatus] = useState<string | null>(null);
|
||
const [artworkPrefetchStatus, setArtworkPrefetchStatus] = useState<string | null>(null);
|
||
const [logsStatus, setLogsStatus] = useState<string | null>(null);
|
||
const [logsLines, setLogsLines] = useState<string[]>([]);
|
||
const [logsCount, setLogsCount] = useState(200);
|
||
const [cacheRows, setCacheRows] = useState<CacheRow[]>([]);
|
||
const [cacheCount, setCacheCount] = useState(50);
|
||
const [cacheStatus, setCacheStatus] = useState<string | null>(null);
|
||
const [cacheLoading, setCacheLoading] = useState(false);
|
||
const [requestsSync, setRequestsSync] = useState<ProgressState | null>(null);
|
||
const [artworkPrefetch, setArtworkPrefetch] = useState<ProgressState | null>(null);
|
||
const [artworkSummary, setArtworkSummary] = useState<ArtworkSummary | null>(null);
|
||
const [artworkSummaryStatus, setArtworkSummaryStatus] = useState<string | null>(null);
|
||
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null);
|
||
const [liveStreamConnected, setLiveStreamConnected] = useState(false);
|
||
const [serviceStatuses, setServiceStatuses] = useState<ServiceStatus[]>([]);
|
||
const [serviceStatusCheckedAt, setServiceStatusCheckedAt] = useState<string | null>(null);
|
||
const requestsSyncRef = useRef<ProgressState | null>(null);
|
||
const artworkPrefetchRef = useRef<ProgressState | null>(null);
|
||
const computeProgressPercent = (completedValue: unknown, totalValue: unknown, statusValue: unknown): number => {
|
||
if (String(statusValue).toLowerCase() === "completed") {
|
||
return 100;
|
||
}
|
||
const completed = Number(completedValue);
|
||
const total = Number(totalValue);
|
||
if (!Number.isFinite(completed) || !Number.isFinite(total) || total <= 0 || completed <= 0) {
|
||
return 0;
|
||
}
|
||
return Math.max(0, Math.min(100, Math.round((completed / total) * 100)));
|
||
};
|
||
|
||
const loadSettings = useCallback(
|
||
async (refreshedKeys?: Set<string>) => {
|
||
const baseUrl = getApiBase();
|
||
const response = await authFetch(`${baseUrl}/admin/settings`);
|
||
if (!response.ok) {
|
||
if (response.status === 401) {
|
||
clearToken();
|
||
router.push("/login");
|
||
return;
|
||
}
|
||
if (response.status === 403) {
|
||
router.push("/");
|
||
return;
|
||
}
|
||
throw new Error("Failed to load settings");
|
||
}
|
||
const data = await response.json();
|
||
const fetched: AdminSetting[] = Array.isArray(data?.settings)
|
||
? data.settings.map((item: AdminSetting) => ({
|
||
...item,
|
||
value: item.value == null ? null : String(item.value),
|
||
}))
|
||
: [];
|
||
setSettings(fetched);
|
||
const initialValues: Record<string, string> = {};
|
||
for (const setting of fetched) {
|
||
if (!setting.sensitive && setting.value) {
|
||
if (BOOL_SETTINGS.has(setting.key)) {
|
||
initialValues[setting.key] = String(setting.value).toLowerCase();
|
||
} else {
|
||
initialValues[setting.key] = setting.value;
|
||
}
|
||
} else {
|
||
initialValues[setting.key] = "";
|
||
}
|
||
}
|
||
setFormValues((current) => {
|
||
if (!refreshedKeys || refreshedKeys.size === 0) {
|
||
return initialValues;
|
||
}
|
||
const nextValues = { ...initialValues };
|
||
for (const [key, value] of Object.entries(current)) {
|
||
if (!refreshedKeys.has(key)) {
|
||
nextValues[key] = value;
|
||
}
|
||
}
|
||
return nextValues;
|
||
});
|
||
setStatus(null);
|
||
},
|
||
[router],
|
||
);
|
||
|
||
const loadArtworkPrefetchStatus = useCallback(async () => {
|
||
try {
|
||
const baseUrl = getApiBase();
|
||
const response = await authFetch(`${baseUrl}/admin/requests/artwork/status`);
|
||
if (!response.ok) {
|
||
return;
|
||
}
|
||
const data = await response.json();
|
||
setArtworkPrefetch(data?.prefetch ?? null);
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}, []);
|
||
|
||
const loadArtworkSummary = useCallback(async () => {
|
||
setArtworkSummaryStatus(null);
|
||
try {
|
||
const baseUrl = getApiBase();
|
||
const response = await authFetch(`${baseUrl}/admin/requests/artwork/summary`);
|
||
if (!response.ok) {
|
||
const text = await response.text();
|
||
throw new Error(text || "Artwork summary fetch failed");
|
||
}
|
||
const data = await response.json();
|
||
setArtworkSummary(data?.summary ?? null);
|
||
} catch (err) {
|
||
console.error(err);
|
||
const message =
|
||
err instanceof Error && err.message
|
||
? err.message.replace(/^\\{"detail":"|"\\}$/g, "")
|
||
: "Could not load artwork stats.";
|
||
setArtworkSummaryStatus(message);
|
||
}
|
||
}, []);
|
||
|
||
const loadOptions = useCallback(async (service: "sonarr" | "radarr") => {
|
||
try {
|
||
const baseUrl = getApiBase();
|
||
const response = await authFetch(`${baseUrl}/admin/${service}/options`);
|
||
if (!response.ok) {
|
||
throw new Error("Options unavailable");
|
||
}
|
||
const data = await response.json();
|
||
if (service === "sonarr") {
|
||
setSonarrOptions({
|
||
rootFolders: Array.isArray(data?.rootFolders) ? data.rootFolders : [],
|
||
qualityProfiles: Array.isArray(data?.qualityProfiles) ? data.qualityProfiles : [],
|
||
});
|
||
setSonarrError(null);
|
||
} else {
|
||
setRadarrOptions({
|
||
rootFolders: Array.isArray(data?.rootFolders) ? data.rootFolders : [],
|
||
qualityProfiles: Array.isArray(data?.qualityProfiles) ? data.qualityProfiles : [],
|
||
});
|
||
setRadarrError(null);
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
if (service === "sonarr") {
|
||
setSonarrError("Could not load Sonarr options.");
|
||
} else {
|
||
setRadarrError("Could not load Radarr options.");
|
||
}
|
||
}
|
||
}, []);
|
||
|
||
const loadServiceStatuses = useCallback(async () => {
|
||
try {
|
||
const baseUrl = getApiBase();
|
||
const response = await authFetch(`${baseUrl}/status/services`);
|
||
if (!response.ok) {
|
||
return;
|
||
}
|
||
const data = await response.json();
|
||
setServiceStatuses(Array.isArray(data?.services) ? data.services : []);
|
||
setServiceStatusCheckedAt(new Date().toISOString());
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const load = async () => {
|
||
if (!getToken()) {
|
||
router.push("/login");
|
||
return;
|
||
}
|
||
try {
|
||
await Promise.all([loadSettings(), loadServiceStatuses()]);
|
||
if (section === "cache" || section === "artwork") {
|
||
await loadArtworkPrefetchStatus();
|
||
await loadArtworkSummary();
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
setStatus("Could not load admin settings.");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
load();
|
||
if (section === "sonarr") {
|
||
void loadOptions("sonarr");
|
||
}
|
||
if (section === "radarr") {
|
||
void loadOptions("radarr");
|
||
}
|
||
}, [loadArtworkPrefetchStatus, loadArtworkSummary, loadOptions, loadServiceStatuses, loadSettings, router, section]);
|
||
|
||
const groupedSettings = useMemo(() => {
|
||
const groups: Record<string, AdminSetting[]> = {};
|
||
for (const setting of settings) {
|
||
const section = setting.key.split("_")[0] ?? "other";
|
||
if (!groups[section]) groups[section] = [];
|
||
groups[section].push(setting);
|
||
}
|
||
return groups;
|
||
}, [settings]);
|
||
|
||
const settingsSection = SETTINGS_SECTION_MAP[section] ?? null;
|
||
const statusNamesBySection: Record<string, string[]> = {
|
||
seerr: ["Seerr", "Jellyseerr", "Jellyseer"],
|
||
jellyseerr: ["Seerr", "Jellyseerr", "Jellyseer"],
|
||
jellyfin: ["Jellyfin"],
|
||
jellystat: ["Jellystat"],
|
||
sonarr: ["Sonarr"],
|
||
radarr: ["Radarr"],
|
||
bazarr: ["Bazarr"],
|
||
prowlarr: ["Prowlarr"],
|
||
qbittorrent: ["qBittorrent", "Qbittorrent"],
|
||
};
|
||
const statusNames = statusNamesBySection[section] ?? statusNamesBySection[settingsSection ?? ""] ?? [];
|
||
const currentServiceStatus = serviceStatuses.find((service) =>
|
||
statusNames.some((name) => name.toLowerCase() === service.name.toLowerCase()),
|
||
);
|
||
const isMagentGroupedSection = section === "magent" || section === "general" || section === "notifications";
|
||
const isSiteGroupedSection = section === "site";
|
||
const isCacheSection = section === "cache";
|
||
const isArtworkSection = section === "artwork";
|
||
const cacheSettingKeys = new Set(["requests_sync_ttl_minutes", "requests_data_source"]);
|
||
const artworkSettingKeys = new Set(["artwork_cache_mode"]);
|
||
const generatedSettingKeys = new Set(["site_changelog", "site_build_number"]);
|
||
const hiddenSettingKeys = new Set([
|
||
"site_nav_show_requests",
|
||
...cacheSettingKeys,
|
||
...artworkSettingKeys,
|
||
...generatedSettingKeys,
|
||
]);
|
||
const obsoleteSettingKeys = new Set(["sonarr_qbittorrent_category", "radarr_qbittorrent_category"]);
|
||
const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key));
|
||
const artworkSettings = settings.filter((setting) => artworkSettingKeys.has(setting.key));
|
||
const buildDefinedSections = (
|
||
definitions: Array<{ key: string; title: string; description: string; keys: string[] }>,
|
||
sourceItems: AdminSetting[],
|
||
includeUnassigned = true,
|
||
): SettingsSectionGroup[] => {
|
||
const byKey = new Map(sourceItems.map((item) => [item.key, item]));
|
||
const assignedKeys = new Set(definitions.flatMap((group) => group.keys));
|
||
const groups = definitions.map((group) => ({
|
||
key: group.key,
|
||
title: group.title,
|
||
description: group.description,
|
||
items: group.keys.map((key) => byKey.get(key)).filter((item): item is AdminSetting => Boolean(item)),
|
||
}));
|
||
if (includeUnassigned) {
|
||
const unassigned = sourceItems.filter((item) => !assignedKeys.has(item.key));
|
||
if (unassigned.length > 0) {
|
||
groups.push({
|
||
key: `${section}-additional`,
|
||
title: "Additional Settings",
|
||
description: "Settings returned by Magent that do not yet belong to a dedicated subsection.",
|
||
items: unassigned.sort((a, b) => a.key.localeCompare(b.key)),
|
||
});
|
||
}
|
||
}
|
||
return groups;
|
||
};
|
||
const standardDefinitions = STANDARD_SECTION_GROUPS[section];
|
||
const standardItems = settingsSection
|
||
? (groupedSettings[settingsSection] ?? []).filter(
|
||
(setting) => !obsoleteSettingKeys.has(setting.key) && !hiddenSettingKeys.has(setting.key),
|
||
)
|
||
: [];
|
||
const settingsSections: SettingsSectionGroup[] = isCacheSection
|
||
? [
|
||
{
|
||
key: "cache",
|
||
title: "Request Cache Strategy",
|
||
description: "Choose where request pages read from and how long cached request records remain fresh.",
|
||
items: cacheSettings,
|
||
},
|
||
]
|
||
: isArtworkSection
|
||
? [
|
||
{
|
||
key: "artwork",
|
||
title: "Artwork Delivery and Storage",
|
||
description:
|
||
"Choose how posters and backdrops are delivered, then inspect or rebuild the local artwork cache.",
|
||
items: artworkSettings,
|
||
},
|
||
]
|
||
: isMagentGroupedSection
|
||
? (() => {
|
||
if (section === "magent") {
|
||
return [];
|
||
}
|
||
const magentItems = groupedSettings.magent ?? [];
|
||
const allowedGroupKeys = MAGENT_GROUPS_BY_SECTION[section] ?? new Set<string>();
|
||
const definitions = MAGENT_SECTION_GROUPS.filter((group) => allowedGroupKeys.has(group.key));
|
||
return buildDefinedSections(definitions, magentItems, false);
|
||
})()
|
||
: isSiteGroupedSection
|
||
? buildDefinedSections(
|
||
SITE_SECTION_GROUPS,
|
||
(groupedSettings.site ?? []).filter((setting) => !hiddenSettingKeys.has(setting.key)),
|
||
)
|
||
: standardDefinitions
|
||
? buildDefinedSections(standardDefinitions, standardItems)
|
||
: [];
|
||
const showLogs = section === "logs";
|
||
const showMaintenance = section === "maintenance";
|
||
const showRequestsExtras = section === "requests";
|
||
const showArtworkExtras = section === "artwork";
|
||
const showCacheExtras = section === "cache";
|
||
const shouldRenderSection = (sectionGroup: { key: string; items?: AdminSetting[] }) => {
|
||
if (sectionGroup.items && sectionGroup.items.length > 0) return true;
|
||
if (showArtworkExtras && sectionGroup.key === "artwork") return true;
|
||
if (showCacheExtras && sectionGroup.key === "cache") return true;
|
||
if (showRequestsExtras && sectionGroup.key === "requests-sync") return true;
|
||
return false;
|
||
};
|
||
const renderedSettingsSections = settingsSections
|
||
.filter(shouldRenderSection)
|
||
.sort((a, b) => Number(a.key === "requests-advanced") - Number(b.key === "requests-advanced"));
|
||
|
||
useEffect(() => {
|
||
requestsSyncRef.current = requestsSync;
|
||
}, [requestsSync]);
|
||
|
||
useEffect(() => {
|
||
artworkPrefetchRef.current = artworkPrefetch;
|
||
}, [artworkPrefetch]);
|
||
|
||
const settingDescriptions: Record<string, string> = {
|
||
issue_confirmation_contact_attempts:
|
||
"Number of confirmation emails sent after an issue is marked fixed. Set 0 to send none and close immediately.",
|
||
issue_confirmation_interval_value:
|
||
"Amount of time between confirmation emails, and the final waiting period before automatic closure.",
|
||
issue_confirmation_interval_unit: "Unit used for the confirmation interval: days, weeks, or months.",
|
||
magent_application_url:
|
||
"Canonical public URL for the Magent web app (used for links and reverse-proxy-aware features).",
|
||
magent_application_port: "Preferred frontend/UI port for local or direct-hosted deployments.",
|
||
magent_api_url: "Canonical public URL for the Magent API when it differs from the app URL.",
|
||
magent_api_port: "Preferred API port for local or direct-hosted deployments.",
|
||
magent_bind_host: "Host/IP to bind the application services to when running without an external process manager.",
|
||
magent_proxy_enabled: "Enable reverse-proxy-aware behavior and use proxy-specific URL settings.",
|
||
magent_proxy_base_url:
|
||
"Base URL Magent should use when it is published behind a proxy path or external proxy hostname.",
|
||
magent_proxy_trust_forwarded_headers: "Trust X-Forwarded-* headers from your reverse proxy.",
|
||
magent_proxy_forwarded_prefix: "Optional path prefix added by your proxy (example: /magent).",
|
||
magent_ssl_bind_enabled: "Enable direct HTTPS binding in Magent (for environments not terminating TLS at a proxy).",
|
||
magent_ssl_certificate_path: "Path to the TLS certificate file on disk (PEM).",
|
||
magent_ssl_private_key_path: "Path to the TLS private key file on disk (PEM).",
|
||
magent_ssl_certificate_pem: "Paste the TLS certificate PEM if you want Magent to store it directly.",
|
||
magent_ssl_private_key_pem: "Paste the TLS private key PEM if you want Magent to store it directly.",
|
||
magent_notify_enabled: "Master switch for Magent notifications. Individual provider toggles still apply.",
|
||
magent_notify_email_enabled: "Enable SMTP email notifications.",
|
||
magent_notify_email_smtp_host: "SMTP server hostname or IP.",
|
||
magent_notify_email_smtp_port: "SMTP port (587 for STARTTLS, 465 for SSL).",
|
||
magent_notify_email_smtp_username: "SMTP account username.",
|
||
magent_notify_email_smtp_password: "SMTP account password or app password.",
|
||
magent_notify_email_from_address: "Sender email address used by Magent.",
|
||
magent_notify_email_from_name: "Sender display name shown to recipients.",
|
||
magent_notify_email_use_tls: "Use STARTTLS after connecting to SMTP.",
|
||
magent_notify_email_use_ssl: "Use implicit TLS/SSL for SMTP (usually port 465).",
|
||
magent_notify_discord_enabled: "Enable Discord webhook notifications.",
|
||
magent_notify_discord_webhook_url:
|
||
"Discord channel webhook URL used for notifications and optional feedback routing.",
|
||
magent_notify_telegram_enabled: "Enable Telegram notifications.",
|
||
magent_notify_telegram_bot_token: "Bot token from BotFather.",
|
||
magent_notify_telegram_chat_id: "Default Telegram chat/group/user ID for notifications.",
|
||
magent_notify_push_enabled: "Enable generic push notifications.",
|
||
magent_notify_push_provider: "Push backend to target (ntfy, gotify, pushover, webhook, etc.).",
|
||
magent_notify_push_base_url: "Base URL for your push provider (for example ntfy/gotify server URL).",
|
||
magent_notify_push_topic: "Topic/channel/room name used by the push provider.",
|
||
magent_notify_push_token: "Provider token/API key/password.",
|
||
magent_notify_push_user_key: "Provider recipient key/user key (for example Pushover user key).",
|
||
magent_notify_push_device: "Optional device or target override, depending on provider.",
|
||
magent_notify_webhook_enabled: "Enable generic webhook notifications.",
|
||
magent_notify_webhook_url: "Generic webhook endpoint for custom integrations or automation flows.",
|
||
jellyseerr_base_url: "Base URL for your Seerr server (FQDN or IP). Scheme is optional.",
|
||
jellyseerr_api_key: "API key used to read requests and status.",
|
||
jellyfin_base_url: "Jellyfin server URL for logins and lookups (FQDN or IP). Scheme is optional.",
|
||
jellyfin_api_key: "Admin API key for syncing users and availability.",
|
||
jellystat_base_url:
|
||
"Jellystat address reachable by Magent, including any base path. Example: http://jellystat:3000.",
|
||
jellystat_api_key: "API key created in Jellystat. Stored privately by Magent and never sent to users’ browsers.",
|
||
jellyfin_public_url: "Public Jellyfin URL for the “Open in Jellyfin” button (FQDN or IP).",
|
||
jellyfin_sync_to_arr: "Auto-add items to Sonarr/Radarr when they already exist in Jellyfin.",
|
||
artwork_cache_mode: "Choose whether posters are cached locally or loaded from the web.",
|
||
sonarr_base_url: "Sonarr server URL for TV tracking (FQDN or IP). Scheme is optional.",
|
||
sonarr_api_key: "API key for Sonarr.",
|
||
bazarr_base_url: "Bazarr server URL used for movie and episode subtitle repairs. Scheme is optional.",
|
||
bazarr_api_key: "API key used to ask Bazarr for fresh subtitles.",
|
||
bazarr_default_language: "Language code Bazarr should search for by default, such as en.",
|
||
sonarr_quality_profile_id:
|
||
"Applied automatically to every new TV request. Users do not choose a quality profile in the request pipeline. If no Magent default is configured, requests use Seerr’s default.",
|
||
sonarr_root_folder: "Root folder where Sonarr stores TV shows.",
|
||
radarr_base_url: "Radarr server URL for movies (FQDN or IP). Scheme is optional.",
|
||
radarr_api_key: "API key for Radarr.",
|
||
radarr_quality_profile_id:
|
||
"Applied automatically to every new movie request. Users do not choose a quality profile in the request pipeline. If no Magent default is configured, requests use Seerr’s default.",
|
||
radarr_root_folder: "Root folder where Radarr stores movies.",
|
||
prowlarr_base_url: "Prowlarr server URL for indexer searches (FQDN or IP). Scheme is optional.",
|
||
prowlarr_api_key: "API key for Prowlarr.",
|
||
qbittorrent_base_url: "qBittorrent server URL for download status (FQDN or IP). Scheme is optional.",
|
||
qbittorrent_username: "qBittorrent login username.",
|
||
qbittorrent_password: "qBittorrent login password.",
|
||
requests_stage_refresh_minutes:
|
||
"Refresh saved request stages in the background. Default: 15 minutes; range: 1 to 1440. Shorter intervals increase Jellyfin traffic and server load. Recent requests always load from the local database.",
|
||
requests_sync_ttl_minutes: "How long saved requests stay fresh before a refresh is needed.",
|
||
requests_poll_interval_seconds: "How often Magent checks if a full refresh should run.",
|
||
requests_delta_sync_interval_minutes: "How often we poll for new or updated requests.",
|
||
requests_full_sync_time: "Daily time to rebuild the full request cache.",
|
||
requests_cleanup_time: "Daily time to trim old request history.",
|
||
requests_cleanup_days: "History older than this is removed during cleanup.",
|
||
requests_data_source: "Pick where Magent should read requests from. Cache-only avoids Seerr lookups on reads.",
|
||
log_level: "How much detail is written to the activity log.",
|
||
log_format: "Use text for local readability or json for production log collection.",
|
||
log_file: "Where the activity log is stored.",
|
||
log_file_max_bytes: "Rotate the log file when it reaches this size in bytes.",
|
||
log_file_backup_count: "How many rotated log files to retain on disk.",
|
||
log_http_client_level:
|
||
"Verbosity for per-call outbound service traffic logs from Seerr, Jellyfin, Sonarr, Radarr, and related clients.",
|
||
log_background_sync_level: "Verbosity for scheduled background sync progress messages.",
|
||
site_build_number: "Build number shown in the account menu (auto-set from releases).",
|
||
site_banner_enabled: "Enable a sitewide banner for announcements.",
|
||
site_banner_message: "Short banner message for maintenance or updates.",
|
||
site_banner_tone: "Preset visual tone used whenever custom colours are blank.",
|
||
site_banner_background_color:
|
||
"Optional six-digit hex colour behind the banner message. Clear it to use the selected tone.",
|
||
site_banner_border_color: "Optional six-digit hex colour around the banner. Clear it to use the selected tone.",
|
||
site_login_message: "Optional message shown only on the logged-out login page. Leave blank to hide it.",
|
||
site_login_show_jellyfin_login: "Show the Jellyfin login button on the login page.",
|
||
site_login_show_local_login: "Show the local Magent login button on the login page.",
|
||
site_login_show_forgot_password: "Show the forgot-password link on the login page.",
|
||
site_login_show_signup_link: "Show the invite signup link on the login page.",
|
||
site_nav_show_requests:
|
||
"Show the New Requests item in the top navigation. Disable it while request creation is unavailable.",
|
||
site_changelog: "One update per line for the public changelog.",
|
||
};
|
||
|
||
const settingPlaceholders: Record<string, string> = {
|
||
magent_application_url: "https://magent.example.com",
|
||
magent_application_port: "3000",
|
||
magent_api_url: "https://api.example.com or https://magent.example.com/api",
|
||
magent_api_port: "8000",
|
||
magent_bind_host: "0.0.0.0",
|
||
magent_proxy_base_url: "https://proxy.example.com/magent",
|
||
magent_proxy_forwarded_prefix: "/magent",
|
||
magent_ssl_certificate_path: "/certs/fullchain.pem",
|
||
magent_ssl_private_key_path: "/certs/privkey.pem",
|
||
magent_ssl_certificate_pem: "-----BEGIN CERTIFICATE-----",
|
||
magent_ssl_private_key_pem: "-----BEGIN PRIVATE KEY-----",
|
||
magent_notify_email_smtp_host: "smtp.office365.com",
|
||
magent_notify_email_smtp_port: "587",
|
||
magent_notify_email_smtp_username: "notifications@example.com",
|
||
magent_notify_email_from_address: "notifications@example.com",
|
||
magent_notify_email_from_name: "Magent",
|
||
log_file_max_bytes: "20000000",
|
||
log_file_backup_count: "10",
|
||
magent_notify_discord_webhook_url: "https://discord.com/api/webhooks/...",
|
||
magent_notify_telegram_bot_token: "123456789:AA...",
|
||
magent_notify_telegram_chat_id: "-1001234567890",
|
||
magent_notify_push_base_url: "https://ntfy.example.com or https://gotify.example.com",
|
||
magent_notify_push_topic: "magent-alerts",
|
||
magent_notify_push_device: "iphone-zak",
|
||
magent_notify_webhook_url: "https://automation.example.com/webhooks/magent",
|
||
jellyseerr_base_url: "https://requests.example.com or 10.30.1.81:5055",
|
||
jellyfin_base_url: "https://jelly.example.com or 10.40.0.80:8096",
|
||
jellystat_base_url: "http://jellystat:3000",
|
||
jellyfin_public_url: "https://jelly.example.com",
|
||
sonarr_base_url: "https://sonarr.example.com or 10.30.1.81:8989",
|
||
bazarr_base_url: "https://bazarr.example.com or 10.30.1.81:6767",
|
||
bazarr_default_language: "en",
|
||
radarr_base_url: "https://radarr.example.com or 10.30.1.81:7878",
|
||
prowlarr_base_url: "https://prowlarr.example.com or 10.30.1.81:9696",
|
||
qbittorrent_base_url: "https://qb.example.com or 10.30.1.81:8080",
|
||
site_login_message: "Sign-in information, an outage notice, or help for users…",
|
||
};
|
||
|
||
const parseActionError = (err: unknown, fallback: string) => {
|
||
if (err instanceof Error && err.message) {
|
||
return err.message.replace(/^\\{"detail":"|"\\}$/g, "");
|
||
}
|
||
return fallback;
|
||
};
|
||
|
||
const buildSettingsPayload = (items: AdminSetting[]) => {
|
||
const payload: Record<string, string> = {};
|
||
for (const setting of items) {
|
||
const rawValue = formValues[setting.key];
|
||
if (typeof rawValue !== "string") {
|
||
continue;
|
||
}
|
||
const value = rawValue.trim();
|
||
if (setting.sensitive && value === "") {
|
||
continue;
|
||
}
|
||
payload[setting.key] = value;
|
||
}
|
||
return payload;
|
||
};
|
||
|
||
const saveSettingGroup = async (sectionGroup: SettingsSectionGroup, options?: { successMessage?: string | null }) => {
|
||
setSectionFeedback((current) => {
|
||
const next = { ...current };
|
||
delete next[sectionGroup.key];
|
||
return next;
|
||
});
|
||
setSectionSaving((current) => ({ ...current, [sectionGroup.key]: true }));
|
||
try {
|
||
const payload = buildSettingsPayload(sectionGroup.items);
|
||
const baseUrl = getApiBase();
|
||
const response = await authFetch(`${baseUrl}/admin/settings`, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!response.ok) {
|
||
const text = await response.text();
|
||
throw new Error(text || "Update failed");
|
||
}
|
||
await loadSettings(new Set(sectionGroup.items.map((item) => item.key)));
|
||
if (sectionGroup.key === "sonarr-connection") await loadOptions("sonarr");
|
||
if (sectionGroup.key === "radarr-connection") await loadOptions("radarr");
|
||
if (options?.successMessage !== null) {
|
||
setSectionFeedback((current) => ({
|
||
...current,
|
||
[sectionGroup.key]: {
|
||
tone: "status",
|
||
message: options?.successMessage ?? `${sectionGroup.title} settings saved.`,
|
||
},
|
||
}));
|
||
}
|
||
return true;
|
||
} catch (err) {
|
||
console.error(err);
|
||
setSectionFeedback((current) => ({
|
||
...current,
|
||
[sectionGroup.key]: {
|
||
tone: "error",
|
||
message: parseActionError(err, "Could not save settings."),
|
||
},
|
||
}));
|
||
return false;
|
||
} finally {
|
||
setSectionSaving((current) => ({ ...current, [sectionGroup.key]: false }));
|
||
}
|
||
};
|
||
|
||
const formatServiceTestFeedback = (result: ServiceTestResult): SectionFeedback => {
|
||
const name = result?.name ?? "Service";
|
||
const state = String(result?.status ?? "unknown").toLowerCase();
|
||
if (state === "up") {
|
||
return { tone: "status", message: `${name} connection test passed.` };
|
||
}
|
||
if (state === "degraded") {
|
||
return {
|
||
tone: "error",
|
||
message: result?.message ? `${name}: ${result.message}` : `${name} reported warnings.`,
|
||
};
|
||
}
|
||
if (state === "not_configured") {
|
||
return { tone: "error", message: `${name} is not fully configured yet.` };
|
||
}
|
||
return {
|
||
tone: "error",
|
||
message: result?.message ? `${name}: ${result.message}` : `${name} connection test failed.`,
|
||
};
|
||
};
|
||
|
||
const getSectionTestLabel = (sectionKey: string) => {
|
||
if (sectionKey === "magent-notify-email") {
|
||
return "Send test email";
|
||
}
|
||
if (sectionKey in SERVICE_TEST_ENDPOINTS) {
|
||
return "Test connection";
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const testSettingGroup = async (sectionGroup: SettingsSectionGroup) => {
|
||
setSectionFeedback((current) => {
|
||
const next = { ...current };
|
||
delete next[sectionGroup.key];
|
||
return next;
|
||
});
|
||
setSectionTesting((current) => ({ ...current, [sectionGroup.key]: true }));
|
||
try {
|
||
const baseUrl = getApiBase();
|
||
if (sectionGroup.key === "magent-notify-email") {
|
||
const recipientEmail = emailTestRecipient.trim() || formValues.magent_notify_email_from_address?.trim();
|
||
const response = await authFetch(`${baseUrl}/admin/settings/test/email`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(recipientEmail ? { recipient_email: recipientEmail } : {}),
|
||
});
|
||
if (!response.ok) {
|
||
const text = await response.text();
|
||
throw new Error(text || "Email test failed");
|
||
}
|
||
const data = await response.json();
|
||
setSectionFeedback((current) => ({
|
||
...current,
|
||
[sectionGroup.key]: {
|
||
tone: data?.warning ? "error" : "status",
|
||
message: data?.warning
|
||
? `SMTP accepted a relay-mode test for ${data?.recipient_email ?? "the configured mailbox"}, but delivery is not guaranteed. ${data.warning}`
|
||
: `Test email sent to ${data?.recipient_email ?? "the configured mailbox"}.`,
|
||
},
|
||
}));
|
||
return;
|
||
}
|
||
|
||
const serviceKey = SERVICE_TEST_ENDPOINTS[sectionGroup.key];
|
||
if (!serviceKey) {
|
||
return;
|
||
}
|
||
const response = await authFetch(`${baseUrl}/status/services/${serviceKey}/test`, {
|
||
method: "POST",
|
||
});
|
||
if (!response.ok) {
|
||
const text = await response.text();
|
||
throw new Error(text || "Connection test failed");
|
||
}
|
||
const data = await response.json();
|
||
await loadServiceStatuses();
|
||
setSectionFeedback((current) => ({
|
||
...current,
|
||
[sectionGroup.key]: formatServiceTestFeedback(data),
|
||
}));
|
||
} catch (err) {
|
||
console.error(err);
|
||
setSectionFeedback((current) => ({
|
||
...current,
|
||
[sectionGroup.key]: {
|
||
tone: "error",
|
||
message: parseActionError(err, "Could not run test."),
|
||
},
|
||
}));
|
||
} finally {
|
||
setSectionTesting((current) => ({ ...current, [sectionGroup.key]: false }));
|
||
}
|
||
};
|
||
|
||
const syncJellyfinUsers = async () => {
|
||
setJellyfinSyncStatus(null);
|
||
try {
|
||
const baseUrl = getApiBase();
|
||
const response = await authFetch(`${baseUrl}/admin/jellyfin/users/sync`, {
|
||
method: "POST",
|
||
});
|
||
if (!response.ok) {
|
||
const text = await response.text();
|
||
throw new Error(text || "Sync failed");
|
||
}
|
||
const data = await response.json();
|
||
setJellyfinSyncStatus(`Imported ${data?.imported ?? 0} users from Jellyfin.`);
|
||
} catch (err) {
|
||
console.error(err);
|
||
const message =
|
||
err instanceof Error && err.message
|
||
? err.message.replace(/^\\{"detail":"|"\\}$/g, "")
|
||
: "Could not import Jellyfin users.";
|
||
setJellyfinSyncStatus(message);
|
||
}
|
||
};
|
||
|
||
const syncRequests = async () => {
|
||
setRequestsSyncStatus(null);
|
||
setRequestsSync({
|
||
status: "running",
|
||
stored: 0,
|
||
total: 0,
|
||
skip: 0,
|
||
message: "Starting sync",
|
||
});
|
||
try {
|
||
const baseUrl = getApiBase();
|
||
const response = await authFetch(`${baseUrl}/admin/requests/sync`, {
|
||
method: "POST",
|
||
});
|
||
if (!response.ok) {
|
||
const text = await response.text();
|
||
throw new Error(text || "Sync failed");
|
||
}
|
||
const data = await response.json();
|
||
setRequestsSync(data?.sync ?? null);
|
||
setRequestsSyncStatus("Sync started.");
|
||
} catch (err) {
|
||
console.error(err);
|
||
const message =
|
||
err instanceof Error && err.message
|
||
? err.message.replace(/^\\{"detail":"|"\\}$/g, "")
|
||
: "Could not sync requests.";
|
||
setRequestsSyncStatus(message);
|
||
}
|
||
};
|
||
|
||
const syncRequestsDelta = async () => {
|
||
setRequestsSyncStatus(null);
|
||
setRequestsSync({
|
||
status: "running",
|
||
stored: 0,
|
||
total: 0,
|
||
skip: 0,
|
||
message: "Starting delta sync",
|
||
});
|
||
try {
|
||
const baseUrl = getApiBase();
|
||
const response = await authFetch(`${baseUrl}/admin/requests/sync/delta`, {
|
||
method: "POST",
|
||
});
|
||
if (!response.ok) {
|
||
const text = await response.text();
|
||
throw new Error(text || "Delta sync failed");
|
||
}
|
||
const data = await response.json();
|
||
setRequestsSync(data?.sync ?? null);
|
||
setRequestsSyncStatus("Delta sync started.");
|
||
} catch (err) {
|
||
console.error(err);
|
||
const message =
|
||
err instanceof Error && err.message
|
||
? err.message.replace(/^\\{"detail":"|"\\}$/g, "")
|
||
: "Could not run delta sync.";
|
||
setRequestsSyncStatus(message);
|
||
}
|
||
};
|
||
|
||
const prefetchArtwork = async () => {
|
||
setArtworkPrefetchStatus(null);
|
||
setArtworkPrefetch({
|
||
status: "running",
|
||
processed: 0,
|
||
total: 0,
|
||
message: "Starting artwork caching",
|
||
});
|
||
try {
|
||
const baseUrl = getApiBase();
|
||
const response = await authFetch(`${baseUrl}/admin/requests/artwork/prefetch`, {
|
||
method: "POST",
|
||
});
|
||
if (!response.ok) {
|
||
const text = await response.text();
|
||
throw new Error(text || "Artwork prefetch failed");
|
||
}
|
||
const data = await response.json();
|
||
setArtworkPrefetch(data?.prefetch ?? null);
|
||
setArtworkPrefetchStatus("Artwork caching started.");
|
||
} catch (err) {
|
||
console.error(err);
|
||
const message =
|
||
err instanceof Error && err.message
|
||
? err.message.replace(/^\\{"detail":"|"\\}$/g, "")
|
||
: "Could not cache artwork.";
|
||
setArtworkPrefetchStatus(message);
|
||
}
|
||
};
|
||
|
||
const prefetchArtworkMissing = async () => {
|
||
setArtworkPrefetchStatus(null);
|
||
setArtworkPrefetch({
|
||
status: "running",
|
||
processed: 0,
|
||
total: 0,
|
||
message: "Starting missing artwork caching",
|
||
});
|
||
try {
|
||
const baseUrl = getApiBase();
|
||
const response = await authFetch(`${baseUrl}/admin/requests/artwork/prefetch?only_missing=1`, { method: "POST" });
|
||
if (!response.ok) {
|
||
const text = await response.text();
|
||
throw new Error(text || "Missing artwork prefetch failed");
|
||
}
|
||
const data = await response.json();
|
||
setArtworkPrefetch(data?.prefetch ?? null);
|
||
setArtworkPrefetchStatus("Missing artwork caching started.");
|
||
} catch (err) {
|
||
console.error(err);
|
||
const message =
|
||
err instanceof Error && err.message
|
||
? err.message.replace(/^\\{"detail":"|"\\}$/g, "")
|
||
: "Could not cache missing artwork.";
|
||
setArtworkPrefetchStatus(message);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
const shouldSubscribe = showRequestsExtras || showArtworkExtras || showLogs;
|
||
if (!shouldSubscribe) {
|
||
setLiveStreamConnected(false);
|
||
return;
|
||
}
|
||
const token = getToken();
|
||
if (!token) {
|
||
setLiveStreamConnected(false);
|
||
return;
|
||
}
|
||
|
||
const baseUrl = getApiBase();
|
||
let closed = false;
|
||
let source: EventSource | null = null;
|
||
|
||
const connect = async () => {
|
||
try {
|
||
const streamToken = await getEventStreamToken();
|
||
if (closed) return;
|
||
const params = new URLSearchParams();
|
||
params.set("stream_token", streamToken);
|
||
if (showLogs) {
|
||
params.set("include_logs", "1");
|
||
params.set("log_lines", String(logsCount));
|
||
}
|
||
const streamUrl = `${baseUrl}/admin/events/stream?${params.toString()}`;
|
||
source = new EventSource(streamUrl);
|
||
|
||
source.onopen = () => {
|
||
if (closed) return;
|
||
setLiveStreamConnected(true);
|
||
};
|
||
|
||
source.onmessage = (event) => {
|
||
if (closed) return;
|
||
setLiveStreamConnected(true);
|
||
try {
|
||
const payload = JSON.parse(event.data);
|
||
if (payload?.type !== "admin_live_state") {
|
||
return;
|
||
}
|
||
|
||
const rawSync =
|
||
payload.requestsSync && typeof payload.requestsSync === "object" ? payload.requestsSync : null;
|
||
const nextSync = rawSync?.status === "idle" ? null : rawSync;
|
||
const prevSync = requestsSyncRef.current;
|
||
requestsSyncRef.current = nextSync;
|
||
setRequestsSync(nextSync);
|
||
if (prevSync?.status === "running" && nextSync?.status && nextSync.status !== "running") {
|
||
setRequestsSyncStatus(nextSync.message || "Sync complete.");
|
||
}
|
||
|
||
const rawArtwork =
|
||
payload.artworkPrefetch && typeof payload.artworkPrefetch === "object" ? payload.artworkPrefetch : null;
|
||
const nextArtwork = rawArtwork?.status === "idle" ? null : rawArtwork;
|
||
const prevArtwork = artworkPrefetchRef.current;
|
||
artworkPrefetchRef.current = nextArtwork;
|
||
setArtworkPrefetch(nextArtwork);
|
||
if (prevArtwork?.status === "running" && nextArtwork?.status && nextArtwork.status !== "running") {
|
||
setArtworkPrefetchStatus(nextArtwork.message || "Artwork caching complete.");
|
||
if (showArtworkExtras) {
|
||
void loadArtworkSummary();
|
||
}
|
||
}
|
||
|
||
if (payload.logs && typeof payload.logs === "object") {
|
||
if (Array.isArray(payload.logs.lines)) {
|
||
setLogsLines(payload.logs.lines);
|
||
setLogsStatus(null);
|
||
} else if (typeof payload.logs.error === "string" && payload.logs.error.trim()) {
|
||
setLogsStatus(payload.logs.error);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
};
|
||
|
||
source.onerror = () => {
|
||
if (closed) return;
|
||
setLiveStreamConnected(false);
|
||
};
|
||
} catch (err) {
|
||
if (closed) return;
|
||
setLiveStreamConnected(false);
|
||
console.error(err);
|
||
}
|
||
};
|
||
|
||
void connect();
|
||
|
||
return () => {
|
||
closed = true;
|
||
setLiveStreamConnected(false);
|
||
source?.close();
|
||
};
|
||
}, [loadArtworkSummary, logsCount, showArtworkExtras, showLogs, showRequestsExtras]);
|
||
|
||
useEffect(() => {
|
||
if (liveStreamConnected || !artworkPrefetch || artworkPrefetch.status !== "running") {
|
||
return;
|
||
}
|
||
let active = true;
|
||
const timer = setInterval(async () => {
|
||
try {
|
||
const baseUrl = getApiBase();
|
||
const response = await authFetch(`${baseUrl}/admin/requests/artwork/status`);
|
||
if (!response.ok) {
|
||
return;
|
||
}
|
||
const data = await response.json();
|
||
if (!active) return;
|
||
setArtworkPrefetch(data?.prefetch ?? null);
|
||
if (data?.prefetch?.status && data.prefetch.status !== "running") {
|
||
setArtworkPrefetchStatus(data.prefetch.message || "Artwork caching complete.");
|
||
void loadArtworkSummary();
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}, 2000);
|
||
return () => {
|
||
active = false;
|
||
clearInterval(timer);
|
||
};
|
||
}, [artworkPrefetch, liveStreamConnected, loadArtworkSummary]);
|
||
|
||
useEffect(() => {
|
||
if (!artworkPrefetch || artworkPrefetch.status === "running") {
|
||
return;
|
||
}
|
||
const timer = setTimeout(() => {
|
||
setArtworkPrefetch(null);
|
||
}, 5000);
|
||
return () => clearTimeout(timer);
|
||
}, [artworkPrefetch]);
|
||
|
||
useEffect(() => {
|
||
if (liveStreamConnected || !requestsSync || requestsSync.status !== "running") {
|
||
return;
|
||
}
|
||
let active = true;
|
||
const timer = setInterval(async () => {
|
||
try {
|
||
const baseUrl = getApiBase();
|
||
const response = await authFetch(`${baseUrl}/admin/requests/sync/status`);
|
||
if (!response.ok) {
|
||
return;
|
||
}
|
||
const data = await response.json();
|
||
if (!active) return;
|
||
setRequestsSync(data?.sync ?? null);
|
||
if (data?.sync?.status && data.sync.status !== "running") {
|
||
setRequestsSyncStatus(data.sync.message || "Sync complete.");
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}, 2000);
|
||
return () => {
|
||
active = false;
|
||
clearInterval(timer);
|
||
};
|
||
}, [liveStreamConnected, requestsSync]);
|
||
|
||
useEffect(() => {
|
||
if (!requestsSync || requestsSync.status === "running") {
|
||
return;
|
||
}
|
||
const timer = setTimeout(() => {
|
||
setRequestsSync(null);
|
||
}, 5000);
|
||
return () => clearTimeout(timer);
|
||
}, [requestsSync]);
|
||
|
||
const loadLogs = useCallback(async () => {
|
||
setLogsStatus(null);
|
||
try {
|
||
const baseUrl = getApiBase();
|
||
const response = await authFetch(`${baseUrl}/admin/logs?lines=${encodeURIComponent(String(logsCount))}`);
|
||
if (!response.ok) {
|
||
const text = await response.text();
|
||
throw new Error(text || "Log fetch failed");
|
||
}
|
||
const data = await response.json();
|
||
if (Array.isArray(data?.lines)) {
|
||
setLogsLines(data.lines);
|
||
} else {
|
||
setLogsLines([]);
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
const message =
|
||
err instanceof Error && err.message ? err.message.replace(/^\\{"detail":"|"\\}$/g, "") : "Could not load logs.";
|
||
setLogsStatus(message);
|
||
}
|
||
}, [logsCount]);
|
||
|
||
useEffect(() => {
|
||
if (!showLogs) {
|
||
return;
|
||
}
|
||
if (liveStreamConnected) {
|
||
return;
|
||
}
|
||
void loadLogs();
|
||
const timer = setInterval(() => {
|
||
void loadLogs();
|
||
}, 5000);
|
||
return () => clearInterval(timer);
|
||
}, [liveStreamConnected, loadLogs, showLogs]);
|
||
|
||
const loadCache = async () => {
|
||
setCacheStatus(null);
|
||
setCacheLoading(true);
|
||
try {
|
||
const baseUrl = getApiBase();
|
||
const response = await authFetch(
|
||
`${baseUrl}/admin/requests/cache?limit=${encodeURIComponent(String(cacheCount))}`,
|
||
);
|
||
if (!response.ok) {
|
||
const text = await response.text();
|
||
throw new Error(text || "Cache fetch failed");
|
||
}
|
||
const data = await response.json();
|
||
if (Array.isArray(data?.rows)) {
|
||
setCacheRows(data.rows);
|
||
} else {
|
||
setCacheRows([]);
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
const message =
|
||
err instanceof Error && err.message
|
||
? err.message.replace(/^\\{"detail":"|"\\}$/g, "")
|
||
: "Could not load cache.";
|
||
setCacheStatus(message);
|
||
} finally {
|
||
setCacheLoading(false);
|
||
}
|
||
};
|
||
|
||
const runRepair = async () => {
|
||
setMaintenanceStatus(null);
|
||
try {
|
||
const baseUrl = getApiBase();
|
||
const response = await authFetch(`${baseUrl}/admin/maintenance/repair`, { method: "POST" });
|
||
if (!response.ok) {
|
||
const text = await response.text();
|
||
throw new Error(text || "Repair failed");
|
||
}
|
||
const data = await response.json();
|
||
setMaintenanceStatus(`Integrity check: ${data?.integrity ?? "unknown"}. Vacuum complete.`);
|
||
} catch (err) {
|
||
console.error(err);
|
||
setMaintenanceStatus("Database repair failed.");
|
||
}
|
||
};
|
||
|
||
const runCleanup = async () => {
|
||
setMaintenanceStatus(null);
|
||
try {
|
||
const baseUrl = getApiBase();
|
||
const response = await authFetch(`${baseUrl}/admin/maintenance/cleanup?days=90`, {
|
||
method: "POST",
|
||
});
|
||
if (!response.ok) {
|
||
const text = await response.text();
|
||
throw new Error(text || "Cleanup failed");
|
||
}
|
||
const data = await response.json();
|
||
setMaintenanceStatus(`Cleaned history older than ${data?.days ?? 90} days.`);
|
||
} catch (err) {
|
||
console.error(err);
|
||
setMaintenanceStatus("Cleanup failed.");
|
||
}
|
||
};
|
||
|
||
const clearLogFile = async () => {
|
||
setMaintenanceStatus(null);
|
||
try {
|
||
const baseUrl = getApiBase();
|
||
const response = await authFetch(`${baseUrl}/admin/maintenance/logs/clear`, {
|
||
method: "POST",
|
||
});
|
||
if (!response.ok) {
|
||
const text = await response.text();
|
||
throw new Error(text || "Clear logs failed");
|
||
}
|
||
setMaintenanceStatus("Log file cleared.");
|
||
setLogsLines([]);
|
||
} catch (err) {
|
||
console.error(err);
|
||
setMaintenanceStatus("Clearing logs failed.");
|
||
}
|
||
};
|
||
|
||
const groupIsDirty = (group: SettingsSectionGroup) =>
|
||
group.items.some((item) => {
|
||
const current = (formValues[item.key] ?? "").trim();
|
||
const saved = item.sensitive ? "" : (item.value ?? "").trim();
|
||
return BOOL_SETTINGS.has(item.key)
|
||
? (current.toLowerCase() === "true") !== (saved.toLowerCase() === "true")
|
||
: current !== saved;
|
||
});
|
||
const discardGroup = (group: SettingsSectionGroup) => {
|
||
setFormValues((current) => {
|
||
const next = { ...current };
|
||
for (const item of group.items) next[item.key] = item.sensitive ? "" : (item.value ?? "");
|
||
return next;
|
||
});
|
||
setSectionFeedback((current) => ({
|
||
...current,
|
||
[group.key]: { tone: "status", message: "Unsaved changes discarded." },
|
||
}));
|
||
};
|
||
const collapsedGroup = (group: SettingsSectionGroup) => {
|
||
if (
|
||
[
|
||
"magent-binding",
|
||
"magent-proxy",
|
||
"magent-ssl",
|
||
"site-navigation",
|
||
"requests-advanced",
|
||
"logs-rotation",
|
||
"logs-components",
|
||
].includes(group.key) ||
|
||
group.key.endsWith("-additional")
|
||
)
|
||
return true;
|
||
if (group.key.startsWith("magent-notify-") && !["magent-notify-core", "magent-notify-email"].includes(group.key)) {
|
||
const enabled = group.items.find((item) => item.key.endsWith("_enabled"));
|
||
return !enabled || String(enabled.value).toLowerCase() !== "true";
|
||
}
|
||
return false;
|
||
};
|
||
|
||
if (loading) {
|
||
return <main className="card">Loading admin settings...</main>;
|
||
}
|
||
|
||
return (
|
||
<AdminShell
|
||
title={SECTION_LABELS[section] ?? "Settings"}
|
||
subtitle={SECTION_DESCRIPTIONS[section] ?? "Manage settings."}
|
||
>
|
||
{status && <div className="error-banner">{status}</div>}
|
||
{currentServiceStatus && (
|
||
<div className="config-service-status" role="status">
|
||
<span className={`config-connection-badge is-${currentServiceStatus.status}`}>
|
||
{serviceStatusLabel(currentServiceStatus.status)}
|
||
</span>
|
||
<span>
|
||
{serviceStatusCheckedAt
|
||
? `Checked at ${new Date(serviceStatusCheckedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`
|
||
: "Not checked yet"}
|
||
</span>
|
||
<button type="button" className="ghost-button" onClick={() => void loadServiceStatuses()}>
|
||
Refresh status
|
||
</button>
|
||
</div>
|
||
)}
|
||
{renderedSettingsSections.length > 0 ? (
|
||
<div className="admin-form admin-zone-stack">
|
||
{renderedSettingsSections.map((sectionGroup) => (
|
||
<SettingsRegion
|
||
id={`config-${sectionGroup.key}`}
|
||
key={sectionGroup.key}
|
||
title={sectionGroup.title}
|
||
collapsed={collapsedGroup(sectionGroup)}
|
||
>
|
||
<form
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
void saveSettingGroup(sectionGroup);
|
||
}}
|
||
>
|
||
<div className="section-header">
|
||
<div className="config-subsection-heading">
|
||
<h2>{sectionGroup.title}</h2>
|
||
</div>
|
||
{sectionGroup.key === "sonarr-library" && (
|
||
<button type="button" onClick={() => loadOptions("sonarr")}>
|
||
Refresh Sonarr options
|
||
</button>
|
||
)}
|
||
{sectionGroup.key === "radarr-library" && (
|
||
<button type="button" onClick={() => loadOptions("radarr")}>
|
||
Refresh Radarr options
|
||
</button>
|
||
)}
|
||
{sectionGroup.key === "jellyfin-users" && (
|
||
<button type="button" onClick={syncJellyfinUsers}>
|
||
Import Jellyfin users
|
||
</button>
|
||
)}
|
||
{showArtworkExtras && sectionGroup.key === "artwork" ? (
|
||
<div className="sync-actions">
|
||
<button type="button" onClick={prefetchArtwork}>
|
||
Cache all artwork now
|
||
</button>
|
||
<button type="button" className="ghost-button" onClick={prefetchArtworkMissing}>
|
||
Sync only missing artwork
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
{showRequestsExtras && sectionGroup.key === "requests-sync" && (
|
||
<div className="sync-actions-block">
|
||
<div className="sync-actions">
|
||
<button type="button" onClick={syncRequests}>
|
||
Run full refresh (rebuild cache)
|
||
</button>
|
||
<button type="button" className="ghost-button" onClick={syncRequestsDelta}>
|
||
Run delta sync (recent changes)
|
||
</button>
|
||
</div>
|
||
<div className="meta sync-note">
|
||
Full refresh rebuilds the entire cache. Delta sync only checks new or updated requests.
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) && (
|
||
<p className="section-subtitle">
|
||
{sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]}
|
||
</p>
|
||
)}
|
||
{sectionGroup.key === "sonarr-library" && sonarrError && (
|
||
<div className="error-banner">{sonarrError}</div>
|
||
)}
|
||
{sectionGroup.key === "radarr-library" && radarrError && (
|
||
<div className="error-banner">{radarrError}</div>
|
||
)}
|
||
{sectionGroup.key === "jellyfin-users" && jellyfinSyncStatus && (
|
||
<div className="status-banner">{jellyfinSyncStatus}</div>
|
||
)}
|
||
{showArtworkExtras && sectionGroup.key === "artwork" && artworkPrefetchStatus && (
|
||
<div className="status-banner">{artworkPrefetchStatus}</div>
|
||
)}
|
||
{showArtworkExtras && sectionGroup.key === "artwork" && artworkSummaryStatus && (
|
||
<div className="status-banner">{artworkSummaryStatus}</div>
|
||
)}
|
||
{showArtworkExtras && sectionGroup.key === "artwork" && (
|
||
<div className="summary">
|
||
<div className="summary-card">
|
||
<strong>Missing artwork</strong>
|
||
<p>{artworkSummary?.missing_artwork ?? "--"}</p>
|
||
<div className="meta">Requests missing poster/backdrop or cache files.</div>
|
||
</div>
|
||
<div className="summary-card">
|
||
<strong>Artwork cache size</strong>
|
||
<p>{formatBytes(artworkSummary?.cache_bytes)}</p>
|
||
<div className="meta">{artworkSummary?.cache_files ?? "--"} cached files</div>
|
||
</div>
|
||
<div className="summary-card">
|
||
<strong>Total requests</strong>
|
||
<p>{artworkSummary?.total_requests ?? "--"}</p>
|
||
<div className="meta">Requests currently tracked in cache.</div>
|
||
</div>
|
||
<div className="summary-card">
|
||
<strong>Cache mode</strong>
|
||
<p>{artworkSummary?.cache_mode ?? "--"}</p>
|
||
<div className="meta">Artwork setting applied to posters/backdrops.</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{showRequestsExtras && sectionGroup.key === "requests-sync" && requestsSyncStatus && (
|
||
<div className="status-banner">{requestsSyncStatus}</div>
|
||
)}
|
||
{showArtworkExtras && sectionGroup.key === "artwork" && artworkPrefetch && (
|
||
<div className="sync-progress">
|
||
<div className="sync-meta">
|
||
<span>Status: {artworkPrefetch.status}</span>
|
||
<span>
|
||
{artworkPrefetch.processed ?? 0}
|
||
{artworkPrefetch.total ? ` / ${artworkPrefetch.total}` : ""} cached
|
||
</span>
|
||
</div>
|
||
<div className={`progress ${artworkPrefetch.status === "completed" ? "progress-complete" : ""}`}>
|
||
<div
|
||
className="progress-fill"
|
||
style={{
|
||
width: `${computeProgressPercent(
|
||
artworkPrefetch.processed,
|
||
artworkPrefetch.total,
|
||
artworkPrefetch.status,
|
||
)}%`,
|
||
}}
|
||
/>
|
||
</div>
|
||
{artworkPrefetch.message && <div className="meta">{artworkPrefetch.message}</div>}
|
||
</div>
|
||
)}
|
||
{showRequestsExtras && sectionGroup.key === "requests-sync" && requestsSync && (
|
||
<div className="sync-progress">
|
||
<div className="sync-meta">
|
||
<span>Status: {requestsSync.status}</span>
|
||
<span>
|
||
{requestsSync.stored ?? 0}
|
||
{requestsSync.total ? ` / ${requestsSync.total}` : ""} synced
|
||
</span>
|
||
</div>
|
||
<div className={`progress ${requestsSync.status === "completed" ? "progress-complete" : ""}`}>
|
||
<div
|
||
className="progress-fill"
|
||
style={{
|
||
width: `${computeProgressPercent(
|
||
requestsSync.stored,
|
||
requestsSync.total,
|
||
requestsSync.status,
|
||
)}%`,
|
||
}}
|
||
/>
|
||
</div>
|
||
{requestsSync.message && <div className="meta">{requestsSync.message}</div>}
|
||
</div>
|
||
)}
|
||
<div className="admin-grid">
|
||
{sectionGroup.items
|
||
.filter((setting) => {
|
||
const enableKey = sectionGroup.items.find((item) => item.key.endsWith("_enabled"))?.key;
|
||
if (enableKey && setting.key !== enableKey && formValues[enableKey] !== "true") return false;
|
||
if (
|
||
sectionGroup.key === "issues-resolution-confirmation" &&
|
||
setting.key !== "issue_confirmation_contact_attempts" &&
|
||
formValues.issue_confirmation_contact_attempts === "0"
|
||
)
|
||
return false;
|
||
return true;
|
||
})
|
||
.map((setting) => {
|
||
const service = setting.key.startsWith("sonarr_") ? sonarrOptions : radarrOptions;
|
||
const isProfile =
|
||
setting.key === "sonarr_quality_profile_id" || setting.key === "radarr_quality_profile_id";
|
||
const isRoot = setting.key === "sonarr_root_folder" || setting.key === "radarr_root_folder";
|
||
const options = isProfile
|
||
? service?.qualityProfiles.map((item) => ({
|
||
value: String(item.id),
|
||
label: item.name || item.label,
|
||
}))
|
||
: isRoot
|
||
? service?.rootFolders.map((item) => ({
|
||
value: String(item.id),
|
||
label: item.path || item.label,
|
||
}))
|
||
: undefined;
|
||
return (
|
||
<SettingField
|
||
key={setting.key}
|
||
setting={setting}
|
||
label={labelFromKey(setting.key)}
|
||
value={formValues[setting.key] ?? ""}
|
||
help={settingDescriptions[setting.key]}
|
||
placeholder={settingPlaceholders[setting.key]}
|
||
boolean={BOOL_SETTINGS.has(setting.key)}
|
||
numeric={NUMBER_SETTINGS.has(setting.key)}
|
||
multiline={TEXTAREA_SETTINGS.has(setting.key)}
|
||
options={options}
|
||
optionsUnavailable={(isRoot || isProfile) && !service}
|
||
onChange={(value) => setFormValues((current) => ({ ...current, [setting.key]: value }))}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
{sectionFeedback[sectionGroup.key] && (
|
||
<div
|
||
className={sectionFeedback[sectionGroup.key]?.tone === "error" ? "error-banner" : "status-banner"}
|
||
>
|
||
{sectionFeedback[sectionGroup.key]?.message}
|
||
</div>
|
||
)}
|
||
<div className="settings-section-actions">
|
||
{groupIsDirty(sectionGroup) && (
|
||
<>
|
||
<span className="config-unsaved" role="status">
|
||
Unsaved changes
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="ghost-button"
|
||
disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]}
|
||
onClick={() => discardGroup(sectionGroup)}
|
||
>
|
||
Discard
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
{sectionGroup.key === "magent-notify-email" ? (
|
||
<label className="settings-inline-field">
|
||
<span>Test email recipient</span>
|
||
<input
|
||
type="email"
|
||
placeholder="Leave blank to use the configured sender"
|
||
value={emailTestRecipient}
|
||
onChange={(event) => setEmailTestRecipient(event.target.value)}
|
||
/>
|
||
</label>
|
||
) : null}
|
||
{getSectionTestLabel(sectionGroup.key) ? (
|
||
<button
|
||
type="button"
|
||
className="ghost-button settings-action-button"
|
||
onClick={() => void testSettingGroup(sectionGroup)}
|
||
disabled={
|
||
groupIsDirty(sectionGroup) ||
|
||
sectionSaving[sectionGroup.key] ||
|
||
sectionTesting[sectionGroup.key]
|
||
}
|
||
title={groupIsDirty(sectionGroup) ? "Save changes before testing." : undefined}
|
||
>
|
||
{sectionTesting[sectionGroup.key] ? "Testing..." : getSectionTestLabel(sectionGroup.key)}
|
||
</button>
|
||
) : null}
|
||
<button
|
||
type="submit"
|
||
className="settings-action-button"
|
||
disabled={
|
||
!groupIsDirty(sectionGroup) || sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]
|
||
}
|
||
>
|
||
{sectionSaving[sectionGroup.key] ? "Saving…" : "Save changes"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</SettingsRegion>
|
||
))}
|
||
</div>
|
||
) : (
|
||
!showMaintenance &&
|
||
!showLogs && (
|
||
<div className="status-banner">
|
||
{section === "magent"
|
||
? "Magent runtime settings have moved to General. Notification provider settings have moved to Notifications."
|
||
: "No settings to show here yet. Try the Cache Control page for artwork and saved-request controls."}
|
||
</div>
|
||
)
|
||
)}
|
||
{showLogs && (
|
||
<section className="admin-section admin-zone" id="logs">
|
||
<div className="section-header">
|
||
<h2>Activity log</h2>
|
||
<div className="log-actions">
|
||
<label className="recent-filter">
|
||
<span>Lines to show</span>
|
||
<select value={logsCount} onChange={(event) => setLogsCount(Number(event.target.value))}>
|
||
<option value={100}>100</option>
|
||
<option value={200}>200</option>
|
||
<option value={500}>500</option>
|
||
<option value={1000}>1000</option>
|
||
</select>
|
||
</label>
|
||
<button type="button" onClick={loadLogs}>
|
||
Refresh log
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{logsStatus && <div className="error-banner">{logsStatus}</div>}
|
||
<pre className="log-viewer">{logsLines.join("")}</pre>
|
||
</section>
|
||
)}
|
||
{showCacheExtras && (
|
||
<section className="admin-section admin-zone" id="cache">
|
||
<div className="section-header">
|
||
<h2>Saved requests</h2>
|
||
<div className="config-inline-controls">
|
||
<label>
|
||
Rows{" "}
|
||
<select value={cacheCount} onChange={(event) => setCacheCount(Number(event.target.value))}>
|
||
{[25, 50, 100, 200].map((count) => (
|
||
<option key={count} value={count}>
|
||
{count}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<button type="button" onClick={loadCache} disabled={cacheLoading}>
|
||
{cacheLoading ? "Loading…" : "Load saved requests"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{cacheStatus && (
|
||
<p className="error-banner" role="alert">
|
||
{cacheStatus}
|
||
</p>
|
||
)}
|
||
<div className="cache-table">
|
||
<div className="cache-row cache-head">
|
||
<span>Request</span>
|
||
<span>Title</span>
|
||
<span>Type</span>
|
||
<span>Status</span>
|
||
<span>Last update</span>
|
||
</div>
|
||
{cacheRows.length === 0 ? (
|
||
<div className="meta">No saved requests loaded yet.</div>
|
||
) : (
|
||
cacheRows.map((row) => (
|
||
<div key={row.request_id} className="cache-row">
|
||
<span>#{row.request_id}</span>
|
||
<span>{row.title || "Untitled"}</span>
|
||
<span>{row.media_type || "unknown"}</span>
|
||
<span>{row.status ?? "n/a"}</span>
|
||
<span>{row.updated_at || row.created_at || "n/a"}</span>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</section>
|
||
)}
|
||
{showMaintenance && (
|
||
<section className="admin-section admin-zone" id="maintenance">
|
||
<div className="section-header">
|
||
<h2>Maintenance</h2>
|
||
</div>
|
||
<div className="maintenance-layout">
|
||
<div className="admin-panel maintenance-tools-panel">
|
||
<div className="maintenance-panel-copy">
|
||
<h3>Recovery and cleanup</h3>
|
||
<p className="lede">Repair the database or remove old history when troubleshooting.</p>
|
||
</div>
|
||
{maintenanceStatus && <div className="status-banner">{maintenanceStatus}</div>}
|
||
<div className="maintenance-action-grid">
|
||
<div className="maintenance-action-card">
|
||
<div className="maintenance-action-copy">
|
||
<h3>Repair database</h3>
|
||
<p>Run integrity and repair routines against the local Magent database.</p>
|
||
</div>
|
||
<button type="button" onClick={runRepair}>
|
||
Repair database
|
||
</button>
|
||
</div>
|
||
<div className="maintenance-action-card">
|
||
<div className="maintenance-action-copy">
|
||
<h3>Clean request history</h3>
|
||
<p>Remove request history entries older than 90 days.</p>
|
||
</div>
|
||
<button type="button" className="ghost-button" onClick={runCleanup}>
|
||
Clean history
|
||
</button>
|
||
</div>
|
||
<div className="maintenance-action-card">
|
||
<div className="maintenance-action-copy">
|
||
<h3>Clear activity log</h3>
|
||
<p>Truncate the local activity log file so fresh troubleshooting starts clean.</p>
|
||
</div>
|
||
<button type="button" className="ghost-button" onClick={clearLogFile}>
|
||
Clear activity log
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<a className="config-tool-link" href="/admin/diagnostics">
|
||
Open system health and diagnostics →
|
||
</a>
|
||
</div>
|
||
</section>
|
||
)}
|
||
</AdminShell>
|
||
);
|
||
}
|