Use react-query for queue UI

New: Season packs and multi-episode releases will show as a single item in the queue
Closes #6537
This commit is contained in:
Mark McDowall 2025-05-21 17:14:50 -07:00
parent e213f156af
commit 5f359e975d
No known key found for this signature in database
58 changed files with 1979 additions and 1911 deletions

View file

@ -0,0 +1,113 @@
import React, { createContext, ReactNode, useContext, useMemo } from 'react';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import Queue from 'typings/Queue';
interface EpisodeDetails {
episodeIds: number[];
}
interface SeriesDetails {
seriesId: number;
}
interface AllDetails {
all: boolean;
}
type QueueDetailsFilter = AllDetails | EpisodeDetails | SeriesDetails;
interface QueueDetailsProps {
children: ReactNode;
}
const QueueDetailsContext = createContext<Queue[] | undefined>(undefined);
export default function QueueDetailsProvider({
children,
...filter
}: QueueDetailsProps & QueueDetailsFilter) {
const { data } = useApiQuery<Queue[]>({
path: '/queue/details',
queryParams: { ...filter },
queryOptions: {
enabled: Object.keys(filter).length > 0,
},
});
return (
<QueueDetailsContext.Provider value={data}>
{children}
</QueueDetailsContext.Provider>
);
}
export function useQueueItemForEpisode(episodeId: number) {
const queue = useContext(QueueDetailsContext);
return useMemo(() => {
return queue?.find((item) => item.episodeIds.includes(episodeId));
}, [episodeId, queue]);
}
export function useIsDownloadingEpisodes(episodeIds: number[]) {
const queue = useContext(QueueDetailsContext);
return useMemo(() => {
if (!queue) {
return false;
}
return queue.some((item) =>
item.episodeIds?.some((e) => episodeIds.includes(e))
);
}, [episodeIds, queue]);
}
export interface SeriesQueueDetails {
count: number;
episodesWithFiles: number;
}
export function useQueueDetailsForSeries(
seriesId: number,
seasonNumber?: number
) {
const queue = useContext(QueueDetailsContext);
return useMemo<SeriesQueueDetails>(() => {
if (!queue) {
return { count: 0, episodesWithFiles: 0 };
}
return queue.reduce<SeriesQueueDetails>(
(acc: SeriesQueueDetails, item) => {
if (
item.trackedDownloadState === 'imported' ||
item.seriesId !== seriesId
) {
return acc;
}
if (seasonNumber != null && item.seasonNumber !== seasonNumber) {
return acc;
}
acc.count++;
if (item.episodeHasFile) {
acc.episodesWithFiles++;
}
return acc;
},
{
count: 0,
episodesWithFiles: 0,
}
);
}, [seriesId, seasonNumber, queue]);
}
export const useQueueDetails = () => {
return useContext(QueueDetailsContext) ?? [];
};

View file

