/** * Component: Admin Settings Page * Documentation: documentation/settings-pages.md */ 'use client'; import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import Link from 'next/link'; import { fetchWithAuth } from '@/lib/utils/api'; import { IndexerFlagConfig } from '@/lib/utils/ranking-algorithm'; import { FlagConfigRow } from '@/components/admin/FlagConfigRow'; import { IndexersTab } from './tabs/IndexersTab'; interface PlexLibrary { id: string; title: string; type: string; } interface IndexerConfig { id: number; name: string; protocol: string; privacy: string; enabled: boolean; priority: number; seedingTimeMinutes: number; rssEnabled: boolean; categories?: number[]; supportsRss?: boolean; } interface Settings { backendMode: 'plex' | 'audiobookshelf'; hasLocalUsers: boolean; audibleRegion: string; plex: { url: string; token: string; libraryId: string; triggerScanAfterImport: boolean; }; audiobookshelf: { serverUrl: string; apiToken: string; libraryId: string; triggerScanAfterImport: boolean; }; oidc: { enabled: boolean; providerName: string; issuerUrl: string; clientId: string; clientSecret: string; accessControlMethod: string; accessGroupClaim: string; accessGroupValue: string; allowedEmails: string; allowedUsernames: string; adminClaimEnabled: boolean; adminClaimName: string; adminClaimValue: string; }; registration: { enabled: boolean; requireAdminApproval: boolean; }; prowlarr: { url: string; apiKey: string; }; downloadClient: { type: string; url: string; username: string; password: string; disableSSLVerify: boolean; remotePathMappingEnabled: boolean; remotePath: string; localPath: string; }; paths: { downloadDir: string; mediaDir: string; metadataTaggingEnabled: boolean; chapterMergingEnabled: boolean; }; ebook: { enabled: boolean; preferredFormat: string; baseUrl: string; flaresolverrUrl: string; }; } interface PendingUser { id: string; plexUsername: string; plexEmail: string | null; authProvider: string | null; createdAt: string; } interface ABSLibrary { id: string; name: string; type: string; itemCount: number; } export default function AdminSettings() { const [settings, setSettings] = useState(null); const [originalSettings, setOriginalSettings] = useState(null); // Track original values const [plexLibraries, setPlexLibraries] = useState([]); const [absLibraries, setAbsLibraries] = useState([]); const [indexers, setIndexers] = useState([]); const [configuredIndexers, setConfiguredIndexers] = useState>([]); const [flagConfigs, setFlagConfigs] = useState([]); const [pendingUsers, setPendingUsers] = useState([]); const [isLocalAdmin, setIsLocalAdmin] = useState(false); const [loading, setLoading] = useState(true); const [loadingLibraries, setLoadingLibraries] = useState(false); const [loadingIndexers, setLoadingIndexers] = useState(false); const [loadingPendingUsers, setLoadingPendingUsers] = useState(false); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); const [validated, setValidated] = useState({ plex: false, audiobookshelf: false, oidc: false, registration: false, prowlarr: false, download: false, paths: false, }); const [testResults, setTestResults] = useState>({}); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>( null ); const [activeTab, setActiveTab] = useState<'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate'>('library'); // BookDate configuration state const [bookdateProvider, setBookdateProvider] = useState('openai'); const [bookdateApiKey, setBookdateApiKey] = useState(''); const [bookdateModel, setBookdateModel] = useState(''); const [bookdateBaseUrl, setBookdateBaseUrl] = useState(''); const [bookdateEnabled, setBookdateEnabled] = useState(true); const [bookdateConfigured, setBookdateConfigured] = useState(false); const [bookdateModels, setBookdateModels] = useState<{ id: string; name: string }[]>([]); const [testingBookdate, setTestingBookdate] = useState(false); const [clearingBookdateSwipes, setClearingBookdateSwipes] = useState(false); // FlareSolverr testing state const [testingFlaresolverr, setTestingFlaresolverr] = useState(false); const [flaresolverrTestResult, setFlaresolverrTestResult] = useState<{ success: boolean; message: string; responseTime?: number; } | null>(null); useEffect(() => { fetchSettings(); fetchCurrentUser(); }, []); const fetchCurrentUser = async () => { try { const response = await fetchWithAuth('/api/auth/me'); if (response.ok) { const data = await response.json(); setIsLocalAdmin(data.user?.isLocalAdmin || false); } } catch (error) { console.error('Failed to fetch current user:', error); } }; // Fetch libraries/indexers when tabs become active or when page first loads useEffect(() => { if (!settings) return; if (activeTab === 'library' && settings.backendMode === 'plex' && settings.plex.url && settings.plex.token) { fetchPlexLibraries(); } else if (activeTab === 'library' && settings.backendMode === 'audiobookshelf' && settings.audiobookshelf.serverUrl && settings.audiobookshelf.apiToken) { fetchABSLibraries(); } }, [activeTab, settings?.plex.url, settings?.plex.token, settings?.audiobookshelf.serverUrl, settings?.audiobookshelf.apiToken, settings?.backendMode]); useEffect(() => { if (!settings) return; if (activeTab === 'prowlarr' && settings.prowlarr.url && settings.prowlarr.apiKey) { fetchIndexers(); } }, [activeTab, settings?.prowlarr.url, settings?.prowlarr.apiKey]); useEffect(() => { if (activeTab === 'bookdate') { fetchBookdateConfig(); } }, [activeTab]); useEffect(() => { if (activeTab === 'auth' && settings?.registration.requireAdminApproval) { fetchPendingUsers(); } }, [activeTab, settings?.registration.requireAdminApproval]); const fetchSettings = async () => { try { const response = await fetchWithAuth('/api/admin/settings'); if (response.ok) { const data = await response.json(); // Convert OIDC allowed lists from JSON arrays to comma-separated strings for display if (data.oidc) { const parseArrayToCommaSeparated = (jsonStr: string): string => { try { const arr = JSON.parse(jsonStr); return Array.isArray(arr) ? arr.join(', ') : ''; } catch { return ''; } }; data.oidc.allowedEmails = parseArrayToCommaSeparated(data.oidc.allowedEmails); data.oidc.allowedUsernames = parseArrayToCommaSeparated(data.oidc.allowedUsernames); } setSettings(data); setOriginalSettings(JSON.parse(JSON.stringify(data))); // Deep copy for comparison } else { console.error('Failed to fetch settings:', response.status, response.statusText); } } catch (error) { console.error('Failed to fetch settings:', error); } finally { setLoading(false); } }; const fetchPlexLibraries = async (force = false) => { if (!force && plexLibraries.length > 0) return; // Already loaded setLoadingLibraries(true); try { const response = await fetchWithAuth('/api/admin/settings/plex/libraries'); if (response.ok) { const data = await response.json(); setPlexLibraries(data.libraries || []); } else { const data = await response.json(); console.error('Failed to fetch Plex libraries:', data); setMessage({ type: 'error', text: data.message || 'Failed to load Plex libraries. Check your Plex URL and token.' }); } } catch (error) { console.error('Failed to fetch Plex libraries:', error); setMessage({ type: 'error', text: 'Failed to load Plex libraries. Check your Plex URL and token.' }); } finally { setLoadingLibraries(false); } }; const fetchABSLibraries = async (force = false) => { if (!force && absLibraries.length > 0) return; // Already loaded setLoadingLibraries(true); try { const response = await fetchWithAuth('/api/admin/settings/audiobookshelf/libraries'); if (response.ok) { const data = await response.json(); setAbsLibraries(data.libraries || []); } else { const data = await response.json(); console.error('Failed to fetch ABS libraries:', data); setMessage({ type: 'error', text: data.message || 'Failed to load Audiobookshelf libraries. Check your server URL and API token.' }); } } catch (error) { console.error('Failed to fetch ABS libraries:', error); setMessage({ type: 'error', text: 'Failed to load Audiobookshelf libraries. Check your server URL and API token.' }); } finally { setLoadingLibraries(false); } }; const fetchPendingUsers = async () => { setLoadingPendingUsers(true); try { const response = await fetchWithAuth('/api/admin/users/pending'); if (response.ok) { const data = await response.json(); setPendingUsers(data.users || []); } else { console.error('Failed to fetch pending users:', response.status); } } catch (error) { console.error('Failed to fetch pending users:', error); } finally { setLoadingPendingUsers(false); } }; const fetchIndexers = async (force = false) => { if (!force && indexers.length > 0) return; // Already loaded setLoadingIndexers(true); try { const response = await fetchWithAuth('/api/admin/settings/prowlarr/indexers'); if (response.ok) { const data = await response.json(); setIndexers(data.indexers || []); setFlagConfigs(data.flagConfigs || []); // Extract configured indexers (enabled ones) for the new IndexerManagement component const configured = (data.indexers || []) .filter((idx: IndexerConfig) => idx.enabled) .map((idx: IndexerConfig) => ({ id: idx.id, name: idx.name, priority: idx.priority, seedingTimeMinutes: idx.seedingTimeMinutes, rssEnabled: idx.rssEnabled, categories: idx.categories || [3030], // Include categories, default to audiobooks })); setConfiguredIndexers(configured); } else { console.error('Failed to fetch indexers:', response.status); // Don't show error on initial load, only if user explicitly tries to load if (force) { setMessage({ type: 'error', text: 'Failed to load indexers. Check your Prowlarr settings.' }); } } } catch (error) { console.error('Failed to fetch indexers:', error); if (force) { setMessage({ type: 'error', text: 'Failed to load Prowlarr indexers. Check your Prowlarr URL and API key.' }); } } finally { setLoadingIndexers(false); } }; const fetchBookdateConfig = async () => { try { const response = await fetchWithAuth('/api/bookdate/config'); const data = await response.json(); if (data.config) { setBookdateProvider(data.config.provider || 'openai'); setBookdateModel(data.config.model || ''); setBookdateBaseUrl(data.config.baseUrl || ''); setBookdateEnabled(data.config.isEnabled !== false); // Default to true setBookdateConfigured(data.config.isVerified || false); } } catch (error) { console.error('Failed to load BookDate config:', error); } }; const handleTestBookdateConnection = async () => { const hasApiKey = bookdateApiKey.trim().length > 0; // Validation if (bookdateProvider === 'custom') { if (!bookdateBaseUrl.trim()) { setMessage({ type: 'error', text: 'Please enter a base URL for custom provider' }); return; } } else { // Allow testing with saved API key if already configured if (!hasApiKey && !bookdateConfigured) { setMessage({ type: 'error', text: 'Please enter an API key' }); return; } } setTestingBookdate(true); setMessage(null); try { const payload: any = { provider: bookdateProvider, }; // Include API key if user entered a new one, otherwise use saved key if (hasApiKey) { payload.apiKey = bookdateApiKey; } else if (bookdateProvider !== 'custom') { payload.useSavedKey = true; } // Include baseUrl for custom provider if (bookdateProvider === 'custom') { payload.baseUrl = bookdateBaseUrl; } const response = await fetchWithAuth('/api/bookdate/test-connection', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Connection test failed'); } setBookdateModels(data.models || []); setMessage({ type: 'success', text: 'Connection successful! Please select a model.' }); // Auto-select first model if none selected if (!bookdateModel && data.models?.length > 0) { setBookdateModel(data.models[0].id); } } catch (error) { setMessage({ type: 'error', text: error instanceof Error ? error.message : 'Connection test failed' }); } finally { setTestingBookdate(false); } }; const handleSaveBookdateConfig = async () => { // Validate: model is required if (!bookdateModel) { setMessage({ type: 'error', text: 'Please select a model' }); return; } // Validate: baseUrl required for custom provider if (bookdateProvider === 'custom') { if (!bookdateBaseUrl.trim()) { setMessage({ type: 'error', text: 'Please enter a base URL for custom provider' }); return; } } else { // Only require API key if not already configured OR if user entered one const hasApiKey = bookdateApiKey.trim().length > 0; if (!bookdateConfigured && !hasApiKey) { setMessage({ type: 'error', text: 'Please enter an API key for initial setup' }); return; } } setSaving(true); setMessage(null); try { const hasApiKey = bookdateApiKey.trim().length > 0; const payload: any = { provider: bookdateProvider, model: bookdateModel, isEnabled: bookdateEnabled, }; // Only include API key if user entered a new one if (hasApiKey) { payload.apiKey = bookdateApiKey; } // Include baseUrl for custom provider if (bookdateProvider === 'custom') { payload.baseUrl = bookdateBaseUrl; } const response = await fetchWithAuth('/api/bookdate/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Failed to save configuration'); } setMessage({ type: 'success', text: 'BookDate configuration saved successfully!' }); setBookdateConfigured(true); setBookdateApiKey(''); // Clear API key from UI after save } catch (error) { setMessage({ type: 'error', text: error instanceof Error ? error.message : 'Failed to save configuration' }); } finally { setSaving(false); } }; const handleClearBookdateSwipes = async () => { if (!confirm('This will clear all swipe history. Continue?')) { return; } setClearingBookdateSwipes(true); setMessage(null); try { const response = await fetchWithAuth('/api/bookdate/swipes', { method: 'DELETE', }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Failed to clear swipe history'); } setMessage({ type: 'success', text: 'Swipe history cleared successfully!' }); } catch (error) { setMessage({ type: 'error', text: error instanceof Error ? error.message : 'Failed to clear swipe history' }); } finally { setClearingBookdateSwipes(false); } }; const handleSaveEbookSettings = async () => { if (!settings) return; setSaving(true); setMessage(null); try { const response = await fetchWithAuth('/api/admin/settings/ebook', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: settings.ebook?.enabled || false, format: settings.ebook?.preferredFormat || 'epub', baseUrl: settings.ebook?.baseUrl || 'https://annas-archive.li', flaresolverrUrl: settings.ebook?.flaresolverrUrl || '', }), }); if (!response.ok) { throw new Error('Failed to save e-book settings'); } setMessage({ type: 'success', text: 'E-book sidecar settings saved successfully!' }); // Update original settings to reflect the saved state setOriginalSettings(JSON.parse(JSON.stringify(settings))); setTimeout(() => setMessage(null), 3000); } catch (error) { setMessage({ type: 'error', text: error instanceof Error ? error.message : 'Failed to save e-book settings', }); } finally { setSaving(false); } }; const testFlaresolverrConnection = async () => { if (!settings?.ebook?.flaresolverrUrl) { setFlaresolverrTestResult({ success: false, message: 'Please enter a FlareSolverr URL first', }); return; } setTestingFlaresolverr(true); setFlaresolverrTestResult(null); try { const response = await fetchWithAuth('/api/admin/settings/ebook/test-flaresolverr', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: settings.ebook.flaresolverrUrl }), }); const result = await response.json(); setFlaresolverrTestResult(result); } catch (error) { setFlaresolverrTestResult({ success: false, message: error instanceof Error ? error.message : 'Test failed', }); } finally { setTestingFlaresolverr(false); } }; const testPlexConnection = async () => { if (!settings) return; setTesting(true); setMessage(null); try { const response = await fetchWithAuth('/api/admin/settings/test-plex', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: settings.plex.url, token: settings.plex.token, }), }); const data = await response.json(); if (data.success) { setValidated({ ...validated, plex: true }); setTestResults({ ...testResults, plex: { success: true, message: `Connected to ${data.serverName}` } }); setMessage({ type: 'success', text: `Connected to ${data.serverName}. You can now save.` }); // Update libraries if (data.libraries) { setPlexLibraries(data.libraries); } } else { setValidated({ ...validated, plex: false }); setTestResults({ ...testResults, plex: { success: false, message: data.error || 'Connection failed' } }); setMessage({ type: 'error', text: data.error || 'Failed to connect to Plex' }); } } catch (error) { setValidated({ ...validated, plex: false }); const errorMsg = error instanceof Error ? error.message : 'Failed to test connection'; setTestResults({ ...testResults, plex: { success: false, message: errorMsg } }); setMessage({ type: 'error', text: errorMsg }); } finally { setTesting(false); } }; const testABSConnection = async () => { if (!settings) return; setTesting(true); setMessage(null); try { const response = await fetchWithAuth('/api/setup/test-abs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ serverUrl: settings.audiobookshelf.serverUrl, apiToken: settings.audiobookshelf.apiToken, }), }); const data = await response.json(); if (data.success) { setValidated({ ...validated, audiobookshelf: true }); setTestResults({ ...testResults, audiobookshelf: { success: true, message: `Connected to Audiobookshelf` } }); setMessage({ type: 'success', text: 'Connected to Audiobookshelf. You can now save.' }); // Update libraries if (data.libraries) { setAbsLibraries(data.libraries); } } else { setValidated({ ...validated, audiobookshelf: false }); setTestResults({ ...testResults, audiobookshelf: { success: false, message: data.error || 'Connection failed' } }); setMessage({ type: 'error', text: data.error || 'Failed to connect to Audiobookshelf' }); } } catch (error) { setValidated({ ...validated, audiobookshelf: false }); const errorMsg = error instanceof Error ? error.message : 'Failed to test connection'; setTestResults({ ...testResults, audiobookshelf: { success: false, message: errorMsg } }); setMessage({ type: 'error', text: errorMsg }); } finally { setTesting(false); } }; const testOIDCConnection = async () => { if (!settings) return; setTesting(true); setMessage(null); try { const response = await fetchWithAuth('/api/setup/test-oidc', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ issuerUrl: settings.oidc.issuerUrl, clientId: settings.oidc.clientId, clientSecret: settings.oidc.clientSecret, }), }); const data = await response.json(); if (data.success) { setValidated({ ...validated, oidc: true }); setTestResults({ ...testResults, oidc: { success: true, message: 'OIDC configuration is valid' } }); setMessage({ type: 'success', text: 'OIDC configuration is valid. You can now save.' }); } else { setValidated({ ...validated, oidc: false }); setTestResults({ ...testResults, oidc: { success: false, message: data.error || 'Connection failed' } }); setMessage({ type: 'error', text: data.error || 'Failed to validate OIDC configuration' }); } } catch (error) { setValidated({ ...validated, oidc: false }); const errorMsg = error instanceof Error ? error.message : 'Failed to test OIDC connection'; setTestResults({ ...testResults, oidc: { success: false, message: errorMsg } }); setMessage({ type: 'error', text: errorMsg }); } finally { setTesting(false); } }; const approveUser = async (userId: string, approve: boolean) => { try { const response = await fetchWithAuth(`/api/admin/users/${userId}/approve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ approve }), }); const data = await response.json(); if (data.success) { setMessage({ type: 'success', text: data.message }); // Refresh pending users list await fetchPendingUsers(); } else { setMessage({ type: 'error', text: data.error || 'Failed to process user approval' }); } } catch (error) { setMessage({ type: 'error', text: error instanceof Error ? error.message : 'Failed to process user approval' }); } }; const testProwlarrConnection = async () => { if (!settings) return; setTesting(true); setMessage(null); try { const response = await fetchWithAuth('/api/admin/settings/test-prowlarr', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: settings.prowlarr.url, apiKey: settings.prowlarr.apiKey, }), }); const data = await response.json(); if (data.success) { setValidated({ ...validated, prowlarr: true }); setTestResults({ ...testResults, prowlarr: { success: true, message: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers` } }); setMessage({ type: 'success', text: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers. You can now save.` }); // Refresh indexers from database (merges saved config with available indexers) await fetchIndexers(true); } else { setValidated({ ...validated, prowlarr: false }); setTestResults({ ...testResults, prowlarr: { success: false, message: data.error || 'Connection failed' } }); setMessage({ type: 'error', text: data.error || 'Failed to connect to Prowlarr' }); } } catch (error) { setValidated({ ...validated, prowlarr: false }); const errorMsg = error instanceof Error ? error.message : 'Failed to test connection'; setTestResults({ ...testResults, prowlarr: { success: false, message: errorMsg } }); setMessage({ type: 'error', text: errorMsg }); } finally { setTesting(false); } }; const testDownloadClientConnection = async () => { if (!settings) return; setTesting(true); setMessage(null); try { const response = await fetchWithAuth('/api/admin/settings/test-download-client', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: settings.downloadClient.type, url: settings.downloadClient.url, username: settings.downloadClient.username, password: settings.downloadClient.password, disableSSLVerify: settings.downloadClient.disableSSLVerify, remotePathMappingEnabled: settings.downloadClient.remotePathMappingEnabled, remotePath: settings.downloadClient.remotePath, localPath: settings.downloadClient.localPath, }), }); const data = await response.json(); if (data.success) { setValidated({ ...validated, download: true }); setTestResults({ ...testResults, download: { success: true, message: `Connected to ${settings.downloadClient.type} (${data.version || 'version unknown'})` } }); setMessage({ type: 'success', text: `Connected to ${settings.downloadClient.type}. You can now save.` }); } else { setValidated({ ...validated, download: false }); setTestResults({ ...testResults, download: { success: false, message: data.error || 'Connection failed' } }); setMessage({ type: 'error', text: data.error || 'Failed to connect to download client' }); } } catch (error) { setValidated({ ...validated, download: false }); const errorMsg = error instanceof Error ? error.message : 'Failed to test connection'; setTestResults({ ...testResults, download: { success: false, message: errorMsg } }); setMessage({ type: 'error', text: errorMsg }); } finally { setTesting(false); } }; const testPaths = async () => { if (!settings) return; setTesting(true); setMessage(null); try { const response = await fetch('/api/setup/test-paths', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ downloadDir: settings.paths.downloadDir, mediaDir: settings.paths.mediaDir, }), }); const data = await response.json(); if (data.success) { setValidated({ ...validated, paths: true }); setTestResults({ ...testResults, paths: { success: true, message: 'All paths are valid and writable' } }); setMessage({ type: 'success', text: 'All paths are valid and writable. You can now save.' }); } else { setValidated({ ...validated, paths: false }); setTestResults({ ...testResults, paths: { success: false, message: data.error || 'Path validation failed' } }); setMessage({ type: 'error', text: data.error || 'Failed to validate paths' }); } } catch (error) { setValidated({ ...validated, paths: false }); const errorMsg = error instanceof Error ? error.message : 'Failed to test paths'; setTestResults({ ...testResults, paths: { success: false, message: errorMsg } }); setMessage({ type: 'error', text: errorMsg }); } finally { setTesting(false); } }; const saveSettings = async () => { if (!settings) return; setSaving(true); setMessage(null); try { // Save settings based on active tab switch (activeTab) { case 'library': // Save Audible region (common to both backends) const audibleResponse = await fetchWithAuth('/api/admin/settings/audible', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ region: settings.audibleRegion }), }); if (!audibleResponse.ok) { throw new Error('Failed to save Audible region settings'); } // Save backend-specific settings if (settings.backendMode === 'plex') { const plexResponse = await fetchWithAuth('/api/admin/settings/plex', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings.plex), }); if (!plexResponse.ok) { throw new Error('Failed to save Plex settings'); } } else { const absResponse = await fetchWithAuth('/api/admin/settings/audiobookshelf', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings.audiobookshelf), }); if (!absResponse.ok) { throw new Error('Failed to save Audiobookshelf settings'); } } break; case 'auth': // Validate: In Audiobookshelf mode, at least one auth method must be enabled OR local users must exist if (settings.backendMode === 'audiobookshelf') { if (!settings.oidc.enabled && !settings.registration.enabled && !settings.hasLocalUsers) { setMessage({ type: 'error', text: 'At least one authentication method must be enabled (OIDC or Manual Registration) since no local users exist. Otherwise, you will be locked out of the system.', }); setSaving(false); return; } } // Save OIDC settings if OIDC is enabled if (settings.oidc.enabled) { // Helper function to parse comma-separated strings into JSON arrays const parseCommaSeparatedToArray = (str: string): string => { if (!str || str.trim() === '') return '[]'; const items = str.split(',').map(s => s.trim()).filter(s => s.length > 0); return JSON.stringify(items); }; const oidcPayload = { ...settings.oidc, allowedEmails: parseCommaSeparatedToArray(settings.oidc.allowedEmails), allowedUsernames: parseCommaSeparatedToArray(settings.oidc.allowedUsernames), }; const oidcResponse = await fetchWithAuth('/api/admin/settings/oidc', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(oidcPayload), }); if (!oidcResponse.ok) { throw new Error('Failed to save OIDC settings'); } } // Save registration settings const registrationResponse = await fetchWithAuth('/api/admin/settings/registration', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings.registration), }); if (!registrationResponse.ok) { throw new Error('Failed to save registration settings'); } break; case 'prowlarr': // Save Prowlarr URL and API key const prowlarrResponse = await fetchWithAuth('/api/admin/settings/prowlarr', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings.prowlarr), }); if (!prowlarrResponse.ok) { throw new Error('Failed to save Prowlarr settings'); } // Save indexer configuration and flag configs // Convert configured indexers to the format expected by the API (with enabled: true) const indexersForSave = configuredIndexers.map((idx) => ({ ...idx, enabled: true, })); const indexersResponse = await fetchWithAuth('/api/admin/settings/prowlarr/indexers', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ indexers: indexersForSave, flagConfigs }), }); if (!indexersResponse.ok) { throw new Error('Failed to save indexer configuration'); } break; case 'download': const downloadResponse = await fetchWithAuth('/api/admin/settings/download-client', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings.downloadClient), }); if (!downloadResponse.ok) { throw new Error('Failed to save download client settings'); } break; case 'paths': const pathsResponse = await fetchWithAuth('/api/admin/settings/paths', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings.paths), }); if (!pathsResponse.ok) { throw new Error('Failed to save paths settings'); } break; default: throw new Error('Unknown settings tab'); } setMessage({ type: 'success', text: 'Settings saved successfully!' }); // Update original settings to reflect the saved state if (settings) { setOriginalSettings(JSON.parse(JSON.stringify(settings))); } setTimeout(() => setMessage(null), 3000); } catch (error) { setMessage({ type: 'error', text: error instanceof Error ? error.message : 'Failed to save settings', }); } finally { setSaving(false); } }; if (loading || !settings) { return (
); } const tabs = [ { id: 'library', label: settings?.backendMode === 'plex' ? 'Plex' : 'Audiobookshelf', icon: '📺' }, ...(settings?.backendMode === 'audiobookshelf' ? [{ id: 'auth', label: 'Authentication', icon: '🔐' }] : []), { id: 'prowlarr', label: 'Indexers', icon: '🔍' }, { id: 'download', label: 'Download Client', icon: '⬇️' }, { id: 'paths', label: 'Paths', icon: '📁' }, { id: 'ebook', label: 'E-book Sidecar', icon: '📖' }, { id: 'bookdate', label: 'BookDate', icon: '📚' }, ]; return (
{/* Header */}

Settings

Configure external services and system preferences

{/* Backend Mode Display */}

Backend Mode: {settings?.backendMode === 'plex' ? 'Plex' : 'Audiobookshelf'}

⚠️ Backend mode cannot be changed after setup. To switch backends, you must reset the instance and run the setup wizard again.

{/* Message Banner */} {message && (

{message.text}

)}
{/* Tabs */}
{/* Content */}
{/* Library Tab - Conditional (Plex or Audiobookshelf) */} {activeTab === 'library' && settings?.backendMode === 'plex' && (

Plex Media Server

Configure your Plex server connection and audiobook library.

{ setSettings({ ...settings, plex: { ...settings.plex, url: e.target.value }, }); setValidated({ ...validated, plex: false }); }} placeholder="http://localhost:32400" />
{ setSettings({ ...settings, plex: { ...settings.plex, token: e.target.value }, }); setValidated({ ...validated, plex: false }); }} placeholder="Enter your Plex token" />

Find your token in Plex settings → Network → Show Advanced

{loadingLibraries ? (
Loading libraries...
) : plexLibraries.length > 0 ? ( ) : (
Test your connection to load libraries.
)}
{/* Audible Region Selection */}

Select the Audible region that matches your metadata engine (Audnexus/Audible Agent) configuration in Plex. This ensures accurate book matching and metadata.

{testResults.plex && (
{testResults.plex.message}
)}
)} {/* Audiobookshelf Tab */} {activeTab === 'library' && settings?.backendMode === 'audiobookshelf' && (

Audiobookshelf Server

Configure your Audiobookshelf server connection and audiobook library.

{ setSettings({ ...settings, audiobookshelf: { ...settings.audiobookshelf, serverUrl: e.target.value }, }); setValidated({ ...validated, audiobookshelf: false }); }} placeholder="http://localhost:13378" />
{ setSettings({ ...settings, audiobookshelf: { ...settings.audiobookshelf, apiToken: e.target.value }, }); setValidated({ ...validated, audiobookshelf: false }); }} placeholder="Enter your Audiobookshelf API token" />

