mirror of
https://github.com/Sonarr/Sonarr
synced 2026-05-05 19:40:56 +02:00
Use react-query for series
This commit is contained in:
parent
49db4a1d76
commit
0521a6c390
91 changed files with 1961 additions and 2173 deletions
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>>();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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 } =
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export interface PropertyFilter {
|
|||
}
|
||||
|
||||
export interface Filter {
|
||||
key: string;
|
||||
key: string | number;
|
||||
label: string | (() => string);
|
||||
filters: PropertyFilter[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
71
frontend/src/Helpers/Hooks/usePendingChangesStore.ts
Normal file
71
frontend/src/Helpers/Hooks/usePendingChangesStore.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 ?? [];
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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[] }) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
49
frontend/src/Series/Index/useSeriesIndexItem.ts
Normal file
49
frontend/src/Series/Index/useSeriesIndexItem.ts
Normal 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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
277
frontend/src/Series/seriesOptionsStore.ts
Normal file
277
frontend/src/Series/seriesOptionsStore.ts
Normal 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 });
|
||||
};
|
||||
16
frontend/src/Series/useExistingSeries.ts
Normal file
16
frontend/src/Series/useExistingSeries.ts
Normal 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;
|
||||
3
frontend/src/Series/useSeasonMonitoredUpdater.ts
Normal file
3
frontend/src/Series/useSeasonMonitoredUpdater.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
const useSeasonMonitoredUpdater = () => {};
|
||||
|
||||
export default useSeasonMonitoredUpdater;
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { createSelector } from 'reselect';
|
||||
import createAllSeriesSelector from './createAllSeriesSelector';
|
||||
|
||||
function createSeriesCountSelector() {
|
||||
return createSelector(createAllSeriesSelector(), (series) => {
|
||||
return series.length;
|
||||
});
|
||||
}
|
||||
|
||||
export default createSeriesCountSelector;
|
||||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue