- {isShowFooter && (
+ {!isSpecialPage && (
;
+}
+
+export default DynamicSettings;
diff --git a/src/pages/settings/tabs/HashingAndReleaseSettings.tsx b/src/pages/settings/tabs/HashingAndReleaseSettings.tsx
new file mode 100644
index 000000000..9d54187fd
--- /dev/null
+++ b/src/pages/settings/tabs/HashingAndReleaseSettings.tsx
@@ -0,0 +1,609 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { mdiCog, mdiInformationVariantCircle, mdiLoading } from '@mdi/js';
+import Icon from '@mdi/react';
+import { cloneDeep } from 'lodash';
+import { useDebounceValue } from 'usehooks-ts';
+
+import ConfigurationModal from '@/components/Dialogs/ConfigurationModal';
+import ProviderInfoModal from '@/components/Dialogs/ProviderInfoModal';
+import DnDList from '@/components/DnDList/DnDList';
+import Button from '@/components/Input/Button';
+import Checkbox from '@/components/Input/Checkbox';
+import TabPills from '@/components/TabPills';
+import toast from '@/components/Toast';
+import {
+ useUpdateHashingSettingsMutation,
+ useUpdateManyHashingProvidersMutation,
+} from '@/core/react-query/hashing/mutations';
+import { useHashingProvidersQuery, useHashingSummaryQuery } from '@/core/react-query/hashing/queries';
+import {
+ useUpdateManyReleaseInfoProvidersMutation,
+ useUpdateReleaseInfoSettingsMutation,
+} from '@/core/react-query/release-info/mutations';
+import { useReleaseInfoProvidersQuery, useReleaseInfoSummaryQuery } from '@/core/react-query/release-info/queries';
+import { usePatchSettingsMutation } from '@/core/react-query/settings/mutations';
+import { useSettingsQuery } from '@/core/react-query/settings/queries';
+import useEventCallback from '@/hooks/useEventCallback';
+
+import type { HashProviderInfoType } from '@/core/types/api/hashing';
+import type { ReleaseProviderInfoType } from '@/core/types/api/release-info';
+import type { DropResult } from '@hello-pangea/dnd';
+
+function HashingAndReleaseSettings() {
+ const [info, setInfo] = useState<{ show: boolean, provider: HashProviderInfoType | ReleaseProviderInfoType | null }>(
+ () => ({ show: false, provider: null }),
+ );
+ const [config, setConfig] = useState<
+ { show: boolean, configGuid: string | null, title: string, description: string }
+ >(() => ({ show: false, configGuid: null, title: '', description: '' }));
+ const hashingProviderSummaryQuery = useHashingSummaryQuery();
+ const releaseProviderSummaryQuery = useReleaseInfoSummaryQuery();
+ const settingsQuery = useSettingsQuery();
+ const hashingProvidersQuery = useHashingProvidersQuery();
+ const releaseProvidersQuery = useReleaseInfoProvidersQuery();
+ const { mutate: patchSettings } = usePatchSettingsMutation();
+ const { mutate: updateHashingSettings } = useUpdateHashingSettingsMutation();
+ const { mutate: updateHashingProviders } = useUpdateManyHashingProvidersMutation();
+ const { mutate: updateReleaseInfoSettings } = useUpdateReleaseInfoSettingsMutation();
+ const { mutate: updateReleaseInfoProviders } = useUpdateManyReleaseInfoProvidersMutation();
+ const initialState = useMemo(() => ({
+ noEd2k: hashingProvidersQuery.data != null && hashingProvidersQuery.data.length > 0
+ && !hashingProvidersQuery.data.some(provider => provider.EnabledHashTypes.includes('ED2K')),
+ hashSummary: hashingProviderSummaryQuery.data ?? { ParallelMode: false },
+ hashProviders: hashingProvidersQuery.data ?? [],
+ releaseSummary: releaseProviderSummaryQuery.data ?? { ParallelMode: false },
+ releaseProviders: releaseProvidersQuery.data ?? [],
+ webuiReleaseProviders: (() => {
+ const releaseProviders = releaseProvidersQuery.data?.slice() ?? [];
+ const webuiProviders = settingsQuery.data?.WebUI_Settings.linking.enabledReleaseProviders ?? [];
+ const webuiProviderOrder = settingsQuery.data?.WebUI_Settings.linking.releaseProviderOrder ?? [];
+
+ const anidbProviderIndex = releaseProviders.findIndex(provider =>
+ provider.Name === 'AniDB' && provider.Plugin.Name === 'Shoko Core'
+ );
+ if (anidbProviderIndex !== -1) {
+ const anidbProviderId = releaseProviders[anidbProviderIndex].ID;
+ releaseProviders.splice(anidbProviderIndex, 1);
+ if (webuiProviderOrder.includes(anidbProviderId)) {
+ webuiProviderOrder.splice(webuiProviderOrder.indexOf(anidbProviderId), 0, anidbProviderId);
+ }
+ if (webuiProviders.includes(anidbProviderId)) {
+ webuiProviders.splice(webuiProviders.indexOf(anidbProviderId), 0, anidbProviderId);
+ }
+ }
+
+ return releaseProviders
+ .sort((providerA, providerB) => {
+ const idA = providerA.ID;
+ const idB = providerB.ID;
+ const indexA = webuiProviderOrder.indexOf(idA);
+ const indexB = webuiProviderOrder.indexOf(idB);
+ if (indexA === -1 && indexB === -1) return 0;
+ if (indexA === -1) return 1;
+ if (indexB === -1) return -1;
+ return indexA - indexB;
+ })
+ .map((provider, index) => ({
+ ...provider,
+ IsEnabled: webuiProviders.includes(provider.ID),
+ Priority: index,
+ }));
+ })(),
+ }), [
+ settingsQuery.data,
+ hashingProviderSummaryQuery.data,
+ releaseProviderSummaryQuery.data,
+ hashingProvidersQuery.data,
+ releaseProvidersQuery.data,
+ ]);
+ const hashOrder = useMemo(() => {
+ const hashes = Array.from(new Set(initialState.hashProviders.flatMap(pro => pro.AvailableHashTypes)));
+ hashes.splice(hashes.indexOf('ED2K'), 1);
+ hashes.unshift('ED2K');
+ return hashes
+ .map(hash => ({
+ hash,
+ providerIDs: initialState.hashProviders.filter(pro => pro.AvailableHashTypes.includes(hash)).map(pro => pro.ID),
+ }));
+ }, [initialState]);
+ const [state, setState] = useState(initialState);
+ const isReady = hashingProviderSummaryQuery.isSuccess
+ && hashingProvidersQuery.isSuccess
+ && releaseProviderSummaryQuery.isSuccess
+ && releaseProvidersQuery.isSuccess;
+
+ const unsavedChanges = useMemo(() => JSON.stringify(state) !== JSON.stringify(initialState), [state, initialState]);
+ const [debouncedUnsavedChanges] = useDebounceValue(unsavedChanges, 100);
+
+ const handleToggleHashingProviderHash = useEventCallback((event: React.ChangeEvent
) => {
+ const id = event.currentTarget.id.slice(0, 36);
+ const sta = cloneDeep(state);
+ const provider = sta.hashProviders.find(pro => pro.ID === id);
+ if (!provider) return;
+ const hash = event.currentTarget.id.slice(37);
+ if (provider.EnabledHashTypes.includes(hash)) {
+ provider.EnabledHashTypes.splice(provider.EnabledHashTypes.indexOf(hash), 1);
+ } else provider.EnabledHashTypes.push(hash);
+
+ const otherProviders = sta.hashProviders.filter(pro => pro.EnabledHashTypes.includes(hash) && pro.ID !== id);
+ if (otherProviders.length > 0) {
+ for (const pro of otherProviders) {
+ pro.EnabledHashTypes.splice(pro.EnabledHashTypes.indexOf(hash), 1);
+ }
+ }
+
+ sta.noEd2k = sta.hashProviders.length > 0 && !sta.hashProviders.some(pro => pro.EnabledHashTypes.includes('ED2K'));
+ setState(sta);
+ });
+
+ const handleToggleParallelHashing = useEventCallback(() => {
+ const sta = cloneDeep(state);
+ sta.hashSummary.ParallelMode = !sta.hashSummary.ParallelMode;
+ setState(sta);
+ });
+
+ const handleToggleReleaseInfoProvider = useEventCallback((event: React.ChangeEvent) => {
+ const id = event.target.id.slice(0, 36);
+ const { checked } = event.target;
+ const sta = cloneDeep(state);
+ sta.releaseProviders.find(pro => pro.ID === id)!.IsEnabled = checked;
+ setState(sta);
+ });
+
+ const handleToggleWebuiReleaseInfoProvider = useEventCallback((event: React.ChangeEvent) => {
+ const id = event.target.id.slice(0, 36);
+ const { checked } = event.target;
+ const sta = cloneDeep(state);
+ sta.webuiReleaseProviders.find(pro => pro.ID === id)!.IsEnabled = checked;
+ setState(sta);
+ });
+
+ const handleToggleParallelRelease = useEventCallback(() => {
+ const sta = cloneDeep(state);
+ sta.releaseSummary.ParallelMode = !sta.releaseSummary.ParallelMode;
+ setState(sta);
+ });
+
+ const handleDragRelease = useEventCallback((result: DropResult) => {
+ if (!result.destination || result.destination.index === result.source.index || !releaseProvidersQuery.isSuccess) {
+ return;
+ }
+
+ const sta = cloneDeep(state);
+ const [removed] = sta.releaseProviders.splice(result.source.index, 1);
+ sta.releaseProviders.splice(result.destination.index, 0, removed);
+ for (let priority = 0; priority < sta.releaseProviders.length; priority += 1) {
+ sta.releaseProviders[priority].Priority = priority;
+ }
+ setState(sta);
+ });
+
+ const handleDragReleaseWebui = useEventCallback((result: DropResult) => {
+ if (!result.destination || result.destination.index === result.source.index || !releaseProvidersQuery.isSuccess) {
+ return;
+ }
+
+ const sta = cloneDeep(state);
+ const [removed] = sta.webuiReleaseProviders.splice(result.source.index, 1);
+ sta.webuiReleaseProviders.splice(result.destination.index, 0, removed);
+ for (let priority = 0; priority < sta.webuiReleaseProviders.length; priority += 1) {
+ sta.webuiReleaseProviders[priority].Priority = priority;
+ }
+ setState(sta);
+ });
+
+ const handleOpenInfo = useEventCallback((event: React.MouseEvent) => {
+ const id = event.currentTarget.id.slice(0, -'-info'.length);
+ const provider = state.releaseProviders.find(item => item.ID === id)
+ ?? state.hashProviders.find(item => item.ID === id)!;
+ setInfo({ show: true, provider });
+ });
+
+ const handleCloseInfo = useEventCallback(() => {
+ setInfo(prev => ({ ...prev, show: false }));
+ });
+
+ const handleOpenConfig = useEventCallback((event: React.MouseEvent) => {
+ const id = event.currentTarget.id.slice(0, -'-config'.length);
+
+ const { Description: description, ID: configGuid, Name: title } = (state.releaseProviders.find(item =>
+ item.ID === id
+ ) ?? state.hashProviders.find(item => item.ID === id)!).Configuration!;
+ setConfig({ show: true, configGuid, title, description: description ?? '' });
+ });
+
+ const handleCloseConfig = useEventCallback(() => {
+ setConfig(prev => ({ ...prev, show: false }));
+ });
+
+ const handleCancel = useEventCallback(() => {
+ setState(cloneDeep(initialState));
+ });
+
+ const updateWebuiReleaseInfoProviders = useEventCallback(
+ async (providers: ReleaseProviderInfoType[]): Promise => {
+ const settings = cloneDeep(settingsQuery.data);
+ const anidbProviderIndex = providers.findIndex(provider =>
+ provider.Name === 'AniDB' && provider.Plugin.Name === 'Shoko Core'
+ );
+ if (anidbProviderIndex !== -1) {
+ providers.splice(anidbProviderIndex, 1);
+ }
+
+ const { enabledReleaseProviders, releaseProviderOrder } = settings.WebUI_Settings.linking;
+ enabledReleaseProviders.length = 0;
+ releaseProviderOrder.length = 0;
+
+ for (const provider of providers) {
+ releaseProviderOrder.push(provider.ID);
+ if (provider.IsEnabled) {
+ enabledReleaseProviders.push(provider.ID);
+ }
+ }
+
+ await (patchSettings(settings) as unknown as Promise);
+ },
+ );
+
+ const handleSave = useEventCallback(() => {
+ const promises: Promise[] = [];
+ if (JSON.stringify(initialState.hashSummary) !== JSON.stringify(state.hashSummary)) {
+ promises.push(updateHashingSettings(state.hashSummary) as unknown as Promise);
+ }
+ if (JSON.stringify(initialState.hashProviders) !== JSON.stringify(state.hashProviders)) {
+ promises.push(updateHashingProviders(state.hashProviders) as unknown as Promise);
+ }
+ if (JSON.stringify(initialState.releaseSummary) !== JSON.stringify(state.releaseSummary)) {
+ promises.push(updateReleaseInfoSettings(state.releaseSummary) as unknown as Promise);
+ }
+ if (JSON.stringify(initialState.releaseProviders) !== JSON.stringify(state.releaseProviders)) {
+ promises.push(updateReleaseInfoProviders(state.releaseProviders) as unknown as Promise);
+ }
+ if (JSON.stringify(initialState.webuiReleaseProviders) !== JSON.stringify(state.webuiReleaseProviders)) {
+ promises.push(updateWebuiReleaseInfoProviders(state.webuiReleaseProviders));
+ }
+ Promise.all(promises)
+ .then(() => {
+ toast.success('Settings saved successfully.');
+ })
+ .catch((error) => {
+ console.error(error);
+ toast.error(`Failed to save settings: ${error}`);
+ });
+ });
+
+ useEffect(() => {
+ if (!debouncedUnsavedChanges) {
+ toast.dismiss('hashing-release-unsaved');
+ } else {
+ toast.info(
+ 'Unsaved Changes',
+ 'Please save before leaving this page.',
+ { autoClose: false, position: 'top-right', toastId: 'hashing-release-unsaved' },
+ );
+ }
+ }, [debouncedUnsavedChanges]);
+
+ useEffect(() => {
+ if (!state.noEd2k) {
+ toast.dismiss('no-ed2k');
+ } else {
+ toast.warning(
+ 'No ED2K Provider',
+ 'Please select an ED2K provider to use.',
+ { autoClose: false, position: 'top-right', toastId: 'no-ed2k' },
+ );
+ }
+ }, [state.noEd2k]);
+
+ useEffect(() => () => {
+ toast.dismiss('hashing-release-unsaved');
+ toast.dismiss('no-ed2k');
+ }, []);
+
+ useEffect(() => {
+ setState(cloneDeep(initialState));
+ }, [initialState]);
+
+ if (!isReady) {
+ return ;
+ }
+
+ return (
+ <>
+ Settings > Hashing & Release | Shoko
+
+
Hashing & Release
+
+ Configure how Shoko hashes files and handles releases.
+
+
+
+
+
+
+
Hashing Options
+
+
+
+
+ {state.hashSummary.ParallelMode
+ ? (
+ <>
+ Run all enabled providers in parallel, and wait until all providers have computed their hashes, then
+ merge their lists.
+ >
+ )
+ : (
+ <>
+ Run each enabled provider in sequential order, until all providers have computed their hashes.
+ >
+ )}
+
+
+ {hashOrder.map(({ hash, providerIDs }) => (
+
+
+
+
+ {hash}
+
+ Providers
+
+
+
+
+ {state.hashProviders.filter(provider => providerIDs.includes(provider.ID)).map(definition => (
+
+
+ {definition.Name}
+
+ (
+ {definition.Plugin.Name}
+ )
+
+
+
+ {definition.Configuration && (
+
+ )}
+
+
+ >
+ }
+ />
+ ))}
+
+
+
+ {`Enabled provider for the ${hash} algorithm. Unselect all to disable the algorithm.`}
+
+
+ ))}
+
+
+
+
+
+
Releases Options
+
+
+
+
+
+
+ {state.releaseSummary.ParallelMode
+ ? (
+ <>
+ Run all enabled providers in parallel, and wait for the highest priority valid result.
+ >
+ )
+ : (
+ <>
+ Run each provider in sequential order defined by the priority below until a valid result is found.
+ >
+ )}
+
+
+
+
+
+
+
+ Auto-Linking Release Providers
+ (Drag to Reorder)
+
+
+
+
+ {state.releaseProviders.map(definition => ({
+ key: definition.ID,
+ item: (
+
+
+ {definition.Name}
+
+ (
+ {definition.Plugin.Name}
+ )
+
+
+
+ {definition.Configuration && (
+
+ )}
+
+
+ >
+ }
+ />
+ ),
+ }))}
+
+
+
+ Enabled auto-search release providers in the automated linking process, in priority order.
+
+
+
+
+
+
+
+
+ Manual Linking Release Providers
+ (Drag to Reorder)
+
+
+
+
+ {state.webuiReleaseProviders.map(definition => ({
+ key: definition.ID,
+ item: (
+
+
+ {definition.Name}
+
+ (
+ {definition.Plugin.Name}
+ )
+
+
+
+ {definition.Configuration && (
+
+ )}
+
+
+ >
+ }
+ />
+ ),
+ }))}
+
+
+
+ Enabled auto-search release providers in the manual linking process, in priority order.
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export default HashingAndReleaseSettings;
diff --git a/src/pages/settings/tabs/ImportSettings.tsx b/src/pages/settings/tabs/ImportSettings.tsx
index 7b1a62210..67c847d9e 100644
--- a/src/pages/settings/tabs/ImportSettings.tsx
+++ b/src/pages/settings/tabs/ImportSettings.tsx
@@ -1,13 +1,49 @@
import React from 'react';
+import { useDispatch } from 'react-redux';
+import { mdiDatabaseEditOutline, mdiDatabaseSearchOutline, mdiFolderPlusOutline } from '@mdi/js';
+import Icon from '@mdi/react';
import { produce } from 'immer';
+import prettyBytes from 'pretty-bytes';
+import Button from '@/components/Input/Button';
import Checkbox from '@/components/Input/Checkbox';
import InputSmall from '@/components/Input/InputSmall';
+import toast from '@/components/Toast';
+import { useRescanManagedFolderMutation } from '@/core/react-query/managed-folder/mutations';
+import { useManagedFoldersQuery } from '@/core/react-query/managed-folder/queries';
+import { setEdit, setStatus } from '@/core/slices/modals/managedFolder';
import useEventCallback from '@/hooks/useEventCallback';
import useSettingsContext from '@/hooks/useSettingsContext';
+import type { ManagedFolderType } from '@/core/types/api/managed-folder';
+
function ImportSettings() {
+ const dispatch = useDispatch();
const { newSettings, updateSetting } = useSettingsContext();
+ const { mutate: rescanManagedFolder } = useRescanManagedFolderMutation();
+ const managedFolderQuery = useManagedFoldersQuery();
+ const managedFolders = managedFolderQuery?.data ?? [] as ManagedFolderType[];
+
+ const handleAddButton = useEventCallback(() => {
+ dispatch(setStatus(true));
+ });
+
+ const handleRescanButton = useEventCallback((event: React.MouseEvent) => {
+ const id = parseInt(event.currentTarget.id.slice(0, -'-rescan'.length), 10);
+ const folder = managedFolders.find(fold => fold.ID === id);
+ rescanManagedFolder(id, {
+ onSuccess: () =>
+ toast.success(
+ 'Scan Managed Folder Success',
+ `Managed Folder ${folder?.Name ?? ''} queued for scanning.`,
+ ),
+ });
+ });
+
+ const handleEditButton = useEventCallback((event: React.MouseEvent) => {
+ const id = parseInt(event.currentTarget.id.slice(0, -'-edit'.length), 10);
+ dispatch(setEdit(id));
+ });
const {
AutomaticallyDeleteDuplicatesOnImport,
@@ -104,6 +140,83 @@ function ImportSettings() {