Found in Audiobookshelf Settings → Users → Your Account → API Tokens

{loadingLibraries ? (
Loading libraries...
) : absLibraries.length > 0 ? ( ) : (
Test your connection to load libraries.
)}
{/* Audible Region Selection */}

Select the Audible region that matches your metadata engine (Audnexus/Audible Agent) configuration in Audiobookshelf. This ensures accurate book matching and metadata.

{testResults.audiobookshelf && (
{testResults.audiobookshelf.message}
)}
)} {/* Prowlarr/Indexers Tab */} {activeTab === 'prowlarr' && ( )} {/* Download Client Tab */} {activeTab === 'download' && (

Download Client

Configure your download client: qBittorrent for torrents or SABnzbd for Usenet/NZB downloads.

{ setSettings({ ...settings, downloadClient: { ...settings.downloadClient, url: e.target.value }, }); setValidated({ ...validated, download: false }); }} placeholder="http://localhost:8080" />
{/* qBittorrent: Username + Password */} {settings.downloadClient.type === 'qbittorrent' && ( <>
{ setSettings({ ...settings, downloadClient: { ...settings.downloadClient, username: e.target.value, }, }); setValidated({ ...validated, download: false }); }} placeholder="admin" />
{ setSettings({ ...settings, downloadClient: { ...settings.downloadClient, password: e.target.value, }, }); setValidated({ ...validated, download: false }); }} placeholder="Enter password" />
)} {/* SABnzbd: API Key only */} {settings.downloadClient.type === 'sabnzbd' && (
{ setSettings({ ...settings, downloadClient: { ...settings.downloadClient, password: e.target.value, }, }); setValidated({ ...validated, download: false }); }} placeholder="Enter SABnzbd API key" />

Find this in SABnzbd under Config → General → API Key

)} {/* SSL Verification Toggle */} {settings.downloadClient.url.startsWith('https') && (
{ setSettings({ ...settings, downloadClient: { ...settings.downloadClient, disableSSLVerify: e.target.checked, }, }); setValidated({ ...validated, download: false }); }} className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" />

Enable this if you're using a self-signed certificate or getting SSL errors. ⚠️ Only use on trusted private networks.

)} {/* Remote Path Mapping */}
{ setSettings({ ...settings, downloadClient: { ...settings.downloadClient, remotePathMappingEnabled: e.target.checked, }, }); setValidated({ ...validated, download: false }); }} className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" />

