From 7a5157df29f439ecac8aa763cec74332452a57fa Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Mon, 24 Nov 2025 21:25:47 -0800 Subject: [PATCH] Use react-query for root folders New: Add tooltip for empty root folders Closes #8196 --- .../ImportSeries/Import/ImportSeries.tsx | 16 +-- .../SelectFolder/ImportSeriesSelectFolder.tsx | 42 +++----- frontend/src/App/State/AppState.ts | 2 - .../Form/Select/RootFolderSelectInput.tsx | 97 +++++++++--------- frontend/src/Components/SignalRListener.tsx | 7 +- frontend/src/RootFolder/RootFolderRow.css | 4 +- .../src/RootFolder/RootFolderRow.css.d.ts | 4 +- frontend/src/RootFolder/RootFolderRow.tsx | 61 +++++++----- frontend/src/RootFolder/RootFolders.tsx | 21 ++-- frontend/src/RootFolder/useRootFolders.ts | 99 +++++++++++++++++++ .../Index/Select/SeriesIndexSelectFooter.tsx | 5 - .../ImportLists/ImportLists/ImportLists.tsx | 2 - .../RootFolder/AddRootFolder.tsx | 20 ++-- .../Tags/AutoTagging/AutoTaggings.tsx | 2 - .../src/Store/Actions/importSeriesActions.js | 3 - frontend/src/Store/Actions/index.js | 2 - .../src/Store/Actions/rootFolderActions.js | 97 ------------------ .../Selectors/createRootFoldersSelector.ts | 15 --- src/NzbDrone.Core/Datastore/TableMapping.cs | 1 + .../HealthCheck/Checks/RootFolderCheck.cs | 35 ++++++- src/NzbDrone.Core/HealthCheck/HealthCheck.cs | 1 + src/NzbDrone.Core/Localization/Core/en.json | 4 + src/NzbDrone.Core/RootFolders/RootFolder.cs | 1 + 23 files changed, 267 insertions(+), 274 deletions(-) create mode 100644 frontend/src/RootFolder/useRootFolders.ts delete mode 100644 frontend/src/Store/Actions/rootFolderActions.js delete mode 100644 frontend/src/Store/Selectors/createRootFoldersSelector.ts diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.tsx b/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.tsx index 515878c63..aabfef521 100644 --- a/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.tsx +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.tsx @@ -12,8 +12,8 @@ import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import { kinds } from 'Helpers/Props'; +import useRootFolders, { useRootFolder } from 'RootFolder/useRootFolders'; import { clearImportSeries } from 'Store/Actions/importSeriesActions'; -import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; import translate from 'Utilities/String/translate'; import ImportSeriesFooter from './ImportSeriesFooter'; import ImportSeriesTable from './ImportSeriesTable'; @@ -27,10 +27,12 @@ function ImportSeries() { const { isFetching: rootFoldersFetching, - isPopulated: rootFoldersPopulated, + isFetched: rootFoldersFetched, error: rootFoldersError, - items: rootFolders, - } = useSelector((state: AppState) => state.rootFolders); + data: rootFolders, + } = useRootFolders(); + + useRootFolder(rootFolderId, false); const { path, unmappedFolders } = useMemo(() => { const rootFolder = rootFolders.find((r) => r.id === rootFolderId); @@ -65,8 +67,6 @@ function ImportSeries() { }, [unmappedFolders]); useEffect(() => { - dispatch(fetchRootFolders({ id: rootFolderId, timeout: false })); - return () => { dispatch(clearImportSeries()); }; @@ -95,7 +95,7 @@ function ImportSeries() { {!rootFoldersError && !rootFoldersFetching && - rootFoldersPopulated && + rootFoldersFetched && !unmappedFolders.length ? ( {translate('AllSeriesInRootFolderHaveBeenImported', { path })} @@ -104,7 +104,7 @@ function ImportSeries() { {!rootFoldersError && !rootFoldersFetching && - rootFoldersPopulated && + rootFoldersFetched && !!unmappedFolders.length && scrollerRef.current ? ( state.rootFolders); + const { isFetching, isFetched, error, data } = useRootFolders(); + const { addRootFolder, isAdding, addError } = useAddRootFolder(); const isWindows = useIsWindows(); const [isAddNewRootFolderModalOpen, setIsAddNewRootFolderModalOpen] = useState(false); - const wasSaving = usePrevious(isSaving); + const wasAdding = usePrevious(isAdding); - const hasRootFolders = items.length > 0; + const hasRootFolders = data.length > 0; const goodFolderExample = isWindows ? 'C:\\tv shows' : '/tv shows'; const badFolderExample = isWindows ? 'C:\\tv shows\\the simpsons' @@ -50,18 +44,14 @@ function ImportSeriesSelectFolder() { const handleNewRootFolderSelect = useCallback( ({ value }: InputChanged) => { - dispatch(addRootFolder({ path: value })); + addRootFolder({ path: value }); }, - [dispatch] + [addRootFolder] ); useEffect(() => { - dispatch(fetchRootFolders()); - }, [dispatch]); - - useEffect(() => { - if (!isSaving && wasSaving && !saveError) { - items.reduce((acc, item) => { + if (!isAdding && wasAdding && !addError) { + data.reduce((acc, item) => { if (item.id > acc) { return item.id; } @@ -69,18 +59,18 @@ function ImportSeriesSelectFolder() { return acc; }, 0); } - }, [isSaving, wasSaving, saveError, items]); + }, [isAdding, wasAdding, addError, data]); return ( - {isFetching && !isPopulated ? : null} + {isFetching && !isFetched ? : null} {!isFetching && error ? ( {translate('RootFoldersLoadError')} ) : null} - {!error && isPopulated && ( + {!error && isFetched && (
{translate('LibraryImportSeriesHeader')} @@ -118,17 +108,17 @@ function ImportSeriesSelectFolder() {
) : null} - {!isSaving && saveError ? ( + {!isAdding && addError ? ( {translate('AddRootFolderError')}
    - {Array.isArray(saveError.responseJSON) ? ( - saveError.responseJSON.map((e, index) => { + {Array.isArray(addError.statusBody) ? ( + addError.statusBody.map((e, index) => { return
  • {e.errorMessage}
  • ; }) ) : ( -
  • {JSON.stringify(saveError.responseJSON)}
  • +
  • {JSON.stringify(addError.statusBody)}
  • )}
diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index 322662271..0cb413399 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -16,7 +16,6 @@ import OrganizePreviewAppState from './OrganizePreviewAppState'; import PathsAppState from './PathsAppState'; import ProviderOptionsAppState from './ProviderOptionsAppState'; import ReleasesAppState from './ReleasesAppState'; -import RootFolderAppState from './RootFolderAppState'; import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState'; import SettingsAppState from './SettingsAppState'; @@ -89,7 +88,6 @@ interface AppState { paths: PathsAppState; providerOptions: ProviderOptionsAppState; releases: ReleasesAppState; - rootFolders: RootFolderAppState; series: SeriesAppState; seriesHistory: SeriesHistoryAppState; seriesIndex: SeriesIndexAppState; diff --git a/frontend/src/Components/Form/Select/RootFolderSelectInput.tsx b/frontend/src/Components/Form/Select/RootFolderSelectInput.tsx index dc199d5ef..699e2892f 100644 --- a/frontend/src/Components/Form/Select/RootFolderSelectInput.tsx +++ b/frontend/src/Components/Form/Select/RootFolderSelectInput.tsx @@ -1,14 +1,9 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { createSelector } from 'reselect'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal'; import usePrevious from 'Helpers/Hooks/usePrevious'; -import { - addRootFolder, - fetchRootFolders, -} from 'Store/Actions/rootFolderActions'; -import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector'; +import useRootFolders, { useAddRootFolder } from 'RootFolder/useRootFolders'; import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs'; +import sortByProp from 'Utilities/Array/sortByProp'; import translate from 'Utilities/String/translate'; import EnhancedSelectInput, { EnhancedSelectInputProps, @@ -37,23 +32,25 @@ export interface RootFolderSelectInputProps includeNoChangeDisabled?: boolean; } -function createRootFolderOptionsSelector( +const useRootFolderOptions = ( value: string | undefined, includeMissingValue: boolean, includeNoChange: boolean, includeNoChangeDisabled: boolean -) { - return createSelector(createRootFoldersSelector(), (rootFolders) => { - const values: RootFolderSelectInputValue[] = rootFolders.items.map( - (rootFolder) => { - return { - key: rootFolder.path, - value: rootFolder.path, - freeSpace: rootFolder.freeSpace, - isMissing: false, - }; - } - ); +) => { + const { data } = useRootFolders(); + + return useMemo(() => { + const sorted = [...data].sort(sortByProp('path')); + + const values: RootFolderSelectInputValue[] = sorted.map((rootFolder) => { + return { + key: rootFolder.path, + value: rootFolder.path, + freeSpace: rootFolder.freeSpace, + isMissing: false, + }; + }); if (includeNoChange) { values.unshift({ @@ -89,13 +86,15 @@ function createRootFolderOptionsSelector( value: translate('AddANewPath'), }); - return { - values, - isSaving: rootFolders.isSaving, - saveError: rootFolders.saveError, - }; - }); -} + return values; + }, [ + data, + value, + includeMissingValue, + includeNoChange, + includeNoChangeDisabled, + ]); +}; function RootFolderSelectInput({ name, @@ -106,19 +105,19 @@ function RootFolderSelectInput({ onChange, ...otherProps }: RootFolderSelectInputProps) { - const dispatch = useDispatch(); - const { values, isSaving, saveError } = useSelector( - createRootFolderOptionsSelector( - value, - includeMissingValue, - includeNoChange, - includeNoChangeDisabled - ) + const values = useRootFolderOptions( + value, + includeMissingValue, + includeNoChange, + includeNoChangeDisabled ); + + const { addRootFolder, isAdding, addError, newRootFolder } = + useAddRootFolder(); + const [isAddNewRootFolderModalOpen, setIsAddNewRootFolderModalOpen] = useState(false); - const [newRootFolderPath, setNewRootFolderPath] = useState(''); - const previousIsSaving = usePrevious(isSaving); + const previousIsAdding = usePrevious(isAdding); const handleChange = useCallback( ({ value: newValue }: EnhancedSelectInputChanged) => { @@ -133,10 +132,9 @@ function RootFolderSelectInput({ const handleNewRootFolderSelect = useCallback( ({ value: newValue }: InputChanged) => { - setNewRootFolderPath(newValue); - dispatch(addRootFolder({ path: newValue })); + addRootFolder({ path: newValue }); }, - [setNewRootFolderPath, dispatch] + [addRootFolder] ); const handleAddRootFolderModalClose = useCallback(() => { @@ -156,18 +154,17 @@ function RootFolderSelectInput({ } } - if (previousIsSaving && !isSaving && !saveError && newRootFolderPath) { - onChange({ name, value: newRootFolderPath }); - setNewRootFolderPath(''); + if (previousIsAdding && !isAdding && !addError && newRootFolder) { + onChange({ name, value: newRootFolder.path }); } }, [ name, value, values, - isSaving, - saveError, - previousIsSaving, - newRootFolderPath, + isAdding, + addError, + newRootFolder, + previousIsAdding, onChange, ]); @@ -192,10 +189,6 @@ function RootFolderSelectInput({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - useEffect(() => { - dispatch(fetchRootFolders()); - }, [dispatch]); - return ( <> { setIsDeleteModalOpen(true); @@ -38,27 +36,38 @@ function RootFolderRow(props: RootFolderRowProps) { }, [setIsDeleteModalOpen]); const onConfirmDelete = useCallback(() => { - dispatch(deleteRootFolder({ id })); - + deleteRootFolder(); setIsDeleteModalOpen(false); - }, [dispatch, id]); + }, [deleteRootFolder]); return ( - {isUnavailable ? ( -
- {path} +
+ {isUnavailable ? ( + path + ) : ( + + {path} + + )} -
- ) : ( - - {path} - - )} + ) : null} + + {accessible && isEmpty ? ( + + ) : null} +
diff --git a/frontend/src/RootFolder/RootFolders.tsx b/frontend/src/RootFolder/RootFolders.tsx index 3c90d8d1d..1a0380bda 100644 --- a/frontend/src/RootFolder/RootFolders.tsx +++ b/frontend/src/RootFolder/RootFolders.tsx @@ -1,15 +1,13 @@ -import React, { useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import React from 'react'; import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import Column from 'Components/Table/Column'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import { kinds } from 'Helpers/Props'; -import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; -import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector'; import translate from 'Utilities/String/translate'; import RootFolderRow from './RootFolderRow'; +import useRootFolders from './useRootFolders'; const rootFolderColumns: Column[] = [ { @@ -35,17 +33,9 @@ const rootFolderColumns: Column[] = [ ]; function RootFolders() { - const { isFetching, isPopulated, error, items } = useSelector( - createRootFoldersSelector() - ); + const { isFetching, isFetched, error, data } = useRootFolders(); - const dispatch = useDispatch(); - - useEffect(() => { - dispatch(fetchRootFolders()); - }, [dispatch]); - - if (isFetching && !isPopulated) { + if (isFetching && !isFetched) { return ; } @@ -58,13 +48,14 @@ function RootFolders() { return ( - {items.map((rootFolder) => { + {data.map((rootFolder) => { return ( diff --git a/frontend/src/RootFolder/useRootFolders.ts b/frontend/src/RootFolder/useRootFolders.ts new file mode 100644 index 000000000..e7f9ba40e --- /dev/null +++ b/frontend/src/RootFolder/useRootFolders.ts @@ -0,0 +1,99 @@ +import { useQueryClient } from '@tanstack/react-query'; +import ModelBase from 'App/ModelBase'; +import useApiMutation from 'Helpers/Hooks/useApiMutation'; +import useApiQuery from 'Helpers/Hooks/useApiQuery'; + +export interface UnmappedFolder { + name: string; + path: string; + relativePath: string; +} + +export interface RootFolder extends ModelBase { + id: number; + path: string; + accessible: boolean; + isEmpty: boolean; + freeSpace?: number; + unmappedFolders: UnmappedFolder[]; +} + +interface AddRootFolder { + path: string; +} + +const DEFAULT_ROOT_FOLDERS: RootFolder[] = []; + +const useRootFolders = () => { + const result = useApiQuery({ + path: '/rootFolder', + }); + + return { + ...result, + data: result.data ?? DEFAULT_ROOT_FOLDERS, + }; +}; + +export const useRootFolder = (id: number, timeout: boolean) => { + const result = useApiQuery({ + path: `/rootFolder/${id}`, + queryParams: { timeout }, + }); + + return { + ...result, + data: result.data ?? DEFAULT_ROOT_FOLDERS, + }; +}; + +export default useRootFolders; + +export const useDeleteRootFolder = (id: number) => { + const queryClient = useQueryClient(); + + const { mutate, isPending, error } = useApiMutation({ + path: `/rootFolder/${id}`, + method: 'DELETE', + mutationOptions: { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/rootFolder'] }); + }, + }, + }); + + return { + deleteRootFolder: mutate, + isDeleting: isPending, + deleteError: error, + }; +}; + +export const useAddRootFolder = () => { + const queryClient = useQueryClient(); + + const { mutate, isPending, error, data } = useApiMutation< + RootFolder, + AddRootFolder + >({ + path: '/rootFolder', + method: 'POST', + mutationOptions: { + onSuccess: (newRootFolder) => { + queryClient.setQueryData( + ['/rootFolder'], + (oldRootFolders = []) => { + return [...oldRootFolders, newRootFolder]; + } + ); + }, + }, + }); + + return { + addRootFolder: mutate, + isAdding: isPending, + addError: error, + newRootFolder: data, + }; +}; diff --git a/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx b/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx index 3ba4e9ba4..d312faf56 100644 --- a/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx +++ b/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx @@ -9,7 +9,6 @@ import PageContentFooter from 'Components/Page/PageContentFooter'; import usePrevious from 'Helpers/Hooks/usePrevious'; import { kinds } from 'Helpers/Props'; import Series from 'Series/Series'; -import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; import { saveSeriesEditor, updateSeriesMonitor, @@ -167,10 +166,6 @@ function SeriesIndexSelectFooter() { } }, [previousIsDeleting, isDeleting, deleteError, unselectAll]); - useEffect(() => { - dispatch(fetchRootFolders()); - }, [dispatch]); - const anySelected = selectedCount > 0; return ( diff --git a/frontend/src/Settings/ImportLists/ImportLists/ImportLists.tsx b/frontend/src/Settings/ImportLists/ImportLists/ImportLists.tsx index 28c577b11..48299ccec 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/ImportLists.tsx +++ b/frontend/src/Settings/ImportLists/ImportLists/ImportLists.tsx @@ -6,7 +6,6 @@ import FieldSet from 'Components/FieldSet'; import Icon from 'Components/Icon'; import PageSectionContent from 'Components/Page/PageSectionContent'; import { icons } from 'Helpers/Props'; -import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; import { cloneImportList, fetchImportLists, @@ -62,7 +61,6 @@ function ImportLists() { useEffect(() => { dispatch(fetchImportLists()); - dispatch(fetchRootFolders()); }, [dispatch]); return ( diff --git a/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.tsx b/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.tsx index dee012cc3..50667b222 100644 --- a/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.tsx +++ b/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.tsx @@ -1,19 +1,15 @@ import React, { useCallback, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import Alert from 'Components/Alert'; import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal'; import Icon from 'Components/Icon'; import Button from 'Components/Link/Button'; import { icons, kinds, sizes } from 'Helpers/Props'; -import { addRootFolder } from 'Store/Actions/rootFolderActions'; -import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector'; +import { useAddRootFolder } from 'RootFolder/useRootFolders'; import translate from 'Utilities/String/translate'; import styles from './AddRootFolder.css'; function AddRootFolder() { - const { isSaving, saveError } = useSelector(createRootFoldersSelector()); - - const dispatch = useDispatch(); + const { addRootFolder, isAdding, addError } = useAddRootFolder(); const [isAddNewRootFolderModalOpen, setIsAddNewRootFolderModalOpen] = useState(false); @@ -24,9 +20,9 @@ function AddRootFolder() { const onNewRootFolderSelect = useCallback( ({ value }: { value: string }) => { - dispatch(addRootFolder({ path: value })); + addRootFolder({ path: value }); }, - [dispatch] + [addRootFolder] ); const onAddRootFolderModalClose = useCallback(() => { @@ -35,17 +31,17 @@ function AddRootFolder() { return ( <> - {!isSaving && saveError ? ( + {!isAdding && addError ? ( {translate('AddRootFolderError')}
    - {Array.isArray(saveError.responseJSON) ? ( - saveError.responseJSON.map((e, index) => { + {Array.isArray(addError.statusBody) ? ( + addError.statusBody.map((e, index) => { return
  • {e.errorMessage}
  • ; }) ) : ( -
  • {JSON.stringify(saveError.responseJSON)}
  • +
  • {JSON.stringify(addError.statusBody)}
  • )}
diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.tsx b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.tsx index 69d718511..7031d9260 100644 --- a/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.tsx +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.tsx @@ -6,7 +6,6 @@ import FieldSet from 'Components/FieldSet'; import Icon from 'Components/Icon'; import PageSectionContent from 'Components/Page/PageSectionContent'; import { icons } from 'Helpers/Props'; -import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; import { cloneAutoTagging, deleteAutoTagging, @@ -61,7 +60,6 @@ export default function AutoTaggings() { useEffect(() => { dispatch(fetchAutoTaggings()); - dispatch(fetchRootFolders()); }, [dispatch]); return ( diff --git a/frontend/src/Store/Actions/importSeriesActions.js b/frontend/src/Store/Actions/importSeriesActions.js index 75ec76591..56e6719c9 100644 --- a/frontend/src/Store/Actions/importSeriesActions.js +++ b/frontend/src/Store/Actions/importSeriesActions.js @@ -9,7 +9,6 @@ import getSectionState from 'Utilities/State/getSectionState'; import updateSectionState from 'Utilities/State/updateSectionState'; import { removeItem, set, updateItem } from './baseActions'; import createHandleActions from './Creators/createHandleActions'; -import { fetchRootFolders } from './rootFolderActions'; // // Variables @@ -259,8 +258,6 @@ export const actionHandlers = handleThunks({ ...addedIds.map((id) => removeItem({ section, id })) ])); - - dispatch(fetchRootFolders()); }); promise.fail((xhr) => { diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index a84d2cc0f..f8cfa1492 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -12,7 +12,6 @@ import * as organizePreview from './organizePreviewActions'; import * as paths from './pathActions'; import * as providerOptions from './providerOptionActions'; import * as releases from './releaseActions'; -import * as rootFolders from './rootFolderActions'; import * as series from './seriesActions'; import * as seriesHistory from './seriesHistoryActions'; import * as seriesIndex from './seriesIndexActions'; @@ -33,7 +32,6 @@ export default [ paths, providerOptions, releases, - rootFolders, series, seriesHistory, seriesIndex, diff --git a/frontend/src/Store/Actions/rootFolderActions.js b/frontend/src/Store/Actions/rootFolderActions.js deleted file mode 100644 index 5c7cd79ae..000000000 --- a/frontend/src/Store/Actions/rootFolderActions.js +++ /dev/null @@ -1,97 +0,0 @@ -import { batchActions } from 'redux-batched-actions'; -import { createThunk, handleThunks } from 'Store/thunks'; -import createAjaxRequest from 'Utilities/createAjaxRequest'; -import { set, updateItem } from './baseActions'; -import createFetchHandler from './Creators/createFetchHandler'; -import createHandleActions from './Creators/createHandleActions'; -import createRemoveItemHandler from './Creators/createRemoveItemHandler'; - -// -// Variables - -export const section = 'rootFolders'; - -// -// State - -export const defaultState = { - isFetching: false, - isPopulated: false, - error: null, - isSaving: false, - saveError: null, - items: [] -}; - -// -// Actions Types - -export const FETCH_ROOT_FOLDERS = 'rootFolders/fetchRootFolders'; -export const ADD_ROOT_FOLDER = 'rootFolders/addRootFolder'; -export const DELETE_ROOT_FOLDER = 'rootFolders/deleteRootFolder'; - -// -// Action Creators - -export const fetchRootFolders = createThunk(FETCH_ROOT_FOLDERS); -export const addRootFolder = createThunk(ADD_ROOT_FOLDER); -export const deleteRootFolder = createThunk(DELETE_ROOT_FOLDER); - -// -// Action Handlers - -export const actionHandlers = handleThunks({ - - [FETCH_ROOT_FOLDERS]: createFetchHandler('rootFolders', '/rootFolder'), - - [DELETE_ROOT_FOLDER]: createRemoveItemHandler( - 'rootFolders', - '/rootFolder', - (state) => state.rootFolders - ), - - [ADD_ROOT_FOLDER]: function(getState, payload, dispatch) { - const path = payload.path; - - dispatch(set({ - section, - isSaving: true - })); - - const promise = createAjaxRequest({ - url: '/rootFolder', - method: 'POST', - data: JSON.stringify({ path }), - dataType: 'json' - }).request; - - promise.done((data) => { - dispatch(batchActions([ - updateItem({ - section, - ...data - }), - - set({ - section, - isSaving: false, - saveError: null - }) - ])); - }); - - promise.fail((xhr) => { - dispatch(set({ - section, - isSaving: false, - saveError: xhr - })); - }); - } - -}); - -// -// Reducers - -export const reducers = createHandleActions({}, defaultState, section); diff --git a/frontend/src/Store/Selectors/createRootFoldersSelector.ts b/frontend/src/Store/Selectors/createRootFoldersSelector.ts deleted file mode 100644 index df2600f78..000000000 --- a/frontend/src/Store/Selectors/createRootFoldersSelector.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createSelector } from 'reselect'; -import RootFolderAppState from 'App/State/RootFolderAppState'; -import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; -import RootFolder from 'typings/RootFolder'; -import sortByProp from 'Utilities/Array/sortByProp'; - -export default function createRootFoldersSelector() { - return createSelector( - createSortedSectionSelector( - 'rootFolders', - sortByProp('path') - ), - (rootFolders: RootFolderAppState) => rootFolders - ); -} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 0656b7630..8d4839478 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -62,6 +62,7 @@ namespace NzbDrone.Core.Datastore Mapper.Entity("RootFolders").RegisterModel() .Ignore(r => r.Accessible) + .Ignore(r => r.IsEmpty) .Ignore(r => r.FreeSpace) .Ignore(r => r.TotalSpace); diff --git a/src/NzbDrone.Core/HealthCheck/Checks/RootFolderCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/RootFolderCheck.cs index ab5a995da..a2f82c804 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/RootFolderCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/RootFolderCheck.cs @@ -32,7 +32,8 @@ namespace NzbDrone.Core.HealthCheck.Checks { var rootFolders = _seriesService.GetAllSeriesPaths() .Select(s => _rootFolderService.GetBestRootFolderPath(s.Value)) - .Distinct(); + .Distinct() + .ToList(); var missingRootFolders = rootFolders.Where(s => !s.IsPathValid(PathValidationType.CurrentOs) || !_diskProvider.FolderExists(s)) .ToList(); @@ -65,6 +66,38 @@ namespace NzbDrone.Core.HealthCheck.Checks "#missing-root-folder"); } + var emptyRootFolders = rootFolders + .Where(r => _diskProvider.FolderEmpty(r)) + .ToList(); + + if (emptyRootFolders.Any()) + { + if (emptyRootFolders.Count == 1) + { + return new HealthCheck(GetType(), + HealthCheckResult.Warning, + HealthCheckReason.RootFolderEmpty, + _localizationService.GetLocalizedString( + "RootFolderEmptyHealthCheckMessage", + new Dictionary + { + { "rootFolderPath", emptyRootFolders.First() } + }), + "#empty-root-folder"); + } + + return new HealthCheck(GetType(), + HealthCheckResult.Warning, + HealthCheckReason.RootFolderEmpty, + _localizationService.GetLocalizedString( + "RootFolderMultipleEmptyHealthCheckMessage", + new Dictionary + { + { "rootFolderPaths", string.Join(" | ", emptyRootFolders) } + }), + "#empty-root-folder"); + } + return new HealthCheck(GetType()); } } diff --git a/src/NzbDrone.Core/HealthCheck/HealthCheck.cs b/src/NzbDrone.Core/HealthCheck/HealthCheck.cs index 598c05619..f5c2a0395 100644 --- a/src/NzbDrone.Core/HealthCheck/HealthCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/HealthCheck.cs @@ -107,6 +107,7 @@ namespace NzbDrone.Core.HealthCheck RemotePathMappingWrongOSPath, RemovedSeriesMultiple, RemovedSeriesSingle, + RootFolderEmpty, RootFolderMissing, RootFolderMultipleMissing, ServerNotification, diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 2fd9abd43..7d5a4a882 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -619,6 +619,8 @@ "EditSeries": "Edit Series", "EditSeriesModalHeader": "Edit - {title}", "EditSizes": "Edit Sizes", + "Empty": "Empty", + "EmptyRootFolderTooltip": "This root does not contain any files or folders. {appName} will not scan for changes or create empty series folders.", "Enable": "Enable", "EnableAutomaticAdd": "Enable Automatic Add", "EnableAutomaticAddSeriesHelpText": "Add series from this list to {appName} when syncs are performed via the UI or by {appName}", @@ -1820,7 +1822,9 @@ "RetentionHelpText": "Usenet only: Set to zero to set for unlimited retention", "RetryingDownloadOn": "Retrying download on {date} at {time}", "RootFolder": "Root Folder", + "RootFolderEmptyHealthCheckMessage": "Empty root folder: {rootFolderPath}", "RootFolderMissingHealthCheckMessage": "Missing root folder: {rootFolderPath}", + "RootFolderMultipleEmptyHealthCheckMessage": "Multiple root folders are empty: {rootFolderPaths}", "RootFolderMultipleMissingHealthCheckMessage": "Multiple root folders are missing: {rootFolderPaths}", "RootFolderPath": "Root Folder Path", "RootFolderSelectFreeSpace": "{freeSpace} Free", diff --git a/src/NzbDrone.Core/RootFolders/RootFolder.cs b/src/NzbDrone.Core/RootFolders/RootFolder.cs index 26fc1b3d0..64c441b63 100644 --- a/src/NzbDrone.Core/RootFolders/RootFolder.cs +++ b/src/NzbDrone.Core/RootFolders/RootFolder.cs @@ -7,6 +7,7 @@ namespace NzbDrone.Core.RootFolders { public string Path { get; set; } public bool Accessible { get; set; } + public bool IsEmpty { get; set; } public long? FreeSpace { get; set; } public long? TotalSpace { get; set; }