'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useRouter } from 'next/navigation' import { authFetch, clearToken, getApiBase, getToken, getEventStreamToken } from '../lib/auth' import AdminShell from '../ui/AdminShell' import AdminDiagnosticsPanel from '../ui/AdminDiagnosticsPanel' 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 }[] } const SECTION_LABELS: Record = { magent: 'Magent', general: 'General', notifications: 'Notifications', seerr: 'Seerr', jellyseerr: 'Seerr', jellyfin: 'Jellyfin', artwork: 'Artwork cache', cache: 'Cache Control', sonarr: 'Sonarr', radarr: 'Radarr', prowlarr: 'Prowlarr', qbittorrent: 'qBittorrent', log: 'Activity log', requests: 'Request sync', site: 'Site', } 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_changelog', 'magent_ssl_certificate_pem', 'magent_ssl_private_key_pem', ]) const URL_SETTINGS = new Set([ 'magent_application_url', 'magent_api_url', 'magent_proxy_base_url', 'magent_notify_discord_webhook_url', 'magent_notify_push_base_url', 'magent_notify_webhook_url', 'jellyseerr_base_url', 'jellyfin_base_url', 'jellyfin_public_url', 'sonarr_base_url', 'radarr_base_url', 'prowlarr_base_url', 'qbittorrent_base_url', ]) 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_delta_sync_interval_minutes', 'requests_cleanup_days', ]) const BANNER_TONES = ['info', 'warning', 'error', 'maintenance'] const SECTION_DESCRIPTIONS: Record = { magent: 'Magent service settings. Runtime and notification controls are organized under General and Notifications.', general: 'Application runtime, binding, reverse proxy, and manual SSL settings for the Magent UI/API.', notifications: 'Notification providers and delivery channel settings used by Magent messaging features.', seerr: 'Connect Seerr where users submit content requests.', jellyseerr: 'Connect Seerr where users submit content requests.', jellyfin: 'Control Jellyfin login and availability checks.', artwork: 'Cache posters/backdrops and review artwork coverage.', cache: 'Manage saved requests cache and refresh behavior.', sonarr: 'TV automation settings.', radarr: 'Movie automation settings.', prowlarr: 'Indexer search settings.', qbittorrent: 'Downloader connection settings.', requests: 'Control how often requests are refreshed and cleaned up.', log: 'Activity log for troubleshooting.', site: 'Sitewide banner, login page visibility, and version details. The changelog is generated from git history during release builds.', } const SETTINGS_SECTION_MAP: Record = { magent: 'magent', general: 'magent', notifications: 'magent', seerr: 'jellyseerr', jellyseerr: 'jellyseerr', jellyfin: 'jellyfin', artwork: null, sonarr: 'sonarr', radarr: 'radarr', prowlarr: 'prowlarr', qbittorrent: 'qbittorrent', requests: 'requests', 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: 'Application', description: 'Canonical application/API URLs and port defaults for the Magent UI/API endpoints.', keys: [ 'magent_application_url', 'magent_application_port', 'magent_api_url', '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: 'Manual SSL Bind', description: 'Optional direct TLS binding values. Paste PEM certificate and private key or provide file paths.', 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', 'magent_notify_webhook_enabled', 'magent_notify_webhook_url', ], }, ] const MAGENT_GROUPS_BY_SECTION: Record> = { general: new Set(['magent-runtime', 'magent-proxy', 'magent-ssl']), notifications: new Set([ 'magent-notify-core', 'magent-notify-email', 'magent-notify-discord', 'magent-notify-telegram', 'magent-notify-push', ]), } const SETTING_LABEL_OVERRIDES: Record = { 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', site_login_show_jellyfin_login: 'Login page: Jellyfin sign-in', 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', 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 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 = { jellyseerr: 'seerr', jellyfin: 'jellyfin', sonarr: 'sonarr', radarr: 'radarr', prowlarr: 'prowlarr', qbittorrent: 'qbittorrent', } export default function SettingsPage({ section }: SettingsPageProps) { const router = useRouter() const [settings, setSettings] = useState([]) const [formValues, setFormValues] = useState>({}) const [status, setStatus] = useState(null) const [sectionFeedback, setSectionFeedback] = useState>({}) const [sectionSaving, setSectionSaving] = useState>({}) const [sectionTesting, setSectionTesting] = useState>({}) const [emailTestRecipient, setEmailTestRecipient] = useState('') const [loading, setLoading] = useState(true) const [sonarrOptions, setSonarrOptions] = useState(null) const [radarrOptions, setRadarrOptions] = useState(null) const [sonarrError, setSonarrError] = useState(null) const [radarrError, setRadarrError] = useState(null) const [jellyfinSyncStatus, setJellyfinSyncStatus] = useState(null) const [requestsSyncStatus, setRequestsSyncStatus] = useState(null) const [artworkPrefetchStatus, setArtworkPrefetchStatus] = useState(null) const [logsStatus, setLogsStatus] = useState(null) const [logsLines, setLogsLines] = useState([]) const [logsCount, setLogsCount] = useState(200) const [cacheRows, setCacheRows] = useState([]) const [cacheCount, setCacheCount] = useState(50) const [cacheStatus, setCacheStatus] = useState(null) const [cacheLoading, setCacheLoading] = useState(false) const [requestsSync, setRequestsSync] = useState(null) const [artworkPrefetch, setArtworkPrefetch] = useState(null) const [artworkSummary, setArtworkSummary] = useState(null) const [artworkSummaryStatus, setArtworkSummaryStatus] = useState(null) const [maintenanceStatus, setMaintenanceStatus] = useState(null) const [maintenanceBusy, setMaintenanceBusy] = useState(false) const [liveStreamConnected, setLiveStreamConnected] = useState(false) const requestsSyncRef = useRef(null) const artworkPrefetchRef = useRef(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) => { 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 = Array.isArray(data?.settings) ? data.settings : [] setSettings(fetched) const initialValues: Record = {} 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.') } } }, []) useEffect(() => { const load = async () => { if (!getToken()) { router.push('/login') return } try { await loadSettings() 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, loadSettings, router, section]) const groupedSettings = useMemo(() => { const groups: Record = {} 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 isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications' const visibleSections = settingsSection ? [settingsSection] : [] const isCacheSection = section === 'cache' const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source']) const artworkSettingKeys = new Set(['artwork_cache_mode']) const generatedSettingKeys = new Set(['site_changelog']) const hiddenSettingKeys = new Set([...cacheSettingKeys, ...artworkSettingKeys, ...generatedSettingKeys]) const requestSettingOrder = [ 'requests_poll_interval_seconds', 'requests_delta_sync_interval_minutes', 'requests_full_sync_time', 'requests_cleanup_time', 'requests_cleanup_days', ] const siteSettingOrder = [ 'site_banner_enabled', 'site_banner_message', 'site_banner_tone', 'site_login_show_jellyfin_login', 'site_login_show_local_login', 'site_login_show_forgot_password', 'site_login_show_signup_link', ] const sortByOrder = (items: AdminSetting[], order: string[]) => { const position = new Map(order.map((key, index) => [key, index])) return [...items].sort((a, b) => { const aIndex = position.get(a.key) ?? Number.POSITIVE_INFINITY const bIndex = position.get(b.key) ?? Number.POSITIVE_INFINITY if (aIndex !== bIndex) return aIndex - bIndex return a.key.localeCompare(b.key) }) } const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key)) const artworkSettings = settings.filter((setting) => artworkSettingKeys.has(setting.key)) const settingsSections: SettingsSectionGroup[] = isCacheSection ? [ { key: 'cache', title: 'Cache control', items: cacheSettings }, { key: 'artwork', title: 'Artwork cache', items: artworkSettings }, ] : isMagentGroupedSection ? (() => { if (section === 'magent') { return [] } const magentItems = groupedSettings.magent ?? [] const byKey = new Map(magentItems.map((item) => [item.key, item])) const allowedGroupKeys = MAGENT_GROUPS_BY_SECTION[section] ?? new Set() const groups: SettingsSectionGroup[] = MAGENT_SECTION_GROUPS.filter((group) => allowedGroupKeys.has(group.key), ).map((group) => { const items = group.keys .map((key) => byKey.get(key)) .filter((item): item is AdminSetting => Boolean(item)) return { key: group.key, title: group.title, description: group.description, items, } }) return groups })() : visibleSections.map((sectionKey) => ({ key: sectionKey, title: SECTION_LABELS[sectionKey] ?? sectionKey, items: (() => { const sectionItems = groupedSettings[sectionKey] ?? [] const filtered = sectionKey === 'requests' || sectionKey === 'artwork' || sectionKey === 'site' ? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key)) : sectionItems if (sectionKey === 'requests') { return sortByOrder(filtered, requestSettingOrder) } if (sectionKey === 'site') { return sortByOrder(filtered, siteSettingOrder) } return filtered })(), })) const showLogs = section === 'logs' const showMaintenance = section === 'maintenance' const showRequestsExtras = section === 'requests' const showArtworkExtras = section === 'cache' 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') return true return false } useEffect(() => { requestsSyncRef.current = requestsSync }, [requestsSync]) useEffect(() => { artworkPrefetchRef.current = artworkPrefetch }, [artworkPrefetch]) const settingDescriptions: Record = { 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.', 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.', sonarr_quality_profile_id: 'Quality profile used when adding TV shows.', sonarr_root_folder: 'Root folder where Sonarr stores TV shows.', sonarr_qbittorrent_category: 'qBittorrent category for manual Sonarr downloads.', 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: 'Quality profile used when adding movies.', radarr_root_folder: 'Root folder where Radarr stores movies.', radarr_qbittorrent_category: 'qBittorrent category for manual Radarr downloads.', 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_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_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: 'Visual tone for the banner.', 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_changelog: 'One update per line for the public changelog.', } const settingPlaceholders: Record = { 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', jellyfin_public_url: 'https://jelly.example.com', sonarr_base_url: 'https://sonarr.example.com or 10.30.1.81:8989', 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', } const buildSelectOptions = ( currentValue: string, options: { id: number; label: string; path?: string }[], includePath: boolean ) => { const optionValues = new Set(options.map((option) => String(option.id))) const list = options.map((option) => ( )) if (currentValue && !optionValues.has(currentValue)) { list.unshift( ) } return list } 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 = {} 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 (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: any): 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 saved = await saveSettingGroup(sectionGroup, { successMessage: null }) if (!saved) { return } 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() 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 || 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 console.error(err) setLiveStreamConnected(false) } } 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 runFlushAndResync = async () => { setMaintenanceStatus(null) setMaintenanceBusy(true) if (typeof window !== 'undefined') { const ok = window.confirm( 'This will perform a nuclear reset: clear cached requests/history, wipe non-admin users, invites, and profiles, then re-sync users and requests from Seerr. Continue?' ) if (!ok) { setMaintenanceBusy(false) return } } try { const baseUrl = getApiBase() setMaintenanceStatus('Running nuclear flush...') const flushResponse = await authFetch(`${baseUrl}/admin/maintenance/flush`, { method: 'POST', }) if (!flushResponse.ok) { const text = await flushResponse.text() throw new Error(text || 'Flush failed') } const flushData = await flushResponse.json() const usersCleared = Number(flushData?.userObjectsCleared?.users ?? 0) setMaintenanceStatus(`Nuclear flush complete. Cleared ${usersCleared} non-admin users. Re-syncing users...`) const usersResyncResponse = await authFetch(`${baseUrl}/admin/jellyseerr/users/resync`, { method: 'POST', }) if (!usersResyncResponse.ok) { const text = await usersResyncResponse.text() throw new Error(text || 'User resync failed') } const usersResyncData = await usersResyncResponse.json() setMaintenanceStatus( `Users re-synced (${usersResyncData?.imported ?? 0} imported). Starting request re-sync...` ) await syncRequests() setMaintenanceStatus('Nuclear flush complete. User and request re-sync running now.') } catch (err) { console.error(err) setMaintenanceStatus('Nuclear flush + resync failed.') } finally { setMaintenanceBusy(false) } } 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 cacheSourceLabel = formValues.requests_data_source === 'always_js' ? 'Seerr direct' : formValues.requests_data_source === 'prefer_cache' ? 'Saved requests only' : 'Saved requests only' const cacheTtlLabel = formValues.requests_sync_ttl_minutes || '60' const maintenanceRail = showMaintenance ? (
Maintenance