Use this when qBittorrent runs on a different machine or uses different mount points (e.g., remote seedbox, Docker containers)

Example: Remote /remote/mnt/d/done → Local /downloads

{/* Warning for existing downloads */} {settings.downloadClient.remotePathMappingEnabled && (

⚠️ Note: Path mapping only affects new downloads. In-progress downloads will continue using their original paths.

)} {/* Conditional Fields */} {settings.downloadClient.remotePathMappingEnabled && (
{ setSettings({ ...settings, downloadClient: { ...settings.downloadClient, remotePath: e.target.value, }, }); setValidated({ ...validated, download: false }); }} />

The path prefix as reported by qBittorrent

{ setSettings({ ...settings, downloadClient: { ...settings.downloadClient, localPath: e.target.value, }, }); setValidated({ ...validated, download: false }); }} />

The actual path where files are accessible

)}
{testResults.download && (
{testResults.download.message}
)}
)} {/* Paths Tab */} {activeTab === 'paths' && (

Directory Paths

Configure download and media directory paths.

{ setSettings({ ...settings, paths: { ...settings.paths, downloadDir: e.target.value }, }); setValidated({ ...validated, paths: false }); }} placeholder="/downloads" className="font-mono" />

Temporary location for torrent downloads (kept for seeding)

{ setSettings({ ...settings, paths: { ...settings.paths, mediaDir: e.target.value }, }); setValidated({ ...validated, paths: false }); }} placeholder="/media/audiobooks" className="font-mono" />

