'use client' import { useEffect, useMemo, useState } from 'react' import { useRouter } from 'next/navigation' import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' 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 }[] } const SECTION_LABELS: Record = { jellyseerr: 'Jellyseerr', jellyfin: 'Jellyfin', artwork: 'Artwork', cache: 'Cache', sonarr: 'Sonarr', radarr: 'Radarr', prowlarr: 'Prowlarr', qbittorrent: 'qBittorrent', log: 'Activity log', requests: 'Request syncing', } const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr']) const SECTION_DESCRIPTIONS: Record = { jellyseerr: 'Connect the request system where users submit content.', jellyfin: 'Control Jellyfin login and availability checks.', artwork: 'Configure how posters and artwork are loaded.', cache: 'Manage saved request data and offline artwork.', sonarr: 'TV automation settings.', radarr: 'Movie automation settings.', prowlarr: 'Indexer search settings.', qbittorrent: 'Downloader connection settings.', requests: 'Sync and refresh cadence for requests.', log: 'Activity log for troubleshooting.', } const SETTINGS_SECTION_MAP: Record = { jellyseerr: 'jellyseerr', jellyfin: 'jellyfin', artwork: 'artwork', sonarr: 'sonarr', radarr: 'radarr', prowlarr: 'prowlarr', qbittorrent: 'qbittorrent', requests: 'requests', cache: null, logs: 'log', maintenance: null, } const labelFromKey = (key: string) => key .replaceAll('_', ' ') .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', 'Refresh saved requests if older than (minutes)') .replace('requests poll interval seconds', 'Background refresh check (seconds)') .replace('requests delta sync interval minutes', 'Check for new or updated requests every (minutes)') .replace('requests full sync time', 'Full refresh time (24h)') .replace('requests cleanup time', 'Clean up old history time (24h)') .replace('requests cleanup days', 'Remove history older than (days)') .replace('requests data source', 'Where requests are loaded from') .replace('jellyfin public url', 'Jellyfin public URL') .replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr') .replace('artwork cache mode', 'Artwork cache mode') type SettingsPageProps = { section: string } export default function SettingsPage({ section }: SettingsPageProps) { const router = useRouter() const [settings, setSettings] = useState([]) const [formValues, setFormValues] = useState>({}) const [status, setStatus] = useState(null) 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 [requestsSync, setRequestsSync] = useState(null) const [artworkPrefetch, setArtworkPrefetch] = useState(null) const [maintenanceStatus, setMaintenanceStatus] = useState(null) const [maintenanceBusy, setMaintenanceBusy] = useState(false) const loadSettings = async () => { 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(initialValues) setStatus(null) } const loadArtworkPrefetchStatus = 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 loadOptions = 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 === 'artwork') { await loadArtworkPrefetchStatus() } } 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') } }, [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 visibleSections = settingsSection ? [settingsSection] : [] const isCacheSection = section === 'cache' const cacheSettingKeys = new Set([ 'requests_sync_ttl_minutes', 'requests_data_source', 'artwork_cache_mode', ]) const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key)) const settingsSections = isCacheSection ? [{ key: 'cache', title: 'Cache settings', items: cacheSettings }] : visibleSections.map((sectionKey) => ({ key: sectionKey, title: SECTION_LABELS[sectionKey] ?? sectionKey, items: sectionKey === 'requests' || sectionKey === 'artwork' ? (groupedSettings[sectionKey] ?? []).filter( (setting) => !cacheSettingKeys.has(setting.key) ) : groupedSettings[sectionKey] ?? [], })) 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') return true return false } const settingDescriptions: Record = { jellyseerr_base_url: 'Base URL for your Jellyseerr server.', jellyseerr_api_key: 'API key used to read requests and status.', jellyfin_base_url: 'Local Jellyfin server URL for logins and lookups.', jellyfin_api_key: 'Admin API key for syncing users and availability.', jellyfin_public_url: 'Public Jellyfin URL used for the “Open in Jellyfin” button.', 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.', 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.', radarr_base_url: 'Radarr server URL for movies.', 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.', prowlarr_base_url: 'Prowlarr server URL for indexer searches.', prowlarr_api_key: 'API key for Prowlarr.', qbittorrent_base_url: 'qBittorrent server URL for download status.', 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 the background checker runs.', requests_delta_sync_interval_minutes: 'How often we check for new or updated requests.', requests_full_sync_time: 'Daily time to refresh the full request list.', requests_cleanup_time: 'Daily time to trim old history.', requests_cleanup_days: 'History older than this is removed during cleanup.', requests_data_source: 'Pick where Magent should read requests from.', log_level: 'How much detail is written to the activity log.', log_file: 'Where the activity log is stored.', } 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 submit = async (event: React.FormEvent) => { event.preventDefault() setStatus(null) const payload: Record = {} const formData = new FormData(event.currentTarget) for (const setting of settings) { const rawValue = formData.get(setting.key) if (typeof rawValue !== 'string') { continue } const value = rawValue.trim() if (value === '') { continue } payload[setting.key] = value } try { 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') } setStatus('Settings saved. New values take effect immediately.') await loadSettings() } catch (err) { console.error(err) const message = err instanceof Error && err.message ? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '') : 'Could not save settings.' setStatus(message) } } 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) 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) 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) 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) } } useEffect(() => { if (!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.') } } catch (err) { console.error(err) } }, 2000) return () => { active = false clearInterval(timer) } }, [artworkPrefetch?.status]) useEffect(() => { if (!artworkPrefetch || artworkPrefetch.status === 'running') { return } const timer = setTimeout(() => { setArtworkPrefetch(null) }, 5000) return () => clearTimeout(timer) }, [artworkPrefetch?.status]) useEffect(() => { if (!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) } }, [requestsSync?.status]) useEffect(() => { if (!requestsSync || requestsSync.status === 'running') { return } const timer = setTimeout(() => { setRequestsSync(null) }, 5000) return () => clearTimeout(timer) }, [requestsSync?.status]) const loadLogs = 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) } } useEffect(() => { if (!showLogs) { return } void loadLogs() const timer = setInterval(() => { void loadLogs() }, 5000) return () => clearInterval(timer) }, [logsCount, showLogs]) const loadCache = async () => { setCacheStatus(null) 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) } } 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 clear cached requests and history, then re-sync from Jellyseerr. Continue?' ) if (!ok) { setMaintenanceBusy(false) return } } try { const baseUrl = getApiBase() setMaintenanceStatus('Flushing database...') const flushResponse = await authFetch(`${baseUrl}/admin/maintenance/flush`, { method: 'POST', }) if (!flushResponse.ok) { const text = await flushResponse.text() throw new Error(text || 'Flush failed') } setMaintenanceStatus('Database flushed. Starting re-sync...') await syncRequests() setMaintenanceStatus('Database flushed. Re-sync running now.') } catch (err) { console.error(err) setMaintenanceStatus('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.') } } if (loading) { return
Loading admin settings...
} return ( router.push('/admin')}> Back to settings } > {settingsSections.length > 0 ? (
{settingsSections .filter(shouldRenderSection) .map((sectionGroup) => (

{sectionGroup.title}

{sectionGroup.key === 'sonarr' && ( )} {sectionGroup.key === 'radarr' && ( )} {sectionGroup.key === 'jellyfin' && ( )} {(showArtworkExtras && sectionGroup.key === 'artwork') || (showCacheExtras && sectionGroup.key === 'cache') ? ( ) : null} {showRequestsExtras && sectionGroup.key === 'requests' && (
)}
{SECTION_DESCRIPTIONS[sectionGroup.key] && (

{SECTION_DESCRIPTIONS[sectionGroup.key]}

)} {sectionGroup.key === 'sonarr' && sonarrError && (
{sonarrError}
)} {sectionGroup.key === 'radarr' && radarrError && (
{radarrError}
)} {sectionGroup.key === 'jellyfin' && jellyfinSyncStatus && (
{jellyfinSyncStatus}
)} {((showArtworkExtras && sectionGroup.key === 'artwork') || (showCacheExtras && sectionGroup.key === 'cache')) && artworkPrefetchStatus && (
{artworkPrefetchStatus}
)} {showRequestsExtras && sectionGroup.key === 'requests' && requestsSyncStatus && (
{requestsSyncStatus}
)} {((showArtworkExtras && sectionGroup.key === 'artwork') || (showCacheExtras && sectionGroup.key === 'cache')) && 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) 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 === 'artwork_cache_mode') { return ( ) } if ( setting.key === 'requests_full_sync_time' || setting.key === 'requests_cleanup_time' ) { return ( ) } if ( setting.key === 'requests_delta_sync_interval_minutes' || setting.key === 'requests_cleanup_days' ) { return ( ) } if (setting.key === 'requests_data_source') { return ( ) } return ( ) })}
))} {status &&
{status}
}
) : (
No settings to show here yet. Try the Cache page for artwork and saved-request controls.
)} {showLogs && (

Activity log

{logsStatus &&
{logsStatus}
}
{logsLines.join('')}
)} {showCacheExtras && (

Saved requests (cache)

{cacheStatus &&
{cacheStatus}
}
Request Title Type Status Last update
{cacheRows.length === 0 ? (
No saved requests loaded yet.
) : ( cacheRows.map((row) => (
#{row.request_id} {row.title || 'Untitled'} {row.media_type || 'unknown'} {row.status ?? 'n/a'} {row.updated_at || row.created_at || 'n/a'}
)) )}
)} {showMaintenance && (

Maintenance

Emergency tools. Use with care: flush will clear saved requests and history.
{maintenanceStatus &&
{maintenanceStatus}
}
)} {showRequestsExtras && (

Scheduled tasks

Automated jobs keep requests and housekeeping up to date.

Quick request check

Every {formValues.requests_delta_sync_interval_minutes || '5'} minutes, checks for new or updated requests.

Full daily refresh

Every day at {formValues.requests_full_sync_time || '00:00'}, refreshes the entire requests list.

History cleanup

Every day at {formValues.requests_cleanup_time || '02:00'}, removes history older than {formValues.requests_cleanup_days || '90'} days.

)}
) }