Use react-query for series

This commit is contained in:
Mark McDowall 2025-11-28 19:37:17 -08:00
parent 49db4a1d76
commit 0521a6c390
91 changed files with 1961 additions and 2173 deletions

View file

@ -11,7 +11,7 @@ import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import { icons, kinds } from 'Helpers/Props';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import useSeries from 'Series/useSeries';
import { useSingleSeries } from 'Series/useSeries';
import Blocklist from 'typings/Blocklist';
import { SelectStateInputProps } from 'typings/props';
import translate from 'Utilities/String/translate';
@ -37,7 +37,7 @@ function BlocklistRow({
source,
columns,
}: BlocklistRowProps) {
const series = useSeries(seriesId);
const series = useSingleSeries(seriesId);
const { isRemoving, removeBlocklistItem } = useRemoveBlocklistItem(id);
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const { toggleSelected, useIsSelected } = useSelect<Blocklist>();

View file

@ -16,7 +16,7 @@ import { icons, tooltipPositions } from 'Helpers/Props';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import useSeries from 'Series/useSeries';
import { useSingleSeries } from 'Series/useSeries';
import CustomFormat from 'typings/CustomFormat';
import { HistoryData, HistoryEventType } from 'typings/History';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
@ -61,7 +61,7 @@ function HistoryRow(props: HistoryRowProps) {
columns,
} = props;
const series = useSeries(seriesId);
const series = useSingleSeries(seriesId);
const episode = useEpisode(episodeId, 'episodes');
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);

View file

@ -21,7 +21,7 @@ import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import useSeries from 'Series/useSeries';
import { useSingleSeries } from 'Series/useSeries';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import CustomFormat from 'typings/CustomFormat';
import { SelectStateInputProps } from 'typings/props';
@ -105,7 +105,7 @@ function QueueRow(props: QueueRowProps) {
onQueueRowModalOpenOrClose,
} = props;
const series = useSeries(seriesId);
const series = useSingleSeries(seriesId);
const episodes = useEpisodesWithIds(episodeIds);
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
createUISettingsSelector()

View file

