From 5f359e975d2330cd47e75d0457831ed28edb0884 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Wed, 21 May 2025 17:14:50 -0700 Subject: [PATCH] Use react-query for queue UI New: Season packs and multi-episode releases will show as a single item in the queue Closes #6537 --- .../Queue/Details/QueueDetailsProvider.tsx | 113 +++ .../src/Activity/Queue/EpisodeCellContent.tsx | 76 ++ .../Queue/EpisodeTitleCellContent.css | 13 + .../Queue/EpisodeTitleCellContent.css.d.ts | 9 + .../Queue/EpisodeTitleCellContent.tsx | 66 ++ frontend/src/Activity/Queue/Queue.tsx | 181 ++--- frontend/src/Activity/Queue/QueueDetails.tsx | 6 +- .../src/Activity/Queue/QueueFilterModal.tsx | 39 +- frontend/src/Activity/Queue/QueueOptions.tsx | 30 +- frontend/src/Activity/Queue/QueueRow.tsx | 131 ++- .../Activity/Queue/RemoveQueueItemModal.tsx | 47 +- .../src/Activity/Queue/Status/QueueStatus.tsx | 30 +- .../Queue/Status/createQueueStatusSelector.ts | 32 - .../Activity/Queue/Status/useQueueStatus.ts | 54 ++ .../{TimeleftCell.css => TimeLeftCell.css} | 2 +- ...eftCell.css.d.ts => TimeLeftCell.css.d.ts} | 2 +- .../{TimeleftCell.tsx => TimeLeftCell.tsx} | 30 +- .../src/Activity/Queue/queueOptionsStore.ts | 164 ++++ frontend/src/Activity/Queue/useQueue.ts | 210 +++++ .../AddNewSeries/AddNewSeriesModalContent.tsx | 6 +- .../AddSeries/AddNewSeries/useAddSeries.ts | 22 +- .../src/AddSeries/addSeriesOptionsStore.ts | 32 +- frontend/src/App/State/AppState.ts | 2 - frontend/src/App/State/QueueAppState.ts | 56 -- frontend/src/Calendar/Agenda/AgendaEvent.tsx | 4 +- frontend/src/Calendar/Calendar.tsx | 14 - .../CalendarMissingEpisodeSearchButton.tsx | 78 ++ frontend/src/Calendar/CalendarPage.tsx | 180 ++-- .../src/Calendar/Events/CalendarEvent.tsx | 4 +- .../Calendar/Events/CalendarEventGroup.tsx | 15 +- .../Events/CalendarEventQueueDetails.tsx | 8 +- frontend/src/Components/SignalRListener.tsx | 46 +- .../Table/Cells/RelativeDateCell.tsx | 1 - frontend/src/Episode/EpisodeStatus.tsx | 9 +- frontend/src/Episode/useEpisodes.ts | 82 ++ frontend/src/Helpers/Hooks/useApiMutation.ts | 17 +- frontend/src/Helpers/Hooks/useApiQuery.ts | 17 +- frontend/src/Helpers/Hooks/useOptionsStore.ts | 128 +++ frontend/src/Helpers/Hooks/usePage.ts | 2 + .../src/Helpers/Hooks/usePagedApiQuery.ts | 43 +- frontend/src/Helpers/createPersist.ts | 54 -- .../Series/Details/SeasonProgressLabel.tsx | 9 +- frontend/src/Series/Details/SeriesDetails.tsx | 766 +++++++++--------- .../Series/Details/SeriesProgressLabel.tsx | 9 +- .../ProgressBar/SeriesIndexProgressBar.tsx | 9 +- frontend/src/Series/Index/SeriesIndex.tsx | 263 +++--- .../Index/createSeriesQueueDetailsSelector.ts | 46 -- .../src/Settings/General/HostSettings.tsx | 4 +- frontend/src/Store/Actions/index.js | 2 - frontend/src/Store/Actions/queueActions.js | 562 ------------- .../Selectors/createQueueItemSelector.ts | 31 - .../src/System/Events/eventOptionsStore.tsx | 30 +- frontend/src/Utilities/Fetch/fetchJson.ts | 4 + .../src/Utilities/Fetch/getQueryString.ts | 51 +- .../src/Utilities/Object/selectUniqueIds.ts | 20 +- frontend/src/typings/Queue.ts | 7 +- src/NzbDrone.Core/Localization/Core/en.json | 4 + src/Sonarr.Api.V5/Queue/QueueResource.cs | 18 +- 58 files changed, 1979 insertions(+), 1911 deletions(-) create mode 100644 frontend/src/Activity/Queue/Details/QueueDetailsProvider.tsx create mode 100644 frontend/src/Activity/Queue/EpisodeCellContent.tsx create mode 100644 frontend/src/Activity/Queue/EpisodeTitleCellContent.css create mode 100644 frontend/src/Activity/Queue/EpisodeTitleCellContent.css.d.ts create mode 100644 frontend/src/Activity/Queue/EpisodeTitleCellContent.tsx delete mode 100644 frontend/src/Activity/Queue/Status/createQueueStatusSelector.ts create mode 100644 frontend/src/Activity/Queue/Status/useQueueStatus.ts rename frontend/src/Activity/Queue/{TimeleftCell.css => TimeLeftCell.css} (87%) rename frontend/src/Activity/Queue/{TimeleftCell.css.d.ts => TimeLeftCell.css.d.ts} (88%) rename frontend/src/Activity/Queue/{TimeleftCell.tsx => TimeLeftCell.tsx} (78%) create mode 100644 frontend/src/Activity/Queue/queueOptionsStore.ts create mode 100644 frontend/src/Activity/Queue/useQueue.ts delete mode 100644 frontend/src/App/State/QueueAppState.ts create mode 100644 frontend/src/Calendar/CalendarMissingEpisodeSearchButton.tsx create mode 100644 frontend/src/Episode/useEpisodes.ts create mode 100644 frontend/src/Helpers/Hooks/useOptionsStore.ts delete mode 100644 frontend/src/Series/Index/createSeriesQueueDetailsSelector.ts delete mode 100644 frontend/src/Store/Actions/queueActions.js delete mode 100644 frontend/src/Store/Selectors/createQueueItemSelector.ts diff --git a/frontend/src/Activity/Queue/Details/QueueDetailsProvider.tsx b/frontend/src/Activity/Queue/Details/QueueDetailsProvider.tsx new file mode 100644 index 000000000..af2d1b652 --- /dev/null +++ b/frontend/src/Activity/Queue/Details/QueueDetailsProvider.tsx @@ -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(undefined); + +export default function QueueDetailsProvider({ + children, + ...filter +}: QueueDetailsProps & QueueDetailsFilter) { + const { data } = useApiQuery({ + path: '/queue/details', + queryParams: { ...filter }, + queryOptions: { + enabled: Object.keys(filter).length > 0, + }, + }); + + return ( + + {children} + + ); +} + +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(() => { + if (!queue) { + return { count: 0, episodesWithFiles: 0 }; + } + + return queue.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, + } + ); + }, [seriesId, seasonNumber, queue]); +} + +export const useQueueDetails = () => { + return useContext(QueueDetailsContext) ?? []; +}; diff --git a/frontend/src/Activity/Queue/EpisodeCellContent.tsx b/frontend/src/Activity/Queue/EpisodeCellContent.tsx new file mode 100644 index 000000000..2204d23cf --- /dev/null +++ b/frontend/src/Activity/Queue/EpisodeCellContent.tsx @@ -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 ( + + ); + } + + const firstEpisode = episodes[0]; + const lastEpisode = episodes[episodes.length - 1]; + + return ( + <> + + {' - '} + + + ); +} diff --git a/frontend/src/Activity/Queue/EpisodeTitleCellContent.css b/frontend/src/Activity/Queue/EpisodeTitleCellContent.css new file mode 100644 index 000000000..2454bb235 --- /dev/null +++ b/frontend/src/Activity/Queue/EpisodeTitleCellContent.css @@ -0,0 +1,13 @@ +.multiple { + cursor: default; +} + +.row { + display: flex; +} + +.episodeNumber { + margin-right: 8px; + font-weight: bold; + cursor: default; +} diff --git a/frontend/src/Activity/Queue/EpisodeTitleCellContent.css.d.ts b/frontend/src/Activity/Queue/EpisodeTitleCellContent.css.d.ts new file mode 100644 index 000000000..de69d25a6 --- /dev/null +++ b/frontend/src/Activity/Queue/EpisodeTitleCellContent.css.d.ts @@ -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; diff --git a/frontend/src/Activity/Queue/EpisodeTitleCellContent.tsx b/frontend/src/Activity/Queue/EpisodeTitleCellContent.tsx new file mode 100644 index 000000000..95411e55b --- /dev/null +++ b/frontend/src/Activity/Queue/EpisodeTitleCellContent.tsx @@ -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 ( + + ); + } + + return ( + {translate('MultipleEpisodes')} + } + title={translate('EpisodeTitles')} + body={ + <> + {episodes.map((episode) => { + return ( +
+
+ {episode.episodeNumber} +
+ + +
+ ); + })} + + } + position="right" + /> + ); +} diff --git a/frontend/src/Activity/Queue/Queue.tsx b/frontend/src/Activity/Queue/Queue.tsx index 9271d58fe..aaf86022e 100644 --- a/frontend/src/Activity/Queue/Queue.tsx +++ b/frontend/src/Activity/Queue/Queue.tsx @@ -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) => { - shouldBlockRefresh.current = false; - dispatch(removeQueueItems({ ids: selectedIds, ...payload })); - setIsConfirmRemoveModalOpen(false); - }, - [selectedIds, setIsConfirmRemoveModalOpen, dispatch] - ); + const handleRemoveSelectedConfirmed = useCallback(() => { + shouldBlockRefresh.current = false; + removeQueueItems({ ids: selectedIds }); + setIsConfirmRemoveModalOpen(false); + }, [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( - 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() { {translate('QueueLoadError')} ) : null} - {isAllPopulated && !hasError && !items.length ? ( + {isAllPopulated && !hasError && !records.length ? ( {selectedFilterKey !== 'all' && count > 0 ? translate('QueueFilterHasNoItems') @@ -263,7 +239,7 @@ function Queue() { ) : null} - {isAllPopulated && !hasError && !!items.length ? ( + {isAllPopulated && !hasError && !!records.length ? (
- {items.map((item) => { + {records.map((item) => { return ( ) : 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; diff --git a/frontend/src/Activity/Queue/QueueDetails.tsx b/frontend/src/Activity/Queue/QueueDetails.tsx index 77da898f3..a3544415d 100644 --- a/frontend/src/Activity/Queue/QueueDetails.tsx +++ b/frontend/src/Activity/Queue/QueueDetails.tsx @@ -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'; diff --git a/frontend/src/Activity/Queue/QueueFilterModal.tsx b/frontend/src/Activity/Queue/QueueFilterModal.tsx index e943a04d8..6b8011a8e 100644 --- a/frontend/src/Activity/Queue/QueueFilterModal.tsx +++ b/frontend/src/Activity/Queue/QueueFilterModal.tsx @@ -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; 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 ( diff --git a/frontend/src/Activity/Queue/QueueOptions.tsx b/frontend/src/Activity/Queue/QueueOptions.tsx index dc2c9dc02..9ae0e65c0 100644 --- a/frontend/src/Activity/Queue/QueueOptions.tsx +++ b/frontend/src/Activity/Queue/QueueOptions.tsx @@ -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) => { - dispatch( - setQueueOption({ - [name]: value, - }) - ); + ({ name, value }: OptionChanged) => { + 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} /> diff --git a/frontend/src/Activity/Queue/QueueRow.tsx b/frontend/src/Activity/Queue/QueueRow.tsx index 25f5cb410..6ee43add1 100644 --- a/frontend/src/Activity/Queue/QueueRow.tsx +++ b/frontend/src/Activity/Queue/QueueRow.tsx @@ -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) => { - onQueueRowModalOpenOrClose(false); - dispatch(removeQueueItem({ id, ...payload })); - setIsRemoveQueueItemModalOpen(false); - }, - [id, setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose, dispatch] - ); + const handleRemoveQueueItemModalConfirmed = useCallback(() => { + onQueueRowModalOpenOrClose(false); + removeQueueItem(); + setIsRemoveQueueItemModalOpen(false); + }, [ + 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 ( - {episode ? ( - - ) : ( - '-' - )} + ); } @@ -233,27 +222,37 @@ function QueueRow(props: QueueRowProps) { if (name === 'episodes.title') { return ( - {series && episode ? ( - - ) : ( - '-' - )} + ); } if (name === 'episodes.airDateUtc') { - if (episode) { - return ; + if (episodes.length === 0) { + return -; } - return -; + if (episodes.length === 1) { + return ( + + ); + } + + return ( + + + {' - '} + + + ); } if (name === 'languages') { @@ -325,13 +324,13 @@ function QueueRow(props: QueueRowProps) { if (name === 'estimatedCompletionTime') { return ( - 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) => { + 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} /> @@ -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} /> diff --git a/frontend/src/Activity/Queue/Status/QueueStatus.tsx b/frontend/src/Activity/Queue/Status/QueueStatus.tsx index 894434e07..741e64b2a 100644 --- a/frontend/src/Activity/Queue/Status/QueueStatus.tsx +++ b/frontend/src/Activity/Queue/Status/QueueStatus.tsx @@ -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 ( diff --git a/frontend/src/Activity/Queue/Status/createQueueStatusSelector.ts b/frontend/src/Activity/Queue/Status/createQueueStatusSelector.ts deleted file mode 100644 index 4fd37b28c..000000000 --- a/frontend/src/Activity/Queue/Status/createQueueStatusSelector.ts +++ /dev/null @@ -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; diff --git a/frontend/src/Activity/Queue/Status/useQueueStatus.ts b/frontend/src/Activity/Queue/Status/useQueueStatus.ts new file mode 100644 index 000000000..5102842f2 --- /dev/null +++ b/frontend/src/Activity/Queue/Status/useQueueStatus.ts @@ -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({ + 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, + }; +} diff --git a/frontend/src/Activity/Queue/TimeleftCell.css b/frontend/src/Activity/Queue/TimeLeftCell.css similarity index 87% rename from frontend/src/Activity/Queue/TimeleftCell.css rename to frontend/src/Activity/Queue/TimeLeftCell.css index cc6001a22..06119b813 100644 --- a/frontend/src/Activity/Queue/TimeleftCell.css +++ b/frontend/src/Activity/Queue/TimeLeftCell.css @@ -1,4 +1,4 @@ -.timeleft { +.timeLeft { composes: cell from '~Components/Table/Cells/TableRowCell.css'; width: 100px; diff --git a/frontend/src/Activity/Queue/TimeleftCell.css.d.ts b/frontend/src/Activity/Queue/TimeLeftCell.css.d.ts similarity index 88% rename from frontend/src/Activity/Queue/TimeleftCell.css.d.ts rename to frontend/src/Activity/Queue/TimeLeftCell.css.d.ts index f5c9402d1..633af0008 100644 --- a/frontend/src/Activity/Queue/TimeleftCell.css.d.ts +++ b/frontend/src/Activity/Queue/TimeLeftCell.css.d.ts @@ -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; diff --git a/frontend/src/Activity/Queue/TimeleftCell.tsx b/frontend/src/Activity/Queue/TimeLeftCell.tsx similarity index 78% rename from frontend/src/Activity/Queue/TimeleftCell.tsx rename to frontend/src/Activity/Queue/TimeLeftCell.tsx index 917a6ad0d..56809b3cf 100644 --- a/frontend/src/Activity/Queue/TimeleftCell.tsx +++ b/frontend/src/Activity/Queue/TimeLeftCell.tsx @@ -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 ( - + } tooltip={translate('DelayingDownloadUntil', { date, time })} @@ -66,7 +66,7 @@ function TimeleftCell(props: TimeleftCellProps) { }); return ( - + } tooltip={translate('RetryingDownloadOn', { date, time })} @@ -77,21 +77,21 @@ function TimeleftCell(props: TimeleftCellProps) { ); } - if (!timeleft || status === 'completed' || status === 'failed') { - return -; + if (!timeLeft || status === 'completed' || status === 'failed') { + return -; } const totalSize = formatBytes(size); - const remainingSize = formatBytes(sizeleft); + const remainingSize = formatBytes(sizeLeft); return ( - {formatTimeSpan(timeleft)} + {formatTimeSpan(timeLeft)} ); } -export default TimeleftCell; +export default TimeLeftCell; diff --git a/frontend/src/Activity/Queue/queueOptionsStore.ts b/frontend/src/Activity/Queue/queueOptionsStore.ts new file mode 100644 index 000000000..a6b4be984 --- /dev/null +++ b/frontend/src/Activity/Queue/queueOptionsStore.ts @@ -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('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; diff --git a/frontend/src/Activity/Queue/useQueue.ts b/frontend/src/Activity/Queue/useQueue.ts new file mode 100644 index 000000000..f67235ed5 --- /dev/null +++ b/frontend/src/Activity/Queue/useQueue.ts @@ -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[] = [ + { + 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({ + 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({ + 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({ + 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(null); + + const { mutate, isPending } = useApiMutation({ + 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({ + path: '/queue/grab/bulk', + method: 'POST', + mutationOptions: { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/queue'] }); + }, + }, + }); + + return { + grabQueueItems: mutate, + isGrabbing: isPending, + }; +}; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.tsx b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.tsx index 6dc1c7e83..da8594946 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.tsx +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.tsx @@ -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); diff --git a/frontend/src/AddSeries/AddNewSeries/useAddSeries.ts b/frontend/src/AddSeries/AddNewSeries/useAddSeries.ts index 1ab2c6d32..ba5f10ea6 100644 --- a/frontend/src/AddSeries/AddNewSeries/useAddSeries.ts +++ b/frontend/src/AddSeries/AddNewSeries/useAddSeries.ts @@ -33,11 +33,19 @@ export const useAddSeries = () => { [dispatch] ); - return useApiMutation({ - path: '/series', - method: 'POST', - mutationOptions: { - onSuccess: onAddSuccess, - }, - }); + const { isPending, error, mutate } = useApiMutation( + { + path: '/series', + method: 'POST', + mutationOptions: { + onSuccess: onAddSuccess, + }, + } + ); + + return { + isAdding: isPending, + addError: error, + addSeries: mutate, + }; }; diff --git a/frontend/src/AddSeries/addSeriesOptionsStore.ts b/frontend/src/AddSeries/addSeriesOptionsStore.ts index 4bc910c91..109130ac7 100644 --- a/frontend/src/AddSeries/addSeriesOptionsStore.ts +++ b/frontend/src/AddSeries/addSeriesOptionsStore.ts @@ -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( - 'add_series_options', - () => { +const { useOptions, useOption, setOption } = + createOptionsStore('add_series_options', () => { return { rootFolderPath: '', monitor: 'all', @@ -25,25 +24,8 @@ const addSeriesOptionsStore = createPersist( searchForCutoffUnmetEpisodes: false, tags: [], }; - } -); + }); -export const useAddSeriesOptions = () => { - return addSeriesOptionsStore((state) => state); -}; - -export const useAddSeriesOption = ( - key: K -) => { - return addSeriesOptionsStore((state) => state[key]); -}; - -export const setAddSeriesOption = ( - key: K, - value: AddSeriesOptions[K] -) => { - addSeriesOptionsStore.setState((state) => ({ - ...state, - [key]: value, - })); -}; +export const useAddSeriesOptions = useOptions; +export const useAddSeriesOption = useOption; +export const setAddSeriesOption = setOption; diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index ae6ca95f4..3af3eb1d9 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -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; diff --git a/frontend/src/App/State/QueueAppState.ts b/frontend/src/App/State/QueueAppState.ts deleted file mode 100644 index 77b2ce2ff..000000000 --- a/frontend/src/App/State/QueueAppState.ts +++ /dev/null @@ -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 { - params: unknown; -} - -export interface QueuePagedAppState - extends AppSectionState, - AppSectionFilterState, - 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; - details: QueueDetailsAppState; - paged: QueuePagedAppState; - options: { - includeUnknownSeriesItems: boolean; - }; - removalOptions: RemovalOptions; -} - -export default QueueAppState; diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.tsx b/frontend/src/Calendar/Agenda/AgendaEvent.tsx index 2fd2d7c54..f32b4b22b 100644 --- a/frontend/src/Calendar/Agenda/AgendaEvent.tsx +++ b/frontend/src/Calendar/Agenda/AgendaEvent.tsx @@ -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() ); diff --git a/frontend/src/Calendar/Calendar.tsx b/frontend/src/Calendar/Calendar.tsx index caa337cf0..3b80fe904 100644 --- a/frontend/src/Calendar/Calendar.tsx +++ b/frontend/src/Calendar/Calendar.tsx @@ -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(items, 'id'); const episodeFileIds = selectUniqueIds( items, 'episodeFileId' ); - if (items.length) { - dispatch(fetchQueueDetails({ episodeIds })); - } - if (episodeFileIds.length) { dispatch(fetchEpisodeFiles({ episodeFileIds })); } @@ -144,18 +133,15 @@ function Calendar() { return (
{isFetching && !isPopulated ? : null} - {!isFetching && error ? ( {translate('CalendarLoadError')} ) : null} - {!error && isPopulated && view === 'agenda' ? (
) : null} - {!error && isPopulated && view !== 'agenda' ? (
diff --git a/frontend/src/Calendar/CalendarMissingEpisodeSearchButton.tsx b/frontend/src/Calendar/CalendarMissingEpisodeSearchButton.tsx new file mode 100644 index 000000000..05ac698fe --- /dev/null +++ b/frontend/src/Calendar/CalendarMissingEpisodeSearchButton.tsx @@ -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((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 ( + + ); +} diff --git a/frontend/src/Calendar/CalendarPage.tsx b/frontend/src/Calendar/CalendarPage.tsx index dcd644b9d..5f91beddb 100644 --- a/frontend/src/Calendar/CalendarPage.tsx +++ b/frontend/src/Calendar/CalendarPage.tsx @@ -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((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(items, 'id'); + }, [items]); + useEffect(() => { if (width === 0) { return; @@ -152,71 +102,67 @@ function CalendarPage() { }, [width, dispatch]); return ( - - - - + + + + + - + - + - - + + - - + + - - - + + + - - {isMeasured ? :
} - {hasSeries && } - + + {isMeasured ? :
} + {hasSeries && } + - + - - + + + ); } diff --git a/frontend/src/Calendar/Events/CalendarEvent.tsx b/frontend/src/Calendar/Events/CalendarEvent.tsx index 079256a0e..7c92a5e8c 100644 --- a/frontend/src/Calendar/Events/CalendarEvent.tsx +++ b/frontend/src/Calendar/Events/CalendarEvent.tsx @@ -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() diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.tsx b/frontend/src/Calendar/Events/CalendarEventGroup.tsx index b0719ff00..ea2aa0567 100644 --- a/frontend/src/Calendar/Events/CalendarEventGroup.tsx +++ b/frontend/src/Calendar/Events/CalendarEventGroup.tsx @@ -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( diff --git a/frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx b/frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx index 2372bc78e..8fae8be8d 100644 --- a/frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx +++ b/frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx @@ -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 ( state.queue.paged.isPopulated - ); - const connection = useRef(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; } diff --git a/frontend/src/Components/Table/Cells/RelativeDateCell.tsx b/frontend/src/Components/Table/Cells/RelativeDateCell.tsx index 1c5be48be..8d7564a7f 100644 --- a/frontend/src/Components/Table/Cells/RelativeDateCell.tsx +++ b/frontend/src/Components/Table/Cells/RelativeDateCell.tsx @@ -20,7 +20,6 @@ function RelativeDateCell(props: RelativeDateCellProps) { date, includeSeconds = false, includeTime = false, - component: Component = TableRowCell, ...otherProps } = props; diff --git a/frontend/src/Episode/EpisodeStatus.tsx b/frontend/src/Episode/EpisodeStatus.tsx index 9bdba0c4f..be470b411 100644 --- a/frontend/src/Episode/EpisodeStatus.tsx +++ b/frontend/src/Episode/EpisodeStatus.tsx @@ -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 (
diff --git a/frontend/src/Episode/useEpisodes.ts b/frontend/src/Episode/useEpisodes.ts new file mode 100644 index 000000000..4a4c0ab9b --- /dev/null +++ b/frontend/src/Episode/useEpisodes.ts @@ -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((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)); +} diff --git a/frontend/src/Helpers/Hooks/useApiMutation.ts b/frontend/src/Helpers/Hooks/useApiMutation.ts index 2c36d83d6..f3aeb0d01 100644 --- a/frontend/src/Helpers/Hooks/useApiMutation.ts +++ b/frontend/src/Helpers/Hooks/useApiMutation.ts @@ -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 extends Omit, 'method'> { method: 'POST' | 'PUT' | 'DELETE'; mutationOptions?: Omit, 'mutationFn'>; + queryParams?: QueryParams; } function useApiMutation(options: MutationOptions) { 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(options: MutationOptions) { return useMutation({ ...options.mutationOptions, - mutationFn: async (data: TData) => - fetchJson({ ...requestOptions, body: data }), + mutationFn: async (data?: TData) => { + const { path, ...otherOptions } = requestOptions; + + return fetchJson({ path, ...otherOptions, body: data }); + }, }); } diff --git a/frontend/src/Helpers/Hooks/useApiQuery.ts b/frontend/src/Helpers/Hooks/useApiQuery.ts index 5ee535ddd..ec4304627 100644 --- a/frontend/src/Helpers/Hooks/useApiQuery.ts +++ b/frontend/src/Helpers/Hooks/useApiQuery.ts @@ -15,22 +15,25 @@ export interface QueryOptions extends FetchJsonOptions { } const useApiQuery = (options: QueryOptions) => { - const requestOptions = useMemo(() => { + const { queryKey, requestOptions } = useMemo(() => { const { path: path, queryOptions, queryParams, ...otherOptions } = options; return { - ...otherOptions, - path: getQueryPath(path) + getQueryString(queryParams), - headers: { - ...options.headers, - 'X-Api-Key': window.Sonarr.apiKey, + 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({ ...requestOptions, signal }), }); diff --git a/frontend/src/Helpers/Hooks/useOptionsStore.ts b/frontend/src/Helpers/Hooks/useOptionsStore.ts new file mode 100644 index 000000000..85b8c611d --- /dev/null +++ b/frontend/src/Helpers/Hooks/useOptionsStore.ts @@ -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 = { + name: keyof T; + value: T[keyof T]; +}; + +export const createOptionsStore = ( + name: string, + state: StateCreator, + options: Omit, 'name' | 'storage'> = {} +) => { + const store = createPersist(name, state, { + merge, + ...options, + }); + + const useOptions = () => { + return store((state) => state); + }; + + const useOption = (key: K) => { + return store((state) => state[key]); + }; + + const setOptions = (options: Partial) => { + store.setState((state) => ({ + ...state, + ...options, + })); + }; + + const setOption = (key: K, value: T[K]) => { + store.setState((state) => ({ + ...state, + [key]: value, + })); + }; + + return { + store, + useOptions, + useOption, + setOptions, + setOption, + }; +}; + +const merge = ( + persistedState: unknown, + currentState: T +) => { + if ('columns' in currentState) { + return { + ...currentState, + ...mergeColumns(persistedState, currentState), + }; + } + + return { + ...currentState, + ...((persistedState as T) ?? {}), + }; +}; + +const mergeColumns = ( + 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 = {}; + + // 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, + }; +}; diff --git a/frontend/src/Helpers/Hooks/usePage.ts b/frontend/src/Helpers/Hooks/usePage.ts index 5b677224d..a4fa2ac30 100644 --- a/frontend/src/Helpers/Hooks/usePage.ts +++ b/frontend/src/Helpers/Hooks/usePage.ts @@ -4,10 +4,12 @@ import { create } from 'zustand'; interface PageStore { events: number; + queue: number; } const pageStore = create(() => ({ events: 1, + queue: 1, })); const usePage = (kind: keyof PageStore) => { diff --git a/frontend/src/Helpers/Hooks/usePagedApiQuery.ts b/frontend/src/Helpers/Hooks/usePagedApiQuery.ts index 5b9b88fdc..a1ce29470 100644 --- a/frontend/src/Helpers/Hooks/usePagedApiQuery.ts +++ b/frontend/src/Helpers/Hooks/usePagedApiQuery.ts @@ -26,7 +26,7 @@ interface PagedQueryResponse { } const usePagedApiQuery = (options: PagedQueryOptions) => { - const requestOptions = useMemo(() => { + const { requestOptions, queryKey } = useMemo(() => { const { path, page, @@ -40,27 +40,38 @@ const usePagedApiQuery = (options: PagedQueryOptions) => { } = options; return { - ...otherOptions, - path: - getQueryPath(path) + - getQueryString({ - ...queryParams, - page, - pageSize, - sortKey, - sortDirection, - filters, - }), - headers: { - ...options.headers, - 'X-Api-Key': window.Sonarr.apiKey, + queryKey: [ + path, + queryParams, + page, + pageSize, + sortKey, + sortDirection, + filters, + ], + requestOptions: { + ...otherOptions, + path: + getQueryPath(path) + + getQueryString({ + ...queryParams, + page, + pageSize, + sortKey, + sortDirection, + filters, + }), + headers: { + ...options.headers, + 'X-Api-Key': window.Sonarr.apiKey, + }, }, }; }, [options]); return useQuery({ ...options.queryOptions, - queryKey: [requestOptions.path], + queryKey, queryFn: async ({ signal }) => { const response = await fetchJson, unknown>({ ...requestOptions, diff --git a/frontend/src/Helpers/createPersist.ts b/frontend/src/Helpers/createPersist.ts index 09fe75711..e13f8a250 100644 --- a/frontend/src/Helpers/createPersist.ts +++ b/frontend/src/Helpers/createPersist.ts @@ -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 = ( name: string, @@ -19,56 +18,3 @@ export const createPersist = ( }) ); }; - -export const mergeColumns = ( - 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 = {}; - - // 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, - }; -}; diff --git a/frontend/src/Series/Details/SeasonProgressLabel.tsx b/frontend/src/Series/Details/SeasonProgressLabel.tsx index fc15d07b4..2140ae24d 100644 --- a/frontend/src/Series/Details/SeasonProgressLabel.tsx +++ b/frontend/src/Series/Details/SeasonProgressLabel.tsx @@ -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 diff --git a/frontend/src/Series/Details/SeriesDetails.tsx b/frontend/src/Series/Details/SeriesDetails.tsx index 77f996d33..fa9fb2554 100644 --- a/frontend/src/Series/Details/SeriesDetails.tsx +++ b/frontend/src/Series/Details/SeriesDetails.tsx @@ -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,424 +461,435 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) { const isPopulated = isEpisodesPopulated && isEpisodeFilesPopulated; return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- -
- + + + + -
-
-
-
- -
+ -
{title}
+ - {alternateTitles.length ? ( -
- - } - title={translate('AlternateTitles')} - body={ - - } - position={tooltipPositions.BOTTOM} + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+ + +
+
+
+
+
- ) : null} -
-
- {previousSeries ? ( - - ) : null} +
{title}
- {nextSeries ? ( - - ) : null} -
-
- -
-
- {runtime ? ( - - {translate('SeriesDetailsRuntime', { runtime })} - - ) : null} - - {ratings.value ? ( - - ) : null} - - - - {runningYears} -
-
- -
- - - -
- - - - {formatBytes(sizeOnDisk)} - + {alternateTitles.length ? ( +
+ + } + title={translate('AlternateTitles')} + body={ + + } + position={tooltipPositions.BOTTOM} + />
- - } - tooltip={{episodeFilesCountMessage}} - kind={kinds.INVERSE} - position={tooltipPositions.BOTTOM} - /> - - -
+ +
+ - ) : null} - -
- - - {translate('Links')} - -
- - } - tooltip={ - - } - kind={kinds.INVERSE} - position={tooltipPositions.BOTTOM} - /> - - {tags.length ? ( - +
+ - {translate('Tags')} + + {formatBytes(sizeOnDisk)} + +
} - tooltip={} + tooltip={{episodeFilesCountMessage}} kind={kinds.INVERSE} position={tooltipPositions.BOTTOM} /> - ) : null} - + + + + + + + {originalLanguage?.name ? ( + + ) : null} + + {network ? ( + + ) : null} + + +
+ + + {translate('Links')} + +
+ + } + tooltip={ + + } + kind={kinds.INVERSE} + position={tooltipPositions.BOTTOM} + /> + + {tags.length ? ( + + + + + {translate('Tags')} + + + } + tooltip={} + kind={kinds.INVERSE} + position={tooltipPositions.BOTTOM} + /> + ) : null} + + +
+ +
{overview}
+ +
- -
{overview}
- -
-
-
- {!isPopulated && !episodesError && !episodeFilesError ? ( - - ) : null} +
+ {!isPopulated && !episodesError && !episodeFilesError ? ( + + ) : null} - {!isFetching && episodesError ? ( - {translate('EpisodesLoadError')} - ) : null} + {!isFetching && episodesError ? ( + + {translate('EpisodesLoadError')} + + ) : null} - {!isFetching && episodeFilesError ? ( - - {translate('EpisodeFilesLoadError')} - - ) : null} + {!isFetching && episodeFilesError ? ( + + {translate('EpisodeFilesLoadError')} + + ) : null} - {isPopulated && !!seasons.length ? ( -
- {seasons - .slice(0) - .reverse() - .map((season) => { - return ( - - ); - })} -
- ) : null} + {isPopulated && !!seasons.length ? ( +
+ {seasons + .slice(0) + .reverse() + .map((season) => { + return ( + + ); + })} +
+ ) : null} - {isPopulated && !seasons.length ? ( - - {translate('NoEpisodeInformation')} - - ) : null} -
+ {isPopulated && !seasons.length ? ( + + {translate('NoEpisodeInformation')} + + ) : null} +
- + - + - + - + - + - - - + + + + ); } diff --git a/frontend/src/Series/Details/SeriesProgressLabel.tsx b/frontend/src/Series/Details/SeriesProgressLabel.tsx index 4c053c8ac..18021206a 100644 --- a/frontend/src/Series/Details/SeriesProgressLabel.tsx +++ b/frontend/src/Series/Details/SeriesProgressLabel.tsx @@ -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 diff --git a/frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.tsx b/frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.tsx index 6f710e37a..3eb9356e8 100644 --- a/frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.tsx +++ b/frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.tsx @@ -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; diff --git a/frontend/src/Series/Index/SeriesIndex.tsx b/frontend/src/Series/Index/SeriesIndex.tsx index 903bee144..eda6fe703 100644 --- a/frontend/src/Series/Index/SeriesIndex.tsx +++ b/frontend/src/Series/Index/SeriesIndex.tsx @@ -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,155 +216,159 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => { const hasNoSeries = !totalItems; return ( - - - - - + + + + + + - + - + - + - + - - - + + + - - {view === 'table' ? ( - + + {view === 'table' ? ( + + + + ) : ( - - ) : ( - + + - )} - + - + + + +
+ + {isFetching && !isPopulated ? : null} - + {!isFetching && !!error ? ( + + {translate('SeriesLoadError')} + + ) : null} - - - -
- - {isFetching && !isPopulated ? : null} + {isLoaded ? ( +
+ - {!isFetching && !!error ? ( - {translate('SeriesLoadError')} + +
+ ) : null} + + {!error && isPopulated && !items.length ? ( + + ) : null} +
+ {isLoaded && !!jumpBarItems.order.length ? ( + ) : null} +
- {isLoaded ? ( -
- + {isSelectMode ? : null} - -
- ) : null} - - {!error && isPopulated && !items.length ? ( - - ) : null} -
- {isLoaded && !!jumpBarItems.order.length ? ( - ) : null} -
- - {isSelectMode ? : null} - - {view === 'posters' ? ( - - ) : null} - {view === 'overview' ? ( - - ) : null} -
-
+ {view === 'overview' ? ( + + ) : null} + + + ); }, 'seriesIndex'); diff --git a/frontend/src/Series/Index/createSeriesQueueDetailsSelector.ts b/frontend/src/Series/Index/createSeriesQueueDetailsSelector.ts deleted file mode 100644 index 0b194161a..000000000 --- a/frontend/src/Series/Index/createSeriesQueueDetailsSelector.ts +++ /dev/null @@ -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; diff --git a/frontend/src/Settings/General/HostSettings.tsx b/frontend/src/Settings/General/HostSettings.tsx index bbaffb8cb..c44ab98e6 100644 --- a/frontend/src/Settings/General/HostSettings.tsx +++ b/frontend/src/Settings/General/HostSettings.tsx @@ -171,7 +171,7 @@ function HostSettings({ ) : null} - {isWindowsService ? ( + {isWindowsService ? null : ( {translate('OpenBrowserOnStart')} @@ -183,7 +183,7 @@ function HostSettings({ {...launchBrowser} /> - ) : null} + )} ); } diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index a0bcc2116..529900379 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -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, diff --git a/frontend/src/Store/Actions/queueActions.js b/frontend/src/Store/Actions/queueActions.js deleted file mode 100644 index 3bc90f1fd..000000000 --- a/frontend/src/Store/Actions/queueActions.js +++ /dev/null @@ -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); - diff --git a/frontend/src/Store/Selectors/createQueueItemSelector.ts b/frontend/src/Store/Selectors/createQueueItemSelector.ts deleted file mode 100644 index 92f8a2a73..000000000 --- a/frontend/src/Store/Selectors/createQueueItemSelector.ts +++ /dev/null @@ -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; diff --git a/frontend/src/System/Events/eventOptionsStore.tsx b/frontend/src/System/Events/eventOptionsStore.tsx index 30f01c273..da417d77e 100644 --- a/frontend/src/System/Events/eventOptionsStore.tsx +++ b/frontend/src/System/Events/eventOptionsStore.tsx @@ -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( +const { useOptions, setOptions, setOption } = createOptionsStore( 'event_options', () => { return { @@ -57,29 +57,9 @@ const eventOptionsStore = createPersist( }, ], }; - }, - { - merge: mergeColumns, } ); -export const useEventOptions = () => { - return eventOptionsStore((state) => state); -}; - -export const setEventOptions = (options: Partial) => { - eventOptionsStore.setState((state) => ({ - ...state, - ...options, - })); -}; - -export const setEventOption = ( - key: K, - value: EventOptions[K] -) => { - eventOptionsStore.setState((state) => ({ - ...state, - [key]: value, - })); -}; +export const useEventOptions = useOptions; +export const setEventOptions = setOptions; +export const setEventOption = setOption; diff --git a/frontend/src/Utilities/Fetch/fetchJson.ts b/frontend/src/Utilities/Fetch/fetchJson.ts index 05d9326f9..1c01cfa0a 100644 --- a/frontend/src/Utilities/Fetch/fetchJson.ts +++ b/frontend/src/Utilities/Fetch/fetchJson.ts @@ -81,6 +81,10 @@ async function fetchJson({ throw new ApiError(path, response.status, response.statusText, body); } + if (response.status === 204) { + return {} as T; + } + return response.json() as T; } diff --git a/frontend/src/Utilities/Fetch/getQueryString.ts b/frontend/src/Utilities/Fetch/getQueryString.ts index 611cdfb4c..5d71347c8 100644 --- a/frontend/src/Utilities/Fetch/getQueryString.ts +++ b/frontend/src/Utilities/Fetch/getQueryString.ts @@ -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,27 +15,34 @@ const getQueryString = (queryParams?: QueryParams) => { return ''; } - const filteredParams = Object.keys(queryParams).reduce< - Record - >((acc, key) => { - const value = queryParams[key]; + const searchParams = Object.keys(queryParams).reduce( + (acc, key) => { + const value = queryParams[key]; + + if (value == null) { + return acc; + } + + if (Array.isArray(value)) { + if (typeof value[0] === 'object') { + (value as PropertyFilter[]).forEach((filter) => { + acc.append(filter.key, String(filter.value)); + }); + } else { + value.forEach((item) => { + acc.append(key, String(item)); + }); + } + } else { + acc.append(key, String(value)); + } - if (value == null) { return acc; - } + }, + new URLSearchParams() + ); - if (Array.isArray(value)) { - value.forEach((filter) => { - acc[filter.key] = String(filter.value); - }); - } else { - acc[key] = String(value); - } - - return acc; - }, {}); - - const paramsString = new URLSearchParams(filteredParams).toString(); + const paramsString = searchParams.toString(); return `?${paramsString}`; }; diff --git a/frontend/src/Utilities/Object/selectUniqueIds.ts b/frontend/src/Utilities/Object/selectUniqueIds.ts index 847613c83..262df432e 100644 --- a/frontend/src/Utilities/Object/selectUniqueIds.ts +++ b/frontend/src/Utilities/Object/selectUniqueIds.ts @@ -1,13 +1,25 @@ import KeysMatching from 'typings/Helpers/KeysMatching'; function selectUniqueIds(items: T[], idProp: KeysMatching) { - 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, 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()); + + return Array.from(result); } export default selectUniqueIds; diff --git a/frontend/src/typings/Queue.ts b/frontend/src/typings/Queue.ts index 8b392601f..885ceaa7d 100644 --- a/frontend/src/typings/Queue.ts +++ b/frontend/src/typings/Queue.ts @@ -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; } diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index bda8d7c56..28fdc0851 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -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", diff --git a/src/Sonarr.Api.V5/Queue/QueueResource.cs b/src/Sonarr.Api.V5/Queue/QueueResource.cs index 28ae34b21..21a448b9b 100644 --- a/src/Sonarr.Api.V5/Queue/QueueResource.cs +++ b/src/Sonarr.Api.V5/Queue/QueueResource.cs @@ -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 }; }