diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.tsx b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.tsx index 0130b3d90..824db04c6 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.tsx +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.tsx @@ -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({ - path: `/series/lookup?term=${query}`, - queryOptions: { - enabled: !!query, - }, - }); + } = useLookupSeries(query); useEffect(() => { setIsFetching(isFetchingApi); @@ -103,7 +97,9 @@ function AddNewSeries() { {!isFetching && !error && !!data.length ? (
{data.map((item) => { - return ; + return ( + + ); })}
) : null} diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.tsx b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.tsx index 5368586ce..6dc1c7e83 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.tsx +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.tsx @@ -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( 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) => { + setAddSeriesOption(name as keyof AddSeriesOptions, value); }, - [dispatch] + [] ); const handleQualityProfileIdChange = useCallback( ({ value }: InputChanged) => { - 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(() => { diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.tsx b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.tsx index 69d31f721..fff4361bf 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.tsx +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.tsx @@ -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({ diff --git a/frontend/src/AddSeries/AddNewSeries/useAddSeries.ts b/frontend/src/AddSeries/AddNewSeries/useAddSeries.ts new file mode 100644 index 000000000..10316a4cf --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/useAddSeries.ts @@ -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({ + 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({ + path: '/series', + method: 'POST', + mutationOptions: { + onSuccess: onAddSuccess, + }, + }); +}; diff --git a/frontend/src/AddSeries/AddSeries.ts b/frontend/src/AddSeries/AddSeries.ts new file mode 100644 index 000000000..984edc74a --- /dev/null +++ b/frontend/src/AddSeries/AddSeries.ts @@ -0,0 +1,7 @@ +import Series from 'Series/Series'; + +interface AddSeries extends Series { + folder: string; +} + +export default AddSeries; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.tsx b/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.tsx index 53fca5ba1..532d4a207 100644 --- a/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.tsx +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.tsx @@ -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(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]); diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.tsx b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.tsx index ef678e466..0139e3e70 100644 --- a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.tsx +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.tsx @@ -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) => { 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( diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.tsx b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.tsx index 72294b757..def4572c9 100644 --- a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.tsx +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.tsx @@ -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()); diff --git a/frontend/src/AddSeries/addSeriesOptionsStore.ts b/frontend/src/AddSeries/addSeriesOptionsStore.ts new file mode 100644 index 000000000..4bc910c91 --- /dev/null +++ b/frontend/src/AddSeries/addSeriesOptionsStore.ts @@ -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( + '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 = ( + key: K +) => { + return addSeriesOptionsStore((state) => state[key]); +}; + +export const setAddSeriesOption = ( + key: K, + value: AddSeriesOptions[K] +) => { + addSeriesOptionsStore.setState((state) => ({ + ...state, + [key]: value, + })); +}; diff --git a/frontend/src/App/State/AddSeriesAppState.ts b/frontend/src/App/State/AddSeriesAppState.ts deleted file mode 100644 index 4bf5ba7cd..000000000 --- a/frontend/src/App/State/AddSeriesAppState.ts +++ /dev/null @@ -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 { - 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; diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index da9996632..6b9e6bee9 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -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; diff --git a/frontend/src/Helpers/Hooks/useApiMutation.ts b/frontend/src/Helpers/Hooks/useApiMutation.ts new file mode 100644 index 000000000..2c36d83d6 --- /dev/null +++ b/frontend/src/Helpers/Hooks/useApiMutation.ts @@ -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 + extends Omit, 'method'> { + method: 'POST' | 'PUT' | 'DELETE'; + mutationOptions?: Omit, 'mutationFn'>; +} + +function useApiMutation(options: MutationOptions) { + const requestOptions = useMemo(() => { + return { + ...options, + path: apiRoot + options.path, + headers: { + ...options.headers, + 'X-Api-Key': window.Sonarr.apiKey, + }, + }; + }, [options]); + + return useMutation({ + ...options.mutationOptions, + mutationFn: async (data: TData) => + fetchJson({ ...requestOptions, body: data }), + }); +} + +export default useApiMutation; diff --git a/frontend/src/Helpers/Hooks/useApiQuery.ts b/frontend/src/Helpers/Hooks/useApiQuery.ts index 4d7c2c01b..6cc0168b6 100644 --- a/frontend/src/Helpers/Hooks/useApiQuery.ts +++ b/frontend/src/Helpers/Hooks/useApiQuery.ts @@ -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 { - path: string; - headers?: HeadersInit; +interface QueryOptions extends FetchJsonOptions { queryOptions?: | Omit, 'queryKey' | 'queryFn'> | undefined; } -const apiRoot = '/api/v5'; // window.Sonarr.apiRoot; - function useApiQuery(options: QueryOptions) { - 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(options: QueryOptions) { 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({ ...requestOptions, signal }), }); } diff --git a/frontend/src/Helpers/createPersist.ts b/frontend/src/Helpers/createPersist.ts new file mode 100644 index 000000000..e13f8a250 --- /dev/null +++ b/frontend/src/Helpers/createPersist.ts @@ -0,0 +1,20 @@ +import { create, type StateCreator } from 'zustand'; +import { persist, type PersistOptions } from 'zustand/middleware'; + +export const createPersist = ( + name: string, + state: StateCreator, + options: Omit, 'name' | 'storage'> = {} +) => { + const instanceName = + window.Sonarr.instanceName.toLowerCase().replace(/ /g, '_') ?? 'sonarr'; + + const finalName = `${instanceName}_${name}`; + + return create( + persist(state, { + ...options, + name: finalName, + }) + ); +}; diff --git a/frontend/src/Store/Actions/addSeriesActions.js b/frontend/src/Store/Actions/addSeriesActions.js deleted file mode 100644 index d0399b22b..000000000 --- a/frontend/src/Store/Actions/addSeriesActions.js +++ /dev/null @@ -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); diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index 26eb89d1f..a0bcc2116 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -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, diff --git a/frontend/src/Store/Selectors/createImportSeriesItemSelector.ts b/frontend/src/Store/Selectors/createImportSeriesItemSelector.ts index d64cae35e..37a41a29f 100644 --- a/frontend/src/Store/Selectors/createImportSeriesItemSelector.ts +++ b/frontend/src/Store/Selectors/createImportSeriesItemSelector.ts @@ -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, }; diff --git a/frontend/src/Store/Selectors/selectSettings.ts b/frontend/src/Store/Selectors/selectSettings.ts index e9a47cdce..bdcbe21a8 100644 --- a/frontend/src/Store/Selectors/selectSettings.ts +++ b/frontend/src/Store/Selectors/selectSettings.ts @@ -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( item: T, pendingChanges?: Partial, - saveError?: Error + saveError?: Error | null ) { const { errors, warnings } = getValidationFailures(saveError); diff --git a/frontend/src/Utilities/Fetch/anySignal.ts b/frontend/src/Utilities/Fetch/anySignal.ts new file mode 100644 index 000000000..9f3844c88 --- /dev/null +++ b/frontend/src/Utilities/Fetch/anySignal.ts @@ -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; diff --git a/frontend/src/Utilities/Fetch/fetchJson.ts b/frontend/src/Utilities/Fetch/fetchJson.ts new file mode 100644 index 000000000..7ebd37b99 --- /dev/null +++ b/frontend/src/Utilities/Fetch/fetchJson.ts @@ -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 extends Omit { + path: string; + headers?: HeadersInit; + body?: TData; + timeout?: number; +} + +export const apiRoot = '/api/v5'; // window.Sonarr.apiRoot; + +async function fetchJson({ + body, + path, + signal, + timeout, + ...options +}: FetchJsonOptions): Promise { + const abortController = new AbortController(); + + let timeoutID: ReturnType | 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; diff --git a/frontend/src/Utilities/Object/getErrorMessage.ts b/frontend/src/Utilities/Object/getErrorMessage.ts index f23a74795..9e0135865 100644 --- a/frontend/src/Utilities/Object/getErrorMessage.ts +++ b/frontend/src/Utilities/Object/getErrorMessage.ts @@ -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, diff --git a/frontend/src/Utilities/Series/monitorOptions.ts b/frontend/src/Utilities/Series/monitorOptions.ts index 5efcc51f4..e6f346681 100644 --- a/frontend/src/Utilities/Series/monitorOptions.ts +++ b/frontend/src/Utilities/Series/monitorOptions.ts @@ -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() { diff --git a/package.json b/package.json index c36955e63..cd7e10aac 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/Sonarr.Api.V5/Series/SeriesController.cs b/src/Sonarr.Api.V5/Series/SeriesController.cs new file mode 100644 index 000000000..7b4e75492 --- /dev/null +++ b/src/Sonarr.Api.V5/Series/SeriesController.cs @@ -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, + IHandle, + IHandle, + IHandle, + IHandle, + IHandle, + IHandle, + IHandle, + IHandle + { + 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 AllSeries(int? tvdbId, bool includeSeasonImages = false) + { + var seriesStats = _seriesStatisticsService.SeriesStatistics(); + var seriesResources = new List(); + + 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 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 AddSeries([FromBody] SeriesResource seriesResource) + { + var series = _addSeriesService.AddSeries(seriesResource.ToModel()); + + return Created(series.Id); + } + + [RestPutById] + [Consumes("application/json")] + [Produces("application/json")] + public ActionResult 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 { 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 resources, Dictionary 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 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); + } + } + } +} diff --git a/src/Sonarr.Api.V5/Series/SeriesFolderAsRootFolderValidator.cs b/src/Sonarr.Api.V5/Series/SeriesFolderAsRootFolderValidator.cs new file mode 100644 index 000000000..44eb96a52 --- /dev/null +++ b/src/Sonarr.Api.V5/Series/SeriesFolderAsRootFolderValidator.cs @@ -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); + } + } +} diff --git a/src/Sonarr.Api.V5/Series/SeriesResource.cs b/src/Sonarr.Api.V5/Series/SeriesResource.cs index 0387d6f65..99b9113fe 100644 --- a/src/Sonarr.Api.V5/Series/SeriesResource.cs +++ b/src/Sonarr.Api.V5/Series/SeriesResource.cs @@ -22,7 +22,7 @@ public class SeriesResource : RestResource public List? Images { get; set; } public Language? OriginalLanguage { get; set; } public string? RemotePoster { get; set; } - public List? Seasons { get; set; } + public List Seasons { get; set; } = new (); public int Year { get; set; } public string? Path { get; set; } public int QualityProfileId { get; set; } diff --git a/src/Sonarr.Http/REST/RestController.cs b/src/Sonarr.Http/REST/RestController.cs index 6a61489cc..1f3ccc4a8 100644 --- a/src/Sonarr.Http/REST/RestController.cs +++ b/src/Sonarr.Http/REST/RestController.cs @@ -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) { diff --git a/yarn.lock b/yarn.lock index 45286ce1e..78ce14442 100644 --- a/yarn.lock +++ b/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==