mirror of
https://github.com/Sonarr/Sonarr.git
synced 2025-04-23 13:57:06 -04:00
Add Series with ReactQuery Mutation
This commit is contained in:
parent
094df71301
commit
591b569bdd
28 changed files with 812 additions and 388 deletions
|
@ -1,6 +1,5 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AddSeries } from 'App/State/AddSeriesAppState';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Alert from 'Components/Alert';
|
||||
import TextInput from 'Components/Form/TextInput';
|
||||
|
@ -10,7 +9,6 @@ import Link from 'Components/Link/Link';
|
|||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||
import useDebounce from 'Helpers/Hooks/useDebounce';
|
||||
import useQueryParams from 'Helpers/Hooks/useQueryParams';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
|
@ -18,6 +16,7 @@ import { InputChanged } from 'typings/inputs';
|
|||
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import AddNewSeriesSearchResult from './AddNewSeriesSearchResult';
|
||||
import { useLookupSeries } from './useAddSeries';
|
||||
import styles from './AddNewSeries.css';
|
||||
|
||||
function AddNewSeries() {
|
||||
|
@ -48,12 +47,7 @@ function AddNewSeries() {
|
|||
isFetching: isFetchingApi,
|
||||
error,
|
||||
data = [],
|
||||
} = useApiQuery<AddSeries[]>({
|
||||
path: `/series/lookup?term=${query}`,
|
||||
queryOptions: {
|
||||
enabled: !!query,
|
||||
},
|
||||
});
|
||||
} = useLookupSeries(query);
|
||||
|
||||
useEffect(() => {
|
||||
setIsFetching(isFetchingApi);
|
||||
|
@ -103,7 +97,9 @@ function AddNewSeries() {
|
|||
{!isFetching && !error && !!data.length ? (
|
||||
<div className={styles.searchResults}>
|
||||
{data.map((item) => {
|
||||
return <AddNewSeriesSearchResult key={item.tvdbId} {...item} />;
|
||||
return (
|
||||
<AddNewSeriesSearchResult key={item.tvdbId} series={item} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import AddSeries from 'AddSeries/AddSeries';
|
||||
import {
|
||||
AddSeriesOptions,
|
||||
setAddSeriesOption,
|
||||
useAddSeriesOptions,
|
||||
} from 'AddSeries/addSeriesOptionsStore';
|
||||
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
|
||||
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
|
||||
import { AddSeries } from 'App/State/AddSeriesAppState';
|
||||
import AppState from 'App/State/AppState';
|
||||
import CheckInput from 'Components/Form/CheckInput';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
|
@ -17,46 +21,43 @@ import ModalFooter from 'Components/Modal/ModalFooter';
|
|||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import { SeriesType } from 'Series/Series';
|
||||
import SeriesPoster from 'Series/SeriesPoster';
|
||||
import { addSeries, setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
|
||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||
import selectSettings from 'Store/Selectors/selectSettings';
|
||||
import useIsWindows from 'System/useIsWindows';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { useAddSeries } from './useAddSeries';
|
||||
import styles from './AddNewSeriesModalContent.css';
|
||||
|
||||
export interface AddNewSeriesModalContentProps
|
||||
extends Pick<
|
||||
AddSeries,
|
||||
'tvdbId' | 'title' | 'year' | 'overview' | 'images' | 'folder'
|
||||
> {
|
||||
initialSeriesType: string;
|
||||
export interface AddNewSeriesModalContentProps {
|
||||
series: AddSeries;
|
||||
initialSeriesType: SeriesType;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function AddNewSeriesModalContent({
|
||||
tvdbId,
|
||||
title,
|
||||
year,
|
||||
overview,
|
||||
images,
|
||||
folder,
|
||||
series,
|
||||
initialSeriesType,
|
||||
onModalClose,
|
||||
}: AddNewSeriesModalContentProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { isAdding, addError, defaults } = useSelector(
|
||||
(state: AppState) => state.addSeries
|
||||
);
|
||||
const { title, year, overview, images, folder } = series;
|
||||
const options = useAddSeriesOptions();
|
||||
const { isSmallScreen } = useSelector(createDimensionsSelector());
|
||||
const isWindows = useIsWindows();
|
||||
|
||||
const { settings, validationErrors, validationWarnings } = useMemo(() => {
|
||||
return selectSettings(defaults, {}, addError);
|
||||
}, [defaults, addError]);
|
||||
const {
|
||||
isPending: isAdding,
|
||||
error: addError,
|
||||
mutate: addSeries,
|
||||
} = useAddSeries();
|
||||
|
||||
const [seriesType, setSeriesType] = useState(
|
||||
const { settings, validationErrors, validationWarnings } = useMemo(() => {
|
||||
return selectSettings(options, {}, addError);
|
||||
}, [options, addError]);
|
||||
|
||||
const [seriesType, setSeriesType] = useState<SeriesType>(
|
||||
initialSeriesType === 'standard'
|
||||
? settings.seriesType.value
|
||||
: initialSeriesType
|
||||
|
@ -74,35 +75,33 @@ function AddNewSeriesModalContent({
|
|||
} = settings;
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
({ name, value }: InputChanged) => {
|
||||
dispatch(setAddSeriesDefault({ [name]: value }));
|
||||
({ name, value }: InputChanged<string | number | boolean | number[]>) => {
|
||||
setAddSeriesOption(name as keyof AddSeriesOptions, value);
|
||||
},
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
const handleQualityProfileIdChange = useCallback(
|
||||
({ value }: InputChanged<string | number>) => {
|
||||
dispatch(setAddSeriesDefault({ qualityProfileId: value }));
|
||||
setAddSeriesOption('qualityProfileId', value as number);
|
||||
},
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
const handleAddSeriesPress = useCallback(() => {
|
||||
dispatch(
|
||||
addSeries({
|
||||
tvdbId,
|
||||
rootFolderPath: rootFolderPath.value,
|
||||
monitor: monitor.value,
|
||||
qualityProfileId: qualityProfileId.value,
|
||||
seriesType,
|
||||
seasonFolder: seasonFolder.value,
|
||||
searchForMissingEpisodes: searchForMissingEpisodes.value,
|
||||
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
|
||||
tags: tags.value,
|
||||
})
|
||||
);
|
||||
addSeries({
|
||||
...series,
|
||||
rootFolderPath: rootFolderPath.value,
|
||||
monitor: monitor.value,
|
||||
qualityProfileId: qualityProfileId.value,
|
||||
seriesType,
|
||||
seasonFolder: seasonFolder.value,
|
||||
searchForMissingEpisodes: searchForMissingEpisodes.value,
|
||||
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
|
||||
tags: tags.value,
|
||||
});
|
||||
}, [
|
||||
tvdbId,
|
||||
series,
|
||||
seriesType,
|
||||
rootFolderPath,
|
||||
monitor,
|
||||
|
@ -111,7 +110,7 @@ function AddNewSeriesModalContent({
|
|||
searchForMissingEpisodes,
|
||||
searchForCutoffUnmetEpisodes,
|
||||
tags,
|
||||
dispatch,
|
||||
addSeries,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AddSeries } from 'App/State/AddSeriesAppState';
|
||||
import AddSeries from 'AddSeries/AddSeries';
|
||||
import HeartRating from 'Components/HeartRating';
|
||||
import Icon from 'Components/Icon';
|
||||
import Label from 'Components/Label';
|
||||
|
@ -16,24 +16,27 @@ import translate from 'Utilities/String/translate';
|
|||
import AddNewSeriesModal from './AddNewSeriesModal';
|
||||
import styles from './AddNewSeriesSearchResult.css';
|
||||
|
||||
type AddNewSeriesSearchResultProps = AddSeries;
|
||||
interface AddNewSeriesSearchResultProps {
|
||||
series: AddSeries;
|
||||
}
|
||||
|
||||
function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) {
|
||||
const {
|
||||
tvdbId,
|
||||
titleSlug,
|
||||
title,
|
||||
year,
|
||||
network,
|
||||
originalLanguage,
|
||||
genres = [],
|
||||
status,
|
||||
statistics = {} as Statistics,
|
||||
ratings,
|
||||
overview,
|
||||
seriesType,
|
||||
images,
|
||||
} = series;
|
||||
|
||||
function AddNewSeriesSearchResult({
|
||||
tvdbId,
|
||||
titleSlug,
|
||||
title,
|
||||
year,
|
||||
network,
|
||||
originalLanguage,
|
||||
genres = [],
|
||||
status,
|
||||
statistics = {} as Statistics,
|
||||
ratings,
|
||||
folder,
|
||||
overview,
|
||||
seriesType,
|
||||
images,
|
||||
}: AddNewSeriesSearchResultProps) {
|
||||
const isExistingSeries = useSelector(createExistingSeriesSelector(tvdbId));
|
||||
const { isSmallScreen } = useSelector(createDimensionsSelector());
|
||||
const [isNewAddSeriesModalOpen, setIsNewAddSeriesModalOpen] = useState(false);
|
||||
|
@ -168,13 +171,8 @@ function AddNewSeriesSearchResult({
|
|||
|
||||
<AddNewSeriesModal
|
||||
isOpen={isNewAddSeriesModalOpen && !isExistingSeries}
|
||||
tvdbId={tvdbId}
|
||||
title={title}
|
||||
year={year}
|
||||
overview={overview}
|
||||
folder={folder}
|
||||
series={series}
|
||||
initialSeriesType={seriesType}
|
||||
images={images}
|
||||
onModalClose={handleAddSeriesModalClose}
|
||||
/>
|
||||
</div>
|
||||
|
|
40
frontend/src/AddSeries/AddNewSeries/useAddSeries.ts
Normal file
40
frontend/src/AddSeries/AddNewSeries/useAddSeries.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import AddSeries from 'AddSeries/AddSeries';
|
||||
import { AddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
|
||||
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||
import Series from 'Series/Series';
|
||||
import { updateItem } from 'Store/Actions/baseActions';
|
||||
|
||||
type AddSeriesPayload = AddSeries & AddSeriesOptions;
|
||||
|
||||
export const useLookupSeries = (query: string) => {
|
||||
return useApiQuery<AddSeries[]>({
|
||||
path: `/series/lookup?term=${query}`,
|
||||
queryOptions: {
|
||||
enabled: !!query,
|
||||
// Disable refetch on window focus to prevent refetching when the user switch tabs
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useAddSeries = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onAddSuccess = useCallback(
|
||||
(data: Series) => {
|
||||
dispatch(updateItem({ section: 'series', ...data }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return useApiMutation<Series, AddSeriesPayload>({
|
||||
path: '/series',
|
||||
method: 'POST',
|
||||
mutationOptions: {
|
||||
onSuccess: onAddSuccess,
|
||||
},
|
||||
});
|
||||
};
|
7
frontend/src/AddSeries/AddSeries.ts
Normal file
7
frontend/src/AddSeries/AddSeries.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import Series from 'Series/Series';
|
||||
|
||||
interface AddSeries extends Series {
|
||||
folder: string;
|
||||
}
|
||||
|
||||
export default AddSeries;
|
|
@ -1,6 +1,10 @@
|
|||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router';
|
||||
import {
|
||||
setAddSeriesOption,
|
||||
useAddSeriesOption,
|
||||
} from 'AddSeries/addSeriesOptionsStore';
|
||||
import { SelectProvider } from 'App/SelectContext';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Alert from 'Components/Alert';
|
||||
|
@ -8,7 +12,6 @@ import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
|||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
|
||||
import { clearImportSeries } from 'Store/Actions/importSeriesActions';
|
||||
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
@ -48,9 +51,7 @@ function ImportSeries() {
|
|||
(state: AppState) => state.settings.qualityProfiles.items
|
||||
);
|
||||
|
||||
const defaultQualityProfileId = useSelector(
|
||||
(state: AppState) => state.addSeries.defaults.qualityProfileId
|
||||
);
|
||||
const defaultQualityProfileId = useAddSeriesOption('qualityProfileId');
|
||||
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
@ -76,9 +77,7 @@ function ImportSeries() {
|
|||
!defaultQualityProfileId ||
|
||||
!qualityProfiles.some((p) => p.id === defaultQualityProfileId)
|
||||
) {
|
||||
dispatch(
|
||||
setAddSeriesDefault({ qualityProfileId: qualityProfiles[0].id })
|
||||
);
|
||||
setAddSeriesOption('qualityProfileId', qualityProfiles[0].id);
|
||||
}
|
||||
}, [defaultQualityProfileId, qualityProfiles, dispatch]);
|
||||
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
AddSeriesOptions,
|
||||
setAddSeriesOption,
|
||||
useAddSeriesOptions,
|
||||
} from 'AddSeries/addSeriesOptionsStore';
|
||||
import { useSelect } from 'App/SelectContext';
|
||||
import AppState from 'App/State/AppState';
|
||||
import CheckInput from 'Components/Form/CheckInput';
|
||||
|
@ -12,7 +17,6 @@ import PageContentFooter from 'Components/Page/PageContentFooter';
|
|||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import { SeriesMonitor, SeriesType } from 'Series/Series';
|
||||
import { setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
|
||||
import {
|
||||
cancelLookupSeries,
|
||||
importSeries,
|
||||
|
@ -33,7 +37,7 @@ function ImportSeriesFooter() {
|
|||
qualityProfileId: defaultQualityProfileId,
|
||||
seriesType: defaultSeriesType,
|
||||
seasonFolder: defaultSeasonFolder,
|
||||
} = useSelector((state: AppState) => state.addSeries.defaults);
|
||||
} = useAddSeriesOptions();
|
||||
|
||||
const { isLookingUpSeries, isImporting, items, importError } = useSelector(
|
||||
(state: AppState) => state.importSeries
|
||||
|
@ -110,7 +114,7 @@ function ImportSeriesFooter() {
|
|||
]);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
({ name, value }: InputChanged) => {
|
||||
({ name, value }: InputChanged<string | number | boolean | number[]>) => {
|
||||
if (name === 'monitor') {
|
||||
setMonitor(value as SeriesMonitor);
|
||||
} else if (name === 'qualityProfileId') {
|
||||
|
@ -121,7 +125,7 @@ function ImportSeriesFooter() {
|
|||
setSeasonFolder(value as boolean);
|
||||
}
|
||||
|
||||
dispatch(setAddSeriesDefault({ [name]: value }));
|
||||
setAddSeriesOption(name as keyof AddSeriesOptions, value);
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
dispatch(
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import React, { RefObject, useCallback, useEffect, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { FixedSizeList, ListChildComponentProps } from 'react-window';
|
||||
import { useAddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
|
||||
import { useSelect } from 'App/SelectContext';
|
||||
import AppState from 'App/State/AppState';
|
||||
import { ImportSeries } from 'App/State/ImportSeriesAppState';
|
||||
|
@ -59,9 +60,8 @@ function ImportSeriesTable({
|
|||
}: ImportSeriesTableProps) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { monitor, qualityProfileId, seriesType, seasonFolder } = useSelector(
|
||||
(state: AppState) => state.addSeries.defaults
|
||||
);
|
||||
const { monitor, qualityProfileId, seriesType, seasonFolder } =
|
||||
useAddSeriesOptions();
|
||||
|
||||
const items = useSelector((state: AppState) => state.importSeries.items);
|
||||
const { isSmallScreen } = useSelector(createDimensionsSelector());
|
||||
|
|
49
frontend/src/AddSeries/addSeriesOptionsStore.ts
Normal file
49
frontend/src/AddSeries/addSeriesOptionsStore.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import { createPersist } from 'Helpers/createPersist';
|
||||
import { SeriesMonitor, SeriesType } from 'Series/Series';
|
||||
|
||||
export interface AddSeriesOptions {
|
||||
rootFolderPath: string;
|
||||
monitor: SeriesMonitor;
|
||||
qualityProfileId: number;
|
||||
seriesType: SeriesType;
|
||||
seasonFolder: boolean;
|
||||
searchForMissingEpisodes: boolean;
|
||||
searchForCutoffUnmetEpisodes: boolean;
|
||||
tags: number[];
|
||||
}
|
||||
|
||||
const addSeriesOptionsStore = createPersist<AddSeriesOptions>(
|
||||
'add_series_options',
|
||||
() => {
|
||||
return {
|
||||
rootFolderPath: '',
|
||||
monitor: 'all',
|
||||
qualityProfileId: 0,
|
||||
seriesType: 'standard',
|
||||
seasonFolder: true,
|
||||
searchForMissingEpisodes: false,
|
||||
searchForCutoffUnmetEpisodes: false,
|
||||
tags: [],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const useAddSeriesOptions = () => {
|
||||
return addSeriesOptionsStore((state) => state);
|
||||
};
|
||||
|
||||
export const useAddSeriesOption = <K extends keyof AddSeriesOptions>(
|
||||
key: K
|
||||
) => {
|
||||
return addSeriesOptionsStore((state) => state[key]);
|
||||
};
|
||||
|
||||
export const setAddSeriesOption = <K extends keyof AddSeriesOptions>(
|
||||
key: K,
|
||||
value: AddSeriesOptions[K]
|
||||
) => {
|
||||
addSeriesOptionsStore.setState((state) => ({
|
||||
...state,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
|
@ -1,25 +0,0 @@
|
|||
import AppSectionState, { Error } from 'App/State/AppSectionState';
|
||||
import Series, { SeriesMonitor, SeriesType } from 'Series/Series';
|
||||
|
||||
export interface AddSeries extends Series {
|
||||
folder: string;
|
||||
}
|
||||
|
||||
interface AddSeriesAppState extends AppSectionState<AddSeries> {
|
||||
isAdding: boolean;
|
||||
isAdded: boolean;
|
||||
addError: Error | undefined;
|
||||
|
||||
defaults: {
|
||||
rootFolderPath: string;
|
||||
monitor: SeriesMonitor;
|
||||
qualityProfileId: number;
|
||||
seriesType: SeriesType;
|
||||
seasonFolder: boolean;
|
||||
tags: number[];
|
||||
searchForMissingEpisodes: boolean;
|
||||
searchForCutoffUnmetEpisodes: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export default AddSeriesAppState;
|
|
@ -1,7 +1,6 @@
|
|||
import ModelBase from 'App/ModelBase';
|
||||
import { FilterBuilderTypes } from 'Helpers/Props/filterBuilderTypes';
|
||||
import { DateFilterValue, FilterType } from 'Helpers/Props/filterTypes';
|
||||
import AddSeriesAppState from './AddSeriesAppState';
|
||||
import { Error } from './AppSectionState';
|
||||
import BlocklistAppState from './BlocklistAppState';
|
||||
import CalendarAppState from './CalendarAppState';
|
||||
|
@ -83,7 +82,6 @@ export interface AppSectionState {
|
|||
}
|
||||
|
||||
interface AppState {
|
||||
addSeries: AddSeriesAppState;
|
||||
app: AppSectionState;
|
||||
blocklist: BlocklistAppState;
|
||||
calendar: CalendarAppState;
|
||||
|
|
34
frontend/src/Helpers/Hooks/useApiMutation.ts
Normal file
34
frontend/src/Helpers/Hooks/useApiMutation.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { Error } from 'App/State/AppSectionState';
|
||||
import fetchJson, {
|
||||
apiRoot,
|
||||
FetchJsonOptions,
|
||||
} from 'Utilities/Fetch/fetchJson';
|
||||
|
||||
interface MutationOptions<T, TData>
|
||||
extends Omit<FetchJsonOptions<TData>, 'method'> {
|
||||
method: 'POST' | 'PUT' | 'DELETE';
|
||||
mutationOptions?: Omit<UseMutationOptions<T, Error, TData>, 'mutationFn'>;
|
||||
}
|
||||
|
||||
function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
|
||||
const requestOptions = useMemo(() => {
|
||||
return {
|
||||
...options,
|
||||
path: apiRoot + options.path,
|
||||
headers: {
|
||||
...options.headers,
|
||||
'X-Api-Key': window.Sonarr.apiKey,
|
||||
},
|
||||
};
|
||||
}, [options]);
|
||||
|
||||
return useMutation<T, Error, TData>({
|
||||
...options.mutationOptions,
|
||||
mutationFn: async (data: TData) =>
|
||||
fetchJson<T, TData>({ ...requestOptions, body: data }),
|
||||
});
|
||||
}
|
||||
|
||||
export default useApiMutation;
|
|
@ -1,45 +1,23 @@
|
|||
import { UndefinedInitialDataOptions, useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import fetchJson, {
|
||||
ApiError,
|
||||
apiRoot,
|
||||
FetchJsonOptions,
|
||||
} from 'Utilities/Fetch/fetchJson';
|
||||
|
||||
interface ApiErrorResponse {
|
||||
message: string;
|
||||
details: string;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
public statusCode: number;
|
||||
public statusText: string;
|
||||
public statusBody?: ApiErrorResponse;
|
||||
|
||||
public constructor(
|
||||
path: string,
|
||||
statusCode: number,
|
||||
statusText: string,
|
||||
statusBody?: ApiErrorResponse
|
||||
) {
|
||||
super(`Request Error: (${statusCode}) ${path}`);
|
||||
|
||||
this.statusCode = statusCode;
|
||||
this.statusText = statusText;
|
||||
this.statusBody = statusBody;
|
||||
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
interface QueryOptions<T> {
|
||||
path: string;
|
||||
headers?: HeadersInit;
|
||||
interface QueryOptions<T> extends FetchJsonOptions<unknown> {
|
||||
queryOptions?:
|
||||
| Omit<UndefinedInitialDataOptions<T, ApiError>, 'queryKey' | 'queryFn'>
|
||||
| undefined;
|
||||
}
|
||||
|
||||
const apiRoot = '/api/v5'; // window.Sonarr.apiRoot;
|
||||
|
||||
function useApiQuery<T>(options: QueryOptions<T>) {
|
||||
const { path, headers } = useMemo(() => {
|
||||
const requestOptions = useMemo(() => {
|
||||
const { queryOptions, ...otherOptions } = options;
|
||||
|
||||
return {
|
||||
...otherOptions,
|
||||
path: apiRoot + options.path,
|
||||
headers: {
|
||||
...options.headers,
|
||||
|
@ -50,28 +28,9 @@ function useApiQuery<T>(options: QueryOptions<T>) {
|
|||
|
||||
return useQuery({
|
||||
...options.queryOptions,
|
||||
queryKey: [path, headers],
|
||||
queryFn: async ({ signal }) => {
|
||||
const response = await fetch(path, {
|
||||
headers,
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// eslint-disable-next-line init-declarations
|
||||
let body;
|
||||
|
||||
try {
|
||||
body = (await response.json()) as ApiErrorResponse;
|
||||
} catch {
|
||||
throw new ApiError(path, response.status, response.statusText);
|
||||
}
|
||||
|
||||
throw new ApiError(path, response.status, response.statusText, body);
|
||||
}
|
||||
|
||||
return response.json() as T;
|
||||
},
|
||||
queryKey: [requestOptions.path],
|
||||
queryFn: async ({ signal }) =>
|
||||
fetchJson<T, unknown>({ ...requestOptions, signal }),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
20
frontend/src/Helpers/createPersist.ts
Normal file
20
frontend/src/Helpers/createPersist.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { create, type StateCreator } from 'zustand';
|
||||
import { persist, type PersistOptions } from 'zustand/middleware';
|
||||
|
||||
export const createPersist = <T>(
|
||||
name: string,
|
||||
state: StateCreator<T>,
|
||||
options: Omit<PersistOptions<T>, 'name' | 'storage'> = {}
|
||||
) => {
|
||||
const instanceName =
|
||||
window.Sonarr.instanceName.toLowerCase().replace(/ /g, '_') ?? 'sonarr';
|
||||
|
||||
const finalName = `${instanceName}_${name}`;
|
||||
|
||||
return create(
|
||||
persist<T>(state, {
|
||||
...options,
|
||||
name: finalName,
|
||||
})
|
||||
);
|
||||
};
|
|
@ -1,183 +0,0 @@
|
|||
import _ from 'lodash';
|
||||
import { createAction } from 'redux-actions';
|
||||
import { batchActions } from 'redux-batched-actions';
|
||||
import { createThunk, handleThunks } from 'Store/thunks';
|
||||
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
||||
import getNewSeries from 'Utilities/Series/getNewSeries';
|
||||
import monitorOptions from 'Utilities/Series/monitorOptions';
|
||||
import * as seriesTypes from 'Utilities/Series/seriesTypes';
|
||||
import getSectionState from 'Utilities/State/getSectionState';
|
||||
import updateSectionState from 'Utilities/State/updateSectionState';
|
||||
import { set, update, updateItem } from './baseActions';
|
||||
import createHandleActions from './Creators/createHandleActions';
|
||||
import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer';
|
||||
|
||||
//
|
||||
// Variables
|
||||
|
||||
export const section = 'addSeries';
|
||||
let abortCurrentRequest = null;
|
||||
|
||||
//
|
||||
// State
|
||||
|
||||
export const defaultState = {
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: null,
|
||||
isAdding: false,
|
||||
isAdded: false,
|
||||
addError: null,
|
||||
items: [],
|
||||
|
||||
defaults: {
|
||||
rootFolderPath: '',
|
||||
monitor: monitorOptions[0].key,
|
||||
qualityProfileId: 0,
|
||||
seriesType: seriesTypes.STANDARD,
|
||||
seasonFolder: true,
|
||||
searchForMissingEpisodes: false,
|
||||
searchForCutoffUnmetEpisodes: false,
|
||||
tags: []
|
||||
}
|
||||
};
|
||||
|
||||
export const persistState = [
|
||||
'addSeries.defaults'
|
||||
];
|
||||
|
||||
//
|
||||
// Actions Types
|
||||
|
||||
export const LOOKUP_SERIES = 'addSeries/lookupSeries';
|
||||
export const ADD_SERIES = 'addSeries/addSeries';
|
||||
export const SET_ADD_SERIES_VALUE = 'addSeries/setAddSeriesValue';
|
||||
export const CLEAR_ADD_SERIES = 'addSeries/clearAddSeries';
|
||||
export const SET_ADD_SERIES_DEFAULT = 'addSeries/setAddSeriesDefault';
|
||||
|
||||
//
|
||||
// Action Creators
|
||||
|
||||
export const lookupSeries = createThunk(LOOKUP_SERIES);
|
||||
export const addSeries = createThunk(ADD_SERIES);
|
||||
export const clearAddSeries = createAction(CLEAR_ADD_SERIES);
|
||||
export const setAddSeriesDefault = createAction(SET_ADD_SERIES_DEFAULT);
|
||||
|
||||
export const setAddSeriesValue = createAction(SET_ADD_SERIES_VALUE, (payload) => {
|
||||
return {
|
||||
section,
|
||||
...payload
|
||||
};
|
||||
});
|
||||
|
||||
//
|
||||
// Action Handlers
|
||||
|
||||
export const actionHandlers = handleThunks({
|
||||
|
||||
[LOOKUP_SERIES]: function(getState, payload, dispatch) {
|
||||
dispatch(set({ section, isFetching: true }));
|
||||
|
||||
if (abortCurrentRequest) {
|
||||
abortCurrentRequest();
|
||||
}
|
||||
|
||||
const { request, abortRequest } = createAjaxRequest({
|
||||
url: '/series/lookup',
|
||||
data: {
|
||||
term: payload.term
|
||||
}
|
||||
});
|
||||
|
||||
abortCurrentRequest = abortRequest;
|
||||
|
||||
request.done((data) => {
|
||||
dispatch(batchActions([
|
||||
update({ section, data }),
|
||||
|
||||
set({
|
||||
section,
|
||||
isFetching: false,
|
||||
isPopulated: true,
|
||||
error: null
|
||||
})
|
||||
]));
|
||||
});
|
||||
|
||||
request.fail((xhr) => {
|
||||
dispatch(set({
|
||||
section,
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: xhr.aborted ? null : xhr
|
||||
}));
|
||||
});
|
||||
},
|
||||
|
||||
[ADD_SERIES]: function(getState, payload, dispatch) {
|
||||
dispatch(set({ section, isAdding: true }));
|
||||
|
||||
const tvdbId = payload.tvdbId;
|
||||
const items = getState().addSeries.items;
|
||||
const newSeries = getNewSeries(_.cloneDeep(_.find(items, { tvdbId })), payload);
|
||||
|
||||
const promise = createAjaxRequest({
|
||||
url: '/series',
|
||||
method: 'POST',
|
||||
dataType: 'json',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(newSeries)
|
||||
}).request;
|
||||
|
||||
promise.done((data) => {
|
||||
dispatch(batchActions([
|
||||
updateItem({ section: 'series', ...data }),
|
||||
|
||||
set({
|
||||
section,
|
||||
isAdding: false,
|
||||
isAdded: true,
|
||||
addError: null
|
||||
})
|
||||
]));
|
||||
});
|
||||
|
||||
promise.fail((xhr) => {
|
||||
dispatch(set({
|
||||
section,
|
||||
isAdding: false,
|
||||
isAdded: false,
|
||||
addError: xhr
|
||||
}));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
//
|
||||
// Reducers
|
||||
|
||||
export const reducers = createHandleActions({
|
||||
|
||||
[SET_ADD_SERIES_VALUE]: createSetSettingValueReducer(section),
|
||||
|
||||
[SET_ADD_SERIES_DEFAULT]: function(state, { payload }) {
|
||||
const newState = getSectionState(state, section);
|
||||
|
||||
newState.defaults = {
|
||||
...newState.defaults,
|
||||
...payload
|
||||
};
|
||||
|
||||
return updateSectionState(state, section, newState);
|
||||
},
|
||||
|
||||
[CLEAR_ADD_SERIES]: function(state) {
|
||||
const {
|
||||
defaults,
|
||||
...otherDefaultState
|
||||
} = defaultState;
|
||||
|
||||
return Object.assign({}, state, otherDefaultState);
|
||||
}
|
||||
|
||||
}, defaultState, section);
|
|
@ -1,4 +1,3 @@
|
|||
import * as addSeries from './addSeriesActions';
|
||||
import * as app from './appActions';
|
||||
import * as blocklist from './blocklistActions';
|
||||
import * as calendar from './calendarActions';
|
||||
|
@ -29,7 +28,6 @@ import * as tags from './tagActions';
|
|||
import * as wanted from './wantedActions';
|
||||
|
||||
export default [
|
||||
addSeries,
|
||||
app,
|
||||
blocklist,
|
||||
calendar,
|
||||
|
|
|
@ -7,10 +7,9 @@ function createImportSeriesItemSelector(id: string) {
|
|||
return createSelector(
|
||||
(_state: AppState, connectorInput: { id: string }) =>
|
||||
connectorInput ? connectorInput.id : id,
|
||||
(state: AppState) => state.addSeries,
|
||||
(state: AppState) => state.importSeries,
|
||||
createAllSeriesSelector(),
|
||||
(connectorId, addSeries, importSeries, series) => {
|
||||
(connectorId, importSeries, series) => {
|
||||
const finalId = id || connectorId;
|
||||
|
||||
const item =
|
||||
|
@ -26,10 +25,6 @@ function createImportSeriesItemSelector(id: string) {
|
|||
});
|
||||
|
||||
return {
|
||||
defaultMonitor: addSeries.defaults.monitor,
|
||||
defaultQualityProfileId: addSeries.defaults.qualityProfileId,
|
||||
defaultSeriesType: addSeries.defaults.seriesType,
|
||||
defaultSeasonFolder: addSeries.defaults.seasonFolder,
|
||||
...item,
|
||||
isExistingSeries,
|
||||
};
|
||||
|
|
|
@ -17,7 +17,7 @@ interface ValidationFailures {
|
|||
warnings: ValidationWarning[];
|
||||
}
|
||||
|
||||
function getValidationFailures(saveError?: Error): ValidationFailures {
|
||||
function getValidationFailures(saveError?: Error | null): ValidationFailures {
|
||||
if (!saveError || saveError.status !== 400) {
|
||||
return {
|
||||
errors: [],
|
||||
|
@ -77,7 +77,7 @@ export interface ModelBaseSetting {
|
|||
function selectSettings<T extends ModelBaseSetting>(
|
||||
item: T,
|
||||
pendingChanges?: Partial<ModelBaseSetting>,
|
||||
saveError?: Error
|
||||
saveError?: Error | null
|
||||
) {
|
||||
const { errors, warnings } = getValidationFailures(saveError);
|
||||
|
||||
|
|
24
frontend/src/Utilities/Fetch/anySignal.ts
Normal file
24
frontend/src/Utilities/Fetch/anySignal.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
const anySignal = (
|
||||
...signals: (AbortSignal | null | undefined)[]
|
||||
): AbortSignal => {
|
||||
const controller = new AbortController();
|
||||
|
||||
for (const signal of signals.filter(Boolean) as AbortSignal[]) {
|
||||
if (signal.aborted) {
|
||||
// Break early if one of the signals is already aborted.
|
||||
controller.abort();
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Listen for abort events on the provided signals and abort the controller.
|
||||
// Automatically removes listeners when the controller is aborted.
|
||||
signal.addEventListener('abort', () => controller.abort(signal.reason), {
|
||||
signal: controller.signal,
|
||||
});
|
||||
}
|
||||
|
||||
return controller.signal;
|
||||
};
|
||||
|
||||
export default anySignal;
|
86
frontend/src/Utilities/Fetch/fetchJson.ts
Normal file
86
frontend/src/Utilities/Fetch/fetchJson.ts
Normal file
|
@ -0,0 +1,86 @@
|
|||
import anySignal from './anySignal';
|
||||
|
||||
export class ApiError extends Error {
|
||||
public statusCode: number;
|
||||
public statusText: string;
|
||||
public statusBody?: ApiErrorResponse;
|
||||
|
||||
public constructor(
|
||||
path: string,
|
||||
statusCode: number,
|
||||
statusText: string,
|
||||
statusBody?: ApiErrorResponse
|
||||
) {
|
||||
super(`Request Error: (${statusCode}) ${path}`);
|
||||
|
||||
this.statusCode = statusCode;
|
||||
this.statusText = statusText;
|
||||
this.statusBody = statusBody;
|
||||
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiErrorResponse {
|
||||
message: string;
|
||||
details: string;
|
||||
}
|
||||
|
||||
export interface FetchJsonOptions<TData> extends Omit<RequestInit, 'body'> {
|
||||
path: string;
|
||||
headers?: HeadersInit;
|
||||
body?: TData;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export const apiRoot = '/api/v5'; // window.Sonarr.apiRoot;
|
||||
|
||||
async function fetchJson<T, TData>({
|
||||
body,
|
||||
path,
|
||||
signal,
|
||||
timeout,
|
||||
...options
|
||||
}: FetchJsonOptions<TData>): Promise<T> {
|
||||
const abortController = new AbortController();
|
||||
|
||||
let timeoutID: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
if (timeout) {
|
||||
timeoutID = setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
const response = await fetch(path, {
|
||||
...options,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
headers: {
|
||||
...options.headers,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: anySignal(abortController.signal, signal),
|
||||
});
|
||||
|
||||
if (timeoutID) {
|
||||
clearTimeout(timeoutID);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// eslint-disable-next-line init-declarations
|
||||
let body;
|
||||
|
||||
try {
|
||||
body = (await response.json()) as ApiErrorResponse;
|
||||
} catch {
|
||||
throw new ApiError(path, response.status, response.statusText);
|
||||
}
|
||||
|
||||
throw new ApiError(path, response.status, response.statusText, body);
|
||||
}
|
||||
|
||||
return response.json() as T;
|
||||
}
|
||||
|
||||
export default fetchJson;
|
|
@ -1,5 +1,5 @@
|
|||
import { Error } from 'App/State/AppSectionState';
|
||||
import { ApiError } from 'Helpers/Hooks/useApiQuery';
|
||||
import { ApiError } from 'Utilities/Fetch/fetchJson';
|
||||
|
||||
function getErrorMessage(
|
||||
error: Error | ApiError | undefined,
|
||||
|
|
|
@ -1,6 +1,12 @@
|
|||
import { SeriesMonitor } from 'Series/Series';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
const monitorOptions = [
|
||||
interface MonitorOption {
|
||||
key: SeriesMonitor;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const monitorOptions: MonitorOption[] = [
|
||||
{
|
||||
key: 'all',
|
||||
get value() {
|
||||
|
|
|
@ -81,7 +81,8 @@
|
|||
"redux-thunk": "2.4.2",
|
||||
"reselect": "4.1.8",
|
||||
"stacktrace-js": "2.0.2",
|
||||
"typescript": "5.7.2"
|
||||
"typescript": "5.7.2",
|
||||
"zustand": "5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.26.0",
|
||||
|
|
374
src/Sonarr.Api.V5/Series/SeriesController.cs
Normal file
374
src/Sonarr.Api.V5/Series/SeriesController.cs
Normal file
|
@ -0,0 +1,374 @@
|
|||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.DataAugmentation.Scene;
|
||||
using NzbDrone.Core.Datastore;
|
||||
using NzbDrone.Core.Datastore.Events;
|
||||
using NzbDrone.Core.MediaCover;
|
||||
using NzbDrone.Core.MediaFiles;
|
||||
using NzbDrone.Core.MediaFiles.Events;
|
||||
using NzbDrone.Core.Messaging.Commands;
|
||||
using NzbDrone.Core.Messaging.Events;
|
||||
using NzbDrone.Core.RootFolders;
|
||||
using NzbDrone.Core.SeriesStats;
|
||||
using NzbDrone.Core.Tv;
|
||||
using NzbDrone.Core.Tv.Commands;
|
||||
using NzbDrone.Core.Tv.Events;
|
||||
using NzbDrone.Core.Validation;
|
||||
using NzbDrone.Core.Validation.Paths;
|
||||
using NzbDrone.SignalR;
|
||||
using Sonarr.Http;
|
||||
using Sonarr.Http.Extensions;
|
||||
using Sonarr.Http.REST;
|
||||
using Sonarr.Http.REST.Attributes;
|
||||
|
||||
namespace Sonarr.Api.V5.Series
|
||||
{
|
||||
[V5ApiController]
|
||||
public class SeriesController : RestControllerWithSignalR<SeriesResource, NzbDrone.Core.Tv.Series>,
|
||||
IHandle<EpisodeImportedEvent>,
|
||||
IHandle<EpisodeFileDeletedEvent>,
|
||||
IHandle<SeriesUpdatedEvent>,
|
||||
IHandle<SeriesEditedEvent>,
|
||||
IHandle<SeriesDeletedEvent>,
|
||||
IHandle<SeriesRenamedEvent>,
|
||||
IHandle<SeriesBulkEditedEvent>,
|
||||
IHandle<MediaCoversUpdatedEvent>
|
||||
{
|
||||
private readonly ISeriesService _seriesService;
|
||||
private readonly IAddSeriesService _addSeriesService;
|
||||
private readonly ISeriesStatisticsService _seriesStatisticsService;
|
||||
private readonly ISceneMappingService _sceneMappingService;
|
||||
private readonly IMapCoversToLocal _coverMapper;
|
||||
private readonly IManageCommandQueue _commandQueueManager;
|
||||
private readonly IRootFolderService _rootFolderService;
|
||||
|
||||
public SeriesController(IBroadcastSignalRMessage signalRBroadcaster,
|
||||
ISeriesService seriesService,
|
||||
IAddSeriesService addSeriesService,
|
||||
ISeriesStatisticsService seriesStatisticsService,
|
||||
ISceneMappingService sceneMappingService,
|
||||
IMapCoversToLocal coverMapper,
|
||||
IManageCommandQueue commandQueueManager,
|
||||
IRootFolderService rootFolderService,
|
||||
RootFolderValidator rootFolderValidator,
|
||||
MappedNetworkDriveValidator mappedNetworkDriveValidator,
|
||||
SeriesPathValidator seriesPathValidator,
|
||||
SeriesExistsValidator seriesExistsValidator,
|
||||
SeriesAncestorValidator seriesAncestorValidator,
|
||||
SystemFolderValidator systemFolderValidator,
|
||||
QualityProfileExistsValidator qualityProfileExistsValidator,
|
||||
RootFolderExistsValidator rootFolderExistsValidator,
|
||||
SeriesFolderAsRootFolderValidator seriesFolderAsRootFolderValidator)
|
||||
: base(signalRBroadcaster)
|
||||
{
|
||||
_seriesService = seriesService;
|
||||
_addSeriesService = addSeriesService;
|
||||
_seriesStatisticsService = seriesStatisticsService;
|
||||
_sceneMappingService = sceneMappingService;
|
||||
|
||||
_coverMapper = coverMapper;
|
||||
_commandQueueManager = commandQueueManager;
|
||||
_rootFolderService = rootFolderService;
|
||||
|
||||
SharedValidator.RuleFor(s => s.Path).Cascade(CascadeMode.Stop)
|
||||
.IsValidPath()
|
||||
.SetValidator(rootFolderValidator)
|
||||
.SetValidator(mappedNetworkDriveValidator)
|
||||
.SetValidator(seriesPathValidator)
|
||||
.SetValidator(seriesAncestorValidator)
|
||||
.SetValidator(systemFolderValidator)
|
||||
.When(s => s.Path.IsNotNullOrWhiteSpace());
|
||||
|
||||
PostValidator.RuleFor(s => s.Path).Cascade(CascadeMode.Stop)
|
||||
.NotEmpty()
|
||||
.IsValidPath()
|
||||
.When(s => s.RootFolderPath.IsNullOrWhiteSpace());
|
||||
PostValidator.RuleFor(s => s.RootFolderPath).Cascade(CascadeMode.Stop)
|
||||
.NotEmpty()
|
||||
.IsValidPath()
|
||||
.SetValidator(rootFolderExistsValidator)
|
||||
.SetValidator(seriesFolderAsRootFolderValidator)
|
||||
.When(s => s.Path.IsNullOrWhiteSpace());
|
||||
|
||||
PutValidator.RuleFor(s => s.Path).Cascade(CascadeMode.Stop)
|
||||
.NotEmpty()
|
||||
.IsValidPath();
|
||||
|
||||
SharedValidator.RuleFor(s => s.QualityProfileId).Cascade(CascadeMode.Stop)
|
||||
.ValidId()
|
||||
.SetValidator(qualityProfileExistsValidator);
|
||||
|
||||
PostValidator.RuleFor(s => s.Title).NotEmpty();
|
||||
PostValidator.RuleFor(s => s.TvdbId).GreaterThan(0).SetValidator(seriesExistsValidator);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Produces("application/json")]
|
||||
public List<SeriesResource> AllSeries(int? tvdbId, bool includeSeasonImages = false)
|
||||
{
|
||||
var seriesStats = _seriesStatisticsService.SeriesStatistics();
|
||||
var seriesResources = new List<SeriesResource>();
|
||||
|
||||
if (tvdbId.HasValue)
|
||||
{
|
||||
seriesResources.AddIfNotNull(_seriesService.FindByTvdbId(tvdbId.Value).ToResource(includeSeasonImages));
|
||||
}
|
||||
else
|
||||
{
|
||||
seriesResources.AddRange(_seriesService.GetAllSeries().Select(s => s.ToResource(includeSeasonImages)));
|
||||
}
|
||||
|
||||
MapCoversToLocal(seriesResources.ToArray());
|
||||
LinkSeriesStatistics(seriesResources, seriesStats.ToDictionary(x => x.SeriesId));
|
||||
PopulateAlternateTitles(seriesResources);
|
||||
seriesResources.ForEach(LinkRootFolderPath);
|
||||
|
||||
return seriesResources;
|
||||
}
|
||||
|
||||
[RestGetById]
|
||||
[Produces("application/json")]
|
||||
public ActionResult<SeriesResource> GetResourceByIdWithErrorHandler(int id, [FromQuery]bool includeSeasonImages = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
var series = GetSeriesResourceById(id, includeSeasonImages);
|
||||
|
||||
return series == null ? NotFound() : series;
|
||||
}
|
||||
catch (ModelNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
protected override SeriesResource? GetResourceById(int id)
|
||||
{
|
||||
var includeSeasonImages = Request?.GetBooleanQueryParameter("includeSeasonImages", false) ?? false;
|
||||
|
||||
// Parse IncludeImages and use it
|
||||
return GetSeriesResourceById(id, includeSeasonImages);
|
||||
}
|
||||
|
||||
private SeriesResource? GetSeriesResourceById(int id, bool includeSeasonImages = false)
|
||||
{
|
||||
var series = _seriesService.GetSeries(id);
|
||||
|
||||
// Parse IncludeImages and use it
|
||||
return GetSeriesResource(series, includeSeasonImages);
|
||||
}
|
||||
|
||||
[RestPostById]
|
||||
[Consumes("application/json")]
|
||||
[Produces("application/json")]
|
||||
public ActionResult<SeriesResource> AddSeries([FromBody] SeriesResource seriesResource)
|
||||
{
|
||||
var series = _addSeriesService.AddSeries(seriesResource.ToModel());
|
||||
|
||||
return Created(series.Id);
|
||||
}
|
||||
|
||||
[RestPutById]
|
||||
[Consumes("application/json")]
|
||||
[Produces("application/json")]
|
||||
public ActionResult<SeriesResource> UpdateSeries([FromBody] SeriesResource seriesResource, [FromQuery] bool moveFiles = false)
|
||||
{
|
||||
var series = _seriesService.GetSeries(seriesResource.Id);
|
||||
|
||||
if (moveFiles)
|
||||
{
|
||||
var sourcePath = series.Path;
|
||||
var destinationPath = seriesResource.Path;
|
||||
|
||||
_commandQueueManager.Push(new MoveSeriesCommand
|
||||
{
|
||||
SeriesId = series.Id,
|
||||
SourcePath = sourcePath,
|
||||
DestinationPath = destinationPath
|
||||
}, trigger: CommandTrigger.Manual);
|
||||
}
|
||||
|
||||
var model = seriesResource.ToModel(series);
|
||||
|
||||
_seriesService.UpdateSeries(model);
|
||||
|
||||
BroadcastResourceChange(ModelAction.Updated, seriesResource);
|
||||
|
||||
return Accepted(seriesResource.Id);
|
||||
}
|
||||
|
||||
[RestDeleteById]
|
||||
public void DeleteSeries(int id, bool deleteFiles = false, bool addImportListExclusion = false)
|
||||
{
|
||||
_seriesService.DeleteSeries(new List<int> { id }, deleteFiles, addImportListExclusion);
|
||||
}
|
||||
|
||||
private SeriesResource? GetSeriesResource(NzbDrone.Core.Tv.Series? series, bool includeSeasonImages)
|
||||
{
|
||||
if (series == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resource = series.ToResource(includeSeasonImages);
|
||||
MapCoversToLocal(resource);
|
||||
FetchAndLinkSeriesStatistics(resource);
|
||||
PopulateAlternateTitles(resource);
|
||||
LinkRootFolderPath(resource);
|
||||
|
||||
return resource;
|
||||
}
|
||||
|
||||
private void MapCoversToLocal(params SeriesResource[] series)
|
||||
{
|
||||
foreach (var seriesResource in series)
|
||||
{
|
||||
_coverMapper.ConvertToLocalUrls(seriesResource.Id, seriesResource.Images);
|
||||
}
|
||||
}
|
||||
|
||||
private void FetchAndLinkSeriesStatistics(SeriesResource resource)
|
||||
{
|
||||
LinkSeriesStatistics(resource, _seriesStatisticsService.SeriesStatistics(resource.Id));
|
||||
}
|
||||
|
||||
private void LinkSeriesStatistics(List<SeriesResource> resources, Dictionary<int, SeriesStatistics> seriesStatistics)
|
||||
{
|
||||
foreach (var series in resources)
|
||||
{
|
||||
if (seriesStatistics.TryGetValue(series.Id, out var stats))
|
||||
{
|
||||
LinkSeriesStatistics(series, stats);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LinkSeriesStatistics(SeriesResource resource, SeriesStatistics seriesStatistics)
|
||||
{
|
||||
// Only set last aired from statistics if it's missing from the series itself
|
||||
resource.LastAired ??= seriesStatistics.LastAired;
|
||||
|
||||
resource.PreviousAiring = seriesStatistics.PreviousAiring;
|
||||
resource.NextAiring = seriesStatistics.NextAiring;
|
||||
resource.Statistics = seriesStatistics.ToResource(resource.Seasons);
|
||||
|
||||
if (seriesStatistics.SeasonStatistics != null)
|
||||
{
|
||||
foreach (var season in resource.Seasons)
|
||||
{
|
||||
season.Statistics = seriesStatistics.SeasonStatistics?.SingleOrDefault(s => s.SeasonNumber == season.SeasonNumber)?.ToResource();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void PopulateAlternateTitles(List<SeriesResource> resources)
|
||||
{
|
||||
foreach (var resource in resources)
|
||||
{
|
||||
PopulateAlternateTitles(resource);
|
||||
}
|
||||
}
|
||||
|
||||
private void PopulateAlternateTitles(SeriesResource resource)
|
||||
{
|
||||
var mappings = _sceneMappingService.FindByTvdbId(resource.TvdbId);
|
||||
|
||||
if (mappings == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
resource.AlternateTitles = mappings.ConvertAll(AlternateTitleResourceMapper.ToResource);
|
||||
}
|
||||
|
||||
private void LinkRootFolderPath(SeriesResource resource)
|
||||
{
|
||||
resource.RootFolderPath = _rootFolderService.GetBestRootFolderPath(resource.Path);
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public void Handle(EpisodeImportedEvent message)
|
||||
{
|
||||
BroadcastResourceChange(ModelAction.Updated, message.ImportedEpisode.SeriesId);
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public void Handle(EpisodeFileDeletedEvent message)
|
||||
{
|
||||
if (message.Reason == DeleteMediaFileReason.Upgrade)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
BroadcastResourceChange(ModelAction.Updated, message.EpisodeFile.SeriesId);
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public void Handle(SeriesUpdatedEvent message)
|
||||
{
|
||||
BroadcastResourceChange(ModelAction.Updated, message.Series.Id);
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public void Handle(SeriesEditedEvent message)
|
||||
{
|
||||
var resource = GetSeriesResource(message.Series, false);
|
||||
|
||||
if (resource == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
resource.EpisodesChanged = message.EpisodesChanged;
|
||||
BroadcastResourceChange(ModelAction.Updated, resource);
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public void Handle(SeriesDeletedEvent message)
|
||||
{
|
||||
foreach (var series in message.Series)
|
||||
{
|
||||
var resource = GetSeriesResource(series, false);
|
||||
|
||||
if (resource == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
BroadcastResourceChange(ModelAction.Deleted, resource);
|
||||
}
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public void Handle(SeriesRenamedEvent message)
|
||||
{
|
||||
BroadcastResourceChange(ModelAction.Updated, message.Series.Id);
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public void Handle(SeriesBulkEditedEvent message)
|
||||
{
|
||||
foreach (var series in message.Series)
|
||||
{
|
||||
var resource = GetSeriesResource(series, false);
|
||||
|
||||
if (resource == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
BroadcastResourceChange(ModelAction.Updated, resource);
|
||||
}
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public void Handle(MediaCoversUpdatedEvent message)
|
||||
{
|
||||
if (message.Updated)
|
||||
{
|
||||
BroadcastResourceChange(ModelAction.Updated, message.Series.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
using FluentValidation.Validators;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.Organizer;
|
||||
|
||||
namespace Sonarr.Api.V5.Series
|
||||
{
|
||||
public class SeriesFolderAsRootFolderValidator : PropertyValidator
|
||||
{
|
||||
private readonly IBuildFileNames _fileNameBuilder;
|
||||
|
||||
public SeriesFolderAsRootFolderValidator(IBuildFileNames fileNameBuilder)
|
||||
{
|
||||
_fileNameBuilder = fileNameBuilder;
|
||||
}
|
||||
|
||||
protected override string GetDefaultMessageTemplate() => "Root folder path '{rootFolderPath}' contains series folder '{seriesFolder}'";
|
||||
|
||||
protected override bool IsValid(PropertyValidatorContext context)
|
||||
{
|
||||
if (context.PropertyValue == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (context.InstanceToValidate is not SeriesResource seriesResource)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var rootFolderPath = context.PropertyValue.ToString();
|
||||
|
||||
if (rootFolderPath.IsNullOrWhiteSpace())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var rootFolder = new DirectoryInfo(rootFolderPath!).Name;
|
||||
var series = seriesResource.ToModel();
|
||||
var seriesFolder = _fileNameBuilder.GetSeriesFolder(series);
|
||||
|
||||
context.MessageFormatter.AppendArgument("rootFolderPath", rootFolderPath);
|
||||
context.MessageFormatter.AppendArgument("seriesFolder", seriesFolder);
|
||||
|
||||
if (seriesFolder == rootFolder)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var distance = seriesFolder.LevenshteinDistance(rootFolder);
|
||||
|
||||
return distance >= Math.Max(1, seriesFolder.Length * 0.2);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -22,7 +22,7 @@ public class SeriesResource : RestResource
|
|||
public List<MediaCover>? Images { get; set; }
|
||||
public Language? OriginalLanguage { get; set; }
|
||||
public string? RemotePoster { get; set; }
|
||||
public List<SeasonResource>? Seasons { get; set; }
|
||||
public List<SeasonResource> Seasons { get; set; } = new ();
|
||||
public int Year { get; set; }
|
||||
public string? Path { get; set; }
|
||||
public int QualityProfileId { get; set; }
|
||||
|
|
|
@ -60,7 +60,9 @@ namespace Sonarr.Http.REST
|
|||
}
|
||||
}
|
||||
|
||||
protected abstract TResource GetResourceById(int id);
|
||||
#nullable enable
|
||||
protected abstract TResource? GetResourceById(int id);
|
||||
#nullable disable
|
||||
|
||||
public override void OnActionExecuting(ActionExecutingContext context)
|
||||
{
|
||||
|
|
25
yarn.lock
25
yarn.lock
|
@ -6255,16 +6255,7 @@ string-template@~0.2.1:
|
|||
resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add"
|
||||
integrity sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw==
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0":
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
dependencies:
|
||||
emoji-regex "^8.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
string-width@^4.1.0, string-width@^4.2.3:
|
||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.3:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
|
@ -6355,14 +6346,7 @@ string_decoder@~1.1.1:
|
|||
dependencies:
|
||||
safe-buffer "~5.1.0"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
dependencies:
|
||||
ansi-regex "^5.0.1"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
|
@ -7188,3 +7172,8 @@ zip-stream@^4.1.0:
|
|||
archiver-utils "^3.0.4"
|
||||
compress-commons "^4.1.2"
|
||||
readable-stream "^3.6.0"
|
||||
|
||||
zustand@5.0.3:
|
||||
version "5.0.3"
|
||||
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.3.tgz#b323435b73d06b2512e93c77239634374b0e407f"
|
||||
integrity sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue