1266 lines
47 KiB
TypeScript
1266 lines
47 KiB
TypeScript
'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<string, string> = {
|
|
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<string, string> = {
|
|
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<string, string | null> = {
|
|
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<AdminSetting[]>([])
|
|
const [formValues, setFormValues] = useState<Record<string, string>>({})
|
|
const [status, setStatus] = useState<string | null>(null)
|
|
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<any[]>([])
|
|
const [cacheCount, setCacheCount] = useState(50)
|
|
const [cacheStatus, setCacheStatus] = useState<string | null>(null)
|
|
const [requestsSync, setRequestsSync] = useState<any | null>(null)
|
|
const [artworkPrefetch, setArtworkPrefetch] = useState<any | null>(null)
|
|
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(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<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(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<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 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<string, string> = {
|
|
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) => (
|
|
<option key={option.id} value={String(option.id)}>
|
|
{includePath && option.path ? option.path : option.label}
|
|
</option>
|
|
))
|
|
if (currentValue && !optionValues.has(currentValue)) {
|
|
list.unshift(
|
|
<option key="custom" value={currentValue}>
|
|
Custom: {currentValue}
|
|
</option>
|
|
)
|
|
}
|
|
return list
|
|
}
|
|
|
|
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault()
|
|
setStatus(null)
|
|
const payload: Record<string, string> = {}
|
|
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 <main className="card">Loading admin settings...</main>
|
|
}
|
|
|
|
return (
|
|
<AdminShell
|
|
title={SECTION_LABELS[section] ?? 'Settings'}
|
|
subtitle={SECTION_DESCRIPTIONS[section] ?? 'Manage settings.'}
|
|
actions={
|
|
<button type="button" onClick={() => router.push('/admin')}>
|
|
Back to settings
|
|
</button>
|
|
}
|
|
>
|
|
{settingsSections.length > 0 ? (
|
|
<form onSubmit={submit} className="admin-form">
|
|
{settingsSections
|
|
.filter(shouldRenderSection)
|
|
.map((sectionGroup) => (
|
|
<section key={sectionGroup.key} className="admin-section">
|
|
<div className="section-header">
|
|
<h2>{sectionGroup.title}</h2>
|
|
{sectionGroup.key === 'sonarr' && (
|
|
<button type="button" onClick={() => loadOptions('sonarr')}>
|
|
Refresh Sonarr options
|
|
</button>
|
|
)}
|
|
{sectionGroup.key === 'radarr' && (
|
|
<button type="button" onClick={() => loadOptions('radarr')}>
|
|
Refresh Radarr options
|
|
</button>
|
|
)}
|
|
{sectionGroup.key === 'jellyfin' && (
|
|
<button type="button" onClick={syncJellyfinUsers}>
|
|
Import Jellyfin users
|
|
</button>
|
|
)}
|
|
{(showArtworkExtras && sectionGroup.key === 'artwork') ||
|
|
(showCacheExtras && sectionGroup.key === 'cache') ? (
|
|
<button type="button" onClick={prefetchArtwork}>
|
|
Cache all artwork now
|
|
</button>
|
|
) : null}
|
|
{showRequestsExtras && sectionGroup.key === 'requests' && (
|
|
<div className="sync-actions">
|
|
<button type="button" onClick={syncRequests}>
|
|
Full refresh
|
|
</button>
|
|
<button type="button" className="ghost-button" onClick={syncRequestsDelta}>
|
|
Quick refresh (new changes)
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{SECTION_DESCRIPTIONS[sectionGroup.key] && (
|
|
<p className="section-subtitle">{SECTION_DESCRIPTIONS[sectionGroup.key]}</p>
|
|
)}
|
|
{sectionGroup.key === 'sonarr' && sonarrError && (
|
|
<div className="error-banner">{sonarrError}</div>
|
|
)}
|
|
{sectionGroup.key === 'radarr' && radarrError && (
|
|
<div className="error-banner">{radarrError}</div>
|
|
)}
|
|
{sectionGroup.key === 'jellyfin' && jellyfinSyncStatus && (
|
|
<div className="status-banner">{jellyfinSyncStatus}</div>
|
|
)}
|
|
{((showArtworkExtras && sectionGroup.key === 'artwork') ||
|
|
(showCacheExtras && sectionGroup.key === 'cache')) &&
|
|
artworkPrefetchStatus && (
|
|
<div className="status-banner">{artworkPrefetchStatus}</div>
|
|
)}
|
|
{showRequestsExtras && sectionGroup.key === 'requests' && requestsSyncStatus && (
|
|
<div className="status-banner">{requestsSyncStatus}</div>
|
|
)}
|
|
{((showArtworkExtras && sectionGroup.key === 'artwork') ||
|
|
(showCacheExtras && sectionGroup.key === 'cache')) &&
|
|
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.total ? '' : 'progress-indeterminate'} ${
|
|
artworkPrefetch.status === 'completed' ? 'progress-complete' : ''
|
|
}`}
|
|
>
|
|
<div
|
|
className="progress-fill"
|
|
style={{
|
|
width:
|
|
artworkPrefetch.status === 'completed'
|
|
? '100%'
|
|
: artworkPrefetch.total
|
|
? `${Math.min(
|
|
100,
|
|
Math.round((artworkPrefetch.processed / artworkPrefetch.total) * 100)
|
|
)}%`
|
|
: '30%',
|
|
}}
|
|
/>
|
|
</div>
|
|
{artworkPrefetch.message && <div className="meta">{artworkPrefetch.message}</div>}
|
|
</div>
|
|
)}
|
|
{showRequestsExtras && sectionGroup.key === 'requests' && 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.total ? '' : 'progress-indeterminate'} ${
|
|
requestsSync.status === 'completed' ? 'progress-complete' : ''
|
|
}`}
|
|
>
|
|
<div
|
|
className="progress-fill"
|
|
style={{
|
|
width:
|
|
requestsSync.status === 'completed'
|
|
? '100%'
|
|
: requestsSync.total
|
|
? `${Math.min(
|
|
100,
|
|
Math.round((requestsSync.stored / requestsSync.total) * 100)
|
|
)}%`
|
|
: '30%',
|
|
}}
|
|
/>
|
|
</div>
|
|
{requestsSync.message && <div className="meta">{requestsSync.message}</div>}
|
|
</div>
|
|
)}
|
|
<div className="admin-grid">
|
|
{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 (
|
|
<label key={setting.key} data-helper={helperText || undefined}>
|
|
<span className="label-row">
|
|
<span>{labelFromKey(setting.key)}</span>
|
|
<span className="meta">
|
|
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
|
|
</span>
|
|
</span>
|
|
<select
|
|
name={setting.key}
|
|
value={value || 'false'}
|
|
onChange={(event) =>
|
|
setFormValues((current) => ({
|
|
...current,
|
|
[setting.key]: event.target.value,
|
|
}))
|
|
}
|
|
>
|
|
<option value="true">Enabled</option>
|
|
<option value="false">Disabled</option>
|
|
</select>
|
|
</label>
|
|
)
|
|
}
|
|
if (isSonarrProfile && sonarrOptions) {
|
|
return (
|
|
<label key={setting.key} data-helper={helperText || undefined}>
|
|
<span className="label-row">
|
|
<span>{labelFromKey(setting.key)}</span>
|
|
<span className="meta">
|
|
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
|
|
{setting.sensitive && setting.isSet ? ' • stored' : ''}
|
|
</span>
|
|
</span>
|
|
<select
|
|
name={setting.key}
|
|
value={value}
|
|
onChange={(event) =>
|
|
setFormValues((current) => ({
|
|
...current,
|
|
[setting.key]: event.target.value,
|
|
}))
|
|
}
|
|
>
|
|
<option value="">Select a quality profile</option>
|
|
{buildSelectOptions(value, sonarrOptions.qualityProfiles, false)}
|
|
</select>
|
|
</label>
|
|
)
|
|
}
|
|
if (isSonarrRoot && sonarrOptions) {
|
|
return (
|
|
<label key={setting.key} data-helper={helperText || undefined}>
|
|
<span className="label-row">
|
|
<span>{labelFromKey(setting.key)}</span>
|
|
<span className="meta">
|
|
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
|
|
{setting.sensitive && setting.isSet ? ' • stored' : ''}
|
|
</span>
|
|
</span>
|
|
<select
|
|
name={setting.key}
|
|
value={value}
|
|
onChange={(event) =>
|
|
setFormValues((current) => ({
|
|
...current,
|
|
[setting.key]: event.target.value,
|
|
}))
|
|
}
|
|
>
|
|
<option value="">Select a root folder</option>
|
|
{buildSelectOptions(value, sonarrOptions.rootFolders, true)}
|
|
</select>
|
|
</label>
|
|
)
|
|
}
|
|
if (isRadarrProfile && radarrOptions) {
|
|
return (
|
|
<label key={setting.key} data-helper={helperText || undefined}>
|
|
<span className="label-row">
|
|
<span>{labelFromKey(setting.key)}</span>
|
|
<span className="meta">
|
|
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
|
|
{setting.sensitive && setting.isSet ? ' • stored' : ''}
|
|
</span>
|
|
</span>
|
|
<select
|
|
name={setting.key}
|
|
value={value}
|
|
onChange={(event) =>
|
|
setFormValues((current) => ({
|
|
...current,
|
|
[setting.key]: event.target.value,
|
|
}))
|
|
}
|
|
>
|
|
<option value="">Select a quality profile</option>
|
|
{buildSelectOptions(value, radarrOptions.qualityProfiles, false)}
|
|
</select>
|
|
</label>
|
|
)
|
|
}
|
|
if (isRadarrRoot && radarrOptions) {
|
|
return (
|
|
<label key={setting.key} data-helper={helperText || undefined}>
|
|
<span className="label-row">
|
|
<span>{labelFromKey(setting.key)}</span>
|
|
<span className="meta">
|
|
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
|
|
{setting.sensitive && setting.isSet ? ' • stored' : ''}
|
|
</span>
|
|
</span>
|
|
<select
|
|
name={setting.key}
|
|
value={value}
|
|
onChange={(event) =>
|
|
setFormValues((current) => ({
|
|
...current,
|
|
[setting.key]: event.target.value,
|
|
}))
|
|
}
|
|
>
|
|
<option value="">Select a root folder</option>
|
|
{buildSelectOptions(value, radarrOptions.rootFolders, true)}
|
|
</select>
|
|
</label>
|
|
)
|
|
}
|
|
if (setting.key === 'log_level') {
|
|
return (
|
|
<label key={setting.key} data-helper={helperText || undefined}>
|
|
<span className="label-row">
|
|
<span>{labelFromKey(setting.key)}</span>
|
|
<span className="meta">
|
|
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
|
|
</span>
|
|
</span>
|
|
<select
|
|
name={setting.key}
|
|
value={value}
|
|
onChange={(event) =>
|
|
setFormValues((current) => ({
|
|
...current,
|
|
[setting.key]: event.target.value,
|
|
}))
|
|
}
|
|
>
|
|
<option value="DEBUG">DEBUG</option>
|
|
<option value="INFO">INFO</option>
|
|
<option value="WARNING">WARNING</option>
|
|
<option value="ERROR">ERROR</option>
|
|
</select>
|
|
</label>
|
|
)
|
|
}
|
|
if (setting.key === 'artwork_cache_mode') {
|
|
return (
|
|
<label key={setting.key} data-helper={helperText || undefined}>
|
|
<span className="label-row">
|
|
<span>{labelFromKey(setting.key)}</span>
|
|
<span className="meta">
|
|
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
|
|
</span>
|
|
</span>
|
|
<select
|
|
name={setting.key}
|
|
value={value || 'remote'}
|
|
onChange={(event) =>
|
|
setFormValues((current) => ({
|
|
...current,
|
|
[setting.key]: event.target.value,
|
|
}))
|
|
}
|
|
>
|
|
<option value="remote">Pull from the internet</option>
|
|
<option value="cache">Cache locally</option>
|
|
</select>
|
|
</label>
|
|
)
|
|
}
|
|
if (
|
|
setting.key === 'requests_full_sync_time' ||
|
|
setting.key === 'requests_cleanup_time'
|
|
) {
|
|
return (
|
|
<label key={setting.key} data-helper={helperText || undefined}>
|
|
<span className="label-row">
|
|
<span>{labelFromKey(setting.key)}</span>
|
|
<span className="meta">
|
|
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
|
|
</span>
|
|
</span>
|
|
<input
|
|
name={setting.key}
|
|
type="time"
|
|
value={value}
|
|
onChange={(event) =>
|
|
setFormValues((current) => ({
|
|
...current,
|
|
[setting.key]: event.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
</label>
|
|
)
|
|
}
|
|
if (
|
|
setting.key === 'requests_delta_sync_interval_minutes' ||
|
|
setting.key === 'requests_cleanup_days'
|
|
) {
|
|
return (
|
|
<label key={setting.key} data-helper={helperText || undefined}>
|
|
<span className="label-row">
|
|
<span>{labelFromKey(setting.key)}</span>
|
|
<span className="meta">
|
|
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
|
|
</span>
|
|
</span>
|
|
<input
|
|
name={setting.key}
|
|
type="number"
|
|
min={1}
|
|
value={value}
|
|
onChange={(event) =>
|
|
setFormValues((current) => ({
|
|
...current,
|
|
[setting.key]: event.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
</label>
|
|
)
|
|
}
|
|
if (setting.key === 'requests_data_source') {
|
|
return (
|
|
<label key={setting.key} data-helper={helperText || undefined}>
|
|
<span className="label-row">
|
|
<span>{labelFromKey(setting.key)}</span>
|
|
<span className="meta">
|
|
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
|
|
</span>
|
|
</span>
|
|
<select
|
|
name={setting.key}
|
|
value={value || 'prefer_cache'}
|
|
onChange={(event) =>
|
|
setFormValues((current) => ({
|
|
...current,
|
|
[setting.key]: event.target.value,
|
|
}))
|
|
}
|
|
>
|
|
<option value="always_js">Always use Jellyseerr (slower)</option>
|
|
<option value="prefer_cache">Use saved requests first (faster)</option>
|
|
</select>
|
|
</label>
|
|
)
|
|
}
|
|
return (
|
|
<label key={setting.key} data-helper={helperText || undefined}>
|
|
<span className="label-row">
|
|
<span>{labelFromKey(setting.key)}</span>
|
|
<span className="meta">
|
|
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
|
|
{setting.sensitive && setting.isSet ? ' • stored' : ''}
|
|
</span>
|
|
</span>
|
|
<input
|
|
name={setting.key}
|
|
type={setting.sensitive ? 'password' : 'text'}
|
|
placeholder={
|
|
setting.sensitive && setting.isSet ? 'Configured (enter to replace)' : ''
|
|
}
|
|
value={value}
|
|
onChange={(event) =>
|
|
setFormValues((current) => ({
|
|
...current,
|
|
[setting.key]: event.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
</label>
|
|
)
|
|
})}
|
|
</div>
|
|
</section>
|
|
))}
|
|
{status && <div className="status-banner">{status}</div>}
|
|
<div className="admin-actions">
|
|
<button type="submit">Save changes</button>
|
|
</div>
|
|
</form>
|
|
) : (
|
|
<div className="status-banner">
|
|
No settings to show here yet. Try the Cache page for artwork and saved-request controls.
|
|
</div>
|
|
)}
|
|
{showLogs && (
|
|
<section className="admin-section" 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" id="cache">
|
|
<div className="section-header">
|
|
<h2>Saved requests (cache)</h2>
|
|
<div className="log-actions">
|
|
<label className="recent-filter">
|
|
<span>Rows to show</span>
|
|
<select
|
|
value={cacheCount}
|
|
onChange={(event) => setCacheCount(Number(event.target.value))}
|
|
>
|
|
<option value={25}>25</option>
|
|
<option value={50}>50</option>
|
|
<option value={100}>100</option>
|
|
<option value={200}>200</option>
|
|
</select>
|
|
</label>
|
|
<button type="button" onClick={loadCache}>
|
|
Load saved requests
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{cacheStatus && <div className="error-banner">{cacheStatus}</div>}
|
|
<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" id="maintenance">
|
|
<div className="section-header">
|
|
<h2>Maintenance</h2>
|
|
</div>
|
|
<div className="status-banner">
|
|
Emergency tools. Use with care: flush will clear saved requests and history.
|
|
</div>
|
|
{maintenanceStatus && <div className="status-banner">{maintenanceStatus}</div>}
|
|
<div className="maintenance-grid">
|
|
<button type="button" onClick={runRepair}>
|
|
Repair database
|
|
</button>
|
|
<button type="button" className="ghost-button" onClick={runCleanup}>
|
|
Clean history (older than 90 days)
|
|
</button>
|
|
<button type="button" className="ghost-button" onClick={clearLogFile}>
|
|
Clear activity log
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="danger-button"
|
|
onClick={runFlushAndResync}
|
|
disabled={maintenanceBusy}
|
|
>
|
|
Flush database + resync
|
|
</button>
|
|
</div>
|
|
</section>
|
|
)}
|
|
{showRequestsExtras && (
|
|
<section className="admin-section" id="schedules">
|
|
<div className="section-header">
|
|
<h2>Scheduled tasks</h2>
|
|
</div>
|
|
<div className="status-banner">
|
|
Automated jobs keep requests and housekeeping up to date.
|
|
</div>
|
|
<div className="schedule-grid">
|
|
<div className="schedule-card">
|
|
<h3>Quick request check</h3>
|
|
<p>
|
|
Every {formValues.requests_delta_sync_interval_minutes || '5'} minutes, checks for
|
|
new or updated requests.
|
|
</p>
|
|
</div>
|
|
<div className="schedule-card">
|
|
<h3>Full daily refresh</h3>
|
|
<p>
|
|
Every day at {formValues.requests_full_sync_time || '00:00'}, refreshes the entire
|
|
requests list.
|
|
</p>
|
|
</div>
|
|
<div className="schedule-card">
|
|
<h3>History cleanup</h3>
|
|
<p>
|
|
Every day at {formValues.requests_cleanup_time || '02:00'}, removes history older
|
|
than {formValues.requests_cleanup_days || '90'} days.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
</AdminShell>
|
|
)
|
|
}
|