/* * Copyright (C) Contributors to the Suwayomi project * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { useMemo } from 'react'; import Link from '@mui/material/Link'; import List from '@mui/material/List'; import ListItem from '@mui/material/ListItem'; import ListItemText from '@mui/material/ListItemText'; import Switch from '@mui/material/Switch'; import ListSubheader from '@mui/material/ListSubheader'; import { d } from 'koration'; import { Trans, useLingui } from '@lingui/react/macro'; import { plural, t as translate } from '@lingui/core/macro'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { TextSetting } from '@/base/components/settings/text/TextSetting.tsx'; import { NumberSetting } from '@/base/components/settings/NumberSetting.tsx'; import { SelectSetting } from '@/base/components/settings/SelectSetting.tsx'; import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx'; import { EmptyViewAbsoluteCentered } from '@/base/components/feedback/EmptyViewAbsoluteCentered.tsx'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { createUpdateMetadataServerSettings, useMetadataServerSettings, } from '@/features/settings/services/ServerSettingsMetadata.ts'; import { makeToast } from '@/base/utils/Toast.ts'; import type { MetadataUpdateSettings } from '@/features/app-updates/AppUpdateChecker.types.ts'; import { getErrorMessage } from '@/lib/HelperFunctions.ts'; import { useAppTitle } from '@/features/navigation-bar/hooks/useAppTitle.ts'; import type { PartialSettingsTypeInput } from '@/lib/graphql/generated/graphql-base.types.ts'; import { AuthMode, CbzMediaType, DatabaseType, SortOrder, StartSyncResult, SyncState, } from '@/lib/graphql/generated/graphql-base.types.ts'; import { AUTH_MODES_SELECT_VALUES, JWT_ACCESS_TOKEN_EXPIRY, JWT_REFRESH_TOKEN_EXPIRY, SYNC_INTERVAL_CUSTOM_VALUE, SYNC_INTERVAL_SELECT_VALUES, SYNC_INTERVAL_SELECT_VALUES_WITH_CUSTOM, SYNC_INTERVAL_VALUES, SYNC_SETTINGS_HIDDEN_BACKUP_FLAGS, SYNC_START_RESULT_TRANSLATION, SYNC_STATE_TRANSLATION, } from '@/features/settings/Settings.constants.ts'; import { ServerAddressSetting } from '@/features/settings/components/ServerAddressSetting.tsx'; import { AuthManager } from '@/features/authentication/AuthManager.ts'; import type { ServerSettings as ServerSettingsType } from '@/features/settings/Settings.types.ts'; import { KoreaderSyncSettings } from '@/features/settings/components/koreaderSync/KoreaderSyncSettings.tsx'; import { Confirmation } from '@/base/AppAwaitableComponent.ts'; import { BackupFlagInclusionDialog } from '@/features/backup/component/BackupFlagInclusionDialog.tsx'; import ListItemButton from '@mui/material/ListItemButton'; import { AwaitableComponent } from 'awaitable-component'; import { getAutoBackupFlagsInfo } from '@/features/backup/Backup.utils.ts'; import type { BackupFlagInclusionState } from '@/features/backup/Backup.types.ts'; import { epochToDate, getDateString } from '@/base/utils/DateHelper.ts'; const convertSyncDataToBackupFlags = (settings: ServerSettingsType): BackupFlagInclusionState => ({ includeManga: settings.syncDataManga, includeChapters: settings.syncDataChapters, includeCategories: settings.syncDataCategories, includeHistory: settings.syncDataHistory, includeTracking: settings.syncDataTracking, includeClientData: false, includeServerSettings: false, }); const convertBackupFlagsToSyncData = (flags: BackupFlagInclusionState): PartialSettingsTypeInput => ({ syncDataManga: flags.includeManga, syncDataChapters: flags.includeChapters, syncDataCategories: flags.includeCategories, syncDataHistory: flags.includeHistory, syncDataTracking: flags.includeTracking, }); const getLogFilesCleanupDisplayValue = (ttl: number): string => { if (ttl === 0) { return translate`Never`; } return plural(ttl, { one: 'Delete log files that are older than # day', other: 'Delete log files that are older than # days', }); }; export const ServerSettings = () => { const { t } = useLingui(); useAppTitle(t`Server`); const syncStatusRequest = requestManager.useGetSyncStatus(); const { settings: { serverInformAvailableUpdate, serverInformVersionUpdated }, loading: areMetadataServerSettingsLoading, request: { error: metadataServerSettingsError, refetch: refetchServerMetadataSettings }, } = useMetadataServerSettings(); const updateMetadataServerSettings = createUpdateMetadataServerSettings< keyof Pick >((e) => makeToast(t`Failed to save changes`, 'error', getErrorMessage(e))); const { data, loading: areServerSettingsLoading, error: serverSettingsError, refetch: refetchServerSettings, } = requestManager.useGetServerSettings(); const [mutateSettings] = requestManager.useUpdateServerSettings(); const koSyncStatus = requestManager.useKoSyncStatus(); const updateSetting = async ( setting: Setting, value: ServerSettingsType[Setting], onCompletion?: (success: boolean) => void, ) => { try { await mutateSettings({ variables: { input: { settings: { [setting]: value } } } }); onCompletion?.(true); } catch (e) { makeToast(t`Failed to save changes`, 'error', getErrorMessage(e)); onCompletion?.(false); } }; const updateSettings = async (settings: PartialSettingsTypeInput, onCompletion?: (success: boolean) => void) => { try { await mutateSettings({ variables: { input: { settings } } }); onCompletion?.(true); } catch (e) { makeToast(t`Failed to save changes`, 'error', getErrorMessage(e)); onCompletion?.(false); } }; const localSettings = useMemo( () => ( {t`Client`} } > updateMetadataServerSettings('serverInformVersionUpdated', e.target.checked)} /> updateMetadataServerSettings('serverInformAvailableUpdate', e.target.checked)} /> ), [serverInformVersionUpdated, serverInformAvailableUpdate], ); const loading = areMetadataServerSettingsLoading || areServerSettingsLoading || koSyncStatus.loading; if (loading) { return ( <> {localSettings} ); } const error = metadataServerSettingsError ?? serverSettingsError ?? koSyncStatus.error; if (error) { return ( <> {localSettings} { if (metadataServerSettingsError) { refetchServerMetadataSettings().catch( defaultPromiseErrorHandler('ServerSettings::refetchServerMetadataSettings'), ); } if (serverSettingsError) { refetchServerSettings().catch( defaultPromiseErrorHandler('ServerSettings::refetchServerSettings'), ); } if (koSyncStatus.error) { koSyncStatus .refetch() .catch(defaultPromiseErrorHandler('ServerSettings::koSyncStatus.refetch')); } }} /> ); } const serverSettings = data!.settings; const koreaderSyncStatus = koSyncStatus.data!.koSyncStatus; const authModeDisabled = !serverSettings.authUsername?.trim() || !serverSettings.authPassword?.trim(); const isH2Database = serverSettings.databaseType === DatabaseType.H2; const isCustomSyncInterval = !SYNC_INTERVAL_VALUES.includes( d(serverSettings.syncInterval).minutes.asWholeMinutes.toISOString(), ); const syncDataFlagsInfo = getAutoBackupFlagsInfo(serverSettings); const includedSyncDataText = syncDataFlagsInfo.true; const excludedSyncDataText = syncDataFlagsInfo.false; const syncStatus = syncStatusRequest.data?.lastSyncStatus; const syncDate = syncStatus?.endDate ?? syncStatus?.startDate; const syncState = syncStatus?.state; const isSyncing = !!syncState && ![SyncState.Success, SyncState.Error].includes(syncState); return ( {localSettings} {t`Server bindings`} } > updateSetting('ip', ip)} value={serverSettings.ip} placeholder="0.0.0.0" /> updateSetting('port', port)} value={serverSettings.port} defaultValue={4567} valueUnit={t`Port`} /> {t`SOCKS proxy`} } > updateSetting('socksProxyEnabled', e.target.checked)} /> settingName={t`SOCKS version`} value={serverSettings.socksProxyVersion} values={[ [4, { text: '4' }], [5, { text: '5' }], ]} handleChange={(socksProxyVersion) => updateSetting('socksProxyVersion', socksProxyVersion)} /> updateSetting('socksProxyHost', proxyHost)} /> updateSetting('socksProxyPort', proxyPort)} /> updateSetting('socksProxyUsername', proxyUsername)} /> updateSetting('socksProxyPassword', proxyPassword)} isPassword /> {t`Authentication`} } > settingName={t`Authentication Mode`} value={serverSettings.authMode} values={AUTH_MODES_SELECT_VALUES} handleChange={(mode) => { updateSetting('authMode', mode, (success) => { if (!success) { return; } if (mode !== AuthMode.UiLogin) { AuthManager.removeTokens(); } AuthManager.setAuthRequired(mode === AuthMode.UiLogin); }); }} disabled={authModeDisabled} /> serverSettings.authMode === AuthMode.None || !!value.trim()} handleChange={(authUsername) => updateSetting('authUsername', authUsername)} /> serverSettings.authMode === AuthMode.None || !!value.trim()} handleChange={(authPassword) => updateSetting('authPassword', authPassword)} /> {serverSettings.authMode === AuthMode.UiLogin && ( <> updateSetting('jwtAudience', audience)} /> updateSetting('jwtTokenExpiry', d(expiry).minutes.toISOString())} showSlider /> updateSetting('jwtRefreshExpiry', d(expiry).days.toISOString())} showSlider /> )} {t`Cloudflare bypass`} } > See{' '} FlareSolverr {' '} for information on how to set it up } /> { const enabled = e.target.checked; if (!enabled) { updateSetting('flareSolverrEnabled', enabled); return; } try { await Confirmation.show({ title: t`Flaresolverr setup confirmation`, message: ( Flaresolverr is a{' '} separate , Suwayomi{' '} unrelated {' '} program that needs to be manually{' '} installed {' '} and{' '} run .
See{' '} FlareSolverr {' '} for information on how to set it up

If it is{' '} not {' '} properly set up and running, enabling this setting{' '} won't {' '} do anything
), actions: { confirm: { title: t`Understood`, }, }, }); updateSetting('flareSolverrEnabled', enabled); } catch (_) { // Ignore } }} />
updateSetting('flareSolverrUrl', url)} /> updateSetting('flareSolverrTimeout', timeout)} /> updateSetting('flareSolverrSessionName', sessionName)} /> updateSetting('flareSolverrSessionTtl', sessionTTL)} /> updateSetting('flareSolverrAsResponseFallback', e.target.checked)} />
{t`OPDS`} } > updateSetting('opdsUseBinaryFileSizes', e.target.checked)} /> updateSetting('opdsItemsPerPage', value)} /> updateSetting('opdsEnablePageReadProgress', e.target.checked)} /> updateSetting('opdsMarkAsReadOnDownload', e.target.checked)} /> updateSetting('opdsShowOnlyUnreadChapters', e.target.checked)} /> updateSetting('opdsShowOnlyDownloadedChapters', e.target.checked)} /> updateSetting('opdsSkipChapterMetadataFeed', e.target.checked)} /> settingName={t`Chapter sort order`} dialogDescription={t`Choose the order in which chapters are displayed.`} value={serverSettings.opdsChapterSortOrder} values={[ [ SortOrder.Asc, { text: t`Ascending`, }, ], [ SortOrder.Desc, { text: t`Descending`, }, ], ]} handleChange={(value) => updateSetting('opdsChapterSortOrder', value)} /> settingName={t`CBZ MIME-Type`} dialogDescription={t`Controls the MimeType that Suwayomi sends in OPDS entries for CBZ archives. Also affects global CBZ download.\nModern follows recent IANA standard (2017), while LEGACY (deprecated mimetype for .cbz) and COMPATIBLE (deprecated mimetype for all comic archives) might be more compatible with older clients.`} value={serverSettings.opdsCbzMimetype} values={[ [ CbzMediaType.Legacy, { text: t`Legacy`, }, ], [ CbzMediaType.Modern, { text: t`Modern`, }, ], [ CbzMediaType.Compatible, { text: t`Compatible`, }, ], ]} handleChange={(value) => updateSetting('opdsCbzMimetype', value)} /> {t`Database`} } > settingName={t`Type`} value={serverSettings.databaseType} values={[ [ DatabaseType.H2, { text: t`H2`, }, ], [ DatabaseType.Postgresql, { text: t`PostgreSQL`, }, ], ]} handleChange={(value) => updateSetting('databaseType', value)} /> updateSetting('databaseUrl', url)} value={serverSettings.databaseUrl} placeholder="postgresql://localhost:5432/suwayomi" disabled={isH2Database} /> updateSetting('databaseUsername', username)} disabled={isH2Database} /> updateSetting('databasePassword', password)} disabled={isH2Database} isPassword /> updateSetting('useHikariConnectionPool', e.target.checked)} /> {t`WebView`} } > updateSetting('kcefEnabled', e.target.checked)} /> {t`Sync`} } > requestManager .startSync() .response.then((response) => { const startResult = response.data?.startSync.result; makeToast( t(SYNC_START_RESULT_TRANSLATION[startResult!]), startResult === StartSyncResult.Success ? 'success' : 'error', ); }) .catch((e) => makeToast(t`Could not start sync`, 'error', getErrorMessage(e))) } > {syncDate && t`Last sync: ${getDateString(epochToDate(Number(syncDate)), true, true)}`}{' '} {syncState && t`- State: ${t(SYNC_STATE_TRANSLATION[syncState])}`} {!!syncStatus?.errorMessage && `\nError: ${syncStatus.errorMessage}`} } /> { try { const flags = await AwaitableComponent.show(BackupFlagInclusionDialog, { title: t`Sync data`, flags: convertSyncDataToBackupFlags(serverSettings), hiddenFlags: SYNC_SETTINGS_HIDDEN_BACKUP_FLAGS, }); await updateSettings(convertBackupFlagsToSyncData(flags)); } catch (e) { // Ignore } }} > {t`Include: ${includedSyncDataText}`} {t`Exclude: ${excludedSyncDataText}`} } slotProps={{ secondary: { sx: { display: 'flex', flexDirection: 'column' } }, }} /> updateSetting('syncInterval', syncInterval)} /> {t`SyncYomi`} } > updateSetting('syncYomiEnabled', e.target.checked)} /> updateSetting('syncYomiHost', host)} /> updateSetting('syncYomiApiKey', apiKey)} isPassword /> {t`Misc`} } > updateSetting('debugLogsEnabled', e.target.checked)} /> updateSetting('systemTrayEnabled', e.target.checked)} /> updateSetting('maxLogFiles', maxFiles)} /> !!value.match(/^[0-9]+(|kb|KB|mb|MB|gb|GB)$/g)} handleChange={(maxLogFileSize) => updateSetting('maxLogFileSize', maxLogFileSize)} /> !!value.match(/^[0-9]+(|kb|KB|mb|MB|gb|GB)$/g)} handleChange={(maxLogFolderSize) => updateSetting('maxLogFolderSize', maxLogFolderSize)} />
); };