Final location for organized audiobook library (Plex scans this directory)

{/* Metadata Tagging Toggle */}
{ setSettings({ ...settings, paths: { ...settings.paths, metadataTaggingEnabled: e.target.checked }, }); setValidated({ ...validated, paths: false }); }} className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />

Automatically write correct title, author, and narrator metadata to m4b and mp3 files during file organization. This significantly improves Plex matching accuracy for audiobooks with missing or incorrect metadata.

{/* Chapter Merging Toggle */}
{ setSettings({ ...settings, paths: { ...settings.paths, chapterMergingEnabled: e.target.checked }, }); setValidated({ ...validated, paths: false }); }} className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />

Automatically merge multi-file chapter downloads into a single M4B audiobook with chapter markers. Improves playback experience and library organization.

{testResults.paths && (
{testResults.paths.message}
)}
)} {/* E-book Sidecar Tab */} {activeTab === 'ebook' && (

E-book Sidecar

Automatically download e-books from Anna's Archive to accompany your audiobooks. E-books are placed in the same folder as the audiobook files.

{/* Enable Toggle */}
{ setSettings({ ...settings, ebook: { ...settings.ebook, enabled: e.target.checked }, }); }} className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />

When enabled, the system will search for e-books matching your audiobook's ASIN and download them to the same folder.

{/* Format Selection */} {settings.ebook?.enabled && (

EPUB is recommended for most e-readers. "Any format" will download the first available format.

)} {/* Base URL (Advanced) */} {settings.ebook?.enabled && (
{ setSettings({ ...settings, ebook: { ...settings.ebook, baseUrl: e.target.value }, }); }} placeholder="https://annas-archive.li" className="font-mono" />

Change this if the primary Anna's Archive mirror is unavailable.

)} {/* FlareSolverr (Optional - for Cloudflare bypass) */} {settings.ebook?.enabled && (
{ setSettings({ ...settings, ebook: { ...settings.ebook, flaresolverrUrl: e.target.value }, }); setFlaresolverrTestResult(null); }} placeholder="http://localhost:8191" className="font-mono flex-1" />

FlareSolverr helps bypass Cloudflare protection on Anna's Archive. Leave empty if not needed.

{flaresolverrTestResult && (
{flaresolverrTestResult.success ? '✓ ' : '✗ '} {flaresolverrTestResult.message}
)}
{!settings.ebook?.flaresolverrUrl && (

Note: Without FlareSolverr, e-book downloads may fail if Anna's Archive has Cloudflare protection enabled. Success rates are typically lower without it.

)}
)} {/* Info Box */}