@ -1,6 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
@ -12,6 +10,7 @@ import PageContentBody from 'Components/Page/PageContentBody';
import useDebounce from 'Helpers/Hooks/useDebounce';
import useQueryParams from 'Helpers/Hooks/useQueryParams';
import { icons, kinds } from 'Helpers/Props';
import { useHasSeries } from 'Series/useSeries';
import { InputChanged } from 'typings/inputs';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
@ -21,11 +20,7 @@ import styles from './AddNewSeries.css';
function AddNewSeries() {
const { term: initialTerm = '' } = useQueryParams<{ term: string }>();
const seriesCount = useSelector(
(state: AppState) => state.series.items.length
);
const hasSeries = useHasSeries();
const [term, setTerm] = useState(initialTerm);
const [isFetching, setIsFetching] = useState(false);
const query = useDebounce(term, term ? 300 : 0);
@ -127,7 +122,7 @@ function AddNewSeries() {
</div>
)}
{!term && !seriesCount ? (
{!term && !hasSeries ? (
<div className={styles.message}>
<div className={styles.noSeriesText}>
{translate('NoSeriesHaveBeenAdded')}

View file

@ -10,8 +10,8 @@ import { icons, kinds, sizes } from 'Helpers/Props';
import { Statistics } from 'Series/Series';
import SeriesGenres from 'Series/SeriesGenres';
import SeriesPoster from 'Series/SeriesPoster';
import useExistingSeries from 'Series/useExistingSeries';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
import translate from 'Utilities/String/translate';
import AddNewSeriesModal from './AddNewSeriesModal';
import styles from './AddNewSeriesSearchResult.css';
@ -37,7 +37,7 @@ function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) {
images,
} = series;
const isExistingSeries = useSelector(createExistingSeriesSelector(tvdbId));
const isExistingSeries = useExistingSeries(tvdbId);
const { isSmallScreen } = useSelector(createDimensionsSelector());
const [isNewAddSeriesModalOpen, setIsNewAddSeriesModalOpen] = useState(false);

View file

@ -1,11 +1,9 @@
import { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { useQueryClient } from '@tanstack/react-query';
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;
@ -24,21 +22,22 @@ export const useLookupSeries = (query: string) => {
};
export const useAddSeries = () => {
const dispatch = useDispatch();
const onAddSuccess = useCallback(
(data: Series) => {
dispatch(updateItem({ section: 'series', ...data }));
},
[dispatch]
);
const queryClient = useQueryClient();
const { isPending, error, mutate } = useApiMutation<Series, AddSeriesPayload>(
{
path: '/series',
method: 'POST',
mutationOptions: {
onSuccess: onAddSuccess,
onSuccess: (newSeries) => {
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
if (!oldSeries) {
return [newSeries];
}
return [...oldSeries, newSeries];
});
},
},
}
);

View file

@ -8,8 +8,8 @@ import FormInputGroup from 'Components/Form/FormInputGroup';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
import { inputTypes } from 'Helpers/Props';
import useExistingSeries from 'Series/useExistingSeries';
import { setImportSeriesValue } from 'Store/Actions/importSeriesActions';
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
import { InputChanged } from 'typings/inputs';
import { SelectStateInputProps } from 'typings/props';
import ImportSeriesSelectSeries from './SelectSeries/ImportSeriesSelectSeries';
@ -44,9 +44,7 @@ function ImportSeriesRow({ id }: ImportSeriesRowProps) {
selectedSeries,
} = useSelector(createItemSelector(id));
const isExistingSeries = useSelector(
createExistingSeriesSelector(selectedSeries?.tvdbId)
);
const isExistingSeries = useExistingSeries(selectedSeries?.tvdbId);
const { getIsSelected, toggleSelected, toggleDisabled } =
useSelect<ImportSeries>();

View file

@ -7,11 +7,11 @@ import AppState from 'App/State/AppState';
import { ImportSeries } from 'App/State/ImportSeriesAppState';
import VirtualTable from 'Components/Table/VirtualTable';
import usePrevious from 'Helpers/Hooks/usePrevious';
import useSeries from 'Series/useSeries';
import {
queueLookupSeries,
setImportSeriesValue,
} from 'Store/Actions/importSeriesActions';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import { CheckInputChanged } from 'typings/inputs';
import { SelectStateInputProps } from 'typings/props';
@ -65,7 +65,7 @@ function ImportSeriesTable({
const items = useSelector((state: AppState) => state.importSeries.items);
const { isSmallScreen } = useSelector(createDimensionsSelector());
const allSeries = useSelector(createAllSeriesSelector());
const { data: allSeries } = useSeries();
const {
allSelected,
allUnselected,

View file

@ -1,9 +1,8 @@
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
import useExistingSeries from 'Series/useExistingSeries';
import ImportSeriesTitle from './ImportSeriesTitle';
import styles from './ImportSeriesSearchResult.css';
@ -22,7 +21,7 @@ function ImportSeriesSearchResult({
network,
onPress,
}: ImportSeriesSearchResultProps) {
const isExistingSeries = useSelector(createExistingSeriesSelector(tvdbId));
const isExistingSeries = useExistingSeries(tvdbId);
const handlePress = useCallback(() => {
onPress(tvdbId);

View file

@ -9,6 +9,7 @@ import {
} from '@floating-ui/react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import useImportSeriesItem from 'AddSeries/ImportSeries/Import/useImportSeriesItem';
import AppState from 'App/State/AppState';
import FormInputButton from 'Components/Form/FormInputButton';
import TextInput from 'Components/Form/TextInput';
@ -20,7 +21,6 @@ import {
queueLookupSeries,
setImportSeriesValue,
} from 'Store/Actions/importSeriesActions';
import createImportSeriesItemSelector from 'Store/Selectors/createImportSeriesItemSelector';
import { InputChanged } from 'typings/inputs';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
@ -51,8 +51,7 @@ function ImportSeriesSelectSeries({
selectedSeries,
isExistingSeries,
term: itemTerm,
// @ts-expect-error - ignoring this for now
} = useSelector(createImportSeriesItemSelector(id, { id }));
} = useImportSeriesItem(id);
const seriesLookupTimeout = useRef<ReturnType<typeof setTimeout>>();

View file

@ -0,0 +1,31 @@
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { ImportSeries } from 'App/State/ImportSeriesAppState';
import useSeries from 'Series/useSeries';
function useImportSeriesItem(id: string) {
const { data: series = [] } = useSeries();
const importSeries = useSelector((state: AppState) => state.importSeries);
return useMemo(() => {
const item =
importSeries.items.find((item) => {
return item.id === id;
}) ?? ({} as ImportSeries);
const selectedSeries = item && item.selectedSeries;
const isExistingSeries =
!!selectedSeries &&
series.some((s) => {
return s.tvdbId === selectedSeries.tvdbId;
});
return {
...item,
isExistingSeries,
};
}, [id, importSeries.items, series]);
}
export default useImportSeriesItem;

View file

@ -9,7 +9,6 @@ import MessagesAppState from './MessagesAppState';
import OAuthAppState from './OAuthAppState';
import OrganizePreviewAppState from './OrganizePreviewAppState';
import ProviderOptionsAppState from './ProviderOptionsAppState';
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
import SettingsAppState from './SettingsAppState';
export interface AppSectionState {
@ -45,9 +44,7 @@ interface AppState {
oAuth: OAuthAppState;
organizePreview: OrganizePreviewAppState;
providerOptions: ProviderOptionsAppState;
series: SeriesAppState;
seriesHistory: SeriesHistoryAppState;
seriesIndex: SeriesIndexAppState;
settings: SettingsAppState;
}

View file

@ -1,66 +0,0 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState';
import Column from 'Components/Table/Column';
import { Filter, FilterBuilderProp } from 'Filters/Filter';
import { SortDirection } from 'Helpers/Props/sortDirections';
import Series from 'Series/Series';
export interface SeriesIndexAppState {
sortKey: string;
sortDirection: SortDirection;
secondarySortKey: string;
secondarySortDirection: SortDirection;
view: string;
posterOptions: {
detailedProgressBar: boolean;
size: string;
showTitle: boolean;
showMonitored: boolean;
showQualityProfile: boolean;
showTags: boolean;
showSearchAction: boolean;
};
overviewOptions: {
detailedProgressBar: boolean;
size: string;
showMonitored: boolean;
showNetwork: boolean;
showQualityProfile: boolean;
showPreviousAiring: boolean;
showAdded: boolean;
showSeasonCount: boolean;
showPath: boolean;
showSizeOnDisk: boolean;
showTags: boolean;
showSearchAction: boolean;
};
tableOptions: {
showBanners: boolean;
showSearchAction: boolean;
};
selectedFilterKey: string;
filterBuilderProps: FilterBuilderProp<Series>[];
filters: Filter[];
columns: Column[];
}
interface SeriesAppState
extends AppSectionState<Series>,
AppSectionDeleteState,
AppSectionSaveState {
itemMap: Record<number, number>;
deleteOptions: {
addImportListExclusion: boolean;
};
pendingChanges: Partial<Series>;
}
export default SeriesAppState;

View file

@ -12,7 +12,7 @@ import episodeEntities from 'Episode/episodeEntities';
import getFinaleTypeName from 'Episode/getFinaleTypeName';
import { useEpisodeFile } from 'EpisodeFile/EpisodeFileProvider';
import { icons, kinds } from 'Helpers/Props';
import useSeries from 'Series/useSeries';
import { useSingleSeries } from 'Series/useSeries';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { convertToTimezone } from 'Utilities/Date/convertToTimezone';
import formatTime from 'Utilities/Date/formatTime';
@ -55,7 +55,7 @@ function AgendaEvent(props: AgendaEventProps) {
showDate,
} = props;
const series = useSeries(seriesId)!;
const series = useSingleSeries(seriesId)!;
const episodeFile = useEpisodeFile(episodeFileId);
const queueItem = useQueueItemForEpisode(id);
const { timeFormat, longDateFormat, enableColorImpairedMode, timeZone } =

View file

@ -21,9 +21,9 @@ import { useCustomFiltersList } from 'Filters/useCustomFilters';
import useMeasure from 'Helpers/Hooks/useMeasure';
import { align, icons } from 'Helpers/Props';
import NoSeries from 'Series/NoSeries';
import { useHasSeries } from 'Series/useSeries';
import { executeCommand } from 'Store/Actions/commandActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import translate from 'Utilities/String/translate';
import Calendar from './Calendar';
@ -54,7 +54,7 @@ function CalendarPage() {
createCommandExecutingSelector(commandNames.RSS_SYNC)
);
const customFilters = useCustomFiltersList('calendar');
const hasSeries = !!useSelector(createSeriesCountSelector());
const hasSeries = useHasSeries();
const [pageContentRef, { width }] = useMeasure();
const [isCalendarLinkModalOpen, setIsCalendarLinkModalOpen] = useState(false);

View file

@ -11,7 +11,7 @@ import episodeEntities from 'Episode/episodeEntities';
import getFinaleTypeName from 'Episode/getFinaleTypeName';
import { useEpisodeFile } from 'EpisodeFile/EpisodeFileProvider';
import { icons, kinds } from 'Helpers/Props';
import useSeries from 'Series/useSeries';
import { useSingleSeries } from 'Series/useSeries';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { convertToTimezone } from 'Utilities/Date/convertToTimezone';
import formatTime from 'Utilities/Date/formatTime';
@ -56,7 +56,7 @@ function CalendarEvent(props: CalendarEventProps) {
onEventModalOpenToggle,
} = props;
const series = useSeries(seriesId);
const series = useSingleSeries(seriesId);
const episodeFile = useEpisodeFile(episodeFileId);
const queueItem = useQueueItemForEpisode(id);

View file

@ -8,7 +8,7 @@ import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import getFinaleTypeName from 'Episode/getFinaleTypeName';
import { icons, kinds } from 'Helpers/Props';
import useSeries from 'Series/useSeries';
import { useSingleSeries } from 'Series/useSeries';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { CalendarItem } from 'typings/Calendar';
import { convertToTimezone } from 'Utilities/Date/convertToTimezone';
@ -32,7 +32,7 @@ function CalendarEventGroup({
onEventModalOpenToggle,
}: CalendarEventGroupProps) {
const isDownloading = useIsDownloadingEpisodes(episodeIds);
const series = useSeries(seriesId)!;
const series = useSingleSeries(seriesId)!;
const { timeFormat, enableColorImpairedMode, timeZone } = useSelector(
createUISettingsSelector()

View file

@ -1,7 +1,5 @@
import React from 'react';
import { useSelector } from 'react-redux';
import Series from 'Series/Series';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import useSeries from 'Series/useSeries';
import sortByProp from 'Utilities/Array/sortByProp';
import FilterBuilderRowValue, {
FilterBuilderRowValueProps,
@ -15,7 +13,7 @@ type SeriesFilterBuilderRowValueProps<T> = Omit<
function SeriesFilterBuilderRowValue<T>(
props: SeriesFilterBuilderRowValueProps<T>
) {
const allSeries: Series[] = useSelector(createAllSeriesSelector());
const { data: allSeries = [] } = useSeries();
const tagList = allSeries
.map((series) => ({ id: series.id, name: series.title }))

View file

@ -125,10 +125,6 @@ export default function SeriesTagInput<V extends number | number[]>({
formInputActions?.setClientWarnings(addTagError?.warnings ?? []);
}, [addTagError, formInputActions]);
useEffect(() => {
console.info('\x1b[36m[MarkTest] formInputActions has changed\x1b[0m');
}, [formInputActions]);
return (
<TagInput
{...otherProps}

View file

@ -28,36 +28,7 @@ function getTestResult(error: ApiError | Error | string | undefined | null) {
};
}
if ('status' in error) {
if (error.status !== 400) {
return {
wasSuccessful: false,
hasWarning: false,
hasError: true,
};
}
const failures = error.responseJSON as ValidationFailure[];
const { hasError, hasWarning } = failures.reduce(
(acc, failure) => {
if (failure.isWarning) {
acc.hasWarning = true;
} else {
acc.hasError = true;
}
return acc;
},
{ hasWarning: false, hasError: false }
);
return {
wasSuccessful: false,
hasWarning,
hasError,
};
} else if ('statusCode' in error) {
if (error instanceof ApiError) {
if (error.statusCode !== 400 || error.statusBody == null) {
return {
wasSuccessful: false,
@ -75,10 +46,33 @@ function getTestResult(error: ApiError | Error | string | undefined | null) {
};
}
if (error.status !== 400) {
return {
wasSuccessful: false,
hasWarning: false,
hasError: true,
};
}
const failures = error.responseJSON as ValidationFailure[];
const { hasError, hasWarning } = failures.reduce(
(acc, failure) => {
if (failure.isWarning) {
acc.hasWarning = true;
} else {
acc.hasError = true;
}
return acc;
},
{ hasWarning: false, hasError: false }
);
return {
wasSuccessful: false,
hasWarning: false,
hasError: true,
hasWarning,
hasError,
};
}

View file

@ -9,7 +9,7 @@ interface ErrorPageProps {
version: string;
isLocalStorageSupported: boolean;
translationsError?: Error;
seriesError?: Error;
seriesError: ApiError | null;
customFiltersError: ApiError | null;
tagsError: ApiError | null;
qualityProfilesError?: Error;

View file

@ -11,16 +11,14 @@ import React, {
useState,
} from 'react';
import Autosuggest from 'react-autosuggest';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { useDispatch } from 'react-redux';
import { useDebouncedCallback } from 'use-debounce';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import useKeyboardShortcuts from 'Helpers/Hooks/useKeyboardShortcuts';
import { icons } from 'Helpers/Props';
import Series from 'Series/Series';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector';
import useSeries from 'Series/useSeries';
import { Tag, useTagList } from 'Tags/useTags';
import translate from 'Utilities/String/translate';
import SeriesSearchResult from './SeriesSearchResult';
@ -69,8 +67,10 @@ interface Section {
suggestions: SeriesSuggestion[] | AddNewSeriesSuggestion[];
}
function createUnoptimizedSelector(tagList: Tag[]) {
return createSelector(createAllSeriesSelector(), (allSeries) => {
function useSeriesSuggestions(tagList: Tag[]) {
const { data: allSeries = [] } = useSeries();
return useMemo(() => {
return allSeries.map((series): SuggestedSeries => {
const {
title,
@ -107,19 +107,12 @@ function createUnoptimizedSelector(tagList: Tag[]) {
}, []),
};
});
});
}
function createSeriesSelector(tagList: Tag[]) {
return createDeepEqualSelector(
createUnoptimizedSelector(tagList),
(series) => series
);
}, [allSeries, tagList]);
}
function SeriesSearchInput() {
const tagList = useTagList();
const series = useSelector(createSeriesSelector(tagList));
const series = useSeriesSuggestions(tagList);
const dispatch = useDispatch();
const { bindShortcut, unbindShortcut } = useKeyboardShortcuts();

View file

@ -11,6 +11,7 @@ import Command from 'Commands/Command';
import Episode from 'Episode/Episode';
import { EpisodeFile } from 'EpisodeFile/EpisodeFile';
import { PagedQueryResponse } from 'Helpers/Hooks/usePagedApiQuery';
import Series from 'Series/Series';
import { setAppValue, setVersion } from 'Store/Actions/appActions';
import { removeItem, updateItem } from 'Store/Actions/baseActions';
import {
@ -18,7 +19,6 @@ import {
finishCommand,
updateCommand,
} from 'Store/Actions/commandActions';
import { fetchSeries } from 'Store/Actions/seriesActions';
import { fetchQualityDefinitions } from 'Store/Actions/settingsActions';
import { repopulatePage } from 'Utilities/pagePopulator';
import SignalRLogger from 'Utilities/SignalRLogger';
@ -84,7 +84,7 @@ function SignalRListener() {
// Repopulate the page (if a repopulator is set) to ensure things
// are in sync after reconnecting.
dispatch(fetchSeries());
queryClient.invalidateQueries({ queryKey: ['/series'] });
dispatch(fetchCommands());
repopulatePage();
});
@ -355,12 +355,49 @@ function SignalRListener() {
}
if (name === 'series') {
if (version < 5) {
return;
}
if (body.action === 'updated') {
dispatch(updateItem({ section: 'series', ...body.resource }));
const updatedItem = body.resource as Series;
queryClient.setQueryData<Series[]>(
['/series'],
(oldData: Series[] | undefined) => {
if (!oldData) {
return oldData;
}
return oldData.map((item) => {
if (item.id === updatedItem.id) {
return {
...item,
...updatedItem,
};
}
return item;
});
}
);
repopulatePage('seriesUpdated');
} else if (body.action === 'deleted') {
dispatch(removeItem({ section: 'series', id: body.resource.id }));
queryClient.setQueriesData(
{ queryKey: ['/series'] },
(oldData: Series[] | undefined) => {
if (!oldData) {
return oldData;
}
return oldData.filter((item) => {
return item.id !== body.resource.id;
});
}
);
}
return;

View file

@ -15,7 +15,7 @@ import useEpisode, {
useToggleEpisodesMonitored,
} from 'Episode/useEpisode';
import Series from 'Series/Series';
import useSeries from 'Series/useSeries';
import { useSingleSeries } from 'Series/useSeries';
import translate from 'Utilities/String/translate';
import EpisodeHistory from './History/EpisodeHistory';
import EpisodeSearch from './Search/EpisodeSearch';
@ -55,7 +55,7 @@ function EpisodeDetailsModalContent({
titleSlug,
monitored: seriesMonitored,
seriesType,
} = useSeries(seriesId) as Series;
} = useSingleSeries(seriesId) as Series;
const {
episodeFileId,

View file

@ -11,7 +11,7 @@ import { useEpisodeFile } from 'EpisodeFile/EpisodeFileProvider';
import { useDeleteEpisodeFile } from 'EpisodeFile/useEpisodeFiles';
import { icons, kinds, sizes } from 'Helpers/Props';
import Series from 'Series/Series';
import useSeries from 'Series/useSeries';
import { useSingleSeries } from 'Series/useSeries';
import QualityProfileName from 'Settings/Profiles/Quality/QualityProfileName';
import translate from 'Utilities/String/translate';
import EpisodeAiring from './EpisodeAiring';
@ -80,7 +80,7 @@ function EpisodeSummary({
episodeFileId,
}: EpisodeSummaryProps) {
const queryClient = useQueryClient();
const { qualityProfileId, network } = useSeries(seriesId) as Series;
const { qualityProfileId, network } = useSingleSeries(seriesId) as Series;
const { airDateUtc, overview } = useEpisode(
episodeId,

View file

@ -22,7 +22,7 @@ export interface PropertyFilter {
}
export interface Filter {
key: string;
key: string | number;
label: string | (() => string);
filters: PropertyFilter[];
}

View file

@ -19,7 +19,7 @@ const useApiQuery = <T>(options: QueryOptions<T>) => {
const { path: path, queryOptions, queryParams, ...otherOptions } = options;
return {
queryKey: [path, queryParams],
queryKey: queryParams ? [path, queryParams] : [path],
requestOptions: {
...otherOptions,
path: getQueryPath(path) + getQueryString(queryParams),

View file

@ -3,9 +3,9 @@ import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import useCustomFilters from 'Filters/useCustomFilters';
import useSeries from 'Series/useSeries';
import { fetchTranslations } from 'Store/Actions/appActions';
import { fetchCustomFilters } from 'Store/Actions/customFilterActions';
import { fetchSeries } from 'Store/Actions/seriesActions';
import {
fetchImportLists,
fetchIndexerFlags,
@ -21,13 +21,14 @@ const createErrorsSelector = ({
customFiltersError,
systemStatusError,
tagsError,
seriesError,
}: {
customFiltersError: ApiError | null;
systemStatusError: ApiError | null;
tagsError: ApiError | null;
seriesError: ApiError | null;
}) =>
createSelector(
(state: AppState) => state.series.error,
(state: AppState) => state.settings.ui.error,
(state: AppState) => state.settings.qualityProfiles.error,
(state: AppState) => state.settings.languages.error,
@ -35,7 +36,6 @@ const createErrorsSelector = ({
(state: AppState) => state.settings.indexerFlags.error,
(state: AppState) => state.app.translations.error,
(
seriesError,
uiSettingsError,
qualityProfilesError,
languagesError,
@ -80,6 +80,8 @@ const useAppPage = () => {
const { isFetched: isCustomFiltersFetched, error: customFiltersError } =
useCustomFilters();
const { isSuccess: isSeriesFetched, error: seriesError } = useSeries();
const { isFetched: isSystemStatusFetched, error: systemStatusError } =
useSystemStatus();
@ -87,7 +89,6 @@ const useAppPage = () => {
const isAppStatePopulated = useSelector(
(state: AppState) =>
state.series.isPopulated &&
state.settings.ui.isPopulated &&
state.settings.qualityProfiles.isPopulated &&
state.settings.languages.isPopulated &&
@ -99,11 +100,17 @@ const useAppPage = () => {
const isPopulated =
isAppStatePopulated &&
isCustomFiltersFetched &&
isSeriesFetched &&
isSystemStatusFetched &&
isTagsFetched;
const { hasError, errors } = useSelector(
createErrorsSelector({ customFiltersError, systemStatusError, tagsError })
createErrorsSelector({
customFiltersError,
seriesError,
systemStatusError,
tagsError,
})
);
const isLocalStorageSupported = useMemo(() => {
@ -120,7 +127,6 @@ const useAppPage = () => {
}, []);
useEffect(() => {
dispatch(fetchSeries());
dispatch(fetchCustomFilters());
dispatch(fetchQualityProfiles());
dispatch(fetchLanguages());

View file

@ -0,0 +1,71 @@
import { useMemo, useRef } from 'react';
import { create, useStore } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
export type PendingChanged<T> = {
name: keyof T;
value: T[keyof T];
};
interface PendingChangesStore<T extends object> {
pendingChanges: Partial<T>;
}
export const usePendingChangesStore = <T extends object>(
initialPendingChanges: Partial<T>
) => {
const store = useRef(
create<PendingChangesStore<T>>()((_set) => {
return {
pendingChanges: initialPendingChanges,
};
})
);
const usePendingChanges = () => {
return useStore(
store.current,
useShallow((state) => {
return state.pendingChanges as Partial<T>;
})
);
};
const setPendingChange = <K extends keyof T>(key: K, value: T[K]) => {
store.current.setState((state) => ({
...state,
pendingChanges: {
...state.pendingChanges,
[key]: value,
},
}));
};
const setPendingChanges = (changes: Partial<T>) => {
store.current.setState((state) => ({
...state,
pendingChanges: {
...state.pendingChanges,
...changes,
},
}));
};
const discardPendingChanges = () => {
return setPendingChanges({} as Partial<T>);
};
const pendingChanges = usePendingChanges();
const hasPendingChanges = useMemo(() => {
return Object.keys(pendingChanges).length > 0;
}, [pendingChanges]);
return {
store,
pendingChanges,
setPendingChange,
discardPendingChanges,
hasPendingChanges,
};
};

View file

@ -1,12 +1,11 @@
import React, { useMemo } from 'react';
import { useSelector } from 'react-redux';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { Season } from 'Series/Series';
import { createSeriesSelectorForHook } from 'Store/Selectors/createSeriesSelector';
import { useSingleSeries } from 'Series/useSeries';
import translate from 'Utilities/String/translate';
import SelectSeasonRow from './SelectSeasonRow';
@ -19,9 +18,9 @@ interface SelectSeasonModalContentProps {
function SelectSeasonModalContent(props: SelectSeasonModalContentProps) {
const { seriesId, modalTitle, onSeasonSelect, onModalClose } = props;
const series = useSelector(createSeriesSelectorForHook(seriesId));
const series = useSingleSeries(seriesId);
const seasons = useMemo<Season[]>(() => {
return series.seasons.slice(0).reverse();
return series?.seasons.slice(0).reverse() || [];
}, [series]);
return (

View file

@ -6,7 +6,6 @@ import React, {
useRef,
useState,
} from 'react';
import { useSelector } from 'react-redux';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
import TextInput from 'Components/Form/TextInput';
import Button from 'Components/Link/Button';
@ -19,7 +18,7 @@ import Column from 'Components/Table/Column';
import VirtualTableRowButton from 'Components/Table/VirtualTableRowButton';
import { scrollDirections } from 'Helpers/Props';
import Series from 'Series/Series';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import useSeries from 'Series/useSeries';
import dimensions from 'Styles/Variables/dimensions';
import { InputChanged } from 'typings/inputs';
import sortByProp from 'Utilities/Array/sortByProp';
@ -104,7 +103,7 @@ function SelectSeriesModalContent(props: SelectSeriesModalContentProps) {
const listRef = useRef<List<RowItemData>>(null);
const scrollerRef = useRef<HTMLDivElement>(null);
const allSeries: Series[] = useSelector(createAllSeriesSelector());
const { data: allSeries = [] } = useSeries();
const [filter, setFilter] = useState('');
const [size, setSize] = useState({ width: 0, height: 0 });
const windowHeight = window.innerHeight;

View file

@ -22,9 +22,9 @@ import { ReleaseEpisode, useGrabRelease } from 'InteractiveSearch/useReleases';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import Series from 'Series/Series';
import { useSingleSeries } from 'Series/useSeries';
import { fetchDownloadClients } from 'Store/Actions/settingsActions';
import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector';
import { createSeriesSelectorForHook } from 'Store/Selectors/createSeriesSelector';
import translate from 'Utilities/String/translate';
import SelectDownloadClientModal from './DownloadClient/SelectDownloadClientModal';
import OverrideMatchData from './OverrideMatchData';
@ -81,9 +81,7 @@ function OverrideMatchModalContent(props: OverrideMatchModalContentProps) {
const previousIsGrabbing = usePrevious(isGrabbing);
const dispatch = useDispatch();
const series: Series | undefined = useSelector(
createSeriesSelectorForHook(seriesId)
);
const series: Series | undefined = useSingleSeries(seriesId);
const { items: downloadClients } = useSelector(
createEnabledDownloadClientsSelector(protocol)
);

View file

@ -15,7 +15,7 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props';
import formatSeason from 'Season/formatSeason';
import useSeries from 'Series/useSeries';
import { useSingleSeries } from 'Series/useSeries';
import { executeCommand } from 'Store/Actions/commandActions';
import { fetchOrganizePreview } from 'Store/Actions/organizePreviewActions';
import { fetchNamingSettings } from 'Store/Actions/settingsActions';
@ -60,7 +60,7 @@ function OrganizePreviewModalContentInner({
item: naming,
} = useSelector((state: AppState) => state.settings.naming);
const series = useSeries(seriesId)!;
const series = useSingleSeries(seriesId)!;
const { allSelected, allUnselected, getSelectedIds, selectAll, unselectAll } =
useSelect<OrganizePreviewModel>();

View file

@ -1,6 +1,4 @@
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
@ -13,8 +11,11 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { icons, inputTypes, kinds } from 'Helpers/Props';
import { Statistics } from 'Series/Series';
import useSeries from 'Series/useSeries';
import { deleteSeries, setDeleteOption } from 'Store/Actions/seriesActions';
import {
setSeriesDeleteOptions,
useSeriesDeleteOptions,
} from 'Series/seriesOptionsStore';
import { useDeleteSeries, useSingleSeries } from 'Series/useSeries';
import { CheckInputChanged } from 'typings/inputs';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
@ -29,16 +30,21 @@ function DeleteSeriesModalContent({
seriesId,
onModalClose,
}: DeleteSeriesModalContentProps) {
const dispatch = useDispatch();
const { title, path, statistics = {} as Statistics } = useSeries(seriesId)!;
const { addImportListExclusion } = useSelector(
(state: AppState) => state.series.deleteOptions
);
const {
title,
path,
statistics = {} as Statistics,
} = useSingleSeries(seriesId)!;
const { addImportListExclusion } = useSeriesDeleteOptions();
const { episodeFileCount = 0, sizeOnDisk = 0 } = statistics;
const [deleteFiles, setDeleteFiles] = useState(false);
const { deleteSeries } = useDeleteSeries(seriesId, {
deleteFiles,
addImportListExclusion,
});
const handleDeleteFilesChange = useCallback(
({ value }: CheckInputChanged) => {
setDeleteFiles(value);
@ -47,18 +53,16 @@ function DeleteSeriesModalContent({
);
const handleDeleteSeriesConfirmed = useCallback(() => {
dispatch(
deleteSeries({ id: seriesId, deleteFiles, addImportListExclusion })
);
deleteSeries();
onModalClose();
}, [seriesId, addImportListExclusion, deleteFiles, dispatch, onModalClose]);
}, [deleteSeries, onModalClose]);
const handleDeleteOptionChange = useCallback(
({ name, value }: CheckInputChanged) => {
dispatch(setDeleteOption({ [name]: value }));
setSeriesDeleteOptions({ [name]: value });
},
[dispatch]
[]
);
return (

View file

@ -17,7 +17,7 @@ import EpisodeFileLanguages from 'EpisodeFile/EpisodeFileLanguages';
import { useEpisodeFile } from 'EpisodeFile/EpisodeFileProvider';
import MediaInfo from 'EpisodeFile/MediaInfo';
import { icons } from 'Helpers/Props';
import useSeries from 'Series/useSeries';
import { useSingleSeries } from 'Series/useSeries';
import MediaInfoModel from 'typings/MediaInfo';
import formatBytes from 'Utilities/Number/formatBytes';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
@ -90,7 +90,7 @@ function EpisodeRow({
monitored: seriesMonitored,
seriesType,
alternateTitles = [],
} = useSeries(seriesId)!;
} = useSingleSeries(seriesId)!;
const episodeFile = useEpisodeFile(episodeFileId);
const customFormats = episodeFile?.customFormats ?? [];

View file

@ -39,11 +39,12 @@ import { Image, Statistics } from 'Series/Series';
import SeriesGenres from 'Series/SeriesGenres';
import SeriesPoster from 'Series/SeriesPoster';
import { getSeriesStatusDetails } from 'Series/SeriesStatus';
import useSeries from 'Series/useSeries';
import useSeries, {
useSingleSeries,
useToggleSeriesMonitored,
} from 'Series/useSeries';
import QualityProfileName from 'Settings/Profiles/Quality/QualityProfileName';
import { executeCommand } from 'Store/Actions/commandActions';
import { toggleSeriesMonitored } from 'Store/Actions/seriesActions';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import sortByProp from 'Utilities/Array/sortByProp';
import { findCommand, isCommandExecuting } from 'Utilities/Command';
@ -86,8 +87,10 @@ interface SeriesDetailsProps {
function SeriesDetails({ seriesId }: SeriesDetailsProps) {
const dispatch = useDispatch();
const series = useSeries(seriesId);
const allSeries = useSelector(createAllSeriesSelector());
const series = useSingleSeries(seriesId);
const { toggleSeriesMonitored, isTogglingSeriesMonitored } =
useToggleSeriesMonitored(seriesId);
const { data: allSeries } = useSeries();
const {
isFetching: isEpisodesFetching,
@ -314,14 +317,11 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
const handleMonitorTogglePress = useCallback(
(value: boolean) => {
dispatch(
toggleSeriesMonitored({
seriesId,
monitored: value,
})
);
toggleSeriesMonitored({
monitored: value,
});
},
[seriesId, dispatch]
[toggleSeriesMonitored]
);
const handleRefreshPress = useCallback(() => {
@ -389,7 +389,6 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
genres,
tags,
year,
isSaving = false,
} = series;
const {
@ -534,7 +533,7 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
<MonitorToggleButton
className={styles.monitorToggleButton}
monitored={monitored}
isSaving={isSaving}
isSaving={isTogglingSeriesMonitored}
size={40}
onPress={handleMonitorTogglePress}
/>

View file

@ -1,14 +1,13 @@
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { useHistory, useParams } from 'react-router';
import NotFound from 'Components/NotFound';
import usePrevious from 'Helpers/Hooks/usePrevious';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import useSeries from 'Series/useSeries';
import translate from 'Utilities/String/translate';
import SeriesDetails from './SeriesDetails';
function SeriesDetailsPage() {
const allSeries = useSelector(createAllSeriesSelector());
const { data: allSeries } = useSeries();
const { titleSlug } = useParams<{ titleSlug: string }>();
const history = useHistory();

View file

@ -32,8 +32,7 @@ import OrganizePreviewModal from 'Organize/OrganizePreviewModal';
import SeriesHistoryModal from 'Series/History/SeriesHistoryModal';
import SeasonInteractiveSearchModal from 'Series/Search/SeasonInteractiveSearchModal';
import { Statistics } from 'Series/Series';
import useSeries from 'Series/useSeries';
import { toggleSeasonMonitored } from 'Store/Actions/seriesActions';
import { useSingleSeries, useToggleSeasonMonitored } from 'Series/useSeries';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import { TableOptionsChangePayload } from 'typings/Table';
@ -103,7 +102,6 @@ interface SeriesDetailsSeasonProps {
monitored: boolean;
seasonNumber: number;
statistics?: Statistics;
isSaving?: boolean;
isExpanded?: boolean;
onExpandPress: (seasonNumber: number, isExpanded: boolean) => void;
}
@ -113,12 +111,11 @@ function SeriesDetailsSeason({
monitored,
seasonNumber,
statistics = {} as Statistics,
isSaving,
isExpanded,
onExpandPress,
}: SeriesDetailsSeasonProps) {
const dispatch = useDispatch();
const { monitored: seriesMonitored, path } = useSeries(seriesId)!;
const { monitored: seriesMonitored, path } = useSingleSeries(seriesId)!;
const { data: items } = useSeasonEpisodes(seriesId, seasonNumber);
const { columns, sortKey, sortDirection } = useEpisodeOptions();
@ -149,6 +146,9 @@ function SeriesDetailsSeason({
const { toggleEpisodesMonitored, isToggling, togglingEpisodeIds } =
useToggleEpisodesMonitored(getQueryKey('episodes')!);
const { toggleSeasonMonitored, isTogglingSeasonMonitored } =
useToggleSeasonMonitored(seriesId);
const lastToggledEpisode = useRef<number | null>(null);
const hasSetInitalExpand = useRef(false);
@ -159,15 +159,12 @@ function SeriesDetailsSeason({
const handleMonitorSeasonPress = useCallback(
(value: boolean) => {
dispatch(
toggleSeasonMonitored({
seriesId,
seasonNumber,
monitored: value,
})
);
toggleSeasonMonitored({
seasonNumber,
monitored: value,
});
},
[seriesId, seasonNumber, dispatch]
[seasonNumber, toggleSeasonMonitored]
);
const handleExpandPress = useCallback(() => {
@ -287,7 +284,7 @@ function SeriesDetailsSeason({
<MonitorToggleButton
monitored={monitored}
isDisabled={!seriesMonitored}
isSaving={isSaving}
isSaving={isTogglingSeasonMonitored}
size={24}
onPress={handleMonitorSeasonPress}
/>

View file

@ -1,7 +1,7 @@
import React from 'react';
import Label from 'Components/Label';
import { kinds, sizes } from 'Helpers/Props';
import useSeries from 'Series/useSeries';
import { useSingleSeries } from 'Series/useSeries';
import { useTagList } from 'Tags/useTags';
import sortByProp from 'Utilities/Array/sortByProp';
@ -10,7 +10,7 @@ interface SeriesTagsProps {
}
function SeriesTags({ seriesId }: SeriesTagsProps) {
const series = useSeries(seriesId)!;
const series = useSingleSeries(seriesId)!;
const tagList = useTagList();
const tags = series.tags

View file

@ -1,7 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import SeriesMonitorNewItemsOptionsPopoverContent from 'AddSeries/SeriesMonitorNewItemsOptionsPopoverContent';
import AppState from 'App/State/AppState';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputButton from 'Components/Form/FormInputButton';
@ -15,6 +13,7 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Popover from 'Components/Tooltip/Popover';
import { usePendingChangesStore } from 'Helpers/Hooks/usePendingChangesStore';
import usePrevious from 'Helpers/Hooks/usePrevious';
import {
icons,
@ -24,8 +23,8 @@ import {
tooltipPositions,
} from 'Helpers/Props';
import MoveSeriesModal from 'Series/MoveSeries/MoveSeriesModal';
import useSeries from 'Series/useSeries';
import { saveSeries, setSeriesValue } from 'Store/Actions/seriesActions';
import Series from 'Series/Series';
import { useSaveSeries, useSingleSeries } from 'Series/useSeries';
import selectSettings from 'Store/Selectors/selectSettings';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
@ -43,7 +42,8 @@ function EditSeriesModalContent({
onModalClose,
onDeleteSeriesPress,
}: EditSeriesModalContentProps) {
const dispatch = useDispatch();
const series = useSingleSeries(seriesId)!;
const {
title,
monitored,
@ -54,22 +54,22 @@ function EditSeriesModalContent({
path,
tags,
rootFolderPath: initialRootFolderPath,
} = useSeries(seriesId)!;
} = series;
const { isSaving, saveError, pendingChanges } = useSelector(
(state: AppState) => state.series
const { pendingChanges, setPendingChange } = usePendingChangesStore<Series>(
{}
);
const wasSaving = usePrevious(isSaving);
const [isRootFolderModalOpen, setIsRootFolderModalOpen] = useState(false);
const [rootFolderPath, setRootFolderPath] = useState(initialRootFolderPath);
const isPathChanging = pendingChanges.path && path !== pendingChanges.path;
const isPathChanging = !!(
pendingChanges.path && path !== pendingChanges.path
);
const [isConfirmMoveModalOpen, setIsConfirmMoveModalOpen] = useState(false);
const { saveSeries, isSaving, saveError } = useSaveSeries(isPathChanging);
const wasSaving = usePrevious(isSaving);
const { settings, ...otherSettings } = useMemo(() => {
return selectSettings(
{
@ -98,10 +98,10 @@ function EditSeriesModalContent({
const handleInputChange = useCallback(
({ name, value }: InputChanged) => {
// @ts-expect-error actions aren't typed
dispatch(setSeriesValue({ name, value }));
// @ts-expect-error name needs to be keyof Series
setPendingChange(name, value);
},
[dispatch]
[setPendingChange]
);
const handleRootFolderPress = useCallback(() => {
@ -134,25 +134,27 @@ function EditSeriesModalContent({
} else {
setIsConfirmMoveModalOpen(false);
dispatch(
saveSeries({
id: seriesId,
moveFiles: false,
})
);
saveSeries({
...series,
...pendingChanges,
});
}
}, [seriesId, isPathChanging, isConfirmMoveModalOpen, dispatch]);
}, [
series,
isPathChanging,
isConfirmMoveModalOpen,
pendingChanges,
saveSeries,
]);
const handleMoveSeriesPress = useCallback(() => {
setIsConfirmMoveModalOpen(false);
dispatch(
saveSeries({
id: seriesId,
moveFiles: true,
})
);
}, [seriesId, dispatch]);
saveSeries({
...series,
...pendingChanges,
});
}, [series, pendingChanges, saveSeries]);
useEffect(() => {
if (!isSaving && wasSaving && !saveError) {

View file

@ -17,7 +17,7 @@ import useEpisode from 'Episode/useEpisode';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import useSeries from 'Series/useSeries';
import { useSingleSeries } from 'Series/useSeries';
import CustomFormat from 'typings/CustomFormat';
import { HistoryData, HistoryEventType } from 'typings/History';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
@ -59,7 +59,7 @@ function SeriesHistoryRow({
customFormatScore,
onMarkAsFailedPress,
}: SeriesHistoryRowProps) {
const series = useSeries(seriesId);
const series = useSingleSeries(seriesId);
const episode = useEpisode(episodeId, 'episodes');
const [isMarkAsFailedModalOpen, setIsMarkAsFailedModalOpen] = useState(false);

View file

@ -1,5 +1,4 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
@ -11,9 +10,11 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import { setSeriesOverviewOption } from 'Store/Actions/seriesIndexActions';
import {
setSeriesOverviewOptions,
useSeriesOverviewOptions,
} from 'Series/seriesOptionsStore';
import translate from 'Utilities/String/translate';
import selectOverviewOptions from '../selectOverviewOptions';
const posterSizeOptions: EnhancedSelectInputValue<string>[] = [
{
@ -40,11 +41,9 @@ interface SeriesIndexOverviewOptionsModalContentProps {
onModalClose(...args: unknown[]): void;
}
function SeriesIndexOverviewOptionsModalContent(
props: SeriesIndexOverviewOptionsModalContentProps
) {
const { onModalClose } = props;
function SeriesIndexOverviewOptionsModalContent({
onModalClose,
}: SeriesIndexOverviewOptionsModalContentProps) {
const {
detailedProgressBar,
size,
@ -58,15 +57,13 @@ function SeriesIndexOverviewOptionsModalContent(
showSizeOnDisk,
showTags,
showSearchAction,
} = useSelector(selectOverviewOptions);
const dispatch = useDispatch();
} = useSeriesOverviewOptions();
const onOverviewOptionChange = useCallback(
({ name, value }: { name: string; value: unknown }) => {
dispatch(setSeriesOverviewOption({ [name]: value }));
setSeriesOverviewOptions({ [name]: value });
},
[dispatch]
[]
);
return (

View file

@ -1,6 +1,6 @@
import classNames from 'classnames';
import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import TextTruncate from 'react-text-truncate';
import { REFRESH_SERIES, SERIES_SEARCH } from 'Commands/commandNames';
import IconButton from 'Components/Link/IconButton';
@ -13,13 +13,13 @@ import EditSeriesModal from 'Series/Edit/EditSeriesModal';
import SeriesIndexProgressBar from 'Series/Index/ProgressBar/SeriesIndexProgressBar';
import SeriesIndexPosterSelect from 'Series/Index/Select/SeriesIndexPosterSelect';
import { Statistics } from 'Series/Series';
import { useSeriesOverviewOptions } from 'Series/seriesOptionsStore';
import SeriesPoster from 'Series/SeriesPoster';
import { executeCommand } from 'Store/Actions/commandActions';
import dimensions from 'Styles/Variables/dimensions';
import fonts from 'Styles/Variables/fonts';
import translate from 'Utilities/String/translate';
import createSeriesIndexItemSelector from '../createSeriesIndexItemSelector';
import selectOverviewOptions from './selectOverviewOptions';
import useSeriesIndexItem from '../useSeriesIndexItem';
import SeriesIndexOverviewInfo from './SeriesIndexOverviewInfo';
import styles from './SeriesIndexOverview.css';
@ -56,9 +56,9 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) {
} = props;
const { series, qualityProfile, isRefreshingSeries, isSearchingSeries } =
useSelector(createSeriesIndexItemSelector(props.seriesId));
useSeriesIndexItem(seriesId);
const overviewOptions = useSelector(selectOverviewOptions);
const overviewOptions = useSeriesOverviewOptions();
const {
title,

View file

@ -1,12 +1,11 @@
import { throttle } from 'lodash';
import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
import useMeasure from 'Helpers/Hooks/useMeasure';
import Series from 'Series/Series';
import { useSeriesOverviewOptions } from 'Series/seriesOptionsStore';
import dimensions from 'Styles/Variables/dimensions';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import selectOverviewOptions from './selectOverviewOptions';
import SeriesIndexOverview from './SeriesIndexOverview';
// Poster container dimensions
@ -72,9 +71,7 @@ function SeriesIndexOverviews(props: SeriesIndexOverviewsProps) {
isSmallScreen,
} = props;
const { size: posterSize, detailedProgressBar } = useSelector(
selectOverviewOptions
);
const { size: posterSize, detailedProgressBar } = useSeriesOverviewOptions();
const listRef = useRef<List>(null);
const [measureRef, bounds] = useMeasure();
const [size, setSize] = useState({ width: 0, height: 0 });

View file

@ -1,9 +0,0 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
const selectOverviewOptions = createSelector(
(state: AppState) => state.seriesIndex.overviewOptions,
(overviewOptions) => overviewOptions
);
export default selectOverviewOptions;

View file

@ -1,5 +1,4 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
@ -11,8 +10,10 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import selectPosterOptions from 'Series/Index/Posters/selectPosterOptions';
import { setSeriesPosterOption } from 'Store/Actions/seriesIndexActions';
import {
setSeriesPosterOptions,
useSeriesPosterOptions,
} from 'Series/seriesOptionsStore';
import translate from 'Utilities/String/translate';
const posterSizeOptions: EnhancedSelectInputValue<string>[] = [
@ -40,13 +41,9 @@ interface SeriesIndexPosterOptionsModalContentProps {
onModalClose(...args: unknown[]): unknown;
}
function SeriesIndexPosterOptionsModalContent(
props: SeriesIndexPosterOptionsModalContentProps
) {
const { onModalClose } = props;
const posterOptions = useSelector(selectPosterOptions);
function SeriesIndexPosterOptionsModalContent({
onModalClose,
}: SeriesIndexPosterOptionsModalContentProps) {
const {
detailedProgressBar,
size,
@ -55,15 +52,13 @@ function SeriesIndexPosterOptionsModalContent(
showQualityProfile,
showTags,
showSearchAction,
} = posterOptions;
const dispatch = useDispatch();
} = useSeriesPosterOptions();
const onPosterOptionChange = useCallback(
({ name, value }: { name: string; value: unknown }) => {
dispatch(setSeriesPosterOption({ [name]: value }));
setSeriesPosterOptions({ [name]: value });
},
[dispatch]
[]
);
return (

View file

@ -13,14 +13,14 @@ import EditSeriesModal from 'Series/Edit/EditSeriesModal';
import SeriesIndexProgressBar from 'Series/Index/ProgressBar/SeriesIndexProgressBar';
import SeriesIndexPosterSelect from 'Series/Index/Select/SeriesIndexPosterSelect';
import { Statistics } from 'Series/Series';
import { useSeriesPosterOptions } from 'Series/seriesOptionsStore';
import SeriesPoster from 'Series/SeriesPoster';
import { executeCommand } from 'Store/Actions/commandActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatDateTime from 'Utilities/Date/formatDateTime';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import translate from 'Utilities/String/translate';
import createSeriesIndexItemSelector from '../createSeriesIndexItemSelector';
import selectPosterOptions from './selectPosterOptions';
import useSeriesIndexItem from '../useSeriesIndexItem';
import SeriesIndexPosterInfo from './SeriesIndexPosterInfo';
import styles from './SeriesIndexPoster.css';
@ -36,7 +36,7 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
const { seriesId, sortKey, isSelectMode, posterWidth, posterHeight } = props;
const { series, qualityProfile, isRefreshingSeries, isSearchingSeries } =
useSelector(createSeriesIndexItemSelector(props.seriesId));
useSeriesIndexItem(seriesId);
const {
detailedProgressBar,
@ -45,7 +45,7 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
showQualityProfile,
showTags,
showSearchAction,
} = useSelector(selectPosterOptions);
} = useSeriesPosterOptions();
const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } =
useSelector(createUISettingsSelector());

View file

@ -1,13 +1,11 @@
import { throttle } from 'lodash';
import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { FixedSizeGrid as Grid, GridChildComponentProps } from 'react-window';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import useMeasure from 'Helpers/Hooks/useMeasure';
import { SortDirection } from 'Helpers/Props/sortDirections';
import SeriesIndexPoster from 'Series/Index/Posters/SeriesIndexPoster';
import Series from 'Series/Series';
import { useSeriesPosterOptions } from 'Series/seriesOptionsStore';
import dimensions from 'Styles/Variables/dimensions';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
@ -51,15 +49,6 @@ interface SeriesIndexPostersProps {
isSmallScreen: boolean;
}
const seriesIndexSelector = createSelector(
(state: AppState) => state.seriesIndex.posterOptions,
(posterOptions) => {
return {
posterOptions,
};
}
);
function Cell({
columnIndex,
rowIndex,
@ -98,17 +87,22 @@ function getWindowScrollTopPosition() {
return document.documentElement.scrollTop || document.body.scrollTop || 0;
}
export default function SeriesIndexPosters(props: SeriesIndexPostersProps) {
export default function SeriesIndexPosters({
scrollerRef,
items,
sortKey,
jumpToCharacter,
isSelectMode,
isSmallScreen,
}: SeriesIndexPostersProps) {
const {
scrollerRef,
items,
sortKey,
jumpToCharacter,
isSelectMode,
isSmallScreen,
} = props;
const { posterOptions } = useSelector(seriesIndexSelector);
detailedProgressBar,
showTitle,
showMonitored,
showQualityProfile,
showTags,
size: posterSize,
} = useSeriesPosterOptions();
const ref = useRef<Grid>(null);
const [measureRef, bounds] = useMeasure();
const [size, setSize] = useState({ width: 0, height: 0 });
@ -120,30 +114,18 @@ export default function SeriesIndexPosters(props: SeriesIndexPostersProps) {
const remainder = width % maximumColumnWidth;
return remainder === 0
? maximumColumnWidth
: Math.floor(
width / (columns + ADDITIONAL_COLUMN_COUNT[posterOptions.size])
);
}, [isSmallScreen, posterOptions, size]);
: Math.floor(width / (columns + ADDITIONAL_COLUMN_COUNT[posterSize]));
}, [isSmallScreen, posterSize, size]);
const columnCount = useMemo(
() => Math.max(Math.floor(size.width / columnWidth), 1),
[size, columnWidth]
);
const padding = props.isSmallScreen
? columnPaddingSmallScreen
: columnPadding;
const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding;
const posterWidth = columnWidth - padding * 2;
const posterHeight = Math.ceil((250 / 170) * posterWidth);
const rowHeight = useMemo(() => {
const {
detailedProgressBar,
showTitle,
showMonitored,
showQualityProfile,
showTags,
} = posterOptions;
const nextAiringHeight = 19;
const heights = [
@ -193,7 +175,16 @@ export default function SeriesIndexPosters(props: SeriesIndexPostersProps) {
}
return heights.reduce((acc, height) => acc + height, 0);
}, [isSmallScreen, posterOptions, sortKey, posterHeight]);
}, [
isSmallScreen,
detailedProgressBar,
showTitle,
showMonitored,
showQualityProfile,
showTags,
sortKey,
posterHeight,
]);
useEffect(() => {
const current = scrollerRef.current;

View file

@ -1,9 +0,0 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
const selectPosterOptions = createSelector(
(state: AppState) => state.seriesIndex.posterOptions,
(posterOptions) => posterOptions
);
export default selectPosterOptions;

View file

@ -1,9 +1,6 @@
import { orderBy } from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { useSelect } from 'App/Select/SelectContext';
import AppState from 'App/State/AppState';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
@ -14,8 +11,11 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
import Series from 'Series/Series';
import { bulkDeleteSeries, setDeleteOption } from 'Store/Actions/seriesActions';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import {
setSeriesDeleteOptions,
useSeriesDeleteOptions,
} from 'Series/seriesOptionsStore';
import useSeries, { useBulkDeleteSeries } from 'Series/useSeries';
import { InputChanged } from 'typings/inputs';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
@ -25,17 +25,12 @@ export interface DeleteSeriesModalContentProps {
onModalClose(): void;
}
const selectDeleteOptions = createSelector(
(state: AppState) => state.series.deleteOptions,
(deleteOptions) => deleteOptions
);
function DeleteSeriesModalContent({
onModalClose,
}: DeleteSeriesModalContentProps) {
const { addImportListExclusion } = useSelector(selectDeleteOptions);
const allSeries: Series[] = useSelector(createAllSeriesSelector());
const dispatch = useDispatch();
const { addImportListExclusion } = useSeriesDeleteOptions();
const { data: allSeries } = useSeries();
const { bulkDeleteSeries } = useBulkDeleteSeries();
const [deleteFiles, setDeleteFiles] = useState(false);
const { useSelectedIds } = useSelect<Series>();
const seriesIds = useSelectedIds();
@ -57,25 +52,21 @@ function DeleteSeriesModalContent({
const onDeleteOptionChange = useCallback(
({ name, value }: { name: string; value: boolean }) => {
dispatch(
setDeleteOption({
[name]: value,
})
);
setSeriesDeleteOptions({
[name]: value,
});
},
[dispatch]
[]
);
const onDeleteSeriesConfirmed = useCallback(() => {
setDeleteFiles(false);
dispatch(
bulkDeleteSeries({
seriesIds,
deleteFiles,
addImportListExclusion,
})
);
bulkDeleteSeries({
seriesIds,
deleteFiles,
addImportListExclusion,
});
onModalClose();
}, [
@ -83,7 +74,7 @@ function DeleteSeriesModalContent({
addImportListExclusion,
setDeleteFiles,
seriesIds,
dispatch,
bulkDeleteSeries,
onModalClose,
]);

View file

@ -1,6 +1,6 @@
import { orderBy } from 'lodash';
import React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useSelect } from 'App/Select/SelectContext';
import { RENAME_SERIES } from 'Commands/commandNames';
import Alert from 'Components/Alert';
@ -12,8 +12,8 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { icons, kinds } from 'Helpers/Props';
import Series from 'Series/Series';
import useSeries from 'Series/useSeries';
import { executeCommand } from 'Store/Actions/commandActions';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import translate from 'Utilities/String/translate';
import styles from './OrganizeSeriesModalContent.css';
@ -24,7 +24,7 @@ export interface OrganizeSeriesModalContentProps {
function OrganizeSeriesModalContent({
onModalClose,
}: OrganizeSeriesModalContentProps) {
const allSeries: Series[] = useSelector(createAllSeriesSelector());
const { data: allSeries } = useSeries();
const dispatch = useDispatch();
const { useSelectedIds } = useSelect<Series>();
const seriesIds = useSelectedIds();

View file

@ -19,12 +19,7 @@ function SeasonDetails(props: SeasonDetailsProps) {
return (
<div className={styles.seasons}>
{latestSeasons.map((season) => {
const {
seasonNumber,
monitored,
statistics,
isSaving = false,
} = season;
const { seasonNumber, monitored, statistics } = season;
return (
<SeasonPassSeason
@ -33,7 +28,6 @@ function SeasonDetails(props: SeasonDetailsProps) {
seasonNumber={seasonNumber}
monitored={monitored}
statistics={statistics}
isSaving={isSaving}
/>
);
})}

View file

@ -1,10 +1,9 @@
import classNames from 'classnames';
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import formatSeason from 'Season/formatSeason';
import { Statistics } from 'Series/Series';
import { toggleSeasonMonitored } from 'Store/Actions/seriesActions';
import { useToggleSeasonMonitored } from 'Series/useSeries';
import translate from 'Utilities/String/translate';
import styles from './SeasonPassSeason.css';
@ -13,7 +12,6 @@ interface SeasonPassSeasonProps {
seasonNumber: number;
monitored: boolean;
statistics: Statistics;
isSaving: boolean;
}
function SeasonPassSeason(props: SeasonPassSeasonProps) {
@ -26,24 +24,22 @@ function SeasonPassSeason(props: SeasonPassSeasonProps) {
totalEpisodeCount: 0,
percentOfEpisodes: 0,
},
isSaving = false,
} = props;
const { episodeFileCount, totalEpisodeCount, percentOfEpisodes } = statistics;
const dispatch = useDispatch();
const { toggleSeasonMonitored, isTogglingSeasonMonitored } =
useToggleSeasonMonitored(seriesId);
const onSeasonMonitoredPress = useCallback(() => {
dispatch(
toggleSeasonMonitored({ seriesId, seasonNumber, monitored: !monitored })
);
}, [seriesId, seasonNumber, monitored, dispatch]);
toggleSeasonMonitored({ seasonNumber, monitored: !monitored });
}, [seasonNumber, monitored, toggleSeasonMonitored]);
return (
<div className={styles.season}>
<div className={styles.info}>
<MonitorToggleButton
monitored={monitored}
isSaving={isSaving}
isSaving={isTogglingSeasonMonitored}
onPress={onSeasonMonitoredPress}
/>

View file

@ -1,8 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { useSelector } from 'react-redux';
import { useSelect } from 'App/Select/SelectContext';
import AppState from 'App/State/AppState';
import { RENAME_SERIES } from 'Commands/commandNames';
import SpinnerButton from 'Components/Link/SpinnerButton';
import PageContentFooter from 'Components/Page/PageContentFooter';
@ -10,9 +8,10 @@ import usePrevious from 'Helpers/Hooks/usePrevious';
import { kinds } from 'Helpers/Props';
import Series from 'Series/Series';
import {
saveSeriesEditor,
updateSeriesMonitor,
} from 'Store/Actions/seriesActions';
useBulkDeleteSeries,
useSaveSeriesEditor,
useUpdateSeriesMonitor,
} from 'Series/useSeries';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import translate from 'Utilities/String/translate';
import DeleteSeriesModal from './Delete/DeleteSeriesModal';
@ -31,28 +30,19 @@ interface SavePayload {
moveFiles?: boolean;
}
const seriesEditorSelector = createSelector(
(state: AppState) => state.series,
(series) => {
const { isSaving, isDeleting, deleteError } = series;
return {
isSaving,
isDeleting,
deleteError,
};
}
);
function SeriesIndexSelectFooter() {
const { isSaving, isDeleting, deleteError } =
useSelector(seriesEditorSelector);
const { saveSeriesEditor, isSavingSeriesEditor } = useSaveSeriesEditor();
const { updateSeriesMonitor, isUpdatingSeriesMonitor } =
useUpdateSeriesMonitor();
const { isBulkDeleting, bulkDeleteError } = useBulkDeleteSeries();
const isOrganizingSeries = useSelector(
createCommandExecutingSelector(RENAME_SERIES)
);
const dispatch = useDispatch();
const isSaving = isSavingSeriesEditor || isUpdatingSeriesMonitor;
const isDeleting = isBulkDeleting;
const deleteError = bulkDeleteError;
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isOrganizeModalOpen, setIsOrganizeModalOpen] = useState(false);
@ -79,14 +69,12 @@ function SeriesIndexSelectFooter() {
setIsSavingSeries(true);
setIsEditModalOpen(false);
dispatch(
saveSeriesEditor({
...payload,
seriesIds,
})
);
saveSeriesEditor({
...payload,
seriesIds,
});
},
[seriesIds, dispatch]
[seriesIds, saveSeriesEditor]
);
const onOrganizePress = useCallback(() => {
@ -106,19 +94,16 @@ function SeriesIndexSelectFooter() {
}, [setIsTagsModalOpen]);
const onApplyTagsPress = useCallback(
(tags: number[], applyTags: string) => {
(tags: number[], _applyTags: string) => {
setIsSavingTags(true);
setIsTagsModalOpen(false);
dispatch(
saveSeriesEditor({
seriesIds,
tags,
applyTags,
})
);
saveSeriesEditor({
seriesIds,
tags,
});
},
[seriesIds, dispatch]
[seriesIds, saveSeriesEditor]
);
const onMonitoringPress = useCallback(() => {
@ -134,14 +119,12 @@ function SeriesIndexSelectFooter() {
setIsSavingMonitoring(true);
setIsMonitoringModalOpen(false);
dispatch(
updateSeriesMonitor({
seriesIds,
monitor,
})
);
updateSeriesMonitor({
series: seriesIds.map((id) => ({ id })),
monitoringOptions: { monitor },
});
},
[seriesIds, dispatch]
[seriesIds, updateSeriesMonitor]
);
const onDeletePress = useCallback(() => {

View file

@ -1,6 +1,5 @@
import { uniq } from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { useSelect } from 'App/Select/SelectContext';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
@ -15,7 +14,7 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import Series from 'Series/Series';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import { useMultipleSeries } from 'Series/useSeries';
import { Tag, useTagList } from 'Tags/useTags';
import translate from 'Utilities/String/translate';
import styles from './TagsModalContent.css';
@ -29,27 +28,24 @@ function TagsModalContent({
onModalClose,
onApplyTagsPress,
}: TagsModalContentProps) {
const allSeries: Series[] = useSelector(createAllSeriesSelector());
const tagList: Tag[] = useTagList();
const [tags, setTags] = useState<number[]>([]);
const [applyTags, setApplyTags] = useState('add');
const { useSelectedIds } = useSelect<Series>();
const seriesIds = useSelectedIds();
const selectedSeries = useMultipleSeries(seriesIds);
const seriesTags = useMemo(() => {
const tags = seriesIds.reduce((acc: number[], id) => {
const s = allSeries.find((s) => s.id === id);
if (s) {
acc.push(...s.tags);
const tags = selectedSeries.reduce((acc: number[], series) => {
if (series) {
acc.push(...series.tags);
}
return acc;
}, []);
return uniq(tags);
}, [allSeries, seriesIds]);
}, [selectedSeries]);
const onTagsChange = useCallback(
({ value }: { value: number[] }) => {

View file

@ -1,15 +1,7 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider';
import { SelectProvider } from 'App/Select/SelectContext';
import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState';
import SeriesAppState, { SeriesIndexAppState } from 'App/State/SeriesAppState';
import { RSS_SYNC } from 'Commands/commandNames';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@ -27,18 +19,17 @@ import { align, icons, kinds } from 'Helpers/Props';
import { DESCENDING } from 'Helpers/Props/sortDirections';
import ParseToolbarButton from 'Parse/ParseToolbarButton';
import NoSeries from 'Series/NoSeries';
import { executeCommand } from 'Store/Actions/commandActions';
import { fetchSeries } from 'Store/Actions/seriesActions';
import {
setSeriesFilter,
setSeriesOption,
setSeriesSort,
setSeriesTableOption,
setSeriesView,
} from 'Store/Actions/seriesIndexActions';
setSeriesTableOptions,
useSeriesOptions,
} from 'Series/seriesOptionsStore';
import { FILTERS, useSeriesIndex } from 'Series/useSeries';
import { executeCommand } from 'Store/Actions/commandActions';
import scrollPositions from 'Store/scrollPositions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createSeriesClientSideCollectionItemsSelector from 'Store/Selectors/createSeriesClientSideCollectionItemsSelector';
import translate from 'Utilities/String/translate';
import SeriesIndexFilterMenu from './Menus/SeriesIndexFilterMenu';
import SeriesIndexSortMenu from './Menus/SeriesIndexSortMenu';
@ -76,19 +67,16 @@ interface SeriesIndexProps {
const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
const {
isFetching,
isPopulated,
error,
isLoading: isFetching,
isFetched,
isError: error,
data,
totalItems,
items,
columns,
selectedFilterKey,
filters,
sortKey,
sortDirection,
view,
}: SeriesAppState & SeriesIndexAppState & ClientSideCollectionAppState =
useSelector(createSeriesClientSideCollectionItemsSelector('seriesIndex'));
} = useSeriesIndex();
const { selectedFilterKey, sortKey, sortDirection, view, columns } =
useSeriesOptions();
const filters = FILTERS;
const customFilters = useCustomFiltersList('series');
@ -104,10 +92,6 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
);
const [isSelectMode, setIsSelectMode] = useState(false);
useEffect(() => {
dispatch(fetchSeries());
}, [dispatch]);
const onRssSyncPress = useCallback(() => {
dispatch(
executeCommand({
@ -120,37 +104,33 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
setIsSelectMode(!isSelectMode);
}, [isSelectMode, setIsSelectMode]);
const onTableOptionChange = useCallback(
(payload: unknown) => {
dispatch(setSeriesTableOption(payload));
},
[dispatch]
);
const onTableOptionChange = useCallback((payload: unknown) => {
setSeriesTableOptions(
payload as Partial<{ showBanners: boolean; showSearchAction: boolean }>
);
}, []);
const onViewSelect = useCallback(
(value: string) => {
dispatch(setSeriesView({ view: value }));
setSeriesOption('view', value);
if (scrollerRef.current) {
scrollerRef.current.scrollTo(0, 0);
}
},
[scrollerRef, dispatch]
[scrollerRef]
);
const onSortSelect = useCallback(
(value: string) => {
dispatch(setSeriesSort({ sortKey: value }));
setSeriesSort({ sortKey: value, sortDirection });
},
[dispatch]
[sortDirection]
);
const onFilterSelect = useCallback(
(value: string | number) => {
dispatch(setSeriesFilter({ selectedFilterKey: value }));
},
[dispatch]
);
const onFilterSelect = useCallback((value: string | number) => {
setSeriesOption('selectedFilterKey', value);
}, []);
const onOptionsPress = useCallback(() => {
setIsOptionsModalOpen(true);
@ -184,7 +164,7 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
};
}
const characters = items.reduce((acc: Record<string, number>, item) => {
const characters = data.reduce((acc: Record<string, number>, item) => {
let char = item.sortTitle.charAt(0);
if (!isNaN(Number(char))) {
@ -211,15 +191,15 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
characters,
order,
};
}, [items, sortKey, sortDirection]);
}, [data, sortKey, sortDirection]);
const ViewComponent = useMemo(() => getViewComponent(view), [view]);
const isLoaded = !!(!error && isPopulated && items.length);
const isLoaded = !!(!error && isFetched && data.length);
const hasNoSeries = !totalItems;
return (
<QueueDetailsProvider all={true}>
<SelectProvider items={items}>
<SelectProvider items={data}>
<PageContent>
<PageToolbar>
<PageToolbarSection>
@ -318,7 +298,7 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
initialScrollTop={props.initialScrollTop}
onScroll={onScroll}
>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{isFetching && !isFetched ? <LoadingIndicator /> : null}
{!isFetching && !!error ? (
<Alert kind={kinds.DANGER}>
@ -330,7 +310,7 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
<div className={styles.contentBodyContainer}>
<ViewComponent
scrollerRef={scrollerRef}
items={items}
items={data}
sortKey={sortKey}
sortDirection={sortDirection}
jumpToCharacter={jumpToCharacter}
@ -342,7 +322,7 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
</div>
) : null}
{!error && isPopulated && !items.length ? (
{!error && isFetched && !data.length ? (
<NoSeries totalItems={totalItems} />
) : null}
</PageContentBody>

View file

@ -1,51 +1,28 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
import Series from 'Series/Series';
import { setSeriesFilter } from 'Store/Actions/seriesIndexActions';
function createSeriesSelector() {
return createSelector(
(state: AppState) => state.series.items,
(series) => {
return series;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.seriesIndex.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
import { setSeriesOption } from 'Series/seriesOptionsStore';
import useSeries, { FILTER_BUILDER } from 'Series/useSeries';
type SeriesIndexFilterModalProps = FilterModalProps<Series>;
export default function SeriesIndexFilterModal(
props: SeriesIndexFilterModalProps
) {
const sectionItems = useSelector(createSeriesSelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const dispatch = useDispatch();
const { data: sectionItems } = useSeries();
const dispatchSetFilter = useCallback(
(payload: unknown) => {
dispatch(setSeriesFilter(payload));
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
setSeriesOption('selectedFilterKey', selectedFilterKey);
},
[dispatch]
[]
);
return (
<FilterModal
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
filterBuilderProps={FILTER_BUILDER}
customFilterType="series"
dispatchSetFilter={dispatchSetFilter}
/>

View file

@ -1,43 +1,15 @@
import classNames from 'classnames';
import React from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { ColorImpairedConsumer } from 'App/ColorImpairedContext';
import SeriesAppState from 'App/State/SeriesAppState';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector';
import useSeries from 'Series/useSeries';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import styles from './SeriesIndexFooter.css';
function createUnoptimizedSelector() {
return createSelector(
createClientSideCollectionSelector('series', 'seriesIndex'),
(series: SeriesAppState) => {
return series.items.map((s) => {
const { monitored, status, statistics } = s;
return {
monitored,
status,
statistics,
};
});
}
);
}
function createSeriesSelector() {
return createDeepEqualSelector(
createUnoptimizedSelector(),
(series) => series
);
}
export default function SeriesIndexFooter() {
const series = useSelector(createSeriesSelector());
const { data: series } = useSeries();
const count = series.length;
let episodes = 0;
let episodeFiles = 0;

View file

@ -1,19 +1,17 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useSelect } from 'App/Select/SelectContext';
import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState';
import SeriesAppState, { SeriesIndexAppState } from 'App/State/SeriesAppState';
import { REFRESH_SERIES } from 'Commands/commandNames';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import { icons } from 'Helpers/Props';
import { useSeriesIndex } from 'Series/useSeries';
import { executeCommand } from 'Store/Actions/commandActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createSeriesClientSideCollectionItemsSelector from 'Store/Selectors/createSeriesClientSideCollectionItemsSelector';
import translate from 'Utilities/String/translate';
interface SeriesIndexRefreshSeriesButtonProps {
isSelectMode: boolean;
selectedFilterKey: string;
selectedFilterKey: string | number;
}
function SeriesIndexRefreshSeriesButton(
@ -22,11 +20,7 @@ function SeriesIndexRefreshSeriesButton(
const isRefreshing = useSelector(
createCommandExecutingSelector(REFRESH_SERIES)
);
const {
items,
totalItems,
}: SeriesAppState & SeriesIndexAppState & ClientSideCollectionAppState =
useSelector(createSeriesClientSideCollectionItemsSelector('seriesIndex'));
const { data, totalItems } = useSeriesIndex();
const dispatch = useDispatch();
const { isSelectMode, selectedFilterKey } = props;
@ -42,7 +36,7 @@ function SeriesIndexRefreshSeriesButton(
const onPress = useCallback(() => {
const seriesToRefresh =
isSelectMode && anySelected ? getSelectedIds() : items.map((m) => m.id);
isSelectMode && anySelected ? getSelectedIds() : data.map((m) => m.id);
dispatch(
executeCommand({
@ -50,7 +44,7 @@ function SeriesIndexRefreshSeriesButton(
seriesIds: seriesToRefresh,
})
);
}, [dispatch, anySelected, isSelectMode, items, getSelectedIds]);
}, [dispatch, anySelected, isSelectMode, data, getSelectedIds]);
return (
<PageToolbarButton

View file

@ -1,6 +1,6 @@
import classNames from 'classnames';
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useSelect } from 'App/Select/SelectContext';
import { REFRESH_SERIES, SERIES_SEARCH } from 'Commands/commandNames';
import CheckInput from 'Components/Form/CheckInput';
@ -16,9 +16,9 @@ import Column from 'Components/Table/Column';
import { icons } from 'Helpers/Props';
import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
import EditSeriesModal from 'Series/Edit/EditSeriesModal';
import createSeriesIndexItemSelector from 'Series/Index/createSeriesIndexItemSelector';
import { Statistics } from 'Series/Series';
import SeriesBanner from 'Series/SeriesBanner';
import { useSeriesTableOptions } from 'Series/seriesOptionsStore';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import { executeCommand } from 'Store/Actions/commandActions';
import { SelectStateInputProps } from 'typings/props';
@ -26,9 +26,9 @@ import formatBytes from 'Utilities/Number/formatBytes';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import SeriesIndexProgressBar from '../ProgressBar/SeriesIndexProgressBar';
import useSeriesIndexItem from '../useSeriesIndexItem';
import hasGrowableColumns from './hasGrowableColumns';
import SeasonsCell from './SeasonsCell';
import selectTableOptions from './selectTableOptions';
import SeriesStatusCell from './SeriesStatusCell';
import styles from './SeriesIndexRow.css';
@ -48,9 +48,9 @@ function SeriesIndexRow(props: SeriesIndexRowProps) {
latestSeason,
isRefreshingSeries,
isSearchingSeries,
} = useSelector(createSeriesIndexItemSelector(props.seriesId));
} = useSeriesIndexItem(seriesId);
const { showBanners, showSearchAction } = useSelector(selectTableOptions);
const { showBanners, showSearchAction } = useSeriesTableOptions();
const {
title,
@ -75,7 +75,6 @@ function SeriesIndexRow(props: SeriesIndexRowProps) {
ratings,
seasons = [],
tags = [],
isSaving = false,
} = series;
const {
@ -178,7 +177,6 @@ function SeriesIndexRow(props: SeriesIndexRowProps) {
monitored={monitored}
status={status}
isSelectMode={isSelectMode}
isSaving={isSaving}
component={VirtualTableRowCell}
/>
);

View file

@ -1,14 +1,14 @@
import React, { RefObject, useEffect, useMemo, useRef } from 'react';
import { useSelector } from 'react-redux';
import { FixedSizeList, ListChildComponentProps } from 'react-window';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Column from 'Components/Table/Column';
import VirtualTable from 'Components/Table/VirtualTable';
import { SortDirection } from 'Helpers/Props/sortDirections';
import Series from 'Series/Series';
import {
useSeriesOption,
useSeriesTableOptions,
} from 'Series/seriesOptionsStore';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import selectTableOptions from './selectTableOptions';
import SeriesIndexRow from './SeriesIndexRow';
import SeriesIndexTableHeader from './SeriesIndexTableHeader';
import styles from './SeriesIndexTable.css';
@ -31,11 +31,6 @@ interface SeriesIndexTableProps {
isSmallScreen: boolean;
}
const columnsSelector = createSelector(
(state: AppState) => state.seriesIndex.columns,
(columns) => columns
);
function Row({ index, style, data }: ListChildComponentProps<RowItemData>) {
const { items, sortKey, columns, isSelectMode } = data;
@ -64,19 +59,17 @@ function Row({ index, style, data }: ListChildComponentProps<RowItemData>) {
);
}
function SeriesIndexTable(props: SeriesIndexTableProps) {
const {
items,
sortKey,
sortDirection,
jumpToCharacter,
isSelectMode,
isSmallScreen,
scrollerRef,
} = props;
const columns = useSelector(columnsSelector);
const { showBanners } = useSelector(selectTableOptions);
function SeriesIndexTable({
items,
sortKey,
sortDirection,
jumpToCharacter,
isSelectMode,
isSmallScreen,
scrollerRef,
}: SeriesIndexTableProps) {
const columns = useSeriesOption('columns');
const { showBanners } = useSeriesTableOptions();
const listRef = useRef<FixedSizeList<RowItemData>>(null);
const rowHeight = useMemo(() => {

View file

@ -1,6 +1,5 @@
import classNames from 'classnames';
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { useSelect } from 'App/Select/SelectContext';
import IconButton from 'Components/Link/IconButton';
import Column from 'Components/Table/Column';
@ -12,9 +11,10 @@ import { icons } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections';
import {
setSeriesSort,
setSeriesTableOption,
} from 'Store/Actions/seriesIndexActions';
setSeriesTableOptions,
} from 'Series/seriesOptionsStore';
import { CheckInputChanged } from 'typings/inputs';
import { TableOptionsChangePayload } from 'typings/Table';
import hasGrowableColumns from './hasGrowableColumns';
import SeriesIndexTableOptions from './SeriesIndexTableOptions';
import styles from './SeriesIndexTableHeader.css';
@ -29,21 +29,29 @@ interface SeriesIndexTableHeaderProps {
function SeriesIndexTableHeader(props: SeriesIndexTableHeaderProps) {
const { showBanners, columns, sortKey, sortDirection, isSelectMode } = props;
const dispatch = useDispatch();
const { allSelected, allUnselected, selectAll, unselectAll } = useSelect();
const onSortPress = useCallback(
(value: string) => {
dispatch(setSeriesSort({ sortKey: value }));
(sortKey: string, sortDirection?: SortDirection) => {
setSeriesSort({ sortKey, sortDirection });
},
[dispatch]
[]
);
const onTableOptionChange = useCallback(
(payload: unknown) => {
dispatch(setSeriesTableOption(payload));
(
payload: TableOptionsChangePayload & {
tableOptions?: { showBanners?: boolean; showSearchAction?: boolean };
}
) => {
if (payload.tableOptions) {
setSeriesTableOptions(payload.tableOptions);
} else {
// Handle standard table options like columns - for now just ignore
// as series table only uses the tableOptions property
}
},
[dispatch]
[]
);
const onSelectAllChange = useCallback(

View file

@ -1,34 +1,25 @@
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import { inputTypes } from 'Helpers/Props';
import {
setSeriesTableOptions,
useSeriesTableOptions,
} from 'Series/seriesOptionsStore';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import selectTableOptions from './selectTableOptions';
interface SeriesIndexTableOptionsProps {
onTableOptionChange(...args: unknown[]): unknown;
}
function SeriesIndexTableOptions() {
const { showBanners, showSearchAction } = useSeriesTableOptions();
function SeriesIndexTableOptions(props: SeriesIndexTableOptionsProps) {
const { onTableOptionChange } = props;
const tableOptions = useSelector(selectTableOptions);
const { showBanners, showSearchAction } = tableOptions;
const onTableOptionChangeWrapper = useCallback(
const handleTableOptionChange = useCallback(
({ name, value }: InputChanged<boolean>) => {
onTableOptionChange({
tableOptions: {
...tableOptions,
[name]: value,
},
setSeriesTableOptions({
[name]: value,
});
},
[tableOptions, onTableOptionChange]
[]
);
return (
@ -41,7 +32,7 @@ function SeriesIndexTableOptions(props: SeriesIndexTableOptionsProps) {
name="showBanners"
value={showBanners}
helpText={translate('ShowBannersHelpText')}
onChange={onTableOptionChangeWrapper}
onChange={handleTableOptionChange}
/>
</FormGroup>
@ -53,7 +44,7 @@ function SeriesIndexTableOptions(props: SeriesIndexTableOptionsProps) {
name="showSearchAction"
value={showSearchAction}
helpText={translate('ShowSearchHelpText')}
onChange={onTableOptionChangeWrapper}
onChange={handleTableOptionChange}
/>
</FormGroup>
</>

View file

@ -1,12 +1,11 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import Icon from 'Components/Icon';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell';
import { icons } from 'Helpers/Props';
import { SeriesStatus } from 'Series/Series';
import { getSeriesStatusDetails } from 'Series/SeriesStatus';
import { toggleSeriesMonitored } from 'Store/Actions/seriesActions';
import { useToggleSeriesMonitored } from 'Series/useSeries';
import translate from 'Utilities/String/translate';
import styles from './SeriesStatusCell.css';
@ -16,28 +15,25 @@ interface SeriesStatusCellProps {
monitored: boolean;
status: SeriesStatus;
isSelectMode: boolean;
isSaving: boolean;
component?: React.ElementType;
}
function SeriesStatusCell(props: SeriesStatusCellProps) {
const {
className,
seriesId,
monitored,
status,
isSelectMode,
isSaving,
component: Component = VirtualTableRowCell,
...otherProps
} = props;
function SeriesStatusCell({
className,
seriesId,
monitored,
status,
isSelectMode,
component: Component = VirtualTableRowCell,
...otherProps
}: SeriesStatusCellProps) {
const statusDetails = getSeriesStatusDetails(status);
const dispatch = useDispatch();
const { toggleSeriesMonitored, isTogglingSeriesMonitored } =
useToggleSeriesMonitored(seriesId);
const onMonitoredPress = useCallback(() => {
dispatch(toggleSeriesMonitored({ seriesId, monitored: !monitored }));
}, [seriesId, monitored, dispatch]);
toggleSeriesMonitored({ monitored: !monitored });
}, [monitored, toggleSeriesMonitored]);
return (
<Component className={className} {...otherProps}>
@ -45,7 +41,7 @@ function SeriesStatusCell(props: SeriesStatusCellProps) {
<MonitorToggleButton
className={styles.statusIcon}
monitored={monitored}
isSaving={isSaving}
isSaving={isTogglingSeriesMonitored}
onPress={onMonitoredPress}
/>
) : (

View file

@ -1,9 +0,0 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
const selectTableOptions = createSelector(
(state: AppState) => state.seriesIndex.tableOptions,
(tableOptions) => tableOptions
);
export default selectTableOptions;

View file

@ -1,45 +0,0 @@
import { maxBy } from 'lodash';
import { createSelector } from 'reselect';
import Command from 'Commands/Command';
import { REFRESH_SERIES, SERIES_SEARCH } from 'Commands/commandNames';
import Series from 'Series/Series';
import createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector';
import createSeriesQualityProfileSelector from 'Store/Selectors/createSeriesQualityProfileSelector';
import { createSeriesSelectorForHook } from 'Store/Selectors/createSeriesSelector';
function createSeriesIndexItemSelector(seriesId: number) {
return createSelector(
createSeriesSelectorForHook(seriesId),
createSeriesQualityProfileSelector(seriesId),
createExecutingCommandsSelector(),
(series: Series, qualityProfile, executingCommands: Command[]) => {
const isRefreshingSeries = executingCommands.some((command) => {
return (
command.name === REFRESH_SERIES &&
command.body.seriesIds?.includes(series.id)
);
});
const isSearchingSeries = executingCommands.some((command) => {
return (
command.name === SERIES_SEARCH && command.body.seriesId === seriesId
);
});
const latestSeason = maxBy(
series.seasons,
(season) => season.seasonNumber
);
return {
series,
qualityProfile,
latestSeason,
isRefreshingSeries,
isSearchingSeries,
};
}
);
}
export default createSeriesIndexItemSelector;

View file

@ -0,0 +1,49 @@
import { maxBy } from 'lodash';
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import Command from 'Commands/Command';
import { REFRESH_SERIES, SERIES_SEARCH } from 'Commands/commandNames';
import { useSingleSeries } from 'Series/useSeries';
import createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector';
import createSeriesQualityProfileSelector from 'Store/Selectors/createSeriesQualityProfileSelector';
function useSeriesIndexItem(seriesId: number) {
const series = useSingleSeries(seriesId);
const qualityProfile = useSelector(
createSeriesQualityProfileSelector(series)
);
const executingCommands: Command[] = useSelector(
createExecutingCommandsSelector()
);
return useMemo(() => {
if (!series) {
throw new Error('Series not found');
}
const isRefreshingSeries = executingCommands.some((command) => {
return (
command.name === REFRESH_SERIES &&
command.body.seriesIds?.includes(series.id)
);
});
const isSearchingSeries = executingCommands.some((command) => {
return (
command.name === SERIES_SEARCH && command.body.seriesId === seriesId
);
});
const latestSeason = maxBy(series.seasons, (season) => season.seasonNumber);
return {
series,
qualityProfile,
latestSeason,
isRefreshingSeries,
isSearchingSeries,
};
}, [series, qualityProfile, executingCommands, seriesId]);
}
export default useSeriesIndexItem;

View file

@ -1,7 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
@ -17,7 +15,7 @@ import ModalHeader from 'Components/Modal/ModalHeader';
import Popover from 'Components/Tooltip/Popover';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import { updateSeriesMonitor } from 'Store/Actions/seriesActions';
import { useUpdateSeriesMonitor } from 'Series/useSeries';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import styles from './MonitoringOptionsModalContent.css';
@ -33,13 +31,14 @@ function MonitoringOptionsModalContent({
seriesId,
onModalClose,
}: MonitoringOptionsModalContentProps) {
const dispatch = useDispatch();
const { isSaving, saveError } = useSelector(
(state: AppState) => state.series
);
const {
updateSeriesMonitor,
isUpdatingSeriesMonitor,
updateSeriesMonitorError,
} = useUpdateSeriesMonitor(true);
const [monitor, setMonitor] = useState(NO_CHANGE);
const wasSaving = usePrevious(isSaving);
const wasSaving = usePrevious(isUpdatingSeriesMonitor);
const handleMonitorChange = useCallback(({ value }: InputChanged<string>) => {
setMonitor(value);
@ -50,20 +49,26 @@ function MonitoringOptionsModalContent({
return;
}
dispatch(
updateSeriesMonitor({
seriesIds: [seriesId],
monitor,
shouldFetchEpisodesAfterUpdate: true,
})
);
}, [monitor, seriesId, dispatch]);
updateSeriesMonitor({
series: [
{
id: seriesId,
},
],
monitoringOptions: { monitor },
});
}, [monitor, seriesId, updateSeriesMonitor]);
useEffect(() => {
if (!isSaving && wasSaving && !saveError) {
if (!isUpdatingSeriesMonitor && wasSaving && !updateSeriesMonitorError) {
onModalClose();
}
}, [isSaving, wasSaving, saveError, onModalClose]);
}, [
isUpdatingSeriesMonitor,
wasSaving,
updateSeriesMonitorError,
onModalClose,
]);
return (
<ModalContent onModalClose={onModalClose}>
@ -100,7 +105,10 @@ function MonitoringOptionsModalContent({
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<SpinnerButton isSpinning={isSaving} onPress={handleSavePress}>
<SpinnerButton
isSpinning={isUpdatingSeriesMonitor}
onPress={handleSavePress}
>
{translate('Save')}
</SpinnerButton>
</ModalFooter>

View file

@ -43,7 +43,6 @@ export interface Season {
monitored: boolean;
seasonNumber: number;
statistics: Statistics;
isSaving?: boolean;
}
export interface Ratings {
@ -102,7 +101,6 @@ interface Series extends ModelBase {
tmdbId: number;
useSceneNumbering: boolean;
year: number;
isSaving?: boolean;
addOptions: SeriesAddOptions;
}

View file

@ -0,0 +1,277 @@
import Column from 'Components/Table/Column';
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
import translate from 'Utilities/String/translate';
export interface SeriesOptions {
selectedFilterKey: string | number;
sortKey: string;
sortDirection: 'ascending' | 'descending';
view: string;
columns: Column[];
posterOptions: {
detailedProgressBar: boolean;
size: 'small' | 'medium' | 'large';
showTitle: boolean;
showMonitored: boolean;
showQualityProfile: boolean;
showTags: boolean;
showSearchAction: boolean;
};
overviewOptions: {
detailedProgressBar: boolean;
size: 'small' | 'medium' | 'large';
showMonitored: boolean;
showNetwork: boolean;
showQualityProfile: boolean;
showPreviousAiring: boolean;
showAdded: boolean;
showSeasonCount: boolean;
showPath: boolean;
showSizeOnDisk: boolean;
showTags: boolean;
showSearchAction: boolean;
};
tableOptions: {
showBanners: boolean;
showSearchAction: boolean;
};
deleteOptions: {
addImportListExclusion: boolean;
};
}
const { useOptions, useOption, setOptions, setOption, setSort, getOptions } =
createOptionsStore<SeriesOptions>('series_options', () => {
return {
selectedFilterKey: 'all',
sortKey: 'sortTitle',
sortDirection: 'ascending',
secondarySortKey: 'sortTitle',
secondarySortDirection: 'ascending',
view: 'posters',
posterOptions: {
detailedProgressBar: false,
size: 'large',
showTitle: false,
showMonitored: true,
showQualityProfile: true,
showTags: false,
showSearchAction: false,
},
overviewOptions: {
detailedProgressBar: false,
size: 'medium',
showMonitored: true,
showNetwork: true,
showQualityProfile: true,
showPreviousAiring: false,
showAdded: false,
showSeasonCount: true,
showPath: false,
showSizeOnDisk: false,
showTags: false,
showSearchAction: false,
},
tableOptions: {
showBanners: false,
showSearchAction: false,
},
deleteOptions: {
addImportListExclusion: false,
},
columns: [
{
name: 'status',
label: '',
columnLabel: () => translate('Status'),
isSortable: true,
isVisible: true,
isModifiable: false,
},
{
name: 'sortTitle',
label: () => translate('SeriesTitle'),
isSortable: true,
isVisible: true,
isModifiable: false,
},
{
name: 'seriesType',
label: () => translate('Type'),
isSortable: true,
isVisible: false,
},
{
name: 'network',
label: () => translate('Network'),
isSortable: true,
isVisible: true,
},
{
name: 'qualityProfileId',
label: () => translate('QualityProfile'),
isSortable: true,
isVisible: true,
},
{
name: 'nextAiring',
label: () => translate('NextAiring'),
isSortable: true,
isVisible: true,
},
{
name: 'previousAiring',
label: () => translate('PreviousAiring'),
isSortable: true,
isVisible: false,
},
{
name: 'originalLanguage',
label: () => translate('OriginalLanguage'),
isSortable: true,
isVisible: false,
},
{
name: 'added',
label: () => translate('Added'),
isSortable: true,
isVisible: false,
},
{
name: 'seasonCount',
label: () => translate('Seasons'),
isSortable: true,
isVisible: true,
},
{
name: 'seasonFolder',
label: () => translate('SeasonFolder'),
isSortable: true,
isVisible: false,
},
{
name: 'episodeProgress',
label: () => translate('Episodes'),
isSortable: true,
isVisible: true,
},
{
name: 'episodeCount',
label: () => translate('EpisodeCount'),
isSortable: true,
isVisible: false,
},
{
name: 'latestSeason',
label: () => translate('LatestSeason'),
isSortable: true,
isVisible: false,
},
{
name: 'year',
label: () => translate('Year'),
isSortable: true,
isVisible: false,
},
{
name: 'path',
label: () => translate('Path'),
isSortable: true,
isVisible: false,
},
{
name: 'sizeOnDisk',
label: () => translate('SizeOnDisk'),
isSortable: true,
isVisible: false,
},
{
name: 'genres',
label: () => translate('Genres'),
isSortable: false,
isVisible: false,
},
{
name: 'ratings',
label: () => translate('Rating'),
isSortable: true,
isVisible: false,
},
{
name: 'certification',
label: () => translate('Certification'),
isSortable: false,
isVisible: false,
},
{
name: 'releaseGroups',
label: () => translate('ReleaseGroups'),
isSortable: false,
isVisible: false,
},
{
name: 'tags',
label: () => translate('Tags'),
isSortable: true,
isVisible: false,
},
{
name: 'useSceneNumbering',
label: () => translate('SceneNumbering'),
isSortable: true,
isVisible: false,
},
{
name: 'monitorNewItems',
label: () => translate('MonitorNewSeasons'),
isSortable: true,
isVisible: false,
},
{
name: 'actions',
label: '',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false,
},
],
};
});
export const useSeriesOptions = useOptions;
export const setSeriesOptions = setOptions;
export const useSeriesOption = useOption;
export const setSeriesOption = setOption;
export const setSeriesSort = setSort;
export const useSeriesPosterOptions = () => useOption('posterOptions');
export const setSeriesPosterOptions = (
options: Partial<SeriesOptions['posterOptions']>
) => {
const currentOptions = getOptions().posterOptions;
setSeriesOption('posterOptions', { ...currentOptions, ...options });
};
export const useSeriesOverviewOptions = () => useOption('overviewOptions');
export const setSeriesOverviewOptions = (
options: Partial<SeriesOptions['overviewOptions']>
) => {
const currentOptions = getOptions().overviewOptions;
setSeriesOption('overviewOptions', { ...currentOptions, ...options });
};
export const useSeriesTableOptions = () => useOption('tableOptions');
export const setSeriesTableOptions = (
options: Partial<SeriesOptions['tableOptions']>
) => {
const currentOptions = getOptions().tableOptions;
setSeriesOption('tableOptions', { ...currentOptions, ...options });
};
export const useSeriesDeleteOptions = () => useOption('deleteOptions');
export const setSeriesDeleteOptions = (
options: Partial<SeriesOptions['deleteOptions']>
) => {
const currentOptions = getOptions().deleteOptions;
setSeriesOption('deleteOptions', { ...currentOptions, ...options });
};

View file

@ -0,0 +1,16 @@
import { useMemo } from 'react';
import useSeries from 'Series/useSeries';
function useExistingSeries(tvdbId: number | undefined) {
const { data: series = [] } = useSeries();
return useMemo(() => {
if (tvdbId == null) {
return false;
}
return series.some((s) => s.tvdbId === tvdbId);
}, [tvdbId, series]);
}
export default useExistingSeries;

View file

@ -0,0 +1,3 @@
const useSeasonMonitoredUpdater = () => {};
export default useSeasonMonitoredUpdater;

View file

@ -1,19 +1,956 @@
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import { useQueryClient } from '@tanstack/react-query';
import moment from 'moment';
import { useCallback, useMemo } from 'react';
import { FilterBuilderTag } from 'Components/Filter/Builder/FilterBuilderRowValue';
import { Filter, FilterBuilderProp } from 'Filters/Filter';
import { useCustomFiltersList } from 'Filters/useCustomFilters';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import {
filterBuilderTypes,
filterBuilderValueTypes,
sortDirections,
} from 'Helpers/Props';
import { FilterType } from 'Helpers/Props/filterTypes';
import getFilterTypePredicate from 'Helpers/Props/getFilterTypePredicate';
import { SortDirection } from 'Helpers/Props/sortDirections';
import sortByProp from 'Utilities/Array/sortByProp';
import clientSideFilterAndSort from 'Utilities/Filter/clientSideFilterAndSort';
import translate from 'Utilities/String/translate';
import Series from './Series';
import { useSeriesOptions } from './seriesOptionsStore';
export function createSeriesSelector(seriesId?: number) {
return createSelector(
(state: AppState) => state.series.itemMap,
(state: AppState) => state.series.items,
(itemMap, allSeries) => {
return seriesId ? allSeries[itemMap[seriesId]] : undefined;
// Date filter predicate helper
const dateFilterPredicate = (
itemDate: string | undefined,
filterValue: string | Date,
type: FilterType
): boolean => {
if (!itemDate) return false;
const predicate = getFilterTypePredicate(type);
return predicate(itemDate, filterValue);
};
export const FILTERS: Filter[] = [
{
key: 'all',
label: () => translate('All'),
filters: [],
},
{
key: 'monitored',
label: () => translate('MonitoredOnly'),
filters: [
{
key: 'monitored',
value: [true],
type: 'equal',
},
],
},
{
key: 'unmonitored',
label: () => translate('UnmonitoredOnly'),
filters: [
{
key: 'monitored',
value: [false],
type: 'equal',
},
],
},
{
key: 'continuing',
label: () => translate('ContinuingOnly'),
filters: [
{
key: 'status',
value: 'continuing',
type: 'equal',
},
],
},
{
key: 'ended',
label: () => translate('EndedOnly'),
filters: [
{
key: 'status',
value: 'ended',
type: 'equal',
},
],
},
{
key: 'missing',
label: () => translate('MissingEpisodes'),
filters: [
{
key: 'missing',
value: [true],
type: 'equal',
},
],
},
];
const SORT_PREDICATES = {
status: (item: Series, _direction: SortDirection) => {
let result = 0;
if (item.monitored) {
result += 2;
}
);
}
function useSeries(seriesId?: number) {
return useSelector(createSeriesSelector(seriesId));
}
if (item.status === 'continuing') {
result++;
}
return result;
},
sizeOnDisk: (item: Series, _direction: SortDirection) => {
return item.statistics?.sizeOnDisk ?? 0;
},
network: (item: Series, _direction: SortDirection) => {
const network = item.network;
return network ? network.toLowerCase() : '';
},
nextAiring: (item: Series, direction: SortDirection) => {
const nextAiring = item.nextAiring;
if (nextAiring) {
return moment(nextAiring).unix();
}
if (direction === sortDirections.DESCENDING) {
return 0;
}
return Number.MAX_VALUE;
},
previousAiring: (item: Series, direction: SortDirection) => {
const previousAiring = item.previousAiring;
if (previousAiring) {
return moment(previousAiring).unix();
}
if (direction === sortDirections.DESCENDING) {
return -Number.MAX_VALUE;
}
return Number.MAX_VALUE;
},
episodeProgress: (item: Series, _direction: SortDirection) => {
const statistics = item.statistics;
const episodeCount = statistics?.episodeCount ?? 0;
const episodeFileCount = statistics?.episodeFileCount ?? 0;
const progress = episodeCount
? (episodeFileCount / episodeCount) * 100
: 100;
return progress + episodeCount / 1000000;
},
episodeCount: (item: Series, _direction: SortDirection) => {
return item.statistics?.totalEpisodeCount ?? 0;
},
seasonCount: (item: Series, _direction: SortDirection) => {
return item.statistics?.seasonCount ?? 0;
},
originalLanguage: (item: Series, _direction: SortDirection) => {
const { originalLanguage } = item;
return originalLanguage?.name ?? '';
},
ratings: (item: Series, _direction: SortDirection) => {
const { ratings } = item;
return ratings.value ?? 0;
},
monitorNewItems: (item: Series, _direction: SortDirection) => {
return item.monitorNewItems === 'all' ? 1 : 0;
},
} as const;
const FILTER_PREDICATES = {
episodeProgress: (item: Series, filterValue: number, type: FilterType) => {
const statistics = item.statistics;
const episodeCount = statistics?.episodeCount ?? 0;
const episodeFileCount = statistics?.episodeFileCount ?? 0;
const progress = episodeCount
? (episodeFileCount / episodeCount) * 100
: 100;
const predicate = getFilterTypePredicate(type);
return predicate(progress, filterValue);
},
missing: (item: Series, _filterValue: boolean, _type: FilterType) => {
const statistics = item.statistics;
const episodeCount = statistics?.episodeCount ?? 0;
const episodeFileCount = statistics?.episodeFileCount ?? 0;
return episodeCount - episodeFileCount > 0;
},
nextAiring: (item: Series, filterValue: string | Date, type: FilterType) => {
return dateFilterPredicate(item.nextAiring, filterValue, type);
},
previousAiring: (
item: Series,
filterValue: string | Date,
type: FilterType
) => {
return dateFilterPredicate(item.previousAiring, filterValue, type);
},
added: (item: Series, filterValue: string | Date, type: FilterType) => {
return dateFilterPredicate(item.added, filterValue, type);
},
ratings: (item: Series, filterValue: number, type: FilterType) => {
const predicate = getFilterTypePredicate(type);
const value = item.ratings.value ?? 0;
return predicate(value * 10, filterValue);
},
ratingVotes: (item: Series, filterValue: number, type: FilterType) => {
const predicate = getFilterTypePredicate(type);
const votes = item.ratings.votes ?? 0;
return predicate(votes, filterValue);
},
originalLanguage: (item: Series, filterValue: string, type: FilterType) => {
const predicate = getFilterTypePredicate(type);
const languageName = item.originalLanguage?.name ?? '';
return predicate(languageName, filterValue);
},
releaseGroups: (item: Series, filterValue: string[], type: FilterType) => {
const releaseGroups = item.statistics?.releaseGroups ?? [];
const predicate = getFilterTypePredicate(type);
return predicate(releaseGroups, filterValue);
},
seasonCount: (item: Series, filterValue: number, type: FilterType) => {
const predicate = getFilterTypePredicate(type);
const seasonCount = item.statistics?.seasonCount ?? 0;
return predicate(seasonCount, filterValue);
},
sizeOnDisk: (item: Series, filterValue: number, type: FilterType) => {
const predicate = getFilterTypePredicate(type);
const sizeOnDisk = item.statistics?.sizeOnDisk ?? 0;
return predicate(sizeOnDisk, filterValue);
},
hasMissingSeason: (item: Series, filterValue: boolean, type: FilterType) => {
const predicate = getFilterTypePredicate(type);
const seasons = item.seasons ?? [];
const hasMissingSeason = seasons.some((season) => {
const { seasonNumber } = season;
const statistics = season.statistics;
const episodeFileCount = statistics?.episodeFileCount ?? 0;
const episodeCount = statistics?.episodeCount ?? 0;
const totalEpisodeCount = statistics?.totalEpisodeCount ?? 0;
return (
seasonNumber > 0 &&
totalEpisodeCount > 0 &&
episodeCount === totalEpisodeCount &&
episodeFileCount === 0
);
});
return predicate(hasMissingSeason, filterValue);
},
seasonsMonitoredStatus: (
item: Series,
filterValue: string,
type: FilterType
) => {
const predicate = getFilterTypePredicate(type);
const seasons = item.seasons ?? [];
const { monitoredCount, unmonitoredCount } = seasons.reduce(
(acc, { seasonNumber, monitored }) => {
if (seasonNumber <= 0) {
return acc;
}
if (monitored) {
acc.monitoredCount++;
} else {
acc.unmonitoredCount++;
}
return acc;
},
{ monitoredCount: 0, unmonitoredCount: 0 }
);
let seasonsMonitoredStatus = 'partial';
if (monitoredCount === 0) {
seasonsMonitoredStatus = 'none';
} else if (unmonitoredCount === 0) {
seasonsMonitoredStatus = 'all';
}
return predicate(seasonsMonitoredStatus, filterValue);
},
} as const;
export const FILTER_BUILDER: FilterBuilderProp<Series>[] = [
{
name: 'monitored',
label: () => translate('Monitored'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.BOOL,
},
{
name: 'status',
label: () => translate('Status'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.SERIES_STATUS,
},
{
name: 'seriesType',
label: () => translate('Type'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.SERIES_TYPES,
},
{
name: 'title',
label: () => translate('Title'),
type: filterBuilderTypes.STRING,
},
{
name: 'network',
label: () => translate('Network'),
type: filterBuilderTypes.ARRAY,
optionsSelector: function (items: Series[]) {
const tagList = items.reduce<FilterBuilderTag<string, string>[]>(
(acc, series) => {
if (series.network) {
acc.push({
id: series.network,
name: series.network,
});
}
return acc;
},
[]
);
return tagList.sort(sortByProp('name'));
},
},
{
name: 'qualityProfileId',
label: () => translate('QualityProfile'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.QUALITY_PROFILE,
},
{
name: 'nextAiring',
label: () => translate('NextAiring'),
type: filterBuilderTypes.DATE,
valueType: filterBuilderValueTypes.DATE,
},
{
name: 'previousAiring',
label: () => translate('PreviousAiring'),
type: filterBuilderTypes.DATE,
valueType: filterBuilderValueTypes.DATE,
},
{
name: 'added',
label: () => translate('Added'),
type: filterBuilderTypes.DATE,
valueType: filterBuilderValueTypes.DATE,
},
{
name: 'seasonCount',
label: () => translate('SeasonCount'),
type: filterBuilderTypes.NUMBER,
},
{
name: 'episodeProgress',
label: () => translate('EpisodeProgress'),
type: filterBuilderTypes.NUMBER,
},
{
name: 'path',
label: () => translate('Path'),
type: filterBuilderTypes.STRING,
},
{
name: 'rootFolderPath',
label: () => translate('RootFolderPath'),
type: filterBuilderTypes.EXACT,
},
{
name: 'sizeOnDisk',
label: () => translate('SizeOnDisk'),
type: filterBuilderTypes.NUMBER,
valueType: filterBuilderValueTypes.BYTES,
},
{
name: 'genres',
label: () => translate('Genres'),
type: filterBuilderTypes.ARRAY,
optionsSelector: function (items: Series[]) {
const tagList = items.reduce<FilterBuilderTag<string, string>[]>(
(acc, series) => {
series.genres.forEach((genre) => {
acc.push({
id: genre,
name: genre,
});
});
return acc;
},
[]
);
return tagList.sort(sortByProp('name'));
},
},
{
name: 'originalLanguage',
label: () => translate('OriginalLanguage'),
type: filterBuilderTypes.EXACT,
optionsSelector: function (items: Series[]) {
const languageList = items.reduce<FilterBuilderTag<string, string>[]>(
(acc, series) => {
if (series.originalLanguage) {
acc.push({
id: series.originalLanguage.name,
name: series.originalLanguage.name,
});
}
return acc;
},
[]
);
return languageList.sort(sortByProp('name'));
},
},
{
name: 'releaseGroups',
label: () => translate('ReleaseGroups'),
type: filterBuilderTypes.ARRAY,
},
{
name: 'ratings',
label: () => translate('Rating'),
type: filterBuilderTypes.NUMBER,
},
{
name: 'ratingVotes',
label: () => translate('RatingVotes'),
type: filterBuilderTypes.NUMBER,
},
{
name: 'certification',
label: () => translate('Certification'),
type: filterBuilderTypes.EXACT,
},
{
name: 'tags',
label: () => translate('Tags'),
type: filterBuilderTypes.ARRAY,
valueType: filterBuilderValueTypes.TAG,
},
{
name: 'useSceneNumbering',
label: () => translate('SceneNumbering'),
type: filterBuilderTypes.EXACT,
},
{
name: 'hasMissingSeason',
label: () => translate('HasMissingSeason'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.BOOL,
},
{
name: 'seasonsMonitoredStatus',
label: () => translate('SeasonsMonitoredStatus'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.SEASONS_MONITORED_STATUS,
},
{
name: 'year',
label: () => translate('Year'),
type: filterBuilderTypes.NUMBER,
},
];
const DEFAULT_SERIES: Series[] = [];
const useSeries = () => {
const { data, ...result } = useApiQuery<Series[]>({
path: '/series',
queryOptions: {
staleTime: 5 * 60 * 1000,
gcTime: Infinity,
},
});
const seriesMap = useMemo(() => {
if (!data) {
return new Map<number, Series>();
}
return new Map<number, Series>(data.map((series) => [series.id, series]));
}, [data]);
return {
...result,
data: data ?? DEFAULT_SERIES,
seriesMap,
};
};
export default useSeries;
export const useSeriesIndex = () => {
const { selectedFilterKey, sortKey, sortDirection } = useSeriesOptions();
const { data: seriesData = [], ...queryResult } = useSeries();
const customFilters = useCustomFiltersList('series');
const data = useMemo(() => {
return clientSideFilterAndSort<
Series,
typeof FILTER_PREDICATES,
typeof SORT_PREDICATES
>(seriesData, {
selectedFilterKey,
filters: FILTERS,
filterPredicates: FILTER_PREDICATES,
customFilters,
sortKey: sortKey as keyof Series,
sortDirection,
secondarySortKey: 'sortTitle',
secondarySortDirection: 'ascending',
sortPredicates: SORT_PREDICATES,
});
}, [customFilters, seriesData, selectedFilterKey, sortKey, sortDirection]);
return {
...queryResult,
data: data.data,
totalItems: data.totalItems,
};
};
export const useHasSeries = () => {
const { data: seriesData = [] } = useSeries();
return useMemo(() => {
return seriesData.length > 0;
}, [seriesData]);
};
export const useSingleSeries = (seriesId?: number) => {
const { seriesMap } = useSeries();
return useMemo(() => {
if (!seriesId) {
return undefined;
}
return seriesMap.get(seriesId);
}, [seriesMap, seriesId]);
};
export const useMultipleSeries = (seriesIds: number[]) => {
const { seriesMap } = useSeries();
return useMemo(() => {
if (seriesIds.length === 0) {
return DEFAULT_SERIES;
}
return seriesIds.reduce((acc: Series[], seriesId) => {
const series = seriesMap.get(seriesId);
if (series) {
acc.push(series);
}
return acc;
}, []);
}, [seriesMap, seriesIds]);
};
interface SaveSeriesPayload extends Partial<Series> {
id: number;
}
interface DeleteSeriesPayload {
deleteFiles?: boolean;
addImportListExclusion?: boolean;
}
interface ToggleSeriesMonitoredPayload {
monitored: boolean;
}
interface ToggleSeasonMonitoredPayload {
seasonNumber: number;
monitored: boolean;
}
interface UpdateSeriesMonitorPayload {
series: {
id: number;
monitored?: boolean;
seasons?: {
seasonNumber: number;
monitored: boolean;
}[];
}[];
monitoringOptions?: {
monitor: string;
};
}
interface BulkDeleteSeriesPayload {
seriesIds: number[];
deleteFiles?: boolean;
addImportListExclusion?: boolean;
}
interface SaveSeriesEditorPayload {
seriesIds: number[];
monitored?: boolean;
qualityProfileId?: number;
seriesType?: string;
seasonFolder?: boolean;
rootFolderPath?: string;
tags?: number[];
}
export const useSaveSeries = (moveFiles?: boolean) => {
const queryClient = useQueryClient();
const { mutate, isPending, error } = useApiMutation<
Series,
SaveSeriesPayload
>({
path: '/series',
queryParams: {
moveFiles,
},
method: 'PUT',
mutationOptions: {
onSuccess: (updatedSeries) => {
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
if (!oldSeries) {
return oldSeries;
}
return oldSeries.map((series) => {
if (series.id === updatedSeries.id) {
return {
...series,
...updatedSeries,
};
}
return series;
});
});
},
},
});
return {
saveSeries: mutate,
isSaving: isPending,
saveError: error,
};
};
export const useDeleteSeries = (
seriesId: number,
options: DeleteSeriesPayload
) => {
const queryClient = useQueryClient();
const { mutate, isPending, error } = useApiMutation<unknown, void>({
path: `/series/${seriesId}`,
queryParams: {
...options,
},
method: 'DELETE',
mutationOptions: {
onSuccess: () => {
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
if (!oldSeries) {
return oldSeries;
}
return oldSeries.filter((series) => series.id !== seriesId);
});
},
},
});
return {
deleteSeries: mutate,
isDeleting: isPending,
deleteError: error,
};
};
export const useToggleSeriesMonitored = (seriesId: number) => {
const queryClient = useQueryClient();
const series = useSingleSeries(seriesId);
const { mutate, isPending, error } = useApiMutation<
Series,
ToggleSeriesMonitoredPayload
>({
path: '/series',
method: 'PUT',
mutationOptions: {
onSuccess: (updatedSeries) => {
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
if (!oldSeries) {
return oldSeries;
}
return oldSeries.map((series) =>
series.id === updatedSeries.id ? updatedSeries : series
);
});
},
},
});
const toggleSeriesMonitored = useCallback(
(payload: ToggleSeriesMonitoredPayload) => {
return mutate({ ...series, ...payload });
},
[series, mutate]
);
return {
toggleSeriesMonitored,
isTogglingSeriesMonitored: isPending,
toggleSeriesMonitoredError: error,
};
};
export const useToggleSeasonMonitored = (seriesId: number) => {
const queryClient = useQueryClient();
const { mutate, isPending, error } = useApiMutation<
Series,
ToggleSeasonMonitoredPayload
>({
path: `/series/${seriesId}/season`,
method: 'PUT',
mutationOptions: {
onSuccess: (updatedSeries) => {
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
if (!oldSeries) {
return oldSeries;
}
return oldSeries.map((series) => {
if (series.id === updatedSeries.id) {
return {
...series,
seasons: series.seasons.map((season) => {
const updatedSeason = updatedSeries.seasons.find(
(s) => s.seasonNumber === season.seasonNumber
);
if (updatedSeason) {
return {
...season,
...updatedSeason,
};
}
return season;
}),
};
}
return series;
});
});
},
},
});
return {
toggleSeasonMonitored: mutate,
isTogglingSeasonMonitored: isPending,
toggleSeasonMonitoredError: error,
};
};
export const useUpdateSeriesMonitor = (
shouldFetchEpisodesAfterUpdate = false
) => {
const queryClient = useQueryClient();
const { mutate, isPending, error } = useApiMutation<
void,
UpdateSeriesMonitorPayload
>({
path: '/seasonPass',
method: 'POST',
mutationOptions: {
onSuccess: (_, variables) => {
if (shouldFetchEpisodesAfterUpdate) {
queryClient.invalidateQueries({ queryKey: ['/episode'] });
}
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
if (!oldSeries) {
return oldSeries;
}
return oldSeries.map((series) => {
const updatedSeries = variables.series.find(
(s) => s.id === series.id
);
if (!updatedSeries) {
return series;
}
return {
...series,
monitored: updatedSeries.monitored ?? series.monitored,
seasons: series.seasons.map((season) => {
const updatedSeason = updatedSeries.seasons?.find(
(s) => s.seasonNumber === season.seasonNumber
);
if (updatedSeason) {
return {
...season,
monitored: updatedSeason.monitored,
};
}
return season;
}),
};
});
});
},
},
});
return {
updateSeriesMonitor: mutate,
isUpdatingSeriesMonitor: isPending,
updateSeriesMonitorError: error,
};
};
export const useSaveSeriesEditor = () => {
const queryClient = useQueryClient();
const { mutate, isPending, error } = useApiMutation<
Series[],
SaveSeriesEditorPayload
>({
path: '/series/editor',
method: 'PUT',
mutationOptions: {
onSuccess: (updatedSeries) => {
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
if (!oldSeries) {
return oldSeries;
}
return oldSeries.map((series) => {
const updatedSeriesData = updatedSeries.find(
(updated) => updated.id === series.id
);
if (updatedSeriesData) {
const {
alternateTitles,
images,
rootFolderPath,
statistics,
...propsToUpdate
} = updatedSeriesData;
return { ...series, ...propsToUpdate };
}
return series;
});
});
},
},
});
return {
saveSeriesEditor: mutate,
isSavingSeriesEditor: isPending,
saveSeriesEditorError: error,
};
};
export const useBulkDeleteSeries = () => {
const queryClient = useQueryClient();
const { mutate, isPending, error } = useApiMutation<
void,
BulkDeleteSeriesPayload
>({
path: '/series/editor',
method: 'DELETE',
mutationOptions: {
onSuccess: (_, variables) => {
const seriesIds = new Set(variables.seriesIds);
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
if (!oldSeries) {
return oldSeries;
}
return oldSeries.filter((series) => !seriesIds.has(series.id));
});
},
},
});
return {
bulkDeleteSeries: mutate,
isBulkDeleting: isPending,
bulkDeleteError: error,
};
};

View file

@ -16,13 +16,13 @@ import ModalHeader from 'Components/Modal/ModalHeader';
import useMeasure from 'Helpers/Hooks/useMeasure';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import useQualityProfileInUse from 'Settings/Profiles/Quality/useQualityProfileInUse';
import {
fetchQualityProfileSchema,
saveQualityProfile,
setQualityProfileValue,
} from 'Store/Actions/settingsActions';
import { createProviderSettingsSelectorHook } from 'Store/Selectors/createProviderSettingsSelector';
import createQualityProfileInUseSelector from 'Store/Selectors/createQualityProfileInUseSelector';
import dimensions from 'Styles/Variables/dimensions';
import { InputChanged } from 'typings/inputs';
import QualityProfile, {
@ -73,7 +73,7 @@ function EditQualityProfileModalContent({
>('qualityProfiles', id)
);
const isInUse = useSelector(createQualityProfileInUseSelector(id));
const isInUse = useQualityProfileInUse(id);
const [measureHeaderRef, { height: headerHeight }] = useMeasure();
const [measureBodyRef, { height: bodyHeight }] = useMeasure();

View file

@ -0,0 +1,24 @@
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import useSeries from 'Series/useSeries';
function useQualityProfileInUse(id: number | undefined) {
const { data: series = [] } = useSeries();
const importLists = useSelector(
(state: AppState) => state.settings.importLists.items
);
return useMemo(() => {
if (!id) {
return false;
}
return (
series.some((s) => s.qualityProfileId === id) ||
importLists.some((list) => list.qualityProfileId === id)
);
}, [id, series, importLists]);
}
export default useQualityProfileInUse;

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import ModelBase from 'App/ModelBase';
@ -11,7 +11,7 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import useSeries from 'Series/useSeries';
import translate from 'Utilities/String/translate';
import TagDetailsDelayProfile from './TagDetailsDelayProfile';
import styles from './TagDetailsModalContent.css';
@ -22,30 +22,25 @@ function findMatchingItems<T extends ModelBase>(ids: number[], items: T[]) {
});
}
function createUnorderedMatchingSeriesSelector(seriesIds: number[]) {
return createSelector(createAllSeriesSelector(), (series) =>
findMatchingItems(seriesIds, series)
);
}
function useMatchingSeries(seriesIds: number[]) {
const { data: allSeries = [] } = useSeries();
function createMatchingSeriesSelector(seriesIds: number[]) {
return createSelector(
createUnorderedMatchingSeriesSelector(seriesIds),
(series) => {
return series.sort((seriesA, seriesB) => {
const sortTitleA = seriesA.sortTitle;
const sortTitleB = seriesB.sortTitle;
return useMemo(() => {
const matchingSeries = findMatchingItems(seriesIds, allSeries);
if (sortTitleA > sortTitleB) {
return 1;
} else if (sortTitleA < sortTitleB) {
return -1;
}
return matchingSeries.sort((seriesA, seriesB) => {
const sortTitleA = seriesA.sortTitle;
const sortTitleB = seriesB.sortTitle;
return 0;
});
}
);
if (sortTitleA > sortTitleB) {
return 1;
} else if (sortTitleA < sortTitleB) {
return -1;
}
return 0;
});
}, [seriesIds, allSeries]);
}
function createMatchingItemSelector<T extends ModelBase>(
@ -84,7 +79,7 @@ function TagDetailsModalContent({
onModalClose,
onDeleteTagPress,
}: TagDetailsModalContentProps) {
const series = useSelector(createMatchingSeriesSelector(seriesIds));
const series = useMatchingSeries(seriesIds);
const delayProfiles = useSelector(
createMatchingItemSelector(

View file

@ -7,9 +7,7 @@ import * as interactiveImportActions from './interactiveImportActions';
import * as oAuth from './oAuthActions';
import * as organizePreview from './organizePreviewActions';
import * as providerOptions from './providerOptionActions';
import * as series from './seriesActions';
import * as seriesHistory from './seriesHistoryActions';
import * as seriesIndex from './seriesIndexActions';
import * as settings from './settingsActions';
export default [
@ -22,8 +20,6 @@ export default [
oAuth,
organizePreview,
providerOptions,
series,
seriesHistory,
seriesIndex,
settings
];

View file

@ -1,840 +0,0 @@
import _ from 'lodash';
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import { queryClient } from 'App/queryClient';
import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, sortDirections } from 'Helpers/Props';
import getFilterTypePredicate from 'Helpers/Props/getFilterTypePredicate';
import { createThunk, handleThunks } from 'Store/thunks';
import sortByProp from 'Utilities/Array/sortByProp';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import dateFilterPredicate from 'Utilities/Date/dateFilterPredicate';
import translate from 'Utilities/String/translate';
import { set, updateItem } from './baseActions';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
import createRemoveItemHandler from './Creators/createRemoveItemHandler';
import createSaveProviderHandler from './Creators/createSaveProviderHandler';
import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer';
//
// Local
const MONITOR_TIMEOUT = 1000;
const seasonsToUpdate = {};
const seasonMonitorToggleTimeouts = {};
//
// Variables
export const section = 'series';
export const filters = [
{
key: 'all',
label: () => translate('All'),
filters: []
},
{
key: 'monitored',
label: () => translate('MonitoredOnly'),
filters: [
{
key: 'monitored',
value: true,
type: filterTypes.EQUAL
}
]
},
{
key: 'unmonitored',
label: () => translate('UnmonitoredOnly'),
filters: [
{
key: 'monitored',
value: false,
type: filterTypes.EQUAL
}
]
},
{
key: 'continuing',
label: () => translate('ContinuingOnly'),
filters: [
{
key: 'status',
value: 'continuing',
type: filterTypes.EQUAL
}
]
},
{
key: 'ended',
label: () => translate('EndedOnly'),
filters: [
{
key: 'status',
value: 'ended',
type: filterTypes.EQUAL
}
]
},
{
key: 'missing',
label: () => translate('MissingEpisodes'),
filters: [
{
key: 'missing',
value: true,
type: filterTypes.EQUAL
}
]
}
];
export const filterPredicates = {
episodeProgress: function(item, filterValue, type) {
const { statistics = {} } = item;
const {
episodeCount = 0,
episodeFileCount
} = statistics;
const progress = episodeCount ?
episodeFileCount / episodeCount * 100 :
100;
const predicate = getFilterTypePredicate(type);
return predicate(progress, filterValue);
},
missing: function(item) {
const { statistics = {} } = item;
return statistics.episodeCount - statistics.episodeFileCount > 0;
},
nextAiring: function(item, filterValue, type) {
return dateFilterPredicate(item.nextAiring, filterValue, type);
},
previousAiring: function(item, filterValue, type) {
return dateFilterPredicate(item.previousAiring, filterValue, type);
},
added: function(item, filterValue, type) {
return dateFilterPredicate(item.added, filterValue, type);
},
ratings: function(item, filterValue, type) {
const predicate = getFilterTypePredicate(type);
const { value = 0 } = item.ratings;
return predicate(value * 10, filterValue);
},
ratingVotes: function(item, filterValue, type) {
const predicate = getFilterTypePredicate(type);
const { votes = 0 } = item.ratings;
return predicate(votes, filterValue);
},
originalLanguage: function(item, filterValue, type) {
const predicate = getFilterTypePredicate(type);
const { originalLanguage } = item;
return predicate(originalLanguage ? originalLanguage.name : '', filterValue);
},
releaseGroups: function(item, filterValue, type) {
const { statistics = {} } = item;
const {
releaseGroups = []
} = statistics;
const predicate = getFilterTypePredicate(type);
return predicate(releaseGroups, filterValue);
},
seasonCount: function(item, filterValue, type) {
const predicate = getFilterTypePredicate(type);
const seasonCount = item.statistics ? item.statistics.seasonCount : 0;
return predicate(seasonCount, filterValue);
},
sizeOnDisk: function(item, filterValue, type) {
const predicate = getFilterTypePredicate(type);
const sizeOnDisk = item.statistics && item.statistics.sizeOnDisk ?
item.statistics.sizeOnDisk :
0;
return predicate(sizeOnDisk, filterValue);
},
hasMissingSeason: function(item, filterValue, type) {
const predicate = getFilterTypePredicate(type);
const { seasons = [] } = item;
const hasMissingSeason = seasons.some((season) => {
const {
seasonNumber,
statistics = {}
} = season;
const {
episodeFileCount = 0,
episodeCount = 0,
totalEpisodeCount = 0
} = statistics;
return (
seasonNumber > 0 &&
totalEpisodeCount > 0 &&
episodeCount === totalEpisodeCount &&
episodeFileCount === 0
);
});
return predicate(hasMissingSeason, filterValue);
},
seasonsMonitoredStatus: function(item, filterValue, type) {
const predicate = getFilterTypePredicate(type);
const { seasons = [] } = item;
const { monitoredCount, unmonitoredCount } = seasons.reduce((acc, { seasonNumber, monitored }) => {
if (seasonNumber <= 0) {
return acc;
}
if (monitored) {
acc.monitoredCount++;
} else {
acc.unmonitoredCount++;
}
return acc;
}, { monitoredCount: 0, unmonitoredCount: 0 });
let seasonsMonitoredStatus = 'partial';
if (monitoredCount === 0) {
seasonsMonitoredStatus = 'none';
} else if (unmonitoredCount === 0) {
seasonsMonitoredStatus = 'all';
}
return predicate(seasonsMonitoredStatus, filterValue);
}
};
export const filterBuilderProps = [
{
name: 'monitored',
label: () => translate('Monitored'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.BOOL
},
{
name: 'status',
label: () => translate('Status'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.SERIES_STATUS
},
{
name: 'seriesType',
label: () => translate('Type'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.SERIES_TYPES
},
{
name: 'title',
label: () => translate('Title'),
type: filterBuilderTypes.STRING
},
{
name: 'network',
label: () => translate('Network'),
type: filterBuilderTypes.ARRAY,
optionsSelector: function(items) {
const tagList = items.reduce((acc, series) => {
if (series.network) {
acc.push({
id: series.network,
name: series.network
});
}
return acc;
}, []);
return tagList.sort(sortByProp('name'));
}
},
{
name: 'qualityProfileId',
label: () => translate('QualityProfile'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.QUALITY_PROFILE
},
{
name: 'nextAiring',
label: () => translate('NextAiring'),
type: filterBuilderTypes.DATE,
valueType: filterBuilderValueTypes.DATE
},
{
name: 'previousAiring',
label: () => translate('PreviousAiring'),
type: filterBuilderTypes.DATE,
valueType: filterBuilderValueTypes.DATE
},
{
name: 'added',
label: () => translate('Added'),
type: filterBuilderTypes.DATE,
valueType: filterBuilderValueTypes.DATE
},
{
name: 'seasonCount',
label: () => translate('SeasonCount'),
type: filterBuilderTypes.NUMBER
},
{
name: 'episodeProgress',
label: () => translate('EpisodeProgress'),
type: filterBuilderTypes.NUMBER
},
{
name: 'path',
label: () => translate('Path'),
type: filterBuilderTypes.STRING
},
{
name: 'rootFolderPath',
label: () => translate('RootFolderPath'),
type: filterBuilderTypes.EXACT
},
{
name: 'sizeOnDisk',
label: () => translate('SizeOnDisk'),
type: filterBuilderTypes.NUMBER,
valueType: filterBuilderValueTypes.BYTES
},
{
name: 'genres',
label: () => translate('Genres'),
type: filterBuilderTypes.ARRAY,
optionsSelector: function(items) {
const tagList = items.reduce((acc, series) => {
series.genres.forEach((genre) => {
acc.push({
id: genre,
name: genre
});
});
return acc;
}, []);
return tagList.sort(sortByProp('name'));
}
},
{
name: 'originalLanguage',
label: () => translate('OriginalLanguage'),
type: filterBuilderTypes.EXACT,
optionsSelector: function(items) {
const languageList = items.reduce((acc, series) => {
if (series.originalLanguage) {
acc.push({
id: series.originalLanguage.name,
name: series.originalLanguage.name
});
}
return acc;
}, []);
return languageList.sort(sortByProp('name'));
}
},
{
name: 'releaseGroups',
label: () => translate('ReleaseGroups'),
type: filterBuilderTypes.ARRAY
},
{
name: 'ratings',
label: () => translate('Rating'),
type: filterBuilderTypes.NUMBER
},
{
name: 'ratingVotes',
label: () => translate('RatingVotes'),
type: filterBuilderTypes.NUMBER
},
{
name: 'certification',
label: () => translate('Certification'),
type: filterBuilderTypes.EXACT
},
{
name: 'tags',
label: () => translate('Tags'),
type: filterBuilderTypes.ARRAY,
valueType: filterBuilderValueTypes.TAG
},
{
name: 'useSceneNumbering',
label: () => translate('SceneNumbering'),
type: filterBuilderTypes.EXACT
},
{
name: 'hasMissingSeason',
label: () => translate('HasMissingSeason'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.BOOL
},
{
name: 'seasonsMonitoredStatus',
label: () => translate('SeasonsMonitoredStatus'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.SEASONS_MONITORED_STATUS
},
{
name: 'year',
label: () => translate('Year'),
type: filterBuilderTypes.NUMBER
}
];
export const sortPredicates = {
status: function(item) {
let result = 0;
if (item.monitored) {
result += 2;
}
if (item.status === 'continuing') {
result++;
}
return result;
},
sizeOnDisk: function(item) {
const { statistics = {} } = item;
return statistics.sizeOnDisk || 0;
}
};
//
// State
export const defaultState = {
isFetching: false,
isPopulated: false,
error: null,
isSaving: false,
saveError: null,
isDeleting: false,
deleteError: null,
items: [],
sortKey: 'sortTitle',
sortDirection: sortDirections.ASCENDING,
pendingChanges: {},
deleteOptions: {
addImportListExclusion: false
}
};
export const persistState = [
'series.deleteOptions'
];
//
// Actions Types
export const FETCH_SERIES = 'series/fetchSeries';
export const SET_SERIES_VALUE = 'series/setSeriesValue';
export const SAVE_SERIES = 'series/saveSeries';
export const DELETE_SERIES = 'series/deleteSeries';
export const TOGGLE_SERIES_MONITORED = 'series/toggleSeriesMonitored';
export const TOGGLE_SEASON_MONITORED = 'series/toggleSeasonMonitored';
export const UPDATE_SERIES_MONITOR = 'series/updateSeriesMonitor';
export const SAVE_SERIES_EDITOR = 'series/saveSeriesEditor';
export const BULK_DELETE_SERIES = 'series/bulkDeleteSeries';
export const SET_DELETE_OPTION = 'series/setDeleteOption';
//
// Action Creators
export const fetchSeries = createThunk(FETCH_SERIES);
export const saveSeries = createThunk(SAVE_SERIES, (payload) => {
const newPayload = {
...payload
};
if (payload.moveFiles) {
newPayload.queryParams = {
moveFiles: true
};
}
delete newPayload.moveFiles;
return newPayload;
});
export const deleteSeries = createThunk(DELETE_SERIES, (payload) => {
return {
...payload,
queryParams: {
deleteFiles: payload.deleteFiles,
addImportListExclusion: payload.addImportListExclusion
}
};
});
export const toggleSeriesMonitored = createThunk(TOGGLE_SERIES_MONITORED);
export const toggleSeasonMonitored = createThunk(TOGGLE_SEASON_MONITORED);
export const updateSeriesMonitor = createThunk(UPDATE_SERIES_MONITOR);
export const saveSeriesEditor = createThunk(SAVE_SERIES_EDITOR);
export const bulkDeleteSeries = createThunk(BULK_DELETE_SERIES);
export const setSeriesValue = createAction(SET_SERIES_VALUE, (payload) => {
return {
section,
...payload
};
});
export const setDeleteOption = createAction(SET_DELETE_OPTION);
//
// Helpers
function getSaveAjaxOptions({ ajaxOptions, payload }) {
if (payload.moveFolder) {
ajaxOptions.url = `${ajaxOptions.url}?moveFolder=true`;
}
return ajaxOptions;
}
//
// Action Handlers
export const actionHandlers = handleThunks({
[FETCH_SERIES]: createFetchHandler(section, '/series'),
[SAVE_SERIES]: createSaveProviderHandler(section, '/series', { getAjaxOptions: getSaveAjaxOptions }),
[DELETE_SERIES]: createRemoveItemHandler(section, '/series'),
[TOGGLE_SERIES_MONITORED]: (getState, payload, dispatch) => {
const {
seriesId: id,
monitored
} = payload;
const series = _.find(getState().series.items, { id });
dispatch(updateItem({
id,
section,
isSaving: true
}));
const promise = createAjaxRequest({
url: `/series/${id}`,
method: 'PUT',
data: JSON.stringify({
...series,
monitored
}),
dataType: 'json'
}).request;
promise.done((data) => {
dispatch(updateItem({
id,
section,
isSaving: false,
monitored
}));
});
promise.fail((xhr) => {
dispatch(updateItem({
id,
section,
isSaving: false
}));
});
},
[TOGGLE_SEASON_MONITORED]: function(getState, payload, dispatch) {
const {
seriesId: id,
seasonNumber,
monitored
} = payload;
const seasonMonitorToggleTimeout = seasonMonitorToggleTimeouts[id];
if (seasonMonitorToggleTimeout) {
clearTimeout(seasonMonitorToggleTimeout);
delete seasonMonitorToggleTimeouts[id];
}
const series = getState().series.items.find((s) => s.id === id);
const seasons = _.cloneDeep(series.seasons);
const season = seasons.find((s) => s.seasonNumber === seasonNumber);
season.isSaving = true;
dispatch(updateItem({
id,
section,
seasons
}));
seasonsToUpdate[seasonNumber] = monitored;
season.monitored = monitored;
seasonMonitorToggleTimeouts[id] = setTimeout(() => {
createAjaxRequest({
url: `/series/${id}`,
method: 'PUT',
data: JSON.stringify({
...series,
seasons
}),
dataType: 'json'
}).request.then(
(data) => {
const changedSeasons = [];
data.seasons.forEach((s) => {
if (seasonsToUpdate.hasOwnProperty(s.seasonNumber)) {
if (s.monitored === seasonsToUpdate[s.seasonNumber]) {
changedSeasons.push(s);
} else {
s.isSaving = true;
}
}
});
const episodesToUpdate = getState().episodes.items.reduce((acc, episode) => {
if (episode.seriesId !== data.id) {
return acc;
}
const changedSeason = changedSeasons.find((s) => s.seasonNumber === episode.seasonNumber);
if (!changedSeason) {
return acc;
}
acc.push(updateItem({
id: episode.id,
section: 'episodes',
monitored: changedSeason.monitored
}));
return acc;
}, []);
dispatch(batchActions([
updateItem({
id,
section,
...data
}),
...episodesToUpdate
]));
changedSeasons.forEach((s) => {
delete seasonsToUpdate[s.seasonNumber];
});
},
(xhr) => {
dispatch(updateItem({
id,
section,
seasons: series.seasons
}));
Object.keys(seasonsToUpdate).forEach((s) => {
delete seasonsToUpdate[s];
});
});
}, MONITOR_TIMEOUT);
},
[UPDATE_SERIES_MONITOR]: function(getState, payload, dispatch) {
const {
seriesIds,
monitor,
monitored,
shouldFetchEpisodesAfterUpdate = false
} = payload;
const series = [];
seriesIds.forEach((id) => {
const seriesToUpdate = { id };
if (monitored != null) {
seriesToUpdate.monitored = monitored;
}
series.push(seriesToUpdate);
});
dispatch(set({
section,
isSaving: true
}));
const promise = createAjaxRequest({
url: '/seasonPass',
method: 'POST',
data: JSON.stringify({
series,
monitoringOptions: { monitor }
}),
dataType: 'json'
}).request;
promise.done((data) => {
if (shouldFetchEpisodesAfterUpdate) {
queryClient.invalidateQueries({ queryKey: ['/episode'] });
}
dispatch(set({
section,
isSaving: false,
saveError: null
}));
});
promise.fail((xhr) => {
dispatch(set({
section,
isSaving: false,
saveError: xhr
}));
});
},
[SAVE_SERIES_EDITOR]: function(getState, payload, dispatch) {
dispatch(set({
section,
isSaving: true
}));
const promise = createAjaxRequest({
url: '/series/editor',
method: 'PUT',
data: JSON.stringify(payload),
dataType: 'json'
}).request;
promise.done((data) => {
dispatch(batchActions([
...data.map((series) => {
const {
alternateTitles,
images,
rootFolderPath,
statistics,
...propsToUpdate
} = series;
return updateItem({
id: series.id,
section: 'series',
...propsToUpdate
});
}),
set({
section,
isSaving: false,
saveError: null
})
]));
});
promise.fail((xhr) => {
dispatch(set({
section,
isSaving: false,
saveError: xhr
}));
});
},
[BULK_DELETE_SERIES]: function(getState, payload, dispatch) {
dispatch(set({
section,
isDeleting: true
}));
const promise = createAjaxRequest({
url: '/series/editor',
method: 'DELETE',
data: JSON.stringify(payload),
dataType: 'json'
}).request;
promise.done(() => {
// SignaR will take care of removing the series from the collection
dispatch(set({
section,
isDeleting: false,
deleteError: null
}));
});
promise.fail((xhr) => {
dispatch(set({
section,
isDeleting: false,
deleteError: xhr
}));
});
}
});
//
// Reducers
export const reducers = createHandleActions({
[SET_SERIES_VALUE]: createSetSettingValueReducer(section),
[SET_DELETE_OPTION]: (state, { payload }) => {
return {
...state,
deleteOptions: {
...payload
}
};
}
}, defaultState, section);

View file

@ -1,369 +0,0 @@
import moment from 'moment';
import { createAction } from 'redux-actions';
import { sortDirections } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import createHandleActions from './Creators/createHandleActions';
import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer';
import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
import { filterBuilderProps, filterPredicates, filters, sortPredicates } from './seriesActions';
//
// Variables
export const section = 'seriesIndex';
//
// State
export const defaultState = {
sortKey: 'sortTitle',
sortDirection: sortDirections.ASCENDING,
secondarySortKey: 'sortTitle',
secondarySortDirection: sortDirections.ASCENDING,
view: 'posters',
posterOptions: {
detailedProgressBar: false,
size: 'large',
showTitle: false,
showMonitored: true,
showQualityProfile: true,
showTags: false,
showSearchAction: false
},
overviewOptions: {
detailedProgressBar: false,
size: 'medium',
showMonitored: true,
showNetwork: true,
showQualityProfile: true,
showPreviousAiring: false,
showAdded: false,
showSeasonCount: true,
showPath: false,
showSizeOnDisk: false,
showTags: false,
showSearchAction: false
},
tableOptions: {
showBanners: false,
showSearchAction: false
},
columns: [
{
name: 'status',
columnLabel: () => translate('Status'),
isSortable: true,
isVisible: true,
isModifiable: false
},
{
name: 'sortTitle',
label: () => translate('SeriesTitle'),
isSortable: true,
isVisible: true,
isModifiable: false
},
{
name: 'seriesType',
label: () => translate('Type'),
isSortable: true,
isVisible: false
},
{
name: 'network',
label: () => translate('Network'),
isSortable: true,
isVisible: true
},
{
name: 'qualityProfileId',
label: () => translate('QualityProfile'),
isSortable: true,
isVisible: true
},
{
name: 'nextAiring',
label: () => translate('NextAiring'),
isSortable: true,
isVisible: true
},
{
name: 'previousAiring',
label: () => translate('PreviousAiring'),
isSortable: true,
isVisible: false
},
{
name: 'originalLanguage',
label: () => translate('OriginalLanguage'),
isSortable: true,
isVisible: false
},
{
name: 'added',
label: () => translate('Added'),
isSortable: true,
isVisible: false
},
{
name: 'seasonCount',
label: () => translate('Seasons'),
isSortable: true,
isVisible: true
},
{
name: 'seasonFolder',
label: () => translate('SeasonFolder'),
isSortable: true,
isVisible: false
},
{
name: 'episodeProgress',
label: () => translate('Episodes'),
isSortable: true,
isVisible: true
},
{
name: 'episodeCount',
label: () => translate('EpisodeCount'),
isSortable: true,
isVisible: false
},
{
name: 'latestSeason',
label: () => translate('LatestSeason'),
isSortable: true,
isVisible: false
},
{
name: 'year',
label: () => translate('Year'),
isSortable: true,
isVisible: false
},
{
name: 'path',
label: () => translate('Path'),
isSortable: true,
isVisible: false
},
{
name: 'sizeOnDisk',
label: () => translate('SizeOnDisk'),
isSortable: true,
isVisible: false
},
{
name: 'genres',
label: () => translate('Genres'),
isSortable: false,
isVisible: false
},
{
name: 'ratings',
label: () => translate('Rating'),
isSortable: true,
isVisible: false
},
{
name: 'certification',
label: () => translate('Certification'),
isSortable: false,
isVisible: false
},
{
name: 'releaseGroups',
label: () => translate('ReleaseGroups'),
isSortable: false,
isVisible: false
},
{
name: 'tags',
label: () => translate('Tags'),
isSortable: true,
isVisible: false
},
{
name: 'useSceneNumbering',
label: () => translate('SceneNumbering'),
isSortable: true,
isVisible: false
},
{
name: 'monitorNewItems',
label: () => translate('MonitorNewSeasons'),
isSortable: true,
isVisible: false
},
{
name: 'actions',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false
}
],
sortPredicates: {
...sortPredicates,
network: function(item) {
const network = item.network;
return network ? network.toLowerCase() : '';
},
nextAiring: function(item, direction) {
const nextAiring = item.nextAiring;
if (nextAiring) {
return moment(nextAiring).unix();
}
if (direction === sortDirections.DESCENDING) {
return 0;
}
return Number.MAX_VALUE;
},
previousAiring: function(item, direction) {
const previousAiring = item.previousAiring;
if (previousAiring) {
return moment(previousAiring).unix();
}
if (direction === sortDirections.DESCENDING) {
return -Number.MAX_VALUE;
}
return Number.MAX_VALUE;
},
episodeProgress: function(item) {
const { statistics = {} } = item;
const {
episodeCount = 0,
episodeFileCount
} = statistics;
const progress = episodeCount ? episodeFileCount / episodeCount * 100 : 100;
return progress + episodeCount / 1000000;
},
episodeCount: function(item) {
const { statistics = {} } = item;
return statistics.totalEpisodeCount || 0;
},
seasonCount: function(item) {
const { statistics = {} } = item;
return statistics.seasonCount;
},
originalLanguage: function(item) {
const { originalLanguage = {} } = item;
return originalLanguage.name;
},
ratings: function(item) {
const { ratings = {} } = item;
return ratings.value;
},
monitorNewItems: function(item) {
return item.monitorNewItems === 'all' ? 1 : 0;
}
},
selectedFilterKey: 'all',
filters,
filterPredicates,
filterBuilderProps
};
export const persistState = [
'seriesIndex.sortKey',
'seriesIndex.sortDirection',
'seriesIndex.selectedFilterKey',
'seriesIndex.customFilters',
'seriesIndex.view',
'seriesIndex.columns',
'seriesIndex.posterOptions',
'seriesIndex.overviewOptions',
'seriesIndex.tableOptions'
];
//
// Actions Types
export const SET_SERIES_SORT = 'seriesIndex/setSeriesSort';
export const SET_SERIES_FILTER = 'seriesIndex/setSeriesFilter';
export const SET_SERIES_VIEW = 'seriesIndex/setSeriesView';
export const SET_SERIES_TABLE_OPTION = 'seriesIndex/setSeriesTableOption';
export const SET_SERIES_POSTER_OPTION = 'seriesIndex/setSeriesPosterOption';
export const SET_SERIES_OVERVIEW_OPTION = 'seriesIndex/setSeriesOverviewOption';
//
// Action Creators
export const setSeriesSort = createAction(SET_SERIES_SORT);
export const setSeriesFilter = createAction(SET_SERIES_FILTER);
export const setSeriesView = createAction(SET_SERIES_VIEW);
export const setSeriesTableOption = createAction(SET_SERIES_TABLE_OPTION);
export const setSeriesPosterOption = createAction(SET_SERIES_POSTER_OPTION);
export const setSeriesOverviewOption = createAction(SET_SERIES_OVERVIEW_OPTION);
//
// Reducers
export const reducers = createHandleActions({
[SET_SERIES_SORT]: createSetClientSideCollectionSortReducer(section),
[SET_SERIES_FILTER]: createSetClientSideCollectionFilterReducer(section),
[SET_SERIES_VIEW]: function(state, { payload }) {
return Object.assign({}, state, { view: payload.view });
},
[SET_SERIES_TABLE_OPTION]: createSetTableOptionReducer(section),
[SET_SERIES_POSTER_OPTION]: function(state, { payload }) {
const posterOptions = state.posterOptions;
return {
...state,
posterOptions: {
...posterOptions,
...payload
}
};
},
[SET_SERIES_OVERVIEW_OPTION]: function(state, { payload }) {
const overviewOptions = state.overviewOptions;
return {
...state,
overviewOptions: {
...overviewOptions,
...payload
}
};
}
}, defaultState, section);

View file

@ -1,13 +0,0 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
function createAllSeriesSelector() {
return createSelector(
(state: AppState) => state.series,
(series) => {
return series.items;
}
);
}
export default createAllSeriesSelector;

View file

@ -1,14 +0,0 @@
import { createSelector } from 'reselect';
import createAllSeriesSelector from './createAllSeriesSelector';
function createExistingSeriesSelector(tvdbId: number | undefined) {
return createSelector(createAllSeriesSelector(), (series) => {
if (tvdbId == null) {
return false;
}
return series.some((s) => s.tvdbId === tvdbId);
});
}
export default createExistingSeriesSelector;

View file

@ -1,35 +0,0 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import { ImportSeries } from 'App/State/ImportSeriesAppState';
import createAllSeriesSelector from './createAllSeriesSelector';
function createImportSeriesItemSelector(id: string) {
return createSelector(
(_state: AppState, connectorInput: { id: string }) =>
connectorInput ? connectorInput.id : id,
(state: AppState) => state.importSeries,
createAllSeriesSelector(),
(connectorId, importSeries, series) => {
const finalId = id || connectorId;
const item =
importSeries.items.find((item) => {
return item.id === finalId;
}) ?? ({} as ImportSeries);
const selectedSeries = item && item.selectedSeries;
const isExistingSeries =
!!selectedSeries &&
series.some((s) => {
return s.tvdbId === selectedSeries.tvdbId;
});
return {
...item,
isExistingSeries,
};
}
);
}
export default createImportSeriesItemSelector;

View file

@ -1,23 +0,0 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Series from 'Series/Series';
function createMultiSeriesSelector(seriesIds: number[]) {
return createSelector(
(state: AppState) => state.series.itemMap,
(state: AppState) => state.series.items,
(itemMap, allSeries) => {
return seriesIds.reduce((acc: Series[], seriesId) => {
const series = allSeries[itemMap[seriesId]];
if (series) {
acc.push(series);
}
return acc;
}, []);
}
);
}
export default createMultiSeriesSelector;

View file

@ -1,22 +0,0 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import createAllSeriesSelector from './createAllSeriesSelector';
function createQualityProfileInUseSelector(id: number | undefined) {
return createSelector(
createAllSeriesSelector(),
(state: AppState) => state.settings.importLists.items,
(series, lists) => {
if (!id) {
return false;
}
return (
series.some((s) => s.qualityProfileId === id) ||
lists.some((list) => list.qualityProfileId === id)
);
}
);
}
export default createQualityProfileInUseSelector;

View file

@ -1,10 +0,0 @@
import { createSelector } from 'reselect';
import createAllSeriesSelector from './createAllSeriesSelector';
function createSeriesCountSelector() {
return createSelector(createAllSeriesSelector(), (series) => {
return series.length;
});
}
export default createSeriesCountSelector;

View file

@ -2,13 +2,15 @@ import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Series from 'Series/Series';
import QualityProfile from 'typings/QualityProfile';
import { createSeriesSelectorForHook } from './createSeriesSelector';
function createSeriesQualityProfileSelector(seriesId: number) {
function createSeriesQualityProfileSelector(series?: Series) {
return createSelector(
(state: AppState) => state.settings.qualityProfiles.items,
createSeriesSelectorForHook(seriesId),
(qualityProfiles: QualityProfile[], series = {} as Series) => {
(qualityProfiles: QualityProfile[]) => {
if (!series) {
return undefined;
}
return qualityProfiles.find(
(profile) => profile.id === series.qualityProfileId
);

View file

@ -1,24 +0,0 @@
import { createSelector } from 'reselect';
export function createSeriesSelectorForHook(seriesId) {
return createSelector(
(state) => state.series.itemMap,
(state) => state.series.items,
(itemMap, allSeries) => {
return seriesId ? allSeries[itemMap[seriesId]] : undefined;
}
);
}
function createSeriesSelector() {
return createSelector(
(state, { seriesId }) => seriesId,
(state) => state.series.itemMap,
(state) => state.series.items,
(seriesId, itemMap, allSeries) => {
return allSeries[itemMap[seriesId]];
}
);
}
export default createSeriesSelector;

View file

@ -1,5 +1,6 @@
import { cloneDeep } from 'lodash';
import { Error } from 'App/State/AppSectionState';
import { getValidationFailures as getValidationFailuresFromApiError } from 'Helpers/Hooks/useApiMutation';
import Field from 'typings/Field';
import {
Failure,
@ -10,6 +11,7 @@ import {
ValidationFailure,
ValidationWarning,
} from 'typings/pending';
import { ApiError } from 'Utilities/Fetch/fetchJson';
import isEmpty from 'Utilities/Object/isEmpty';
export interface ValidationFailures {
@ -18,9 +20,20 @@ export interface ValidationFailures {
}
export function getValidationFailures(
saveError?: Error | null
saveError?: ApiError | Error | null
): ValidationFailures {
if (!saveError || saveError.status !== 400) {
if (!saveError) {
return {
errors: [],
warnings: [],
};
}
if (saveError instanceof ApiError) {
return getValidationFailuresFromApiError(saveError);
}
if (saveError.status !== 400) {
return {
errors: [],
warnings: [],
@ -79,7 +92,7 @@ export interface ModelBaseSetting {
function selectSettings<T extends ModelBaseSetting>(
item: T,
pendingChanges?: Partial<ModelBaseSetting>,
saveError?: Error | null
saveError?: ApiError | Error | null
) {
const { errors, warnings } = getValidationFailures(saveError);

View file

@ -1,8 +1,7 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { CommandBody } from 'Commands/Command';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import createMultiSeriesSelector from 'Store/Selectors/createMultiSeriesSelector';
import { useMultipleSeries } from 'Series/useSeries';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate';
import styles from './QueuedTaskRowNameCell.css';
@ -39,7 +38,7 @@ export default function QueuedTaskRowNameCell(
seriesIds.push(body.seriesId);
}
const series = useSelector(createMultiSeriesSelector(seriesIds));
const series = useMultipleSeries(seriesIds);
const sortedSeries = series.sort(sortByProp('sortTitle'));
return (

View file

@ -12,7 +12,7 @@ import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import EpisodeFileLanguages from 'EpisodeFile/EpisodeFileLanguages';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import useSeries from 'Series/useSeries';
import { useSingleSeries } from 'Series/useSeries';
import { SelectStateInputProps } from 'typings/props';
import styles from './CutoffUnmetRow.css';
@ -49,7 +49,7 @@ function CutoffUnmetRow({
title,
columns,
}: CutoffUnmetRowProps) {
const series = useSeries(seriesId);
const series = useSingleSeries(seriesId);
const { toggleSelected, useIsSelected } = useSelect<Episode>();
const isSelected = useIsSelected(id);

View file

@ -11,7 +11,7 @@ import EpisodeStatus from 'Episode/EpisodeStatus';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import useSeries from 'Series/useSeries';
import { useSingleSeries } from 'Series/useSeries';
import { SelectStateInputProps } from 'typings/props';
import styles from './MissingRow.css';
@ -48,7 +48,7 @@ function MissingRow({
title,
columns,
}: MissingRowProps) {
const series = useSeries(seriesId);
const series = useSingleSeries(seriesId);
const { toggleSelected, useIsSelected } = useSelect<Episode>();
const isSelected = useIsSelected(id);