diff --git a/frontend/src/Settings/useProviderSettings.ts b/frontend/src/Settings/useProviderSettings.ts index 06ef539eb..809547b5c 100644 --- a/frontend/src/Settings/useProviderSettings.ts +++ b/frontend/src/Settings/useProviderSettings.ts @@ -1,14 +1,24 @@ -import { useQueryClient } from '@tanstack/react-query'; -import { useCallback, useMemo, useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useMemo, useRef, useState } from 'react'; import ModelBase from 'App/ModelBase'; -import useApiMutation from 'Helpers/Hooks/useApiMutation'; +import useApiMutation, { + getValidationFailures, +} from 'Helpers/Hooks/useApiMutation'; import useApiQuery, { QueryOptions } from 'Helpers/Hooks/useApiQuery'; import { usePendingChangesStore } from 'Helpers/Hooks/usePendingChangesStore'; import { usePendingFieldsStore } from 'Helpers/Hooks/usePendingFieldsStore'; import selectSettings from 'Store/Selectors/selectSettings'; import { PendingSection } from 'typings/pending'; import Provider from 'typings/Provider'; -import { ApiError } from 'Utilities/Fetch/fetchJson'; +import fetchJson, { ApiError } from 'Utilities/Fetch/fetchJson'; +import getQueryPath from 'Utilities/Fetch/getQueryPath'; +import getQueryString, { QueryParams } from 'Utilities/Fetch/getQueryString'; + +export type SkipValidation = 'none' | 'warnings' | 'all'; +export interface SaveOptions { + skipTesting?: boolean; + skipValidation?: SkipValidation; +} interface BaseManageProviderSettings extends Omit>, 'settings'> { @@ -80,28 +90,60 @@ export const useSaveProviderSettings = ( ) => { const queryClient = useQueryClient(); - const { mutate, isPending, error } = useApiMutation({ - path: id ? `${path}/${id}` : path, - method: id ? 'PUT' : 'POST', - mutationOptions: { - onSuccess: (updatedSettings: T) => { - queryClient.setQueryData([path], (oldData = []) => { - if (id) { - return oldData.map((item) => - item.id === updatedSettings.id ? updatedSettings : item - ); - } + const { mutate, isPending, error } = useMutation< + T, + ApiError, + { + data: T; + } & SaveOptions + >({ + mutationFn: async ({ data, skipTesting, skipValidation }) => { + const queryParams: QueryParams = {}; - return [...oldData, updatedSettings]; - }); - onSuccess?.(updatedSettings); - }, - onError, + if (skipTesting) { + queryParams.skipTesting = true; + } + + if (skipValidation && skipValidation !== 'none') { + queryParams.skipValidation = skipValidation; + } + + return fetchJson({ + path: + getQueryPath(id ? `${path}/${id}` : path) + + getQueryString(queryParams), + method: id ? 'PUT' : 'POST', + headers: { + 'X-Api-Key': window.Sonarr.apiKey, + 'X-Sonarr-Client': 'Sonarr', + }, + body: data, + }); }, + onSuccess: (updatedSettings: T) => { + queryClient.setQueryData([path], (oldData = []) => { + if (id) { + return oldData.map((item) => + item.id === updatedSettings.id ? updatedSettings : item + ); + } + + return [...oldData, updatedSettings]; + }); + onSuccess?.(updatedSettings); + }, + onError, }); + const save = useCallback( + (data: T, options?: SaveOptions) => { + mutate({ data, ...options }); + }, + [mutate] + ); + return { - save: mutate, + save, isSaving: isPending, saveError: error, }; @@ -112,17 +154,41 @@ export const useTestProvider = ( onSuccess?: () => void, onError?: (error: ApiError) => void ) => { - const { mutate, isPending, error } = useApiMutation({ - path: `${path}/test`, - method: 'POST', - mutationOptions: { - onSuccess, - onError, + const { mutate, isPending, error } = useMutation< + void, + ApiError, + { data: T } & SaveOptions + >({ + mutationFn: async ({ data, skipValidation }) => { + const queryParams: QueryParams = {}; + + if (skipValidation && skipValidation !== 'none') { + queryParams.skipValidation = skipValidation; + } + + return fetchJson({ + path: getQueryPath(`${path}/test`) + getQueryString(queryParams), + method: 'POST', + headers: { + 'X-Api-Key': window.Sonarr.apiKey, + 'X-Sonarr-Client': 'Sonarr', + }, + body: data, + }); }, + onSuccess, + onError, }); + const test = useCallback( + (data: T, options?: SaveOptions) => { + mutate({ data, ...options }); + }, + [mutate] + ); + return { - test: mutate, + test, isTesting: isPending, testError: error, }; @@ -135,12 +201,14 @@ export const useManageProviderSettings = ( ): ManageProviderSettings => { const provider = useProviderWithDefault(id, defaultProvider, path); const [mutationError, setMutationError] = useState(null); + const lastSaveData = useRef(null); const { pendingChanges, setPendingChange, unsetPendingChange, clearPendingChanges, + hasPendingChanges, } = usePendingChangesStore({}); const { @@ -154,6 +222,7 @@ export const useManageProviderSettings = ( setMutationError(null); clearPendingChanges(); clearPendingFields(); + lastSaveData.current = null; }, [clearPendingChanges, clearPendingFields]); const handleTestSuccess = useCallback(() => { @@ -219,8 +288,40 @@ export const useManageProviderSettings = ( } as T; } - save(updatedSettings); - }, [provider, pendingChanges, pendingFields, save]); + const serializedSettings = JSON.stringify(updatedSettings); + const isResave = lastSaveData.current === serializedSettings; + lastSaveData.current = serializedSettings; + + const saveOptions: SaveOptions = {}; + + // For existing providers with no pending changes, skip testing and all validation. + if (provider.id > 0 && !hasPendingChanges && !hasPendingFields) { + saveOptions.skipTesting = true; + saveOptions.skipValidation = 'all'; + } else { + // If resaving the exact same settings as the previous attempt, skip testing. + if (isResave) { + saveOptions.skipTesting = true; + } + + // If the last save returned only warnings, skip warning validation on the next save. + const { errors, warnings } = getValidationFailures(mutationError); + + if (errors.length === 0 && warnings.length > 0) { + saveOptions.skipValidation = 'warnings'; + } + } + + save(updatedSettings, saveOptions); + }, [ + provider, + pendingChanges, + pendingFields, + hasPendingChanges, + hasPendingFields, + mutationError, + save, + ]); const testProvider = useCallback(() => { let updatedSettings: T = { @@ -246,8 +347,17 @@ export const useManageProviderSettings = ( } as T; } - test(updatedSettings); - }, [provider, pendingChanges, pendingFields, test]); + const testOptions: SaveOptions = {}; + + // If the last operation returned only warnings, skip warning validation on the next test. + const { errors, warnings } = getValidationFailures(mutationError); + + if (errors.length === 0 && warnings.length > 0) { + testOptions.skipValidation = 'warnings'; + } + + test(updatedSettings, testOptions); + }, [provider, pendingChanges, pendingFields, mutationError, test]); const updateValue = useCallback( (key: K, value: T[K]) => { diff --git a/src/Sonarr.Api.V5/Provider/ProviderControllerBase.cs b/src/Sonarr.Api.V5/Provider/ProviderControllerBase.cs index 6b5ceb3d0..faaa2aa2e 100644 --- a/src/Sonarr.Api.V5/Provider/ProviderControllerBase.cs +++ b/src/Sonarr.Api.V5/Provider/ProviderControllerBase.cs @@ -76,13 +76,13 @@ namespace Sonarr.Api.V5.Provider [RestPostById] [Consumes("application/json")] [Produces("application/json")] - public ActionResult CreateProvider([FromBody] TProviderResource providerResource, [FromQuery] bool forceSave = false) + public ActionResult CreateProvider([FromBody] TProviderResource providerResource, [FromQuery] bool skipTesting = false, [FromQuery] SkipValidation skipValidation = SkipValidation.None) { - var providerDefinition = GetDefinition(providerResource, null, true, !forceSave, false); + var providerDefinition = GetDefinition(providerResource, null, skipValidation, false); - if (providerDefinition.Enable) + if (providerDefinition.Enable && !skipTesting) { - Test(providerDefinition, !forceSave); + Test(providerDefinition, skipValidation); } providerDefinition = _providerFactory.Create(providerDefinition); @@ -93,7 +93,7 @@ namespace Sonarr.Api.V5.Provider [RestPutById] [Consumes("application/json")] [Produces("application/json")] - public ActionResult UpdateProvider([FromRoute] int id, [FromBody] TProviderResource providerResource, [FromQuery] bool forceSave = false) + public ActionResult UpdateProvider([FromRoute] int id, [FromBody] TProviderResource providerResource, [FromQuery] bool skipTesting = false, [FromQuery] SkipValidation skipValidation = SkipValidation.None) { // TODO: Remove fallback to Id from body in next API version bump var existingDefinition = _providerFactory.Find(id) ?? _providerFactory.Find(providerResource.Id); @@ -103,15 +103,15 @@ namespace Sonarr.Api.V5.Provider return NotFound(); } - var providerDefinition = GetDefinition(providerResource, existingDefinition, true, !forceSave, false); + var providerDefinition = GetDefinition(providerResource, existingDefinition, skipValidation, false); // Compare settings separately because they are not serialized with the definition. var hasDefinitionChanged = !existingDefinition.Equals(providerDefinition) || !existingDefinition.Settings.Equals(providerDefinition.Settings); - // Only test existing definitions if it is enabled and forceSave isn't set and the definition has changed. - if (providerDefinition.Enable && !forceSave && hasDefinitionChanged) + // Only test existing definitions if it is enabled, skipTesting isn't set and the definition has changed. + if (providerDefinition.Enable && !skipTesting && hasDefinitionChanged) { - Test(providerDefinition, true); + Test(providerDefinition, skipValidation); } if (hasDefinitionChanged) @@ -163,13 +163,13 @@ namespace Sonarr.Api.V5.Provider return Accepted(_providerFactory.Update(definitionsToUpdate).Select(x => _resourceMapper.ToResource(x))); } - private TProviderDefinition GetDefinition(TProviderResource providerResource, TProviderDefinition? existingDefinition, bool validate, bool includeWarnings, bool forceValidate) + private TProviderDefinition GetDefinition(TProviderResource providerResource, TProviderDefinition? existingDefinition, SkipValidation skipValidation, bool forceValidate) { var definition = _resourceMapper.ToModel(providerResource, existingDefinition); - if (validate && (definition.Enable || forceValidate)) + if (skipValidation != SkipValidation.All && (definition.Enable || forceValidate)) { - Validate(definition, includeWarnings); + Validate(definition, skipValidation); } return definition; @@ -218,12 +218,12 @@ namespace Sonarr.Api.V5.Provider [SkipValidation(true, false)] [HttpPost("test")] [Consumes("application/json")] - public ActionResult Test([FromBody] TProviderResource providerResource, [FromQuery] bool forceTest = false) + public ActionResult Test([FromBody] TProviderResource providerResource, [FromQuery] SkipValidation skipValidation = SkipValidation.None) { var existingDefinition = providerResource.Id > 0 ? _providerFactory.Find(providerResource.Id) : null; - var providerDefinition = GetDefinition(providerResource, existingDefinition, true, !forceTest, true); + var providerDefinition = GetDefinition(providerResource, existingDefinition, skipValidation, true); - Test(providerDefinition, true); + Test(providerDefinition, skipValidation); return NoContent(); } @@ -261,7 +261,7 @@ namespace Sonarr.Api.V5.Provider public IActionResult RequestAction([FromRoute] string name, [FromBody] TProviderResource providerResource) { var existingDefinition = providerResource.Id > 0 ? _providerFactory.Find(providerResource.Id) : null; - var providerDefinition = GetDefinition(providerResource, existingDefinition, false, false, false); + var providerDefinition = GetDefinition(providerResource, existingDefinition, SkipValidation.All, false); var query = Request.Query.ToDictionary(x => x.Key, x => x.Value.ToString()); @@ -288,30 +288,30 @@ namespace Sonarr.Api.V5.Provider BroadcastResourceChange(ModelAction.Deleted, message.ProviderId); } - private void Validate(TProviderDefinition definition, bool includeWarnings) + private void Validate(TProviderDefinition definition, SkipValidation skipValidation) { var validationResult = definition.Settings.Validate(); - VerifyValidationResult(validationResult, includeWarnings); + VerifyValidationResult(validationResult, skipValidation); } - protected virtual void Test(TProviderDefinition definition, bool includeWarnings) + protected virtual void Test(TProviderDefinition definition, SkipValidation skipValidation) { var validationResult = _providerFactory.Test(definition); - VerifyValidationResult(validationResult, includeWarnings); + VerifyValidationResult(validationResult, skipValidation); } - protected void VerifyValidationResult(ValidationResult validationResult, bool includeWarnings) + protected void VerifyValidationResult(ValidationResult validationResult, SkipValidation skipValidation) { var result = validationResult as NzbDroneValidationResult ?? new NzbDroneValidationResult(validationResult.Errors); - if (includeWarnings && (!result.IsValid || result.HasWarnings)) + if (skipValidation == SkipValidation.None && (!result.IsValid || result.HasWarnings)) { throw new ValidationException(result.Failures); } - if (!result.IsValid) + if (skipValidation == SkipValidation.Warnings && !result.IsValid) { throw new ValidationException(result.Errors); } diff --git a/src/Sonarr.Api.V5/Provider/SkipValidation.cs b/src/Sonarr.Api.V5/Provider/SkipValidation.cs new file mode 100644 index 000000000..93b4a2b93 --- /dev/null +++ b/src/Sonarr.Api.V5/Provider/SkipValidation.cs @@ -0,0 +1,9 @@ +namespace Sonarr.Api.V5.Provider +{ + public enum SkipValidation + { + None = 0, + Warnings = 1, + All = 2 + } +}