How it works

  • • Searches Anna's Archive in two ways:
  • 1. First tries ASIN (exact match - most accurate)
  • 2. Falls back to title + author (with book/language filters)
  • • Downloads matching e-book in your preferred format
  • • Places e-book file in the same folder as the audiobook
  • • If no match is found or download fails, audiobook download continues normally
  • • Completely optional and non-blocking
{/* Warning Box */}

⚠️ Important Note

Anna's Archive is a shadow library. Use of this feature is at your own discretion and responsibility. Ensure compliance with your local laws and regulations.

{/* Save Button */}
)} {/* BookDate Tab */} {activeTab === 'bookdate' && (

BookDate Configuration

Configure global AI-powered audiobook recommendations. All users share this API key, but receive personalized recommendations based on their individual library and ratings.

{/* Enable/Disable Toggle */} {bookdateConfigured && (

BookDate Feature

{bookdateEnabled ? 'Feature is currently enabled' : 'Feature is currently disabled'}

)} {/* AI Provider */}
{/* Base URL Input - Show for Custom Provider */} {bookdateProvider === 'custom' && (
{ setBookdateBaseUrl(e.target.value); setBookdateModels([]); }} placeholder="http://localhost:11434/v1" />

Examples:
• Ollama: http://localhost:11434/v1
• LM Studio: http://localhost:1234/v1
• vLLM: http://localhost:8000/v1

)} {/* API Key */}
{ setBookdateApiKey(e.target.value); setBookdateModels([]); }} placeholder={ bookdateProvider === 'custom' ? 'Leave blank for local models' : bookdateConfigured ? '••••••••••••••••' : (bookdateProvider === 'openai' ? 'sk-...' : 'sk-ant-...') } />

{bookdateProvider === 'custom' ? 'Optional: Leave blank if your endpoint does not require authentication (e.g., Ollama, LM Studio)' : 'The API key is stored securely and encrypted. Leave blank to keep existing key.'}

{/* Test Connection Button */} {/* Model Selection */} {bookdateModels.length > 0 && (
)} {/* Note about per-user settings */} {(bookdateModels.length > 0 || bookdateConfigured) && bookdateModel && (

Note: Library scope and custom prompt preferences are now configured per-user. Users can adjust these settings in their BookDate preferences (settings icon on the BookDate page).

)} {/* Save Button */} {bookdateModel && (
)} {/* Clear Swipe History */} {bookdateConfigured && (

Clear All Swipe History

Remove all swipe history and cached recommendations for ALL users. This will reset everyone's BookDate recommendations.

)}
)} {/* Authentication Tab - Only visible in ABS mode */} {activeTab === 'auth' && settings?.backendMode === 'audiobookshelf' && (
{/* OIDC Settings Section */}

OIDC Authentication

Configure OpenID Connect (OIDC) authentication for single sign-on with Authentik, Keycloak, or other providers.

{ setSettings({ ...settings, oidc: { ...settings.oidc, enabled: e.target.checked }, }); setValidated({ ...validated, oidc: false }); }} className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />

Allow users to log in using an external OIDC provider

{settings.oidc.enabled && ( <>
{ setSettings({ ...settings, oidc: { ...settings.oidc, providerName: e.target.value }, }); setValidated({ ...validated, oidc: false }); }} placeholder="Authentik" />

Display name for the login button

{ setSettings({ ...settings, oidc: { ...settings.oidc, issuerUrl: e.target.value }, }); setValidated({ ...validated, oidc: false }); }} placeholder="https://auth.example.com/application/o/readmeabook/" />

OIDC provider's issuer URL (must support .well-known/openid-configuration)

{ setSettings({ ...settings, oidc: { ...settings.oidc, clientId: e.target.value }, }); setValidated({ ...validated, oidc: false }); }} placeholder="readmeabook-client" />
{ setSettings({ ...settings, oidc: { ...settings.oidc, clientSecret: e.target.value }, }); setValidated({ ...validated, oidc: false }); }} placeholder="Enter client secret" />
{testResults.oidc && (
{testResults.oidc.message}
)}
{/* Access Control Section */}