Admin tools

Repair, cleanup, diagnostics, and nuclear resync are grouped into a single operating page.

Runtime

Service state

Maintenance job {maintenanceBusy ? 'Running' : 'Idle'}
Live updates {liveStreamConnected ? 'Connected' : 'Polling'}
Log lines in view {logsLines.length}
Last tool status {maintenanceStatus || 'Idle'}
) : undefined const cacheRail = showCacheExtras ? (
Cache control

Saved requests

Load and inspect cached request entries from the right rail.

Data source {cacheSourceLabel}
Refresh TTL {cacheTtlLabel} min
Rows loaded {cacheRows.length}
Live updates {liveStreamConnected ? 'Connected' : 'Polling'}
{cacheStatus &&
{cacheStatus}
}
Artwork

Cache stats

Missing artwork {artworkSummary?.missing_artwork ?? '--'}
Cache size {formatBytes(artworkSummary?.cache_bytes)}
Cached files {artworkSummary?.cache_files ?? '--'}
Mode {artworkSummary?.cache_mode ?? '--'}
) : undefined if (loading) { return
Loading admin settings...
} return ( router.push('/admin')}> Back to settings } > {status &&
{status}
} {settingsSections.length > 0 ? (
{settingsSections .filter(shouldRenderSection) .map((sectionGroup) => (

{sectionGroup.key === 'requests' ? 'Request sync controls' : sectionGroup.title}

{sectionGroup.key === 'sonarr' && ( )} {sectionGroup.key === 'radarr' && ( )} {sectionGroup.key === 'jellyfin' && ( )} {showArtworkExtras && sectionGroup.key === 'artwork' ? (
) : null} {showRequestsExtras && sectionGroup.key === 'requests' && (
Full refresh rebuilds the entire cache. Delta sync only checks new or updated requests.
)}
{(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) && (!settingsSection || isMagentGroupedSection) && (

{sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]}

)} {section === 'general' && sectionGroup.key === 'magent-runtime' && (
Runtime host/port and SSL values are configuration settings. Container/process restarts may still be required before bind/port changes take effect.
)} {sectionGroup.key === 'sonarr' && sonarrError && (
{sonarrError}
)} {sectionGroup.key === 'radarr' && radarrError && (
{radarrError}
)} {sectionGroup.key === 'jellyfin' && jellyfinSyncStatus && (
{jellyfinSyncStatus}
)} {showArtworkExtras && sectionGroup.key === 'artwork' && artworkPrefetchStatus && (
{artworkPrefetchStatus}
)} {showArtworkExtras && sectionGroup.key === 'artwork' && artworkSummaryStatus && (
{artworkSummaryStatus}
)} {showArtworkExtras && sectionGroup.key === 'artwork' && (
Missing artwork

{artworkSummary?.missing_artwork ?? '--'}

Requests missing poster/backdrop or cache files.
Artwork cache size

{formatBytes(artworkSummary?.cache_bytes)}

{artworkSummary?.cache_files ?? '--'} cached files
Total requests

{artworkSummary?.total_requests ?? '--'}

Requests currently tracked in cache.
Cache mode

{artworkSummary?.cache_mode ?? '--'}

Artwork setting applied to posters/backdrops.
)} {showRequestsExtras && sectionGroup.key === 'requests' && requestsSyncStatus && (
{requestsSyncStatus}
)} {showRequestsExtras && sectionGroup.key === 'requests' && (
Full refresh checks only decide when to run a full refresh. The delta sync interval polls for new or updated requests.
)} {showArtworkExtras && sectionGroup.key === 'artwork' && artworkPrefetch && (
Status: {artworkPrefetch.status} {artworkPrefetch.processed ?? 0} {artworkPrefetch.total ? ` / ${artworkPrefetch.total}` : ''} cached
{artworkPrefetch.message &&
{artworkPrefetch.message}
}
)} {showRequestsExtras && sectionGroup.key === 'requests' && requestsSync && (
Status: {requestsSync.status} {requestsSync.stored ?? 0} {requestsSync.total ? ` / ${requestsSync.total}` : ''} synced
{requestsSync.message &&
{requestsSync.message}
}
)}
{sectionGroup.items.map((setting) => { const value = formValues[setting.key] ?? '' const helperText = settingDescriptions[setting.key] const isSonarrProfile = setting.key === 'sonarr_quality_profile_id' const isSonarrRoot = setting.key === 'sonarr_root_folder' const isRadarrProfile = setting.key === 'radarr_quality_profile_id' const isRadarrRoot = setting.key === 'radarr_root_folder' const isBoolSetting = BOOL_SETTINGS.has(setting.key) const isUrlSetting = URL_SETTINGS.has(setting.key) const inputPlaceholder = setting.sensitive && setting.isSet ? 'Configured (enter to replace)' : settingPlaceholders[setting.key] ?? '' if (isBoolSetting) { return ( ) } if (isSonarrProfile && sonarrOptions) { return ( ) } if (isSonarrRoot && sonarrOptions) { return ( ) } if (isRadarrProfile && radarrOptions) { return ( ) } if (isRadarrRoot && radarrOptions) { return ( ) } if (setting.key === 'log_level') { return ( ) } if ( setting.key === 'log_http_client_level' || setting.key === 'log_background_sync_level' ) { return ( ) } if (setting.key === 'artwork_cache_mode') { return ( ) } if (setting.key === 'site_banner_tone') { return ( ) } if (setting.key === 'magent_notify_push_provider') { return ( ) } if ( setting.key === 'requests_full_sync_time' || setting.key === 'requests_cleanup_time' ) { return ( ) } if (NUMBER_SETTINGS.has(setting.key)) { return ( ) } if (setting.key === 'requests_data_source') { return ( ) } if (TEXTAREA_SETTINGS.has(setting.key)) { const isPemField = setting.key === 'magent_ssl_certificate_pem' || setting.key === 'magent_ssl_private_key_pem' return (