/** * 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'; 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; supportsRss?: boolean; } interface Settings { backendMode: 'plex' | 'audiobookshelf'; hasLocalUsers: boolean; plex: { url: string; token: string; libraryId: string; }; audiobookshelf: { serverUrl: string; apiToken: string; libraryId: string; }; 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; remotePathMappingEnabled: boolean; remotePath: string; localPath: string; }; paths: { downloadDir: string; mediaDir: string; metadataTaggingEnabled: boolean; }; } 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 [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' | 'account' | 'bookdate'>('library'); // Password change form state const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '', }); const [changingPassword, setChangingPassword] = useState(false); // BookDate configuration state const [bookdateProvider, setBookdateProvider] = useState('openai'); const [bookdateApiKey, setBookdateApiKey] = useState(''); const [bookdateModel, setBookdateModel] = 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); 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 || []); } 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 || ''); 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; // 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 { payload.useSavedKey = true; } 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; } // 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 payload: any = { provider: bookdateProvider, model: bookdateModel, isEnabled: bookdateEnabled, }; // Only include API key if user entered a new one if (hasApiKey) { payload.apiKey = bookdateApiKey; } 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 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, 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 changePassword = async () => { setChangingPassword(true); setMessage(null); try { const response = await fetchWithAuth('/api/admin/settings/change-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(passwordForm), }); const data = await response.json(); if (data.success) { setMessage({ type: 'success', text: 'Password changed successfully!' }); // Clear form setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '', }); setTimeout(() => setMessage(null), 5000); } else { setMessage({ type: 'error', text: data.error || 'Failed to change password' }); } } catch (error) { setMessage({ type: 'error', text: error instanceof Error ? error.message : 'Failed to change password', }); } finally { setChangingPassword(false); } }; const saveSettings = async () => { if (!settings) return; setSaving(true); setMessage(null); try { // Save settings based on active tab switch (activeTab) { case 'library': 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 if indexers are loaded if (indexers.length > 0) { const indexersResponse = await fetchWithAuth('/api/admin/settings/prowlarr/indexers', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ indexers }), }); 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: 'bookdate', label: 'BookDate', icon: '📚' }, ...(isLocalAdmin ? [{ id: 'account', label: 'Account', 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.
)}
{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.
)}
{testResults.audiobookshelf && (
{testResults.audiobookshelf.message}
)}
)} {/* Prowlarr/Indexers Tab */} {activeTab === 'prowlarr' && (

Indexer Configuration

Configure your Prowlarr connection and select which indexers to use with priority and seeding time.

{ setSettings({ ...settings, prowlarr: { ...settings.prowlarr, url: e.target.value }, }); // Only invalidate if URL actually changed from original if (originalSettings && e.target.value !== originalSettings.prowlarr.url) { setValidated({ ...validated, prowlarr: false }); } }} placeholder="http://localhost:9696" />
{ setSettings({ ...settings, prowlarr: { ...settings.prowlarr, apiKey: e.target.value }, }); // Only invalidate if API key actually changed from original if (originalSettings && e.target.value !== originalSettings.prowlarr.apiKey) { setValidated({ ...validated, prowlarr: false }); } }} placeholder="Enter API key" />

Found in Prowlarr Settings → General → Security → API Key

{testResults.prowlarr && (
{testResults.prowlarr.message}
)}

Indexer Configuration

{indexers.length > 0 && !loadingIndexers && ( {indexers.filter(idx => idx.enabled).length} enabled )}
{loadingIndexers ? (
Loading indexers...
) : indexers.length > 0 ? (
{indexers.map((indexer) => (
{ setIndexers( indexers.map((idx) => idx.id === indexer.id ? { ...idx, enabled: e.target.checked } : idx ) ); }} className="mt-1 h-5 w-5 rounded border-gray-300" />

{indexer.name}

{indexer.protocol}
{ const value = parseInt(e.target.value) || 10; setIndexers( indexers.map((idx) => idx.id === indexer.id ? { ...idx, priority: Math.max(1, Math.min(25, value)) } : idx ) ); }} disabled={!indexer.enabled} className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 disabled:opacity-50" />

Higher = preferred

{ const value = e.target.value === '' ? 0 : parseInt(e.target.value); setIndexers( indexers.map((idx) => idx.id === indexer.id ? { ...idx, seedingTimeMinutes: isNaN(value) ? 0 : value } : idx ) ); }} disabled={!indexer.enabled} className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 disabled:opacity-50" />

0 = unlimited

{ setIndexers( indexers.map((idx) => idx.id === indexer.id ? { ...idx, rssEnabled: e.target.checked } : idx ) ); }} disabled={!indexer.enabled || indexer.supportsRss === false} className="h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:opacity-50" />

Auto check for new releases

))}
) : (

No indexers configured.

{settings.prowlarr.url && settings.prowlarr.apiKey ? 'Click "Refresh Indexers" above to load available indexers from Prowlarr.' : 'Enter your Prowlarr URL and API key above, then click "Test Connection" to load indexers.'}

)}
)} {/* Download Client Tab */} {activeTab === 'download' && (

Download Client

Configure your torrent download client (qBittorrent/Transmission).

{ setSettings({ ...settings, downloadClient: { ...settings.downloadClient, url: e.target.value }, }); setValidated({ ...validated, download: false }); }} placeholder="http://localhost:8080" />
{ 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" />
{/* 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.

{testResults.paths && (
{testResults.paths.message}
)}
)} {/* 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 */}
{/* API Key */}
{ setBookdateApiKey(e.target.value); setBookdateModels([]); }} placeholder={ bookdateConfigured ? '••••••••••••••••' : (bookdateProvider === 'openai' ? 'sk-...' : 'sk-ant-...') } />

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

)}
)}
)} {/* Account Tab - Only visible to local admin */} {activeTab === 'account' && isLocalAdmin && (

Account Security

Change your local admin account password.

{/* Info Box */}

Local Admin Account

This password is for your local admin account created during setup. This is separate from media server authentication and is used to log in to the admin portal.

setPasswordForm({ ...passwordForm, currentPassword: e.target.value }) } placeholder="Enter current password" autoComplete="current-password" />
setPasswordForm({ ...passwordForm, newPassword: e.target.value }) } placeholder="Enter new password" autoComplete="new-password" />

Must be at least 8 characters

setPasswordForm({ ...passwordForm, confirmPassword: e.target.value }) } placeholder="Confirm new password" autoComplete="new-password" />
)}
{/* Footer - Hide for Account tab */} {activeTab !== 'account' && activeTab !== 'bookdate' && (
{(() => { // 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; })()}
)}
); }