Access Control

Control who can log in to your application. This is separate from admin permissions.

{settings.oidc.accessControlMethod === 'open' && 'Anyone who can authenticate with your OIDC provider will have access'} {settings.oidc.accessControlMethod === 'group_claim' && 'Only users with a specific group/claim can access'} {settings.oidc.accessControlMethod === 'allowed_list' && 'Only explicitly allowed users can access'} {settings.oidc.accessControlMethod === 'admin_approval' && 'New users must be approved by an admin before access is granted'}

{settings.oidc.accessControlMethod === 'group_claim' && ( <>
{ setSettings({ ...settings, oidc: { ...settings.oidc, accessGroupClaim: e.target.value }, }); setValidated({ ...validated, oidc: false }); }} placeholder="groups" />

The OIDC claim field that contains group membership (usually "groups" or "roles")

{ setSettings({ ...settings, oidc: { ...settings.oidc, accessGroupValue: e.target.value }, }); setValidated({ ...validated, oidc: false }); }} placeholder="readmeabook-users" />

Users must be in this group to access the application

)} {settings.oidc.accessControlMethod === 'allowed_list' && ( <>
{ setSettings({ ...settings, oidc: { ...settings.oidc, allowedEmails: e.target.value }, }); setValidated({ ...validated, oidc: false }); }} placeholder="user1@example.com, user2@example.com" />

Enter email addresses separated by commas

{ setSettings({ ...settings, oidc: { ...settings.oidc, allowedUsernames: e.target.value }, }); setValidated({ ...validated, oidc: false }); }} placeholder="john_doe, jane_smith" />

Enter usernames separated by commas

)}
{/* Admin Role Mapping Section */}

Admin Role Mapping

Automatically grant admin permissions based on OIDC claims (e.g., group membership). The first user will always become admin.

{ setSettings({ ...settings, oidc: { ...settings.oidc, adminClaimEnabled: e.target.checked }, }); setValidated({ ...validated, oidc: false }); }} className="mt-1 h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" />

Automatically grant admin role to users with specific OIDC claim values

{settings.oidc.adminClaimEnabled && ( <>
{ setSettings({ ...settings, oidc: { ...settings.oidc, adminClaimName: e.target.value }, }); setValidated({ ...validated, oidc: false }); }} placeholder="groups" />

The OIDC claim field to check for admin role (usually "groups" or "roles")

{ setSettings({ ...settings, oidc: { ...settings.oidc, adminClaimValue: e.target.value }, }); setValidated({ ...validated, oidc: false }); }} placeholder="readmeabook-admin" />

Users with this value in their claim will be granted admin role

Example Configuration

In Authentik: Create a group called "readmeabook-admin", add users to it, and set "Admin Claim Value" to "readmeabook-admin"

)}
)}
{/* Registration Settings Section */}

Manual Registration

Configure manual user registration settings.

{ setSettings({ ...settings, registration: { ...settings.registration, enabled: e.target.checked }, }); setValidated({ ...validated, registration: false }); }} className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />

Allow users to create accounts manually with username/password

{settings.registration.enabled && (
{ setSettings({ ...settings, registration: { ...settings.registration, requireAdminApproval: e.target.checked }, }); setValidated({ ...validated, registration: false }); }} className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />

New users must be approved by an admin before they can log in

)}
{/* Warning: No auth methods enabled AND no local users exist */} {settings.backendMode === 'audiobookshelf' && !settings.oidc.enabled && !settings.registration.enabled && !settings.hasLocalUsers && (

No Authentication Methods Available

You must enable at least one authentication method (OIDC or Manual Registration) since no local users exist. Saving with both disabled will lock you out of the system.

)} {/* Info: Registration disabled but local users can still log in */} {settings.backendMode === 'audiobookshelf' && !settings.oidc.enabled && !settings.registration.enabled && settings.hasLocalUsers && (

Manual Registration Disabled

New user registration is disabled. Existing local users can still log in with their credentials.

)} {/* Pending Users Section */} {settings.registration.enabled && settings.registration.requireAdminApproval && (

Pending User Approvals

Review and approve or reject user registration requests.

{loadingPendingUsers ? (
Loading pending users...
) : pendingUsers.length > 0 ? (
{pendingUsers.map((user) => (

{user.plexUsername}

{user.plexEmail && (

{user.plexEmail}

)}

Registered: {new Date(user.createdAt).toLocaleDateString()}

))}
) : (

No pending user approvals

)}
)}
)}
{/* Footer - Hide for BookDate and E-book tabs (they have their own save buttons) */} {activeTab !== 'bookdate' && activeTab !== 'ebook' && (
{(() => { // For Library tab: check based on backend mode if (activeTab === 'library' && settings) { if (settings.backendMode === 'plex' && !validated.plex) { return (

Please test your connection before saving

); } if (settings.backendMode === 'audiobookshelf' && !validated.audiobookshelf) { return (

Please test your connection before saving

); } } // For Auth tab: no validation message (toggles don't need testing) if (activeTab === 'auth') { return null; } // For Prowlarr: show message only if URL/API key changed and not validated if (activeTab === 'prowlarr' && originalSettings && settings) { const connectionChanged = settings.prowlarr.url !== originalSettings.prowlarr.url || settings.prowlarr.apiKey !== originalSettings.prowlarr.apiKey; if (connectionChanged && !validated.prowlarr) { return (

Please test your connection before saving

); } } // For other tabs: show message if not validated if (activeTab === 'download' && !validated.download) { return (

Please test your connection before saving

); } if (activeTab === 'paths' && !validated.paths) { return (

Please test paths before saving

); } return null; })()}
)}
); }