Add Series with ReactQuery Mutation

v5-develop
Mark McDowall 4 days ago
parent 094df71301
commit 591b569bdd

@ -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 {
isPending: isAdding,
error: addError,
mutate: addSeries,
} = useAddSeries();
const { settings, validationErrors, validationWarnings } = useMemo(() => {
return selectSettings(defaults, {}, addError);
}, [defaults, addError]);
return selectSettings(options, {}, addError);
}, [options, addError]);
const [seriesType, setSeriesType] = useState(
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;
function AddNewSeriesSearchResult({
tvdbId,
titleSlug,
title,
year,
network,
originalLanguage,
genres = [],
status,
statistics = {} as Statistics,
ratings,
folder,
overview,
seriesType,
images,
}: AddNewSeriesSearchResultProps) {
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;
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>

@ -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,
},
});
};

@ -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());

@ -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;

@ -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 }),
});
}

@ -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);

@ -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;

@ -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",

@ -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)
{

@ -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…
Cancel
Save