@ -0,0 +1,76 @@
import React from 'react';
import Episode from 'Episode/Episode';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import Series from 'Series/Series';
import translate from 'Utilities/String/translate';
interface EpisodeCellContentProps {
episodes: Episode[];
isFullSeason: boolean;
seasonNumber?: number;
series?: Series;
}
export default function EpisodeCellContent({
episodes,
isFullSeason,
seasonNumber,
series,
}: EpisodeCellContentProps) {
if (episodes.length === 0) {
return '-';
}
if (isFullSeason && seasonNumber != null) {
return translate('SeasonNumberToken', { seasonNumber });
}
if (episodes.length === 1) {
const episode = episodes[0];
return (
<SeasonEpisodeNumber
seasonNumber={episode.seasonNumber}
episodeNumber={episode.episodeNumber}
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
seriesType={series?.seriesType}
alternateTitles={series?.alternateTitles}
sceneSeasonNumber={episode.sceneSeasonNumber}
sceneEpisodeNumber={episode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={episode.sceneAbsoluteEpisodeNumber}
unverifiedSceneNumbering={episode.unverifiedSceneNumbering}
/>
);
}
const firstEpisode = episodes[0];
const lastEpisode = episodes[episodes.length - 1];
return (
<>
<SeasonEpisodeNumber
seasonNumber={firstEpisode.seasonNumber}
episodeNumber={firstEpisode.episodeNumber}
absoluteEpisodeNumber={firstEpisode.absoluteEpisodeNumber}
seriesType={series?.seriesType}
alternateTitles={series?.alternateTitles}
sceneSeasonNumber={firstEpisode.sceneSeasonNumber}
sceneEpisodeNumber={firstEpisode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={firstEpisode.sceneAbsoluteEpisodeNumber}
unverifiedSceneNumbering={firstEpisode.unverifiedSceneNumbering}
/>
{' - '}
<SeasonEpisodeNumber
seasonNumber={lastEpisode.seasonNumber}
episodeNumber={lastEpisode.episodeNumber}
absoluteEpisodeNumber={lastEpisode.absoluteEpisodeNumber}
seriesType={series?.seriesType}
alternateTitles={series?.alternateTitles}
sceneSeasonNumber={lastEpisode.sceneSeasonNumber}
sceneEpisodeNumber={lastEpisode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={lastEpisode.sceneAbsoluteEpisodeNumber}
unverifiedSceneNumbering={lastEpisode.unverifiedSceneNumbering}
/>
</>
);
}

View file

@ -0,0 +1,13 @@
.multiple {
cursor: default;
}
.row {
display: flex;
}
.episodeNumber {
margin-right: 8px;
font-weight: bold;
cursor: default;
}

View file

@ -0,0 +1,9 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'episodeNumber': string;
'multiple': string;
'row': string;
}
export const cssExports: CssExports;
export default cssExports;

View file

@ -0,0 +1,66 @@
import React from 'react';
import Popover from 'Components/Tooltip/Popover';
import Episode from 'Episode/Episode';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import Series from 'Series/Series';
import translate from 'Utilities/String/translate';
import styles from './EpisodeTitleCellContent.css';
interface EpisodeTitleCellContentProps {
episodes: Episode[];
series?: Series;
}
export default function EpisodeTitleCellContent({
episodes,
series,
}: EpisodeTitleCellContentProps) {
if (episodes.length === 0 || !series) {
return '-';
}
if (episodes.length === 1) {
const episode = episodes[0];
return (
<EpisodeTitleLink
episodeId={episode.id}
seriesId={series.id}
episodeTitle={episode.title}
episodeEntity="episodes"
showOpenSeriesButton={true}
/>
);
}
return (
<Popover
anchor={
<span className={styles.multiple}>{translate('MultipleEpisodes')}</span>
}
title={translate('EpisodeTitles')}
body={
<>
{episodes.map((episode) => {
return (
<div key={episode.id} className={styles.row}>
<div className={styles.episodeNumber}>
{episode.episodeNumber}
</div>
<EpisodeTitleLink
episodeId={episode.id}
seriesId={series.id}
episodeTitle={episode.title}
episodeEntity="episodes"
showOpenSeriesButton={true}
/>
</div>
);
})}
</>
}
position="right"
/>
);
}

View file

@ -7,7 +7,6 @@ import React, {
useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@ -22,28 +21,15 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager';
import usePaging from 'Components/Table/usePaging';
import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector';
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { align, icons, kinds } from 'Helpers/Props';
import { executeCommand } from 'Store/Actions/commandActions';
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
import {
clearQueue,
fetchQueue,
gotoQueuePage,
grabQueueItems,
removeQueueItems,
setQueueFilter,
setQueueSort,
setQueueTableOption,
} from 'Store/Actions/queueActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import { CheckInputChanged } from 'typings/inputs';
import { SelectStateInputProps } from 'typings/props';
import QueueItem from 'typings/Queue';
import { TableOptionsChangePayload } from 'typings/Table';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import {
@ -54,33 +40,51 @@ import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import QueueFilterModal from './QueueFilterModal';
import QueueOptions from './QueueOptions';
import {
setQueueOption,
setQueueOptions,
useQueueOptions,
} from './queueOptionsStore';
import QueueRow from './QueueRow';
import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal';
import createQueueStatusSelector from './Status/createQueueStatusSelector';
import RemoveQueueItemModal from './RemoveQueueItemModal';
import useQueueStatus from './Status/useQueueStatus';
import useQueue, {
useFilters,
useGrabQueueItems,
useRemoveQueueItems,
} from './useQueue';
const DEFAULT_DATA = {
records: [],
totalPages: 0,
totalRecords: 0,
};
function Queue() {
const requestCurrentPage = useCurrentPage();
const dispatch = useDispatch();
const {
isFetching,
isPopulated,
data,
error,
items,
columns,
selectedFilterKey,
filters,
sortKey,
sortDirection,
isFetching,
isFetched,
isLoading,
page,
pageSize,
totalPages,
totalRecords,
isGrabbing,
isRemoving,
} = useSelector((state: AppState) => state.queue.paged);
goToPage,
refetch,
} = useQueue();
const { count } = useSelector(createQueueStatusSelector());
const { records, totalPages = 0, totalRecords } = data ?? DEFAULT_DATA;
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
useQueueOptions();
const filters = useFilters();
const { isRemoving, removeQueueItems } = useRemoveQueueItems();
const { isGrabbing, grabQueueItems } = useGrabQueueItems();
const { count } = useQueueStatus();
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
useSelector(createEpisodesFetchingSelector());
const customFilters = useSelector(createCustomFiltersSelector('queue'));
@ -100,41 +104,46 @@ function Queue() {
}, [selectedState]);
const isPendingSelected = useMemo(() => {
return items.some((item) => {
return records.some((item) => {
return selectedIds.indexOf(item.id) > -1 && item.status === 'delay';
});
}, [items, selectedIds]);
}, [records, selectedIds]);
const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] =
useState(false);
const isRefreshing =
isFetching || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
isLoading || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
const isAllPopulated =
isPopulated &&
(isEpisodesPopulated || !items.length || items.every((e) => !e.episodeId));
isFetched &&
(isEpisodesPopulated ||
!records.length ||
records.every((e) => !e.episodeIds?.length));
const hasError = error || episodesError;
const selectedCount = selectedIds.length;
const disableSelectedActions = selectedCount === 0;
const handleSelectAllChange = useCallback(
({ value }: CheckInputChanged) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
setSelectState({
type: value ? 'selectAll' : 'unselectAll',
items: records,
});
},
[items, setSelectState]
[records, setSelectState]
);
const handleSelectedChange = useCallback(
({ id, value, shiftKey = false }: SelectStateInputProps) => {
setSelectState({
type: 'toggleSelected',
items,
items: records,
id,
isSelected: value,
shiftKey,
});
},
[items, setSelectState]
[records, setSelectState]
);
const handleRefreshPress = useCallback(() => {
@ -150,93 +159,60 @@ function Queue() {
}, []);
const handleGrabSelectedPress = useCallback(() => {
dispatch(grabQueueItems({ ids: selectedIds }));
}, [selectedIds, dispatch]);
grabQueueItems({ ids: selectedIds });
}, [selectedIds, grabQueueItems]);
const handleRemoveSelectedPress = useCallback(() => {
shouldBlockRefresh.current = true;
setIsConfirmRemoveModalOpen(true);
}, [setIsConfirmRemoveModalOpen]);
const handleRemoveSelectedConfirmed = useCallback(
(payload: RemovePressProps) => {
const handleRemoveSelectedConfirmed = useCallback(() => {
shouldBlockRefresh.current = false;
dispatch(removeQueueItems({ ids: selectedIds, ...payload }));
removeQueueItems({ ids: selectedIds });
setIsConfirmRemoveModalOpen(false);
},
[selectedIds, setIsConfirmRemoveModalOpen, dispatch]
);
}, [selectedIds, setIsConfirmRemoveModalOpen, removeQueueItems]);
const handleConfirmRemoveModalClose = useCallback(() => {
shouldBlockRefresh.current = false;
setIsConfirmRemoveModalOpen(false);
}, [setIsConfirmRemoveModalOpen]);
const {
handleFirstPagePress,
handlePreviousPagePress,
handleNextPagePress,
handleLastPagePress,
handlePageSelect,
} = usePaging({
page,
totalPages,
gotoPage: gotoQueuePage,
});
const handleFilterSelect = useCallback(
(selectedFilterKey: string | number) => {
dispatch(setQueueFilter({ selectedFilterKey }));
setQueueOption('selectedFilterKey', selectedFilterKey);
},
[dispatch]
[]
);
const handleSortPress = useCallback(
(sortKey: string) => {
dispatch(setQueueSort({ sortKey }));
},
[dispatch]
);
const handleSortPress = useCallback((sortKey: string) => {
setQueueOption('sortKey', sortKey);
}, []);
const handleTableOptionChange = useCallback(
(payload: TableOptionsChangePayload) => {
dispatch(setQueueTableOption(payload));
setQueueOptions(payload);
if (payload.pageSize) {
dispatch(gotoQueuePage({ page: 1 }));
goToPage(1);
}
},
[dispatch]
[goToPage]
);
useEffect(() => {
if (requestCurrentPage) {
dispatch(fetchQueue());
} else {
dispatch(gotoQueuePage({ page: 1 }));
}
return () => {
dispatch(clearQueue());
};
}, [requestCurrentPage, dispatch]);
useEffect(() => {
const episodeIds = selectUniqueIds<QueueItem, number | undefined>(
items,
'episodeId'
);
const episodeIds = selectUniqueIds(records, 'episodeIds');
if (episodeIds.length) {
dispatch(fetchEpisodes({ episodeIds }));
} else {
dispatch(clearEpisodes());
}
}, [items, dispatch]);
}, [records, dispatch]);
useEffect(() => {
const repopulate = () => {
dispatch(fetchQueue());
refetch();
};
registerPagePopulator(repopulate);
@ -244,7 +220,7 @@ function Queue() {
return () => {
unregisterPagePopulator(repopulate);
};
}, [dispatch]);
}, [refetch]);
if (!shouldBlockRefresh.current) {
currentQueue.current = (
@ -255,7 +231,7 @@ function Queue() {
<Alert kind={kinds.DANGER}>{translate('QueueLoadError')}</Alert>
) : null}
{isAllPopulated && !hasError && !items.length ? (
{isAllPopulated && !hasError && !records.length ? (
<Alert kind={kinds.INFO}>
{selectedFilterKey !== 'all' && count > 0
? translate('QueueFilterHasNoItems')
@ -263,7 +239,7 @@ function Queue() {
</Alert>
) : null}
{isAllPopulated && !hasError && !!items.length ? (
{isAllPopulated && !hasError && !!records.length ? (
<div>
<Table
selectAll={true}
@ -279,11 +255,10 @@ function Queue() {
onSortPress={handleSortPress}
>
<TableBody>
{items.map((item) => {
{records.map((item) => {
return (
<QueueRow
key={item.id}
episodeId={item.episodeId}
isSelected={selectedState[item.id]}
columns={columns}
{...item}
@ -302,11 +277,7 @@ function Queue() {
totalPages={totalPages}
totalRecords={totalRecords}
isFetching={isFetching}
onFirstPagePress={handleFirstPagePress}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
onPageSelect={goToPage}
/>
</div>
) : null}
@ -377,7 +348,7 @@ function Queue() {
canChangeCategory={
isConfirmRemoveModalOpen &&
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);
const item = records.find((i) => i.id === id);
return !!(item && item.downloadClientHasPostImportCategory);
})
@ -385,7 +356,7 @@ function Queue() {
canIgnore={
isConfirmRemoveModalOpen &&
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);
const item = records.find((i) => i.id === id);
return !!(item && item.seriesId && item.episodeId);
})
@ -393,7 +364,7 @@ function Queue() {
isPending={
isConfirmRemoveModalOpen &&
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);
const item = records.find((i) => i.id === id);
if (!item) {
return false;

View file

@ -14,7 +14,7 @@ import styles from './QueueDetails.css';
interface QueueDetailsProps {
title: string;
size: number;
sizeleft: number;
sizeLeft: number;
estimatedCompletionTime?: string;
status: string;
trackedDownloadState?: QueueTrackedDownloadState;
@ -28,7 +28,7 @@ function QueueDetails(props: QueueDetailsProps) {
const {
title,
size,
sizeleft,
sizeLeft,
status,
trackedDownloadState = 'downloading',
trackedDownloadStatus = 'ok',
@ -37,7 +37,7 @@ function QueueDetails(props: QueueDetailsProps) {
progressBar,
} = props;
const progress = 100 - (sizeleft / size) * 100;
const progress = 100 - (sizeLeft / size) * 100;
const isDownloading = status === 'downloading';
const isPaused = status === 'paused';
const hasWarning = trackedDownloadStatus === 'warning';

View file

@ -1,49 +1,26 @@
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 { setQueueFilter } from 'Store/Actions/queueActions';
function createQueueSelector() {
return createSelector(
(state: AppState) => state.queue.paged.items,
(queueItems) => {
return queueItems;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.queue.paged.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
import { setQueueOption } from './queueOptionsStore';
import useQueue, { FILTER_BUILDER } from './useQueue';
type QueueFilterModalProps = FilterModalProps<History>;
export default function QueueFilterModal(props: QueueFilterModalProps) {
const sectionItems = useSelector(createQueueSelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const { data } = useQueue();
const customFilterType = 'queue';
const dispatch = useDispatch();
const dispatchSetFilter = useCallback(
(payload: unknown) => {
dispatch(setQueueFilter(payload));
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
setQueueOption('selectedFilterKey', selectedFilterKey);
},
[dispatch]
[]
);
return (
<FilterModal
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
sectionItems={data?.records ?? []}
filterBuilderProps={FILTER_BUILDER}
customFilterType={customFilterType}
dispatchSetFilter={dispatchSetFilter}
/>

View file

@ -1,33 +1,30 @@
import React, { useCallback } 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';
import { OptionChanged } from 'Helpers/Hooks/useOptionsStore';
import { inputTypes } from 'Helpers/Props';
import { gotoQueuePage, setQueueOption } from 'Store/Actions/queueActions';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import {
QueueOptions as QueueOptionsType,
setQueueOption,
useQueueOption,
} from './queueOptionsStore';
import useQueue from './useQueue';
function QueueOptions() {
const dispatch = useDispatch();
const { includeUnknownSeriesItems } = useSelector(
(state: AppState) => state.queue.options
);
const includeUnknownSeriesItems = useQueueOption('includeUnknownSeriesItems');
const { goToPage } = useQueue();
const handleOptionChange = useCallback(
({ name, value }: InputChanged<boolean>) => {
dispatch(
setQueueOption({
[name]: value,
})
);
({ name, value }: OptionChanged<QueueOptionsType>) => {
setQueueOption(name, value);
if (name === 'includeUnknownSeriesItems') {
dispatch(gotoQueuePage({ page: 1 }));
goToPage(1);
}
},
[dispatch]
[goToPage]
);
return (
@ -39,6 +36,7 @@ function QueueOptions() {
name="includeUnknownSeriesItems"
value={includeUnknownSeriesItems}
helpText={translate('ShowUnknownSeriesItemsHelpText')}
// @ts-expect-error - The typing for inputs needs more work
onChange={handleOptionChange}
/>
</FormGroup>

View file

@ -1,7 +1,6 @@
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
import { Error } from 'App/State/AppSectionState';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import ProgressBar from 'Components/ProgressBar';
@ -15,16 +14,13 @@ import DownloadProtocol from 'DownloadClient/DownloadProtocol';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import useEpisode from 'Episode/useEpisode';
import useEpisodes from 'Episode/useEpisodes';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
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 { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import CustomFormat from 'typings/CustomFormat';
import { SelectStateInputProps } from 'typings/props';
@ -36,15 +32,18 @@ import {
import formatBytes from 'Utilities/Number/formatBytes';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import EpisodeCellContent from './EpisodeCellContent';
import EpisodeTitleCellContent from './EpisodeTitleCellContent';
import QueueStatusCell from './QueueStatusCell';
import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal';
import TimeleftCell from './TimeleftCell';
import RemoveQueueItemModal from './RemoveQueueItemModal';
import TimeLeftCell from './TimeLeftCell';
import { useGrabQueueItem, useRemoveQueueItem } from './useQueue';
import styles from './QueueRow.css';
interface QueueRowProps {
id: number;
seriesId?: number;
episodeId?: number;
episodeIds: number[];
downloadId?: string;
title: string;
status: string;
@ -58,16 +57,16 @@ interface QueueRowProps {
customFormatScore: number;
protocol: DownloadProtocol;
indexer?: string;
isFullSeason: boolean;
seasonNumbers: number[];
outputPath?: string;
downloadClient?: string;
downloadClientHasPostImportCategory?: boolean;
estimatedCompletionTime?: string;
added?: string;
timeleft?: string;
timeLeft?: string;
size: number;
sizeleft: number;
isGrabbing?: boolean;
grabError?: Error;
sizeLeft: number;
isRemoving?: boolean;
isSelected?: boolean;
columns: Column[];
@ -79,7 +78,7 @@ function QueueRow(props: QueueRowProps) {
const {
id,
seriesId,
episodeId,
episodeIds,
downloadId,
title,
status,
@ -97,25 +96,25 @@ function QueueRow(props: QueueRowProps) {
downloadClient,
downloadClientHasPostImportCategory,
estimatedCompletionTime,
isFullSeason,
seasonNumbers,
added,
timeleft,
timeLeft,
size,
sizeleft,
isGrabbing = false,
grabError,
isRemoving = false,
sizeLeft,
isSelected,
columns,
onSelectedChange,
onQueueRowModalOpenOrClose,
} = props;
const dispatch = useDispatch();
const series = useSeries(seriesId);
const episode = useEpisode(episodeId, 'episodes');
const episodes = useEpisodes(episodeIds, 'episodes');
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
createUISettingsSelector()
);
const { removeQueueItem, isRemoving } = useRemoveQueueItem(id);
const { grabQueueItem, isGrabbing, grabError } = useGrabQueueItem(id);
const [isRemoveQueueItemModalOpen, setIsRemoveQueueItemModalOpen] =
useState(false);
@ -124,8 +123,8 @@ function QueueRow(props: QueueRowProps) {
useState(false);
const handleGrabPress = useCallback(() => {
dispatch(grabQueueItem({ id }));
}, [id, dispatch]);
grabQueueItem();
}, [grabQueueItem]);
const handleInteractiveImportPress = useCallback(() => {
onQueueRowModalOpenOrClose(true);
@ -142,21 +141,22 @@ function QueueRow(props: QueueRowProps) {
setIsRemoveQueueItemModalOpen(true);
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
const handleRemoveQueueItemModalConfirmed = useCallback(
(payload: RemovePressProps) => {
const handleRemoveQueueItemModalConfirmed = useCallback(() => {
onQueueRowModalOpenOrClose(false);
dispatch(removeQueueItem({ id, ...payload }));
removeQueueItem();
setIsRemoveQueueItemModalOpen(false);
},
[id, setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose, dispatch]
);
}, [
setIsRemoveQueueItemModalOpen,
removeQueueItem,
onQueueRowModalOpenOrClose,
]);
const handleRemoveQueueItemModalClose = useCallback(() => {
onQueueRowModalOpenOrClose(false);
setIsRemoveQueueItemModalOpen(false);
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
const progress = 100 - (sizeleft / size) * 100;
const progress = 100 - (sizeLeft / size) * 100;
const showInteractiveImport =
status === 'completed' && trackedDownloadStatus === 'warning';
const isPending =
@ -209,23 +209,12 @@ function QueueRow(props: QueueRowProps) {
if (name === 'episode') {
return (
<TableRowCell key={name}>
{episode ? (
<SeasonEpisodeNumber
seasonNumber={episode.seasonNumber}
episodeNumber={episode.episodeNumber}
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
seriesType={series?.seriesType}
alternateTitles={series?.alternateTitles}
sceneSeasonNumber={episode.sceneSeasonNumber}
sceneEpisodeNumber={episode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={
episode.sceneAbsoluteEpisodeNumber
}
unverifiedSceneNumbering={episode.unverifiedSceneNumbering}
<EpisodeCellContent
episodes={episodes}
isFullSeason={isFullSeason}
seasonNumber={seasonNumbers[0]}
series={series}
/>
) : (
'-'
)}
</TableRowCell>
);
}
@ -233,27 +222,37 @@ function QueueRow(props: QueueRowProps) {
if (name === 'episodes.title') {
return (
<TableRowCell key={name}>
{series && episode ? (
<EpisodeTitleLink
episodeId={episode.id}
seriesId={series.id}
episodeTitle={episode.title}
episodeEntity="episodes"
showOpenSeriesButton={true}
/>
) : (
'-'
)}
<EpisodeTitleCellContent episodes={episodes} series={series} />
</TableRowCell>
);
}
if (name === 'episodes.airDateUtc') {
if (episode) {
return <RelativeDateCell key={name} date={episode.airDateUtc} />;
if (episodes.length === 0) {
return <TableRowCell key={name}>-</TableRowCell>;
}
return <TableRowCell key={name}>-</TableRowCell>;
if (episodes.length === 1) {
return (
<RelativeDateCell key={name} date={episodes[0].airDateUtc} />
);
}
return (
<TableRowCell key={name}>
<RelativeDateCell
key={name}
component="span"
date={episodes[0].airDateUtc}
/>
{' - '}
<RelativeDateCell
key={name}
component="span"
date={episodes[episodes.length - 1].airDateUtc}
/>
</TableRowCell>
);
}
if (name === 'languages') {
@ -325,13 +324,13 @@ function QueueRow(props: QueueRowProps) {
if (name === 'estimatedCompletionTime') {
return (
<TimeleftCell
<TimeLeftCell
key={name}
status={status}
estimatedCompletionTime={estimatedCompletionTime}
timeleft={timeleft}
timeLeft={timeLeft}
size={size}
sizeleft={sizeleft}
sizeLeft={sizeLeft}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}

View file

@ -1,6 +1,4 @@
import React, { useCallback, useMemo } 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';
@ -11,19 +9,16 @@ 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 { OptionChanged } from 'Helpers/Hooks/useOptionsStore';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import { setQueueRemovalOption } from 'Store/Actions/queueActions';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import {
QueueOptions,
setQueueOption,
useQueueOption,
} from './queueOptionsStore';
import styles from './RemoveQueueItemModal.css';
export interface RemovePressProps {
remove: boolean;
changeCategory: boolean;
blocklist: boolean;
skipRedownload: boolean;
}
interface RemoveQueueItemModalProps {
isOpen: boolean;
sourceTitle?: string;
@ -31,7 +26,7 @@ interface RemoveQueueItemModalProps {
canIgnore: boolean;
isPending: boolean;
selectedCount?: number;
onRemovePress(props: RemovePressProps): void;
onRemovePress(): void;
onModalClose: () => void;
}
@ -47,13 +42,8 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
onModalClose,
} = props;
const dispatch = useDispatch();
const multipleSelected = selectedCount && selectedCount > 1;
const { removalMethod, blocklistMethod } = useSelector(
(state: AppState) => state.queue.removalOptions
);
const { removalMethod, blocklistMethod } = useQueueOption('removalOptions');
const { title, message } = useMemo(() => {
if (!selectedCount) {
@ -138,20 +128,19 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
}, [isPending, multipleSelected]);
const handleRemovalOptionInputChange = useCallback(
({ name, value }: InputChanged) => {
dispatch(setQueueRemovalOption({ [name]: value }));
({ name, value }: OptionChanged<QueueOptions['removalOptions']>) => {
setQueueOption('removalOptions', {
removalMethod,
blocklistMethod,
[name]: value,
});
},
[dispatch]
[removalMethod, blocklistMethod]
);
const handleConfirmRemove = useCallback(() => {
onRemovePress({
remove: removalMethod === 'removeFromClient',
changeCategory: removalMethod === 'changeCategory',
blocklist: blocklistMethod !== 'doNotBlocklist',
skipRedownload: blocklistMethod === 'blocklistOnly',
});
}, [removalMethod, blocklistMethod, onRemovePress]);
onRemovePress();
}, [onRemovePress]);
const handleModalClose = useCallback(() => {
onModalClose();
@ -178,6 +167,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
helpTextWarning={translate(
'RemoveQueueItemRemovalMethodHelpTextWarning'
)}
// @ts-expect-error - The typing for inputs needs more work
onChange={handleRemovalOptionInputChange}
/>
</FormGroup>
@ -196,6 +186,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
value={blocklistMethod}
values={blocklistMethodOptions}
helpText={translate('BlocklistReleaseHelpText')}
// @ts-expect-error - The typing for inputs needs more work
onChange={handleRemovalOptionInputChange}
/>
</FormGroup>

View file

@ -1,33 +1,9 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import React from 'react';
import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { fetchQueueStatus } from 'Store/Actions/queueActions';
import createQueueStatusSelector from './createQueueStatusSelector';
import useQueueStatus from './useQueueStatus';
function QueueStatus() {
const dispatch = useDispatch();
const { isConnected, isReconnecting } = useSelector(
(state: AppState) => state.app
);
const { isPopulated, count, errors, warnings } = useSelector(
createQueueStatusSelector()
);
const wasReconnecting = usePrevious(isReconnecting);
useEffect(() => {
if (!isPopulated) {
dispatch(fetchQueueStatus());
}
}, [isPopulated, dispatch]);
useEffect(() => {
if (isConnected && wasReconnecting) {
dispatch(fetchQueueStatus());
}
}, [isConnected, wasReconnecting, dispatch]);
const { errors, warnings, count } = useQueueStatus();
return (
<PageSidebarStatus count={count} errors={errors} warnings={warnings} />

View file

@ -1,32 +0,0 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
function createQueueStatusSelector() {
return createSelector(
(state: AppState) => state.queue.status.isPopulated,
(state: AppState) => state.queue.status.item,
(state: AppState) => state.queue.options.includeUnknownSeriesItems,
(isPopulated, status, includeUnknownSeriesItems) => {
const {
errors,
warnings,
unknownErrors,
unknownWarnings,
count,
totalCount,
} = status;
return {
...status,
isPopulated,
count: includeUnknownSeriesItems ? totalCount : count,
errors: includeUnknownSeriesItems ? errors || unknownErrors : errors,
warnings: includeUnknownSeriesItems
? warnings || unknownWarnings
: warnings,
};
}
);
}
export default createQueueStatusSelector;

View file

@ -0,0 +1,54 @@
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import { useQueueOption } from '../queueOptionsStore';
export interface QueueStatus {
totalCount: number;
count: number;
unknownCount: number;
errors: boolean;
warnings: boolean;
unknownErrors: boolean;
unknownWarnings: boolean;
}
export default function useQueueStatus() {
const includeUnknownSeriesItems = useQueueOption('includeUnknownSeriesItems');
const { data } = useApiQuery<QueueStatus>({
path: '/queue/status',
queryParams: {
includeUnknownSeriesItems,
},
});
if (!data) {
return {
count: 0,
errors: false,
warnings: false,
};
}
const {
errors,
warnings,
unknownErrors,
unknownWarnings,
count,
totalCount,
} = data;
if (includeUnknownSeriesItems) {
return {
count: totalCount,
errors: errors || unknownErrors,
warnings: warnings || unknownWarnings,
};
}
return {
count,
errors,
warnings,
};
}

View file

@ -1,4 +1,4 @@
.timeleft {
.timeLeft {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 100px;

View file

@ -1,7 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'timeleft': string;
'timeLeft': string;
}
export const cssExports: CssExports;
export default cssExports;

View file

@ -8,26 +8,26 @@ import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import styles from './TimeleftCell.css';
import styles from './TimeLeftCell.css';
interface TimeleftCellProps {
interface TimeLeftCellProps {
estimatedCompletionTime?: string;
timeleft?: string;
timeLeft?: string;
status: string;
size: number;
sizeleft: number;
sizeLeft: number;
showRelativeDates: boolean;
shortDateFormat: string;
timeFormat: string;
}
function TimeleftCell(props: TimeleftCellProps) {
function TimeLeftCell(props: TimeLeftCellProps) {
const {
estimatedCompletionTime,
timeleft,
timeLeft,
status,
size,
sizeleft,
sizeLeft,
showRelativeDates,
shortDateFormat,
timeFormat,
@ -44,7 +44,7 @@ function TimeleftCell(props: TimeleftCellProps) {
});
return (
<TableRowCell className={styles.timeleft}>
<TableRowCell className={styles.timeLeft}>
<Tooltip
anchor={<Icon name={icons.INFO} />}
tooltip={translate('DelayingDownloadUntil', { date, time })}
@ -66,7 +66,7 @@ function TimeleftCell(props: TimeleftCellProps) {
});
return (
<TableRowCell className={styles.timeleft}>
<TableRowCell className={styles.timeLeft}>
<Tooltip
anchor={<Icon name={icons.INFO} />}
tooltip={translate('RetryingDownloadOn', { date, time })}
@ -77,21 +77,21 @@ function TimeleftCell(props: TimeleftCellProps) {
);
}
if (!timeleft || status === 'completed' || status === 'failed') {
return <TableRowCell className={styles.timeleft}>-</TableRowCell>;
if (!timeLeft || status === 'completed' || status === 'failed') {
return <TableRowCell className={styles.timeLeft}>-</TableRowCell>;
}
const totalSize = formatBytes(size);
const remainingSize = formatBytes(sizeleft);
const remainingSize = formatBytes(sizeLeft);
return (
<TableRowCell
className={styles.timeleft}
className={styles.timeLeft}
title={`${remainingSize} / ${totalSize}`}
>
{formatTimeSpan(timeleft)}
{formatTimeSpan(timeLeft)}
</TableRowCell>
);
}
export default TimeleftCell;
export default TimeLeftCell;

View file

@ -0,0 +1,164 @@
import React from 'react';
import Icon from 'Components/Icon';
import Column from 'Components/Table/Column';
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
import { icons } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections';
import translate from 'Utilities/String/translate';
interface QueueRemovalOptions {
removalMethod: 'changeCategory' | 'ignore' | 'removeFromClient';
blocklistMethod: 'blocklistAndSearch' | 'blocklistOnly' | 'doNotBlocklist';
}
export interface QueueOptions {
includeUnknownSeriesItems: boolean;
pageSize: number;
selectedFilterKey: string | number;
sortKey: string;
sortDirection: SortDirection;
columns: Column[];
removalOptions: QueueRemovalOptions;
}
const { useOptions, useOption, setOptions, setOption } =
createOptionsStore<QueueOptions>('queue_options', () => {
return {
includeUnknownSeriesItems: true,
pageSize: 20,
selectedFilterKey: 'all',
sortKey: 'time',
sortDirection: 'descending',
columns: [
{
name: 'status',
label: '',
columnLabel: () => translate('Status'),
isSortable: true,
isVisible: true,
isModifiable: false,
},
{
name: 'series.sortTitle',
label: () => translate('Series'),
isSortable: true,
isVisible: true,
},
{
name: 'episode',
label: () => translate('EpisodeMaybePlural'),
isSortable: true,
isVisible: true,
},
{
name: 'episodes.title',
label: () => translate('EpisodeTitleMaybePlural'),
isSortable: true,
isVisible: true,
},
{
name: 'episodes.airDateUtc',
label: () => translate('EpisodeAirDate'),
isSortable: true,
isVisible: false,
},
{
name: 'languages',
label: () => translate('Languages'),
isSortable: true,
isVisible: false,
},
{
name: 'quality',
label: () => translate('Quality'),
isSortable: true,
isVisible: true,
},
{
name: 'customFormats',
label: () => translate('Formats'),
isSortable: false,
isVisible: true,
},
{
name: 'customFormatScore',
columnLabel: () => translate('CustomFormatScore'),
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore'),
}),
isVisible: false,
},
{
name: 'protocol',
label: () => translate('Protocol'),
isSortable: true,
isVisible: false,
},
{
name: 'indexer',
label: () => translate('Indexer'),
isSortable: true,
isVisible: false,
},
{
name: 'downloadClient',
label: () => translate('DownloadClient'),
isSortable: true,
isVisible: false,
},
{
name: 'title',
label: () => translate('ReleaseTitle'),
isSortable: true,
isVisible: false,
},
{
name: 'size',
label: () => translate('Size'),
isSortable: true,
isVisible: false,
},
{
name: 'outputPath',
label: () => translate('OutputPath'),
isSortable: false,
isVisible: false,
},
{
name: 'estimatedCompletionTime',
label: () => translate('TimeLeft'),
isSortable: true,
isVisible: true,
},
{
name: 'added',
label: () => translate('Added'),
isSortable: true,
isVisible: false,
},
{
name: 'progress',
label: () => translate('Progress'),
isSortable: true,
isVisible: true,
},
{
name: 'actions',
label: '',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false,
},
],
removalOptions: {
removalMethod: 'removeFromClient',
blocklistMethod: 'doNotBlocklist',
},
};
});
export const useQueueOptions = useOptions;
export const setQueueOptions = setOptions;
export const useQueueOption = useOption;
export const setQueueOption = setOption;

View file

@ -0,0 +1,210 @@
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { CustomFilter, Filter, FilterBuilderProp } from 'App/State/AppState';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import usePage from 'Helpers/Hooks/usePage';
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
import { filterBuilderValueTypes } from 'Helpers/Props';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import Queue from 'typings/Queue';
import getQueryString from 'Utilities/Fetch/getQueryString';
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
import translate from 'Utilities/String/translate';
import { useQueueOptions } from './queueOptionsStore';
interface BulkQueueData {
ids: number[];
}
export const FILTERS: Filter[] = [
{
key: 'all',
label: () => translate('All'),
filters: [],
},
];
export const FILTER_BUILDER: FilterBuilderProp<Queue>[] = [
{
name: 'seriesIds',
label: () => translate('Series'),
type: 'equal',
valueType: filterBuilderValueTypes.SERIES,
},
{
name: 'quality',
label: () => translate('Quality'),
type: 'equal',
valueType: filterBuilderValueTypes.QUALITY,
},
{
name: 'languages',
label: () => translate('Languages'),
type: 'contains',
valueType: filterBuilderValueTypes.LANGUAGE,
},
{
name: 'protocol',
label: () => translate('Protocol'),
type: 'equal',
valueType: filterBuilderValueTypes.PROTOCOL,
},
{
name: 'status',
label: () => translate('Status'),
type: 'equal',
valueType: filterBuilderValueTypes.QUEUE_STATUS,
},
];
const useQueue = () => {
const { page, goToPage } = usePage('queue');
const {
includeUnknownSeriesItems,
pageSize,
selectedFilterKey,
sortKey,
sortDirection,
} = useQueueOptions();
const customFilters = useSelector(
createCustomFiltersSelector('queue')
) as CustomFilter[];
const filters = useMemo(() => {
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
}, [selectedFilterKey, customFilters]);
const { refetch, ...query } = usePagedApiQuery<Queue>({
path: '/queue',
page,
pageSize,
filters,
queryParams: {
includeUnknownSeriesItems,
},
sortKey,
sortDirection,
queryOptions: {
placeholderData: keepPreviousData,
},
});
const handleGoToPage = useCallback(
(page: number) => {
goToPage(page);
},
[goToPage]
);
return {
...query,
goToPage: handleGoToPage,
page,
refetch,
};
};
export default useQueue;
export const useFilters = () => {
return FILTERS;
};
const useRemovalOptions = () => {
const { removalOptions } = useQueueOptions();
return {
remove: removalOptions.removalMethod === 'removeFromClient',
changeCategory: removalOptions.removalMethod === 'changeCategory',
blocklist: removalOptions.blocklistMethod !== 'doNotBlocklist',
skipRedownload: removalOptions.blocklistMethod === 'blocklistOnly',
};
};
export const useRemoveQueueItem = (id: number) => {
const queryClient = useQueryClient();
const removalOptions = useRemovalOptions();
const { mutate, isPending } = useApiMutation<unknown, void>({
path: `/queue/${id}${getQueryString(removalOptions)}`,
method: 'DELETE',
mutationOptions: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/queue'] });
},
},
});
return {
removeQueueItem: mutate,
isRemoving: isPending,
};
};
export const useRemoveQueueItems = () => {
const queryClient = useQueryClient();
const removalOptions = useRemovalOptions();
const { mutate, isPending } = useApiMutation<unknown, BulkQueueData>({
path: `/queue/bulk${getQueryString(removalOptions)}`,
method: 'DELETE',
mutationOptions: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/queue'] });
},
},
});
return {
removeQueueItems: mutate,
isRemoving: isPending,
};
};
export const useGrabQueueItem = (id: number) => {
const queryClient = useQueryClient();
const [grabError, setGrabError] = useState<string | null>(null);
const { mutate, isPending } = useApiMutation<unknown, void>({
path: `/queue/grab/${id}`,
method: 'POST',
mutationOptions: {
onMutate: () => {
setGrabError(null);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/queue'] });
},
onError: () => {
setGrabError('Error grabbing queue item');
},
},
});
return {
grabQueueItem: mutate,
isGrabbing: isPending,
grabError,
};
};
export const useGrabQueueItems = () => {
const queryClient = useQueryClient();
// Explicitly define the types for the mutation so we can pass in no arguments to mutate as expected.
const { mutate, isPending } = useApiMutation<unknown, BulkQueueData>({
path: '/queue/grab/bulk',
method: 'POST',
mutationOptions: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/queue'] });
},
},
});
return {
grabQueueItems: mutate,
isGrabbing: isPending,
};
};

View file

@ -47,11 +47,7 @@ function AddNewSeriesModalContent({
const { isSmallScreen } = useSelector(createDimensionsSelector());
const isWindows = useIsWindows();
const {
isPending: isAdding,
error: addError,
mutate: addSeries,
} = useAddSeries();
const { isAdding, addError, addSeries } = useAddSeries();
const { settings, validationErrors, validationWarnings } = useMemo(() => {
return selectSettings(options, {}, addError);

View file

@ -33,11 +33,19 @@ export const useAddSeries = () => {
[dispatch]
);
return useApiMutation<Series, AddSeriesPayload>({
const { isPending, error, mutate } = useApiMutation<Series, AddSeriesPayload>(
{
path: '/series',
method: 'POST',
mutationOptions: {
onSuccess: onAddSuccess,
},
});
}
);
return {
isAdding: isPending,
addError: error,
addSeries: mutate,
};
};

View file

@ -1,4 +1,4 @@
import { createPersist } from 'Helpers/createPersist';
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
import { SeriesMonitor, SeriesType } from 'Series/Series';
export interface AddSeriesOptions {
@ -12,9 +12,8 @@ export interface AddSeriesOptions {
tags: number[];
}
const addSeriesOptionsStore = createPersist<AddSeriesOptions>(
'add_series_options',
() => {
const { useOptions, useOption, setOption } =
createOptionsStore<AddSeriesOptions>('add_series_options', () => {
return {
rootFolderPath: '',
monitor: 'all',
@ -25,25 +24,8 @@ const addSeriesOptionsStore = createPersist<AddSeriesOptions>(
searchForCutoffUnmetEpisodes: false,
tags: [],
};
}
);
});
export const useAddSeriesOptions = () => {
return addSeriesOptionsStore((state) => state);
};
export const useAddSeriesOption = <K extends keyof AddSeriesOptions>(
key: K
) => {
return addSeriesOptionsStore((state) => state[key]);
};
export const setAddSeriesOption = <K extends keyof AddSeriesOptions>(
key: K,
value: AddSeriesOptions[K]
) => {
addSeriesOptionsStore.setState((state) => ({
...state,
[key]: value,
}));
};
export const useAddSeriesOptions = useOptions;
export const useAddSeriesOption = useOption;
export const setAddSeriesOption = setOption;

View file

@ -18,7 +18,6 @@ import OrganizePreviewAppState from './OrganizePreviewAppState';
import ParseAppState from './ParseAppState';
import PathsAppState from './PathsAppState';
import ProviderOptionsAppState from './ProviderOptionsAppState';
import QueueAppState from './QueueAppState';
import ReleasesAppState from './ReleasesAppState';
import RootFolderAppState from './RootFolderAppState';
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
@ -99,7 +98,6 @@ interface AppState {
parse: ParseAppState;
paths: PathsAppState;
providerOptions: ProviderOptionsAppState;
queue: QueueAppState;
releases: ReleasesAppState;
rootFolders: RootFolderAppState;
series: SeriesAppState;

View file

@ -1,56 +0,0 @@
import Queue from 'typings/Queue';
import AppSectionState, {
AppSectionFilterState,
AppSectionItemState,
Error,
PagedAppSectionState,
TableAppSectionState,
} from './AppSectionState';
export interface QueueStatus {
totalCount: number;
count: number;
unknownCount: number;
errors: boolean;
warnings: boolean;
unknownErrors: boolean;
unknownWarnings: boolean;
}
export interface QueueDetailsAppState extends AppSectionState<Queue> {
params: unknown;
}
export interface QueuePagedAppState
extends AppSectionState<Queue>,
AppSectionFilterState<Queue>,
PagedAppSectionState,
TableAppSectionState {
isGrabbing: boolean;
grabError: Error;
isRemoving: boolean;
removeError: Error;
}
export type RemovalMethod = 'removeFromClient' | 'changeCategory' | 'ignore';
export type BlocklistMethod =
| 'doNotBlocklist'
| 'blocklistAndSearch'
| 'blocklistOnly';
interface RemovalOptions {
removalMethod: RemovalMethod;
blocklistMethod: BlocklistMethod;
}
interface QueueAppState {
status: AppSectionItemState<QueueStatus>;
details: QueueDetailsAppState;
paged: QueuePagedAppState;
options: {
includeUnknownSeriesItems: boolean;
};
removalOptions: RemovalOptions;
}
export default QueueAppState;

View file

@ -2,6 +2,7 @@ import classNames from 'classnames';
import moment from 'moment';
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
import AppState from 'App/State/AppState';
import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
import getStatusStyle from 'Calendar/getStatusStyle';
@ -13,7 +14,6 @@ import getFinaleTypeName from 'Episode/getFinaleTypeName';
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds } from 'Helpers/Props';
import useSeries from 'Series/useSeries';
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber';
@ -57,7 +57,7 @@ function AgendaEvent(props: AgendaEventProps) {
const series = useSeries(seriesId)!;
const episodeFile = useEpisodeFile(episodeFileId);
const queueItem = useSelector(createQueueItemSelectorForHook(id));
const queueItem = useQueueItemForEpisode(id);
const { timeFormat, longDateFormat, enableColorImpairedMode } = useSelector(
createUISettingsSelector()
);

View file

@ -17,10 +17,6 @@ import {
clearEpisodeFiles,
fetchEpisodeFiles,
} from 'Store/Actions/episodeFileActions';
import {
clearQueueDetails,
fetchQueueDetails,
} from 'Store/Actions/queueActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
@ -74,7 +70,6 @@ function Calendar() {
return () => {
dispatch(clearCalendar());
dispatch(clearQueueDetails());
dispatch(clearEpisodeFiles());
clearTimeout(updateTimeout.current);
};
@ -90,7 +85,6 @@ function Calendar() {
useEffect(() => {
const repopulate = () => {
dispatch(fetchQueueDetails({ time, view }));
dispatch(fetchCalendar({ time, view }));
};
@ -125,16 +119,11 @@ function Calendar() {
useEffect(() => {
if (!previousItems || hasDifferentItems(items, previousItems)) {
const episodeIds = selectUniqueIds<Episode, number>(items, 'id');
const episodeFileIds = selectUniqueIds<Episode, number>(
items,
'episodeFileId'
);
if (items.length) {
dispatch(fetchQueueDetails({ episodeIds }));
}
if (episodeFileIds.length) {
dispatch(fetchEpisodeFiles({ episodeFileIds }));
}
@ -144,18 +133,15 @@ function Calendar() {
return (
<div className={styles.calendar}>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert>
) : null}
{!error && isPopulated && view === 'agenda' ? (
<div className={styles.calendarContent}>
<CalendarHeader />
<Agenda />
</div>
) : null}
{!error && isPopulated && view !== 'agenda' ? (
<div className={styles.calendarContent}>
<CalendarHeader />

View file

@ -0,0 +1,78 @@
import moment from 'moment';
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { useQueueDetails } from 'Activity/Queue/Details/QueueDetailsProvider';
import AppState from 'App/State/AppState';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import { icons } from 'Helpers/Props';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import Queue from 'typings/Queue';
import { isCommandExecuting } from 'Utilities/Command';
import isBefore from 'Utilities/Date/isBefore';
import translate from 'Utilities/String/translate';
function createIsSearchingSelector() {
return createSelector(
(state: AppState) => state.calendar.searchMissingCommandId,
createCommandsSelector(),
(searchMissingCommandId, commands) => {
if (searchMissingCommandId == null) {
return false;
}
return isCommandExecuting(
commands.find((command) => {
return command.id === searchMissingCommandId;
})
);
}
);
}
function createMissingEpisodeIdsSelector(queueDetails: Queue[]) {
return createSelector(
(state: AppState) => state.calendar.start,
(state: AppState) => state.calendar.end,
(state: AppState) => state.calendar.items,
(start, end, episodes) => {
return episodes.reduce<number[]>((acc, episode) => {
const airDateUtc = episode.airDateUtc;
if (
!episode.episodeFileId &&
moment(airDateUtc).isAfter(start) &&
moment(airDateUtc).isBefore(end) &&
isBefore(episode.airDateUtc) &&
!queueDetails.some(
(details) => !!details.episode && details.episode.id === episode.id
)
) {
acc.push(episode.id);
}
return acc;
}, []);
}
);
}
export default function CalendarMissingEpisodeSearchButton() {
const queueDetails = useQueueDetails();
const missingEpisodeIds = useSelector(
createMissingEpisodeIdsSelector(queueDetails)
);
const isSearchingForMissing = useSelector(createIsSearchingSelector());
const handlePress = useCallback(() => {}, []);
return (
<PageToolbarButton
label={translate('SearchForMissing')}
iconName={icons.SEARCH}
isDisabled={!missingEpisodeIds.length}
isSpinning={isSearchingForMissing}
onPress={handlePress}
/>
);
}

View file

@ -1,7 +1,6 @@
import moment from 'moment';
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import QueueDetails from 'Activity/Queue/Details/QueueDetailsProvider';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import FilterMenu from 'Components/Menu/FilterMenu';
@ -11,24 +10,23 @@ import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import Episode from 'Episode/Episode';
import useMeasure from 'Helpers/Hooks/useMeasure';
import { align, icons } from 'Helpers/Props';
import NoSeries from 'Series/NoSeries';
import {
searchMissing,
setCalendarDaysCount,
setCalendarFilter,
} from 'Store/Actions/calendarActions';
import { executeCommand } from 'Store/Actions/commandActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector';
import { isCommandExecuting } from 'Utilities/Command';
import isBefore from 'Utilities/Date/isBefore';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import translate from 'Utilities/String/translate';
import Calendar from './Calendar';
import CalendarFilterModal from './CalendarFilterModal';
import CalendarMissingEpisodeSearchButton from './CalendarMissingEpisodeSearchButton';
import CalendarLinkModal from './iCal/CalendarLinkModal';
import Legend from './Legend/Legend';
import CalendarOptionsModal from './Options/CalendarOptionsModal';
@ -36,60 +34,12 @@ import styles from './CalendarPage.css';
const MINIMUM_DAY_WIDTH = 120;
function createMissingEpisodeIdsSelector() {
return createSelector(
(state: AppState) => state.calendar.start,
(state: AppState) => state.calendar.end,
(state: AppState) => state.calendar.items,
(state: AppState) => state.queue.details.items,
(start, end, episodes, queueDetails) => {
return episodes.reduce<number[]>((acc, episode) => {
const airDateUtc = episode.airDateUtc;
if (
!episode.episodeFileId &&
moment(airDateUtc).isAfter(start) &&
moment(airDateUtc).isBefore(end) &&
isBefore(episode.airDateUtc) &&
!queueDetails.some(
(details) => !!details.episode && details.episode.id === episode.id
)
) {
acc.push(episode.id);
}
return acc;
}, []);
}
);
}
function createIsSearchingSelector() {
return createSelector(
(state: AppState) => state.calendar.searchMissingCommandId,
createCommandsSelector(),
(searchMissingCommandId, commands) => {
if (searchMissingCommandId == null) {
return false;
}
return isCommandExecuting(
commands.find((command) => {
return command.id === searchMissingCommandId;
})
);
}
);
}
function CalendarPage() {
const dispatch = useDispatch();
const { selectedFilterKey, filters } = useSelector(
const { selectedFilterKey, filters, items } = useSelector(
(state: AppState) => state.calendar
);
const missingEpisodeIds = useSelector(createMissingEpisodeIdsSelector());
const isSearchingForMissing = useSelector(createIsSearchingSelector());
const isRssSyncExecuting = useSelector(
createCommandExecutingSelector(commandNames.RSS_SYNC)
);
@ -127,10 +77,6 @@ function CalendarPage() {
);
}, [dispatch]);
const handleSearchMissingPress = useCallback(() => {
dispatch(searchMissing({ episodeIds: missingEpisodeIds }));
}, [missingEpisodeIds, dispatch]);
const handleFilterSelect = useCallback(
(key: string | number) => {
dispatch(setCalendarFilter({ selectedFilterKey: key }));
@ -138,6 +84,10 @@ function CalendarPage() {
[dispatch]
);
const episodeIds = useMemo(() => {
return selectUniqueIds<Episode, number>(items, 'id');
}, [items]);
useEffect(() => {
if (width === 0) {
return;
@ -152,6 +102,7 @@ function CalendarPage() {
}, [width, dispatch]);
return (
<QueueDetails episodeIds={episodeIds}>
<PageContent title={translate('Calendar')}>
<PageToolbar>
<PageToolbarSection>
@ -170,13 +121,7 @@ function CalendarPage() {
onPress={handleRssSyncPress}
/>
<PageToolbarButton
label={translate('SearchForMissing')}
iconName={icons.SEARCH}
isDisabled={!missingEpisodeIds.length}
isSpinning={isSearchingForMissing}
onPress={handleSearchMissingPress}
/>
<CalendarMissingEpisodeSearchButton />
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
@ -217,6 +162,7 @@ function CalendarPage() {
onModalClose={handleOptionsModalClose}
/>
</PageContent>
</QueueDetails>
);
}

View file

@ -2,6 +2,7 @@ import classNames from 'classnames';
import moment from 'moment';
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
import AppState from 'App/State/AppState';
import getStatusStyle from 'Calendar/getStatusStyle';
import Icon from 'Components/Icon';
@ -12,7 +13,6 @@ import getFinaleTypeName from 'Episode/getFinaleTypeName';
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds } from 'Helpers/Props';
import useSeries from 'Series/useSeries';
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber';
@ -58,7 +58,7 @@ function CalendarEvent(props: CalendarEventProps) {
const series = useSeries(seriesId);
const episodeFile = useEpisodeFile(episodeFileId);
const queueItem = useSelector(createQueueItemSelectorForHook(id));
const queueItem = useQueueItemForEpisode(id);
const { timeFormat, enableColorImpairedMode } = useSelector(
createUISettingsSelector()

View file

@ -2,7 +2,7 @@ import classNames from 'classnames';
import moment from 'moment';
import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { useIsDownloadingEpisodes } from 'Activity/Queue/Details/QueueDetailsProvider';
import AppState from 'App/State/AppState';
import getStatusStyle from 'Calendar/getStatusStyle';
import Icon from 'Components/Icon';
@ -18,17 +18,6 @@ import translate from 'Utilities/String/translate';
import CalendarEvent from './CalendarEvent';
import styles from './CalendarEventGroup.css';
function createIsDownloadingSelector(episodeIds: number[]) {
return createSelector(
(state: AppState) => state.queue.details,
(details) => {
return details.items.some(
(item) => item.episodeId && episodeIds.includes(item.episodeId)
);
}
);
}
interface CalendarEventGroupProps {
episodeIds: number[];
seriesId: number;
@ -42,7 +31,7 @@ function CalendarEventGroup({
events,
onEventModalOpenToggle,
}: CalendarEventGroupProps) {
const isDownloading = useSelector(createIsDownloadingSelector(episodeIds));
const isDownloading = useIsDownloadingEpisodes(episodeIds);
const series = useSeries(seriesId)!;
const { timeFormat, enableColorImpairedMode } = useSelector(

View file

@ -10,7 +10,7 @@ import {
interface CalendarEventQueueDetailsProps {
title: string;
size: number;
sizeleft: number;
sizeLeft: number;
estimatedCompletionTime?: string;
status: string;
trackedDownloadState: QueueTrackedDownloadState;
@ -22,7 +22,7 @@ interface CalendarEventQueueDetailsProps {
function CalendarEventQueueDetails({
title,
size,
sizeleft,
sizeLeft,
estimatedCompletionTime,
status,
trackedDownloadState,
@ -30,13 +30,13 @@ function CalendarEventQueueDetails({
statusMessages,
errorMessage,
}: CalendarEventQueueDetailsProps) {
const progress = size ? 100 - (sizeleft / size) * 100 : 0;
const progress = size ? 100 - (sizeLeft / size) * 100 : 0;
return (
<QueueDetails
title={title}
size={size}
sizeleft={sizeleft}
sizeLeft={sizeLeft}
estimatedCompletionTime={estimatedCompletionTime}
status={status}
trackedDownloadState={trackedDownloadState}

View file

@ -3,19 +3,18 @@ import {
HubConnectionBuilder,
LogLevel,
} from '@microsoft/signalr';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import ModelBase from 'App/ModelBase';
import AppState from 'App/State/AppState';
import Command from 'Commands/Command';
import { setAppValue, setVersion } from 'Store/Actions/appActions';
import { removeItem, update, updateItem } from 'Store/Actions/baseActions';
import { removeItem, updateItem } from 'Store/Actions/baseActions';
import {
fetchCommands,
finishCommand,
updateCommand,
} from 'Store/Actions/commandActions';
import { fetchQueue, fetchQueueDetails } from 'Store/Actions/queueActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import { fetchSeries } from 'Store/Actions/seriesActions';
import { fetchQualityDefinitions } from 'Store/Actions/settingsActions';
@ -33,15 +32,13 @@ interface SignalRMessage {
resource: ModelBase;
version: string;
};
version: number | undefined;
}
function SignalRListener() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const isQueuePopulated = useSelector(
(state: AppState) => state.queue.paged.isPopulated
);
const connection = useRef<HubConnection | null>(null);
const handleStartFail = useRef((error: unknown) => {
@ -97,9 +94,14 @@ function SignalRListener() {
});
const handleReceiveMessage = useRef((message: SignalRMessage) => {
console.debug('[signalR] received', message.name, message.body);
console.debug(
`[signalR] received ${message.name}${
message.version ? ` v${message.version}` : ''
}`,
message.body
);
const { name, body } = message;
const { name, body, version = 0 } = message;
if (name === 'calendar') {
if (body.action === 'updated') {
@ -235,20 +237,36 @@ function SignalRListener() {
}
if (name === 'queue') {
if (isQueuePopulated) {
dispatch(fetchQueue());
if (version < 5) {
return;
}
queryClient.invalidateQueries({ queryKey: ['/queue'] });
return;
}
if (name === 'queue/details') {
dispatch(fetchQueueDetails());
if (version < 5) {
return;
}
queryClient.invalidateQueries({ queryKey: ['/queue/details'] });
return;
}
if (name === 'queue/status') {
dispatch(update({ section: 'queue.status', data: body.resource }));
if (version < 5) {
return;
}
const statusDetails = queryClient.getQueriesData({
queryKey: ['/queue/status'],
});
statusDetails.forEach(([queryKey]) => {
queryClient.setQueryData(queryKey, () => body.resource);
});
return;
}

View file

@ -20,7 +20,6 @@ function RelativeDateCell(props: RelativeDateCellProps) {
date,
includeSeconds = false,
includeTime = false,
component: Component = TableRowCell,
...otherProps
} = props;

View file

@ -1,5 +1,5 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
import QueueDetails from 'Activity/Queue/QueueDetails';
import Icon from 'Components/Icon';
import ProgressBar from 'Components/ProgressBar';
@ -7,7 +7,6 @@ import Episode from 'Episode/Episode';
import useEpisode, { EpisodeEntity } from 'Episode/useEpisode';
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds, sizes } from 'Helpers/Props';
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
import isBefore from 'Utilities/Date/isBefore';
import translate from 'Utilities/String/translate';
import EpisodeQuality from './EpisodeQuality';
@ -30,7 +29,7 @@ function EpisodeStatus({
grabbed = false,
} = useEpisode(episodeId, episodeEntity) as Episode;
const queueItem = useSelector(createQueueItemSelectorForHook(episodeId));
const queueItem = useQueueItemForEpisode(episodeId);
const episodeFile = useEpisodeFile(episodeFileId);
const hasEpisodeFile = !!episodeFile;
@ -38,9 +37,9 @@ function EpisodeStatus({
const hasAired = isBefore(airDateUtc);
if (isQueued) {
const { sizeleft, size } = queueItem;
const { sizeLeft, size } = queueItem;
const progress = size ? 100 - (sizeleft / size) * 100 : 0;
const progress = size ? 100 - (sizeLeft / size) * 100 : 0;
return (
<div className={styles.center}>

View file

@ -0,0 +1,82 @@
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Episode from './Episode';
export type EpisodeEntity =
| 'calendar'
| 'episodes'
| 'interactiveImport.episodes'
| 'wanted.cutoffUnmet'
| 'wanted.missing';
function getEpisodes(episodeIds: number[], episodes: Episode[]) {
return episodeIds.reduce<Episode[]>((acc, id) => {
const episode = episodes.find((episode) => episode.id === id);
if (episode) {
acc.push(episode);
}
return acc;
}, []);
}
function createEpisodeSelector(episodeIds: number[]) {
return createSelector(
(state: AppState) => state.episodes.items,
(episodes) => {
return getEpisodes(episodeIds, episodes);
}
);
}
function createCalendarEpisodeSelector(episodeIds: number[]) {
return createSelector(
(state: AppState) => state.calendar.items as Episode[],
(episodes) => {
return getEpisodes(episodeIds, episodes);
}
);
}
function createWantedCutoffUnmetEpisodeSelector(episodeIds: number[]) {
return createSelector(
(state: AppState) => state.wanted.cutoffUnmet.items,
(episodes) => {
return getEpisodes(episodeIds, episodes);
}
);
}
function createWantedMissingEpisodeSelector(episodeIds: number[]) {
return createSelector(
(state: AppState) => state.wanted.missing.items,
(episodes) => {
return getEpisodes(episodeIds, episodes);
}
);
}
export default function useEpisodes(
episodeIds: number[],
episodeEntity: EpisodeEntity
) {
let selector = createEpisodeSelector;
switch (episodeEntity) {
case 'calendar':
selector = createCalendarEpisodeSelector;
break;
case 'wanted.cutoffUnmet':
selector = createWantedCutoffUnmetEpisodeSelector;
break;
case 'wanted.missing':
selector = createWantedMissingEpisodeSelector;
break;
default:
break;
}
return useSelector(selector(episodeIds));
}

View file

@ -1,22 +1,22 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useMemo } from 'react';
import { Error } from 'App/State/AppSectionState';
import fetchJson, {
apiRoot,
FetchJsonOptions,
} from 'Utilities/Fetch/fetchJson';
import fetchJson, { FetchJsonOptions } from 'Utilities/Fetch/fetchJson';
import getQueryPath from 'Utilities/Fetch/getQueryPath';
import getQueryString, { QueryParams } from 'Utilities/Fetch/getQueryString';
interface MutationOptions<T, TData>
extends Omit<FetchJsonOptions<TData>, 'method'> {
method: 'POST' | 'PUT' | 'DELETE';
mutationOptions?: Omit<UseMutationOptions<T, Error, TData>, 'mutationFn'>;
queryParams?: QueryParams;
}
function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
const requestOptions = useMemo(() => {
return {
...options,
path: apiRoot + options.path,
path: getQueryPath(options.path) + getQueryString(options.queryParams),
headers: {
...options.headers,
'X-Api-Key': window.Sonarr.apiKey,
@ -26,8 +26,11 @@ function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
return useMutation<T, Error, TData>({
...options.mutationOptions,
mutationFn: async (data: TData) =>
fetchJson<T, TData>({ ...requestOptions, body: data }),
mutationFn: async (data?: TData) => {
const { path, ...otherOptions } = requestOptions;
return fetchJson<T, TData>({ path, ...otherOptions, body: data });
},
});
}

View file

@ -15,22 +15,25 @@ export interface QueryOptions<T> extends FetchJsonOptions<unknown> {
}
const useApiQuery = <T>(options: QueryOptions<T>) => {
const requestOptions = useMemo(() => {
const { queryKey, requestOptions } = useMemo(() => {
const { path: path, queryOptions, queryParams, ...otherOptions } = options;
return {
queryKey: [path, queryParams],
requestOptions: {
...otherOptions,
path: getQueryPath(path) + getQueryString(queryParams),
headers: {
...options.headers,
'X-Api-Key': window.Sonarr.apiKey,
},
},
};
}, [options]);
return useQuery({
...options.queryOptions,
queryKey: [requestOptions.path],
queryKey,
queryFn: async ({ signal }) =>
fetchJson<T, unknown>({ ...requestOptions, signal }),
});

View file

@ -0,0 +1,128 @@
import { StateCreator } from 'zustand';
import { PersistOptions } from 'zustand/middleware';
import Column from 'Components/Table/Column';
import { createPersist } from 'Helpers/createPersist';
type TSettingsWithoutColumns = object;
interface TSettingsWithColumns {
columns: Column[];
}
type TSettingd = TSettingsWithoutColumns | TSettingsWithColumns;
export type OptionChanged<T> = {
name: keyof T;
value: T[keyof T];
};
export const createOptionsStore = <T extends TSettingd>(
name: string,
state: StateCreator<T>,
options: Omit<PersistOptions<T>, 'name' | 'storage'> = {}
) => {
const store = createPersist<T>(name, state, {
merge,
...options,
});
const useOptions = () => {
return store((state) => state);
};
const useOption = <K extends keyof T>(key: K) => {
return store((state) => state[key]);
};
const setOptions = (options: Partial<T>) => {
store.setState((state) => ({
...state,
...options,
}));
};
const setOption = <K extends keyof T>(key: K, value: T[K]) => {
store.setState((state) => ({
...state,
[key]: value,
}));
};
return {
store,
useOptions,
useOption,
setOptions,
setOption,
};
};
const merge = <T extends TSettingd>(
persistedState: unknown,
currentState: T
) => {
if ('columns' in currentState) {
return {
...currentState,
...mergeColumns(persistedState, currentState),
};
}
return {
...currentState,
...((persistedState as T) ?? {}),
};
};
const mergeColumns = <T extends { columns: Column[] }>(
persistedState: unknown,
currentState: T
) => {
const currentColumns = currentState.columns;
const persistedColumns = (persistedState as T).columns;
const columns: Column[] = [];
// Add persisted columns in the same order they're currently in
// as long as they haven't been removed.
persistedColumns.forEach((persistedColumn) => {
const column = currentColumns.find((i) => i.name === persistedColumn.name);
if (column) {
const newColumn: Partial<Column> = {};
// We can't use a spread operator or Object.assign to clone the column
// or any accessors are lost and can break translations.
for (const prop of Object.keys(column)) {
const attributes = Object.getOwnPropertyDescriptor(column, prop);
if (!attributes) {
return;
}
Object.defineProperty(newColumn, prop, attributes);
}
newColumn.isVisible = persistedColumn.isVisible;
columns.push(newColumn as Column);
}
});
// Add any columns added to the app in the initial position.
currentColumns.forEach((currentColumn, index) => {
const persistedColumnIndex = persistedColumns.findIndex(
(i) => i.name === currentColumn.name
);
const column = Object.assign({}, currentColumn);
if (persistedColumnIndex === -1) {
columns.splice(index, 0, column);
}
});
return {
...(persistedState as T),
columns,
};
};

View file

@ -4,10 +4,12 @@ import { create } from 'zustand';
interface PageStore {
events: number;
queue: number;
}
const pageStore = create<PageStore>(() => ({
events: 1,
queue: 1,
}));
const usePage = (kind: keyof PageStore) => {

View file

@ -26,7 +26,7 @@ interface PagedQueryResponse<T> {
}
const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
const requestOptions = useMemo(() => {
const { requestOptions, queryKey } = useMemo(() => {
const {
path,
page,
@ -40,6 +40,16 @@ const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
} = options;
return {
queryKey: [
path,
queryParams,
page,
pageSize,
sortKey,
sortDirection,
filters,
],
requestOptions: {
...otherOptions,
path:
getQueryPath(path) +
@ -55,12 +65,13 @@ const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
...options.headers,
'X-Api-Key': window.Sonarr.apiKey,
},
},
};
}, [options]);
return useQuery({
...options.queryOptions,
queryKey: [requestOptions.path],
queryKey,
queryFn: async ({ signal }) => {
const response = await fetchJson<PagedQueryResponse<T>, unknown>({
...requestOptions,

View file

@ -1,6 +1,5 @@
import { create, type StateCreator } from 'zustand';
import { persist, type PersistOptions } from 'zustand/middleware';
import Column from 'Components/Table/Column';
export const createPersist = <T>(
name: string,
@ -19,56 +18,3 @@ export const createPersist = <T>(
})
);
};
export const mergeColumns = <T extends { columns: Column[] }>(
persistedState: unknown,
currentState: T
) => {
const currentColumns = currentState.columns;
const persistedColumns = (persistedState as T).columns;
const columns: Column[] = [];
// Add persisted columns in the same order they're currently in
// as long as they haven't been removed.
persistedColumns.forEach((persistedColumn) => {
const column = currentColumns.find((i) => i.name === persistedColumn.name);
if (column) {
const newColumn: Partial<Column> = {};
// We can't use a spread operator or Object.assign to clone the column
// or any accessors are lost and can break translations.
for (const prop of Object.keys(column)) {
const attributes = Object.getOwnPropertyDescriptor(column, prop);
if (!attributes) {
return;
}
Object.defineProperty(newColumn, prop, attributes);
}
newColumn.isVisible = persistedColumn.isVisible;
columns.push(newColumn as Column);
}
});
// Add any columns added to the app in the initial position.
currentColumns.forEach((currentColumn, index) => {
const persistedColumnIndex = persistedColumns.findIndex(
(i) => i.name === currentColumn.name
);
const column = Object.assign({}, currentColumn);
if (persistedColumnIndex === -1) {
columns.splice(index, 0, column);
}
});
return {
...(persistedState as T),
columns,
};
};

View file

@ -1,10 +1,7 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { useQueueDetailsForSeries } from 'Activity/Queue/Details/QueueDetailsProvider';
import Label from 'Components/Label';
import { kinds, sizes } from 'Helpers/Props';
import createSeriesQueueItemsDetailsSelector, {
SeriesQueueDetails,
} from 'Series/Index/createSeriesQueueDetailsSelector';
function getEpisodeCountKind(
monitored: boolean,
@ -44,9 +41,7 @@ function SeasonProgressLabel({
episodeCount,
episodeFileCount,
}: SeasonProgressLabelProps) {
const queueDetails: SeriesQueueDetails = useSelector(
createSeriesQueueItemsDetailsSelector(seriesId, seasonNumber)
);
const queueDetails = useQueueDetailsForSeries(seriesId, seasonNumber);
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
const text = newDownloads

View file

@ -2,6 +2,7 @@ import moment from 'moment';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import Alert from 'Components/Alert';
@ -47,10 +48,6 @@ import {
clearEpisodeFiles,
fetchEpisodeFiles,
} from 'Store/Actions/episodeFileActions';
import {
clearQueueDetails,
fetchQueueDetails,
} from 'Store/Actions/queueActions';
import { toggleSeriesMonitored } from 'Store/Actions/seriesActions';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
@ -380,7 +377,6 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
const populate = useCallback(() => {
dispatch(fetchEpisodes({ seriesId }));
dispatch(fetchEpisodeFiles({ seriesId }));
dispatch(fetchQueueDetails({ seriesId }));
}, [seriesId, dispatch]);
useEffect(() => {
@ -394,7 +390,6 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
unregisterPagePopulator(populate);
dispatch(clearEpisodes());
dispatch(clearEpisodeFiles());
dispatch(clearQueueDetails());
};
}, [populate, dispatch]);
@ -466,6 +461,7 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
const isPopulated = isEpisodesPopulated && isEpisodeFilesPopulated;
return (
<QueueDetailsProvider seriesId={seriesId}>
<PageContent title={title}>
<PageToolbar>
<PageToolbarSection>
@ -681,7 +677,9 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
<div>
<Icon name={icons.PROFILE} size={17} />
<span className={styles.qualityProfileName}>
<QualityProfileName qualityProfileId={qualityProfileId} />
<QualityProfileName
qualityProfileId={qualityProfileId}
/>
</span>
</div>
</Label>
@ -768,10 +766,15 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
{tags.length ? (
<Tooltip
anchor={
<Label className={styles.detailsLabel} size={sizes.LARGE}>
<Label
className={styles.detailsLabel}
size={sizes.LARGE}
>
<Icon name={icons.TAGS} size={17} />
<span className={styles.tags}>{translate('Tags')}</span>
<span className={styles.tags}>
{translate('Tags')}
</span>
</Label>
}
tooltip={<SeriesTags seriesId={seriesId} />}
@ -802,7 +805,9 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
) : null}
{!isFetching && episodesError ? (
<Alert kind={kinds.DANGER}>{translate('EpisodesLoadError')}</Alert>
<Alert kind={kinds.DANGER}>
{translate('EpisodesLoadError')}
</Alert>
) : null}
{!isFetching && episodeFilesError ? (
@ -884,6 +889,7 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
/>
</PageContentBody>
</PageContent>
</QueueDetailsProvider>
);
}

View file

@ -1,10 +1,7 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { useQueueDetailsForSeries } from 'Activity/Queue/Details/QueueDetailsProvider';
import Label from 'Components/Label';
import { kinds, sizes } from 'Helpers/Props';
import createSeriesQueueItemsDetailsSelector, {
SeriesQueueDetails,
} from 'Series/Index/createSeriesQueueDetailsSelector';
function getEpisodeCountKind(
monitored: boolean,
@ -42,9 +39,7 @@ function SeriesProgressLabel({
episodeCount,
episodeFileCount,
}: SeriesProgressLabelProps) {
const queueDetails: SeriesQueueDetails = useSelector(
createSeriesQueueItemsDetailsSelector(seriesId)
);
const queueDetails = useQueueDetailsForSeries(seriesId);
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
const text = newDownloads

View file

@ -1,10 +1,7 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { useQueueDetailsForSeries } from 'Activity/Queue/Details/QueueDetailsProvider';
import ProgressBar from 'Components/ProgressBar';
import { sizes } from 'Helpers/Props';
import createSeriesQueueItemsDetailsSelector, {
SeriesQueueDetails,
} from 'Series/Index/createSeriesQueueDetailsSelector';
import { SeriesStatus } from 'Series/Series';
import getProgressBarKind from 'Utilities/Series/getProgressBarKind';
import translate from 'Utilities/String/translate';
@ -37,9 +34,7 @@ function SeriesIndexProgressBar(props: SeriesIndexProgressBarProps) {
isStandalone,
} = props;
const queueDetails: SeriesQueueDetails = useSelector(
createSeriesQueueItemsDetailsSelector(seriesId, seasonNumber)
);
const queueDetails = useQueueDetailsForSeries(seriesId, seasonNumber);
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
const progress = episodeCount ? (episodeFileCount / episodeCount) * 100 : 100;

View file

@ -6,6 +6,7 @@ import React, {
useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider';
import { SelectProvider } from 'App/SelectContext';
import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState';
import SeriesAppState, { SeriesIndexAppState } from 'App/State/SeriesAppState';
@ -26,7 +27,6 @@ import { DESCENDING } from 'Helpers/Props/sortDirections';
import ParseToolbarButton from 'Parse/ParseToolbarButton';
import NoSeries from 'Series/NoSeries';
import { executeCommand } from 'Store/Actions/commandActions';
import { fetchQueueDetails } from 'Store/Actions/queueActions';
import { fetchSeries } from 'Store/Actions/seriesActions';
import {
setSeriesFilter,
@ -104,7 +104,6 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
useEffect(() => {
dispatch(fetchSeries());
dispatch(fetchQueueDetails({ all: true }));
}, [dispatch]);
const onRssSyncPress = useCallback(() => {
@ -217,6 +216,7 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
const hasNoSeries = !totalItems;
return (
<QueueDetailsProvider all={true}>
<SelectProvider items={items}>
<PageContent>
<PageToolbar>
@ -319,7 +319,9 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{!isFetching && !!error ? (
<Alert kind={kinds.DANGER}>{translate('SeriesLoadError')}</Alert>
<Alert kind={kinds.DANGER}>
{translate('SeriesLoadError')}
</Alert>
) : null}
{isLoaded ? (
@ -366,6 +368,7 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
) : null}
</PageContent>
</SelectProvider>
</QueueDetailsProvider>
);
}, 'seriesIndex');

View file

@ -1,46 +0,0 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
export interface SeriesQueueDetails {
count: number;
episodesWithFiles: number;
}
function createSeriesQueueDetailsSelector(
seriesId: number,
seasonNumber?: number
) {
return createSelector(
(state: AppState) => state.queue.details.items,
(queueItems) => {
return queueItems.reduce(
(acc: SeriesQueueDetails, item) => {
if (
item.trackedDownloadState === 'imported' ||
item.seriesId !== seriesId
) {
return acc;
}
if (seasonNumber != null && item.seasonNumber !== seasonNumber) {
return acc;
}
acc.count++;
if (item.episodeHasFile) {
acc.episodesWithFiles++;
}
return acc;
},
{
count: 0,
episodesWithFiles: 0,
}
);
}
);
}
export default createSeriesQueueDetailsSelector;

View file

@ -171,7 +171,7 @@ function HostSettings({
</FormGroup>
) : null}
{isWindowsService ? (
{isWindowsService ? null : (
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('OpenBrowserOnStart')}</FormLabel>
@ -183,7 +183,7 @@ function HostSettings({
{...launchBrowser}
/>
</FormGroup>
) : null}
)}
</FieldSet>
);
}

View file

@ -16,7 +16,6 @@ import * as organizePreview from './organizePreviewActions';
import * as parse from './parseActions';
import * as paths from './pathActions';
import * as providerOptions from './providerOptionActions';
import * as queue from './queueActions';
import * as releases from './releaseActions';
import * as rootFolders from './rootFolderActions';
import * as series from './seriesActions';
@ -46,7 +45,6 @@ export default [
parse,
paths,
providerOptions,
queue,
releases,
rootFolders,
series,

View file

@ -1,562 +0,0 @@
import _ from 'lodash';
import React from 'react';
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import Icon from 'Components/Icon';
import { filterBuilderTypes, filterBuilderValueTypes, icons, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import serverSideCollectionHandlers from 'Utilities/State/serverSideCollectionHandlers';
import translate from 'Utilities/String/translate';
import { set, updateItem } from './baseActions';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
import createClearReducer from './Creators/Reducers/createClearReducer';
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
//
// Variables
export const section = 'queue';
const status = `${section}.status`;
const details = `${section}.details`;
const paged = `${section}.paged`;
//
// State
export const defaultState = {
options: {
includeUnknownSeriesItems: true
},
removalOptions: {
removalMethod: 'removeFromClient',
blocklistMethod: 'doNotBlocklist'
},
status: {
isFetching: false,
isPopulated: false,
error: null,
item: {}
},
details: {
isFetching: false,
isPopulated: false,
error: null,
items: [],
params: {}
},
paged: {
isFetching: false,
isPopulated: false,
pageSize: 20,
sortKey: 'timeleft',
sortDirection: sortDirections.ASCENDING,
error: null,
items: [],
isGrabbing: false,
isRemoving: false,
columns: [
{
name: 'status',
columnLabel: () => translate('Status'),
isSortable: true,
isVisible: true,
isModifiable: false
},
{
name: 'series.sortTitle',
label: () => translate('Series'),
isSortable: true,
isVisible: true
},
{
name: 'episode',
label: () => translate('Episode'),
isSortable: true,
isVisible: true
},
{
name: 'episodes.title',
label: () => translate('EpisodeTitle'),
isSortable: true,
isVisible: true
},
{
name: 'episodes.airDateUtc',
label: () => translate('EpisodeAirDate'),
isSortable: true,
isVisible: false
},
{
name: 'languages',
label: () => translate('Languages'),
isSortable: true,
isVisible: false
},
{
name: 'quality',
label: () => translate('Quality'),
isSortable: true,
isVisible: true
},
{
name: 'customFormats',
label: () => translate('Formats'),
isSortable: false,
isVisible: true
},
{
name: 'customFormatScore',
columnLabel: () => translate('CustomFormatScore'),
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore')
}),
isVisible: false
},
{
name: 'protocol',
label: () => translate('Protocol'),
isSortable: true,
isVisible: false
},
{
name: 'indexer',
label: () => translate('Indexer'),
isSortable: true,
isVisible: false
},
{
name: 'downloadClient',
label: () => translate('DownloadClient'),
isSortable: true,
isVisible: false
},
{
name: 'title',
label: () => translate('ReleaseTitle'),
isSortable: true,
isVisible: false
},
{
name: 'size',
label: () => translate('Size'),
isSortable: true,
isVisible: false
},
{
name: 'outputPath',
label: () => translate('OutputPath'),
isSortable: false,
isVisible: false
},
{
name: 'estimatedCompletionTime',
label: () => translate('TimeLeft'),
isSortable: true,
isVisible: true
},
{
name: 'added',
label: () => translate('Added'),
isSortable: true,
isVisible: false
},
{
name: 'progress',
label: () => translate('Progress'),
isSortable: true,
isVisible: true
},
{
name: 'actions',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false
}
],
selectedFilterKey: 'all',
filters: [
{
key: 'all',
label: 'All',
filters: []
}
],
filterBuilderProps: [
{
name: 'seriesIds',
label: () => translate('Series'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.SERIES
},
{
name: 'quality',
label: () => translate('Quality'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.QUALITY
},
{
name: 'languages',
label: () => translate('Languages'),
type: filterBuilderTypes.CONTAINS,
valueType: filterBuilderValueTypes.LANGUAGE
},
{
name: 'protocol',
label: () => translate('Protocol'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.PROTOCOL
},
{
name: 'status',
label: () => translate('Status'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.QUEUE_STATUS
}
]
}
};
export const persistState = [
'queue.options',
'queue.removalOptions',
'queue.paged.pageSize',
'queue.paged.sortKey',
'queue.paged.sortDirection',
'queue.paged.columns',
'queue.paged.selectedFilterKey'
];
//
// Helpers
function fetchDataAugmenter(getState, payload, data) {
data.includeUnknownSeriesItems = getState().queue.options.includeUnknownSeriesItems;
}
//
// Actions Types
export const FETCH_QUEUE_STATUS = 'queue/fetchQueueStatus';
export const FETCH_QUEUE_DETAILS = 'queue/fetchQueueDetails';
export const CLEAR_QUEUE_DETAILS = 'queue/clearQueueDetails';
export const FETCH_QUEUE = 'queue/fetchQueue';
export const GOTO_FIRST_QUEUE_PAGE = 'queue/gotoQueueFirstPage';
export const GOTO_PREVIOUS_QUEUE_PAGE = 'queue/gotoQueuePreviousPage';
export const GOTO_NEXT_QUEUE_PAGE = 'queue/gotoQueueNextPage';
export const GOTO_LAST_QUEUE_PAGE = 'queue/gotoQueueLastPage';
export const GOTO_QUEUE_PAGE = 'queue/gotoQueuePage';
export const SET_QUEUE_SORT = 'queue/setQueueSort';
export const SET_QUEUE_FILTER = 'queue/setQueueFilter';
export const SET_QUEUE_TABLE_OPTION = 'queue/setQueueTableOption';
export const SET_QUEUE_OPTION = 'queue/setQueueOption';
export const SET_QUEUE_REMOVAL_OPTION = 'queue/setQueueRemoveOption';
export const CLEAR_QUEUE = 'queue/clearQueue';
export const GRAB_QUEUE_ITEM = 'queue/grabQueueItem';
export const GRAB_QUEUE_ITEMS = 'queue/grabQueueItems';
export const REMOVE_QUEUE_ITEM = 'queue/removeQueueItem';
export const REMOVE_QUEUE_ITEMS = 'queue/removeQueueItems';
//
// Action Creators
export const fetchQueueStatus = createThunk(FETCH_QUEUE_STATUS);
export const fetchQueueDetails = createThunk(FETCH_QUEUE_DETAILS);
export const clearQueueDetails = createAction(CLEAR_QUEUE_DETAILS);
export const fetchQueue = createThunk(FETCH_QUEUE);
export const gotoQueueFirstPage = createThunk(GOTO_FIRST_QUEUE_PAGE);
export const gotoQueuePreviousPage = createThunk(GOTO_PREVIOUS_QUEUE_PAGE);
export const gotoQueueNextPage = createThunk(GOTO_NEXT_QUEUE_PAGE);
export const gotoQueueLastPage = createThunk(GOTO_LAST_QUEUE_PAGE);
export const gotoQueuePage = createThunk(GOTO_QUEUE_PAGE);
export const setQueueSort = createThunk(SET_QUEUE_SORT);
export const setQueueFilter = createThunk(SET_QUEUE_FILTER);
export const setQueueTableOption = createAction(SET_QUEUE_TABLE_OPTION);
export const setQueueOption = createAction(SET_QUEUE_OPTION);
export const setQueueRemovalOption = createAction(SET_QUEUE_REMOVAL_OPTION);
export const clearQueue = createAction(CLEAR_QUEUE);
export const grabQueueItem = createThunk(GRAB_QUEUE_ITEM);
export const grabQueueItems = createThunk(GRAB_QUEUE_ITEMS);
export const removeQueueItem = createThunk(REMOVE_QUEUE_ITEM);
export const removeQueueItems = createThunk(REMOVE_QUEUE_ITEMS);
//
// Helpers
const fetchQueueDetailsHelper = createFetchHandler(details, '/queue/details');
//
// Action Handlers
export const actionHandlers = handleThunks({
[FETCH_QUEUE_STATUS]: createFetchHandler(status, '/queue/status'),
[FETCH_QUEUE_DETAILS]: function(getState, payload, dispatch) {
let params = payload;
// If the payload params are empty try to get params from state.
if (params && !_.isEmpty(params)) {
dispatch(set({ section: details, params }));
} else {
params = getState().queue.details.params;
}
// Ensure there are params before trying to fetch the queue
// so we don't make a bad request to the server.
if (params && !_.isEmpty(params)) {
fetchQueueDetailsHelper(getState, params, dispatch);
}
},
...createServerSideCollectionHandlers(
paged,
'/queue',
fetchQueue,
{
[serverSideCollectionHandlers.FETCH]: FETCH_QUEUE,
[serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_QUEUE_PAGE,
[serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_QUEUE_PAGE,
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_QUEUE_PAGE,
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_QUEUE_PAGE,
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_QUEUE_PAGE,
[serverSideCollectionHandlers.SORT]: SET_QUEUE_SORT,
[serverSideCollectionHandlers.FILTER]: SET_QUEUE_FILTER
},
fetchDataAugmenter
),
[GRAB_QUEUE_ITEM]: function(getState, payload, dispatch) {
const id = payload.id;
dispatch(updateItem({ section: paged, id, isGrabbing: true }));
const promise = createAjaxRequest({
url: `/queue/grab/${id}`,
method: 'POST'
}).request;
promise.done((data) => {
dispatch(batchActions([
fetchQueue(),
set({
section: paged,
isGrabbing: false,
grabError: null
})
]));
});
promise.fail((xhr) => {
dispatch(updateItem({
section: paged,
id,
isGrabbing: false,
grabError: xhr
}));
});
},
[GRAB_QUEUE_ITEMS]: function(getState, payload, dispatch) {
const ids = payload.ids;
dispatch(batchActions([
...ids.map((id) => {
return updateItem({
section: paged,
id,
isGrabbing: true
});
}),
set({
section: paged,
isGrabbing: true
})
]));
const promise = createAjaxRequest({
url: '/queue/grab/bulk',
method: 'POST',
dataType: 'json',
data: JSON.stringify(payload)
}).request;
promise.done((data) => {
dispatch(fetchQueue());
dispatch(batchActions([
...ids.map((id) => {
return updateItem({
section: paged,
id,
isGrabbing: false,
grabError: null
});
}),
set({
section: paged,
isGrabbing: false,
grabError: null
})
]));
});
promise.fail((xhr) => {
dispatch(batchActions([
...ids.map((id) => {
return updateItem({
section: paged,
id,
isGrabbing: false,
grabError: null
});
}),
set({ section: paged, isGrabbing: false })
]));
});
},
[REMOVE_QUEUE_ITEM]: function(getState, payload, dispatch) {
const {
id,
remove,
blocklist,
skipRedownload,
changeCategory
} = payload;
dispatch(updateItem({ section: paged, id, isRemoving: true }));
const promise = createAjaxRequest({
url: `/queue/${id}?removeFromClient=${remove}&blocklist=${blocklist}&skipRedownload=${skipRedownload}&changeCategory=${changeCategory}`,
method: 'DELETE'
}).request;
promise.done((data) => {
dispatch(fetchQueue());
});
promise.fail((xhr) => {
dispatch(updateItem({ section: paged, id, isRemoving: false }));
});
},
[REMOVE_QUEUE_ITEMS]: function(getState, payload, dispatch) {
const {
ids,
remove,
blocklist,
skipRedownload,
changeCategory
} = payload;
dispatch(batchActions([
...ids.map((id) => {
return updateItem({
section: paged,
id,
isRemoving: true
});
}),
set({ section: paged, isRemoving: true })
]));
const promise = createAjaxRequest({
url: `/queue/bulk?removeFromClient=${remove}&blocklist=${blocklist}&skipRedownload=${skipRedownload}&changeCategory=${changeCategory}`,
method: 'DELETE',
dataType: 'json',
contentType: 'application/json',
data: JSON.stringify({ ids })
}).request;
promise.done((data) => {
// Don't use batchActions with thunks
dispatch(fetchQueue());
dispatch(set({ section: paged, isRemoving: false }));
});
promise.fail((xhr) => {
dispatch(batchActions([
...ids.map((id) => {
return updateItem({
section: paged,
id,
isRemoving: false
});
}),
set({ section: paged, isRemoving: false })
]));
});
}
});
//
// Reducers
export const reducers = createHandleActions({
[CLEAR_QUEUE_DETAILS]: createClearReducer(details, defaultState.details),
[SET_QUEUE_TABLE_OPTION]: createSetTableOptionReducer(paged),
[SET_QUEUE_OPTION]: function(state, { payload }) {
const queueOptions = state.options;
return {
...state,
options: {
...queueOptions,
...payload
}
};
},
[SET_QUEUE_REMOVAL_OPTION]: function(state, { payload }) {
const queueRemovalOptions = state.removalOptions;
return {
...state,
removalOptions: {
...queueRemovalOptions,
...payload
}
};
},
[CLEAR_QUEUE]: createClearReducer(paged, {
isFetching: false,
isPopulated: false,
error: null,
items: [],
totalPages: 0,
totalRecords: 0
})
}, defaultState, section);

View file

@ -1,31 +0,0 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
export function createQueueItemSelectorForHook(episodeId: number) {
return createSelector(
(state: AppState) => state.queue.details.items,
(details) => {
if (!episodeId || !details) {
return null;
}
return details.find((item) => item.episodeId === episodeId);
}
);
}
function createQueueItemSelector() {
return createSelector(
(_: AppState, { episodeId }: { episodeId: number }) => episodeId,
(state: AppState) => state.queue.details.items,
(episodeId, details) => {
if (!episodeId || !details) {
return null;
}
return details.find((item) => item.episodeId === episodeId);
}
);
}
export default createQueueItemSelector;

View file

@ -1,5 +1,5 @@
import Column from 'Components/Table/Column';
import { createPersist, mergeColumns } from 'Helpers/createPersist';
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
import { SortDirection } from 'Helpers/Props/sortDirections';
import translate from 'Utilities/String/translate';
@ -11,7 +11,7 @@ export interface EventOptions {
columns: Column[];
}
const eventOptionsStore = createPersist<EventOptions>(
const { useOptions, setOptions, setOption } = createOptionsStore<EventOptions>(
'event_options',
() => {
return {
@ -57,29 +57,9 @@ const eventOptionsStore = createPersist<EventOptions>(
},
],
};
},
{
merge: mergeColumns,
}
);
export const useEventOptions = () => {
return eventOptionsStore((state) => state);
};
export const setEventOptions = (options: Partial<EventOptions>) => {
eventOptionsStore.setState((state) => ({
...state,
...options,
}));
};
export const setEventOption = <K extends keyof EventOptions>(
key: K,
value: EventOptions[K]
) => {
eventOptionsStore.setState((state) => ({
...state,
[key]: value,
}));
};
export const useEventOptions = useOptions;
export const setEventOptions = setOptions;
export const setEventOption = setOption;

View file

@ -81,6 +81,10 @@ async function fetchJson<T, TData>({
throw new ApiError(path, response.status, response.statusText, body);
}
if (response.status === 204) {
return {} as T;
}
return response.json() as T;
}

View file

@ -1,7 +1,13 @@
import { PropertyFilter } from 'App/State/AppState';
export interface QueryParams {
[key: string]: string | number | boolean | PropertyFilter[] | undefined;
[key: string]:
| string
| number
| boolean
| PropertyFilter[]
| number[]
| undefined;
}
const getQueryString = (queryParams?: QueryParams) => {
@ -9,9 +15,8 @@ const getQueryString = (queryParams?: QueryParams) => {
return '';
}
const filteredParams = Object.keys(queryParams).reduce<
Record<string, string>
>((acc, key) => {
const searchParams = Object.keys(queryParams).reduce<URLSearchParams>(
(acc, key) => {
const value = queryParams[key];
if (value == null) {
@ -19,17 +24,25 @@ const getQueryString = (queryParams?: QueryParams) => {
}
if (Array.isArray(value)) {
value.forEach((filter) => {
acc[filter.key] = String(filter.value);
if (typeof value[0] === 'object') {
(value as PropertyFilter[]).forEach((filter) => {
acc.append(filter.key, String(filter.value));
});
} else {
acc[key] = String(value);
value.forEach((item) => {
acc.append(key, String(item));
});
}
} else {
acc.append(key, String(value));
}
return acc;
}, {});
},
new URLSearchParams()
);
const paramsString = new URLSearchParams(filteredParams).toString();
const paramsString = searchParams.toString();
return `?${paramsString}`;
};

View file

@ -1,13 +1,25 @@
import KeysMatching from 'typings/Helpers/KeysMatching';
function selectUniqueIds<T, K>(items: T[], idProp: KeysMatching<T, K>) {
return items.reduce((acc: K[], item) => {
if (item[idProp] && acc.indexOf(item[idProp] as K) === -1) {
acc.push(item[idProp] as K);
const result = items.reduce((acc: Set<K>, item) => {
if (!item[idProp]) {
return acc;
}
const value = item[idProp] as K;
if (Array.isArray(value)) {
value.forEach((v) => {
acc.add(v);
});
} else {
acc.add(value);
}
return acc;
}, []);
}, new Set<K>());
return Array.from(result);
}
export default selectUniqueIds;

View file

@ -29,8 +29,8 @@ interface Queue extends ModelBase {
customFormatScore: number;
size: number;
title: string;
sizeleft: number;
timeleft: string;
sizeLeft: number;
timeLeft: string;
estimatedCompletionTime: string;
added?: string;
status: string;
@ -45,8 +45,11 @@ interface Queue extends ModelBase {
episodeHasFile: boolean;
seriesId?: number;
episodeId?: number;
episodeIds: number[];
seasonNumber?: number;
seasonNumbers: number[];
downloadClientHasPostImportCategory: boolean;
isFullSeason: boolean;
episode?: Episode;
}

View file

@ -662,6 +662,7 @@
"EpisodeInfo": "Episode Info",
"EpisodeIsDownloading": "Episode is downloading",
"EpisodeIsNotMonitored": "Episode is not monitored",
"EpisodeMaybePlural": "Episode(s)",
"EpisodeMissingAbsoluteNumber": "Episode does not have an absolute episode number",
"EpisodeMissingFromDisk": "Episode missing from disk",
"EpisodeMonitoring": "Episode Monitoring",
@ -671,6 +672,8 @@
"EpisodeRequested": "Episode Requested",
"EpisodeSearchResultsLoadError": "Unable to load results for this episode search. Try again later",
"EpisodeTitle": "Episode Title",
"EpisodeTitles": "Episode Titles",
"EpisodeTitleMaybePlural": "Episode Title(s)",
"EpisodeTitleFootNote": "Optionally control truncation to a maximum number of bytes including ellipsis (`...`). Truncating from the end (e.g. `{Episode Title:30}`) or the beginning (e.g. `{Episode Title:-30}`) are both supported. Episode titles will be automatically truncated to file system limitations if necessary.",
"EpisodeTitleRequired": "Episode Title Required",
"EpisodeTitleRequiredHelpText": "Prevent importing for up to 48 hours if the episode title is in the naming format and the episode title is TBA",
@ -1286,6 +1289,7 @@
"MultiEpisodeStyle": "Multi Episode Style",
"MultiLanguages": "Multi-Languages",
"MultiSeason": "Multi-Season",
"MultipleEpisodes": "Multiple Episodes",
"MustContain": "Must Contain",
"MustContainHelpText": "The release must contain at least one of these terms (case insensitive)",
"MustNotContain": "Must Not Contain",

View file

@ -23,11 +23,8 @@ public class QueueResource : RestResource
public int CustomFormatScore { get; set; }
public decimal Size { get; set; }
public string? Title { get; set; }
// Collides with existing properties due to case-insensitive deserialization
// public decimal SizeLeft { get; set; }
// public TimeSpan? TimeLeft { get; set; }
public decimal SizeLeft { get; set; }
public TimeSpan? TimeLeft { get; set; }
public DateTime? EstimatedCompletionTime { get; set; }
public DateTime? Added { get; set; }
public QueueStatus Status { get; set; }
@ -42,6 +39,7 @@ public class QueueResource : RestResource
public string? Indexer { get; set; }
public string? OutputPath { get; set; }
public int EpisodesWithFilesCount { get; set; }
public bool IsFullSeason { get; set; }
}
public static class QueueResourceMapper
@ -65,11 +63,8 @@ public static QueueResource ToResource(this NzbDrone.Core.Queue.Queue model, boo
CustomFormatScore = customFormatScore,
Size = model.Size,
Title = model.Title,
// Collides with existing properties due to case-insensitive deserialization
// SizeLeft = model.SizeLeft,
// TimeLeft = model.TimeLeft,
SizeLeft = model.SizeLeft,
TimeLeft = model.TimeLeft,
EstimatedCompletionTime = model.EstimatedCompletionTime,
Added = model.Added,
Status = model.Status,
@ -83,7 +78,8 @@ public static QueueResource ToResource(this NzbDrone.Core.Queue.Queue model, boo
DownloadClientHasPostImportCategory = model.DownloadClientHasPostImportCategory,
Indexer = model.Indexer,
OutputPath = model.OutputPath,
EpisodesWithFilesCount = model.Episodes.Count(e => e.HasFile)
EpisodesWithFilesCount = model.Episodes.Count(e => e.HasFile),
IsFullSeason = model.RemoteEpisode?.ParsedEpisodeInfo?.FullSeason ?? false
};
}