diff --git a/frontend/src/Activity/History/History.tsx b/frontend/src/Activity/History/History.tsx index 3f5f5899c..535a1962b 100644 --- a/frontend/src/Activity/History/History.tsx +++ b/frontend/src/Activity/History/History.tsx @@ -20,7 +20,6 @@ import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelect import useCurrentPage from 'Helpers/Hooks/useCurrentPage'; import { align, icons, kinds } from 'Helpers/Props'; import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions'; -import { clearEpisodeFiles } from 'Store/Actions/episodeFileActions'; import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; import HistoryItem from 'typings/History'; import { TableOptionsChangePayload } from 'typings/Table'; @@ -95,7 +94,6 @@ function History() { useEffect(() => { return () => { dispatch(clearEpisodes()); - dispatch(clearEpisodeFiles()); }; }, [requestCurrentPage, dispatch]); diff --git a/frontend/src/Activity/Queue/Details/QueueDetailsProvider.tsx b/frontend/src/Activity/Queue/Details/QueueDetailsProvider.tsx index af2d1b652..05d1b8235 100644 --- a/frontend/src/Activity/Queue/Details/QueueDetailsProvider.tsx +++ b/frontend/src/Activity/Queue/Details/QueueDetailsProvider.tsx @@ -1,4 +1,9 @@ -import React, { createContext, ReactNode, useContext, useMemo } from 'react'; +import React, { + createContext, + PropsWithChildren, + useContext, + useMemo, +} from 'react'; import useApiQuery from 'Helpers/Hooks/useApiQuery'; import Queue from 'typings/Queue'; @@ -16,16 +21,12 @@ interface AllDetails { type QueueDetailsFilter = AllDetails | EpisodeDetails | SeriesDetails; -interface QueueDetailsProps { - children: ReactNode; -} - const QueueDetailsContext = createContext(undefined); export default function QueueDetailsProvider({ children, ...filter -}: QueueDetailsProps & QueueDetailsFilter) { +}: PropsWithChildren) { const { data } = useApiQuery({ path: '/queue/details', queryParams: { ...filter }, diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index 9e5b9d8d6..322662271 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -6,7 +6,6 @@ import BlocklistAppState from './BlocklistAppState'; import CaptchaAppState from './CaptchaAppState'; import CommandAppState from './CommandAppState'; import CustomFiltersAppState from './CustomFiltersAppState'; -import EpisodeFilesAppState from './EpisodeFilesAppState'; import EpisodesAppState from './EpisodesAppState'; import HistoryAppState, { SeriesHistoryAppState } from './HistoryAppState'; import ImportSeriesAppState from './ImportSeriesAppState'; @@ -80,7 +79,6 @@ interface AppState { captcha: CaptchaAppState; commands: CommandAppState; customFilters: CustomFiltersAppState; - episodeFiles: EpisodeFilesAppState; episodeHistory: HistoryAppState; episodes: EpisodesAppState; episodesSelection: EpisodesAppState; diff --git a/frontend/src/App/State/EpisodeFilesAppState.ts b/frontend/src/App/State/EpisodeFilesAppState.ts deleted file mode 100644 index 5e6e94a06..000000000 --- a/frontend/src/App/State/EpisodeFilesAppState.ts +++ /dev/null @@ -1,10 +0,0 @@ -import AppSectionState, { - AppSectionDeleteState, -} from 'App/State/AppSectionState'; -import { EpisodeFile } from 'EpisodeFile/EpisodeFile'; - -interface EpisodeFilesAppState - extends AppSectionState, - AppSectionDeleteState {} - -export default EpisodeFilesAppState; diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.tsx b/frontend/src/Calendar/Agenda/AgendaEvent.tsx index 87ea414a0..dc53e8657 100644 --- a/frontend/src/Calendar/Agenda/AgendaEvent.tsx +++ b/frontend/src/Calendar/Agenda/AgendaEvent.tsx @@ -10,7 +10,7 @@ import Link from 'Components/Link/Link'; import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal'; import episodeEntities from 'Episode/episodeEntities'; import getFinaleTypeName from 'Episode/getFinaleTypeName'; -import useEpisodeFile from 'EpisodeFile/useEpisodeFile'; +import { useEpisodeFile } from 'EpisodeFile/EpisodeFileProvider'; import { icons, kinds } from 'Helpers/Props'; import useSeries from 'Series/useSeries'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; diff --git a/frontend/src/Calendar/Calendar.tsx b/frontend/src/Calendar/Calendar.tsx index 87500fa33..f52c707b3 100644 --- a/frontend/src/Calendar/Calendar.tsx +++ b/frontend/src/Calendar/Calendar.tsx @@ -3,16 +3,10 @@ import { useDispatch, useSelector } from 'react-redux'; import * as commandNames from 'Commands/commandNames'; import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import Episode from 'Episode/Episode'; import useCurrentPage from 'Helpers/Hooks/useCurrentPage'; import usePrevious from 'Helpers/Hooks/usePrevious'; import { kinds } from 'Helpers/Props'; -import { - clearEpisodeFiles, - fetchEpisodeFiles, -} from 'Store/Actions/episodeFileActions'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; import { registerPagePopulator, unregisterPagePopulator, @@ -33,7 +27,7 @@ function Calendar() { const requestCurrentPage = useCurrentPage(); const updateTimeout = useRef>(); - const { data, isFetching, isLoading, error, refetch } = useCalendar(); + const { isFetching, isLoading, error, refetch } = useCalendar(); const view = useCalendarOption('view'); const isRefreshingSeries = useSelector( @@ -57,10 +51,9 @@ function Calendar() { handleScheduleUpdate(); return () => { - dispatch(clearEpisodeFiles()); clearTimeout(updateTimeout.current); }; - }, [dispatch, handleScheduleUpdate]); + }, [handleScheduleUpdate]); useEffect(() => { if (!requestCurrentPage) { @@ -93,17 +86,6 @@ function Calendar() { } }, [isRefreshingSeries, wasRefreshingSeries, refetch]); - useEffect(() => { - const episodeFileIds = selectUniqueIds( - data, - 'episodeFileId' - ); - - if (episodeFileIds.length) { - dispatch(fetchEpisodeFiles({ episodeFileIds })); - } - }, [data, dispatch]); - return (
{isLoading ? : null} diff --git a/frontend/src/Calendar/CalendarPage.tsx b/frontend/src/Calendar/CalendarPage.tsx index 7bf1f54be..512a2be66 100644 --- a/frontend/src/Calendar/CalendarPage.tsx +++ b/frontend/src/Calendar/CalendarPage.tsx @@ -1,6 +1,12 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { + PropsWithChildren, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import QueueDetails from 'Activity/Queue/Details/QueueDetailsProvider'; +import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider'; import * as commandNames from 'Commands/commandNames'; import FilterMenu from 'Components/Menu/FilterMenu'; import PageContent from 'Components/Page/PageContent'; @@ -10,6 +16,7 @@ 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 EpisodeFileProvider from 'EpisodeFile/EpisodeFileProvider'; import useMeasure from 'Helpers/Hooks/useMeasure'; import { align, icons } from 'Helpers/Props'; import NoSeries from 'Series/NoSeries'; @@ -88,6 +95,10 @@ function CalendarPage() { return selectUniqueIds(data, 'id'); }, [data]); + const episodeFileIds = useMemo(() => { + return selectUniqueIds(data, 'episodeFileId'); + }, [data]); + useEffect(() => { if (width === 0) { return; @@ -102,7 +113,10 @@ function CalendarPage() { }, [width]); return ( - + @@ -162,8 +176,22 @@ function CalendarPage() { onModalClose={handleOptionsModalClose} /> - + ); } export default CalendarPage; + +function CalendarPageProvider({ + episodeIds, + episodeFileIds, + children, +}: PropsWithChildren<{ episodeIds: number[]; episodeFileIds: number[] }>) { + return ( + + + {children} + + + ); +} diff --git a/frontend/src/Calendar/Events/CalendarEvent.tsx b/frontend/src/Calendar/Events/CalendarEvent.tsx index 3a2c89a6a..8f8b862f6 100644 --- a/frontend/src/Calendar/Events/CalendarEvent.tsx +++ b/frontend/src/Calendar/Events/CalendarEvent.tsx @@ -9,7 +9,7 @@ import Link from 'Components/Link/Link'; import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal'; import episodeEntities from 'Episode/episodeEntities'; import getFinaleTypeName from 'Episode/getFinaleTypeName'; -import useEpisodeFile from 'EpisodeFile/useEpisodeFile'; +import { useEpisodeFile } from 'EpisodeFile/EpisodeFileProvider'; import { icons, kinds } from 'Helpers/Props'; import useSeries from 'Series/useSeries'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; diff --git a/frontend/src/Components/SignalRListener.tsx b/frontend/src/Components/SignalRListener.tsx index 434eec104..e82018aa2 100644 --- a/frontend/src/Components/SignalRListener.tsx +++ b/frontend/src/Components/SignalRListener.tsx @@ -9,6 +9,7 @@ import { useDispatch } from 'react-redux'; import ModelBase from 'App/ModelBase'; import Command from 'Commands/Command'; import Episode from 'Episode/Episode'; +import { EpisodeFile } from 'EpisodeFile/EpisodeFile'; import { PagedQueryResponse } from 'Helpers/Hooks/usePagedApiQuery'; import { setAppValue, setVersion } from 'Store/Actions/appActions'; import { removeItem, updateItem } from 'Store/Actions/baseActions'; @@ -164,15 +165,61 @@ function SignalRListener() { } if (name === 'episodefile') { - const section = 'episodeFiles'; + if (version < 5) { + return; + } if (body.action === 'updated') { - dispatch(updateItem({ section, ...body.resource })); + const updatedItem = body.resource as EpisodeFile; + + queryClient.setQueriesData( + { queryKey: ['/episodeFile'] }, + (oldData: EpisodeFile[] | undefined) => { + if (!oldData) { + return oldData; + } + + const itemIndex = oldData.findIndex( + (item) => item.id === updatedItem.id + ); + + // Add episode file to the end + if (itemIndex === -1) { + return [...oldData, updatedItem]; + } + + return oldData.map((item) => { + if (item.id === updatedItem.id) { + return updatedItem; + } + + return item; + }); + } + ); // Repopulate the page to handle recently imported file repopulatePage('episodeFileUpdated'); } else if (body.action === 'deleted') { - dispatch(removeItem({ section, id: body.resource.id })); + const id = body.resource.id; + + queryClient.setQueriesData( + { queryKey: ['/episodeFile'] }, + (oldData: EpisodeFile[] | undefined) => { + if (!oldData) { + return oldData; + } + + const itemIndex = oldData.findIndex((item) => item.id === id); + + // Add episode file to the end + if (itemIndex === -1) { + return oldData; + } + + return oldData.filter((item) => item.id !== id); + } + ); repopulatePage('episodeFileDeleted'); } diff --git a/frontend/src/Episode/EpisodeStatus.tsx b/frontend/src/Episode/EpisodeStatus.tsx index 924718564..0ce46950b 100644 --- a/frontend/src/Episode/EpisodeStatus.tsx +++ b/frontend/src/Episode/EpisodeStatus.tsx @@ -4,7 +4,7 @@ import QueueDetails from 'Activity/Queue/QueueDetails'; import Icon from 'Components/Icon'; import ProgressBar from 'Components/ProgressBar'; import useEpisode, { EpisodeEntity } from 'Episode/useEpisode'; -import useEpisodeFile from 'EpisodeFile/useEpisodeFile'; +import { useEpisodeFile } from 'EpisodeFile/EpisodeFileProvider'; import { icons, kinds, sizes } from 'Helpers/Props'; import isBefore from 'Utilities/Date/isBefore'; import translate from 'Utilities/String/translate'; diff --git a/frontend/src/Episode/Summary/EpisodeSummary.tsx b/frontend/src/Episode/Summary/EpisodeSummary.tsx index 3d8454dd8..4ccb7f728 100644 --- a/frontend/src/Episode/Summary/EpisodeSummary.tsx +++ b/frontend/src/Episode/Summary/EpisodeSummary.tsx @@ -1,5 +1,5 @@ +import { useQueryClient } from '@tanstack/react-query'; import React, { useCallback, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; import Icon from 'Components/Icon'; import Label from 'Components/Label'; import Column from 'Components/Table/Column'; @@ -7,15 +7,12 @@ import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import Episode from 'Episode/Episode'; import useEpisode, { EpisodeEntity } from 'Episode/useEpisode'; -import useEpisodeFile from 'EpisodeFile/useEpisodeFile'; +import { useEpisodeFile } from 'EpisodeFile/EpisodeFileProvider'; +import { useDeleteEpisodeFile } from 'EpisodeFile/useEpisodeFiles'; import { icons, kinds, sizes } from 'Helpers/Props'; import Series from 'Series/Series'; import useSeries from 'Series/useSeries'; import QualityProfileName from 'Settings/Profiles/Quality/QualityProfileName'; -import { - deleteEpisodeFile, - fetchEpisodeFile, -} from 'Store/Actions/episodeFileActions'; import translate from 'Utilities/String/translate'; import EpisodeAiring from './EpisodeAiring'; import EpisodeFileRow from './EpisodeFileRow'; @@ -76,11 +73,13 @@ interface EpisodeSummaryProps { episodeFileId?: number; } -function EpisodeSummary(props: EpisodeSummaryProps) { - const { seriesId, episodeId, episodeEntity, episodeFileId } = props; - - const dispatch = useDispatch(); - +function EpisodeSummary({ + seriesId, + episodeId, + episodeEntity, + episodeFileId, +}: EpisodeSummaryProps) { + const queryClient = useQueryClient(); const { qualityProfileId, network } = useSeries(seriesId) as Series; const { airDateUtc, overview } = useEpisode( @@ -97,22 +96,22 @@ function EpisodeSummary(props: EpisodeSummaryProps) { qualityCutoffNotMet, customFormats, customFormatScore, - } = useEpisodeFile(episodeFileId) || {}; + } = useEpisodeFile(episodeFileId) ?? {}; + + const { deleteEpisodeFile } = useDeleteEpisodeFile( + episodeFileId!, + episodeEntity + ); const handleDeleteEpisodeFile = useCallback(() => { - dispatch( - deleteEpisodeFile({ - id: episodeFileId, - episodeEntity, - }) - ); - }, [episodeFileId, episodeEntity, dispatch]); + deleteEpisodeFile(); + }, [deleteEpisodeFile]); useEffect(() => { if (episodeFileId && !path) { - dispatch(fetchEpisodeFile({ id: episodeFileId })); + queryClient.invalidateQueries({ queryKey: ['/episodeFile'] }); } - }, [episodeFileId, path, dispatch]); + }, [episodeFileId, path, queryClient]); const hasOverview = !!overview; diff --git a/frontend/src/Episode/useEpisode.ts b/frontend/src/Episode/useEpisode.ts index aac16cf51..cb2cc7abb 100644 --- a/frontend/src/Episode/useEpisode.ts +++ b/frontend/src/Episode/useEpisode.ts @@ -46,7 +46,7 @@ function createNoOpEpisodeSelector(_episodeId?: number) { ); } -const getQueryKey = (episodeEntity: EpisodeEntity) => { +export const getQueryKey = (episodeEntity: EpisodeEntity) => { switch (episodeEntity) { case 'calendar': return episodeQueryKeyStore.getState().calendar; diff --git a/frontend/src/EpisodeFile/EpisodeFileLanguages.tsx b/frontend/src/EpisodeFile/EpisodeFileLanguages.tsx index 110196cef..90fb526f8 100644 --- a/frontend/src/EpisodeFile/EpisodeFileLanguages.tsx +++ b/frontend/src/EpisodeFile/EpisodeFileLanguages.tsx @@ -1,6 +1,6 @@ import React from 'react'; import EpisodeLanguages from 'Episode/EpisodeLanguages'; -import useEpisodeFile from './useEpisodeFile'; +import { useEpisodeFile } from './EpisodeFileProvider'; interface EpisodeFileLanguagesProps { episodeFileId: number | undefined; diff --git a/frontend/src/EpisodeFile/EpisodeFileProvider.tsx b/frontend/src/EpisodeFile/EpisodeFileProvider.tsx new file mode 100644 index 000000000..217053e15 --- /dev/null +++ b/frontend/src/EpisodeFile/EpisodeFileProvider.tsx @@ -0,0 +1,42 @@ +import React, { + createContext, + PropsWithChildren, + useContext, + useMemo, +} from 'react'; +import { EpisodeFile } from './EpisodeFile'; +import useEpisodeFiles, { EpisodeFileFilter } from './useEpisodeFiles'; + +export const EpisodeFileContext = createContext( + undefined +); + +export default function EpisodeFileProvider({ + children, + ...filter +}: PropsWithChildren) { + const { data } = useEpisodeFiles(filter); + + return ( + + {children} + + ); +} + +export function useEpisodeFile(id: number | undefined) { + const episodeFiles = useContext(EpisodeFileContext); + + return useMemo(() => { + if (id === undefined) { + return undefined; + } + + return episodeFiles?.find((item) => item.id === id); + }, [id, episodeFiles]); +} + +export interface SeriesEpisodeFile { + count: number; + episodesWithFiles: number; +} diff --git a/frontend/src/EpisodeFile/MediaInfo.tsx b/frontend/src/EpisodeFile/MediaInfo.tsx index 2a72ee5bb..1b8e363da 100644 --- a/frontend/src/EpisodeFile/MediaInfo.tsx +++ b/frontend/src/EpisodeFile/MediaInfo.tsx @@ -1,7 +1,7 @@ import React from 'react'; import getLanguageName from 'Utilities/String/getLanguageName'; import translate from 'Utilities/String/translate'; -import useEpisodeFile from './useEpisodeFile'; +import { useEpisodeFile } from './EpisodeFileProvider'; function formatLanguages(languages: string | undefined) { if (!languages) { diff --git a/frontend/src/EpisodeFile/mediaInfoTypes.js b/frontend/src/EpisodeFile/mediaInfoTypes.js deleted file mode 100644 index 5ff3ee1e4..000000000 --- a/frontend/src/EpisodeFile/mediaInfoTypes.js +++ /dev/null @@ -1,5 +0,0 @@ -export const AUDIO = 'audio'; -export const AUDIO_LANGUAGES = 'audioLanguages'; -export const SUBTITLES = 'subtitles'; -export const VIDEO = 'video'; -export const VIDEO_DYNAMIC_RANGE_TYPE = 'videoDynamicRangeType'; diff --git a/frontend/src/EpisodeFile/useEpisodeFile.ts b/frontend/src/EpisodeFile/useEpisodeFile.ts deleted file mode 100644 index 76fc8cc4d..000000000 --- a/frontend/src/EpisodeFile/useEpisodeFile.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useSelector } from 'react-redux'; -import { createSelector } from 'reselect'; -import AppState from 'App/State/AppState'; - -function createEpisodeFileSelector(episodeFileId?: number) { - return createSelector( - (state: AppState) => state.episodeFiles.items, - (episodeFiles) => { - return episodeFiles.find(({ id }) => id === episodeFileId); - } - ); -} - -function useEpisodeFile(episodeFileId: number | undefined) { - return useSelector(createEpisodeFileSelector(episodeFileId)); -} - -export default useEpisodeFile; diff --git a/frontend/src/EpisodeFile/useEpisodeFiles.ts b/frontend/src/EpisodeFile/useEpisodeFiles.ts new file mode 100644 index 000000000..b7c455053 --- /dev/null +++ b/frontend/src/EpisodeFile/useEpisodeFiles.ts @@ -0,0 +1,107 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { EpisodeEntity, getQueryKey } from 'Episode/useEpisode'; +import useApiMutation from 'Helpers/Hooks/useApiMutation'; +import useApiQuery from 'Helpers/Hooks/useApiQuery'; +import { EpisodeFile } from './EpisodeFile'; + +const DEFAULT_EPISODE_FILES: EpisodeFile[] = []; + +interface SeriesEpisodeFiles { + seriesId: number; +} + +interface EpisodeFileIds { + episodeFileIds: number[]; +} + +export type EpisodeFileFilter = SeriesEpisodeFiles | EpisodeFileIds; + +const useEpisodeFiles = (params: EpisodeFileFilter) => { + const result = useApiQuery({ + path: '/episodeFile', + queryParams: { ...params }, + queryOptions: { + enabled: + ('seriesId' in params && params.seriesId !== undefined) || + ('episodeFileIds' in params && params.episodeFileIds?.length > 0), + }, + }); + + return { + ...result, + data: result.data ?? DEFAULT_EPISODE_FILES, + hasEpisodeFiles: !!result.data?.length, + }; +}; + +export default useEpisodeFiles; + +export const useDeleteEpisodeFile = ( + id: number, + episodeEntity: EpisodeEntity +) => { + const queryClient = useQueryClient(); + + const { mutate, error, isPending } = useApiMutation({ + path: `/episodeFile/${id}`, + method: 'DELETE', + mutationOptions: { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/episodeFile'] }); + queryClient.invalidateQueries({ + queryKey: [getQueryKey(episodeEntity)], + }); + }, + }, + }); + + return { + deleteEpisodeFile: mutate, + isDeleting: isPending, + deleteError: error, + }; +}; + +export const useDeleteEpisodeFiles = () => { + const queryClient = useQueryClient(); + + const { mutate, error, isPending } = useApiMutation({ + path: '/episodeFile/bulk', + method: 'DELETE', + mutationOptions: { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/episodeFile'] }); + queryClient.invalidateQueries({ queryKey: ['/episode'] }); + }, + }, + }); + + return { + deleteEpisodeFiles: mutate, + isDeleting: isPending, + deleteError: error, + }; +}; + +export const useUpdateEpisodeFiles = () => { + const queryClient = useQueryClient(); + + const { mutate, isPending, error } = useApiMutation< + unknown, + Partial[] + >({ + path: '/episodeFile/bulk', + method: 'PUT', + mutationOptions: { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/episodeFile'] }); + }, + }, + }); + + return { + updateEpisodeFiles: mutate, + isUpdating: isPending, + updateError: error, + }; +}; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx index 5ca735c99..9ab7ea603 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx @@ -24,6 +24,10 @@ import Column from 'Components/Table/Column'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import { EpisodeFile } from 'EpisodeFile/EpisodeFile'; +import { + useDeleteEpisodeFiles, + useUpdateEpisodeFiles, +} from 'EpisodeFile/useEpisodeFiles'; import usePrevious from 'Helpers/Hooks/usePrevious'; import { align, icons, kinds, scrollDirections } from 'Helpers/Props'; import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal'; @@ -43,10 +47,6 @@ import Language from 'Language/Language'; import { QualityModel } from 'Quality/Quality'; import Series from 'Series/Series'; import { executeCommand } from 'Store/Actions/commandActions'; -import { - deleteEpisodeFiles, - updateEpisodeFiles, -} from 'Store/Actions/episodeFileActions'; import { clearInteractiveImport, fetchInteractiveImportItems, @@ -200,17 +200,6 @@ function isSameEpisodeFile( return !hasDifferentItems(originalFile.episodes, episodes); } -const episodeFilesInfoSelector = createSelector( - (state: AppState) => state.episodeFiles.isDeleting, - (state: AppState) => state.episodeFiles.deleteError, - (isDeleting, deleteError) => { - return { - isDeleting, - deleteError, - }; - } -); - const importModeSelector = createSelector( (state: AppState) => state.interactiveImport.importMode, (importMode) => { @@ -269,7 +258,11 @@ function InteractiveImportModalContentInner( createClientSideCollectionSelector('interactiveImport') ); - const { isDeleting, deleteError } = useSelector(episodeFilesInfoSelector); + const { isDeleting, deleteEpisodeFiles, deleteError } = + useDeleteEpisodeFiles(); + + const { updateEpisodeFiles } = useUpdateEpisodeFiles(); + const importMode = useSelector(importModeSelector); const [invalidRowsSelected, setInvalidRowsSelected] = useState([]); @@ -492,8 +485,8 @@ function InteractiveImportModalContentInner( return acc; }, []); - dispatch(deleteEpisodeFiles({ episodeFileIds })); - }, [items, selectedIds, setIsConfirmDeleteModalOpen, dispatch]); + deleteEpisodeFiles({ episodeFileIds }); + }, [items, selectedIds, setIsConfirmDeleteModalOpen, deleteEpisodeFiles]); const onConfirmDeleteModalClose = useCallback(() => { setIsConfirmDeleteModalOpen(false); @@ -602,11 +595,7 @@ function InteractiveImportModalContentInner( let shouldClose = false; if (existingFiles.length) { - dispatch( - updateEpisodeFiles({ - files: existingFiles, - }) - ); + updateEpisodeFiles(existingFiles); shouldClose = true; } @@ -635,6 +624,7 @@ function InteractiveImportModalContentInner( selectedIds, onModalClose, dispatch, + updateEpisodeFiles, ]); const onSortPress = useCallback( diff --git a/frontend/src/Series/Details/EpisodeRow.tsx b/frontend/src/Series/Details/EpisodeRow.tsx index fb3afb4d2..00ec26de1 100644 --- a/frontend/src/Series/Details/EpisodeRow.tsx +++ b/frontend/src/Series/Details/EpisodeRow.tsx @@ -14,9 +14,8 @@ import EpisodeStatus from 'Episode/EpisodeStatus'; import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; import IndexerFlags from 'Episode/IndexerFlags'; import EpisodeFileLanguages from 'EpisodeFile/EpisodeFileLanguages'; +import { useEpisodeFile } from 'EpisodeFile/EpisodeFileProvider'; import MediaInfo from 'EpisodeFile/MediaInfo'; -import * as mediaInfoTypes from 'EpisodeFile/mediaInfoTypes'; -import useEpisodeFile from 'EpisodeFile/useEpisodeFile'; import { icons } from 'Helpers/Props'; import useSeries from 'Series/useSeries'; import MediaInfoModel from 'typings/MediaInfo'; @@ -223,10 +222,7 @@ function EpisodeRow({ if (name === 'audioInfo') { return ( - + ); } @@ -234,10 +230,7 @@ function EpisodeRow({ if (name === 'audioLanguages') { return ( - + ); } @@ -245,10 +238,7 @@ function EpisodeRow({ if (name === 'subtitleLanguages') { return ( - + ); } @@ -256,10 +246,7 @@ function EpisodeRow({ if (name === 'videoCodec') { return ( - + ); } @@ -268,7 +255,7 @@ function EpisodeRow({ return ( diff --git a/frontend/src/Series/Details/SeriesDetails.tsx b/frontend/src/Series/Details/SeriesDetails.tsx index e5e68dfdd..8f9cff51f 100644 --- a/frontend/src/Series/Details/SeriesDetails.tsx +++ b/frontend/src/Series/Details/SeriesDetails.tsx @@ -2,7 +2,6 @@ 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'; @@ -21,6 +20,7 @@ import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import Popover from 'Components/Tooltip/Popover'; import Tooltip from 'Components/Tooltip/Tooltip'; +import useEpisodeFiles from 'EpisodeFile/useEpisodeFiles'; import usePrevious from 'Helpers/Hooks/usePrevious'; import { align, @@ -44,10 +44,6 @@ import useSeries from 'Series/useSeries'; import QualityProfileName from 'Settings/Profiles/Quality/QualityProfileName'; import { executeCommand } from 'Store/Actions/commandActions'; import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions'; -import { - clearEpisodeFiles, - fetchEpisodeFiles, -} from 'Store/Actions/episodeFileActions'; import { toggleSeriesMonitored } from 'Store/Actions/seriesActions'; import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; @@ -63,6 +59,7 @@ import translate from 'Utilities/String/translate'; import toggleSelected from 'Utilities/Table/toggleSelected'; import SeriesAlternateTitles from './SeriesAlternateTitles'; import SeriesDetailsLinks from './SeriesDetailsLinks'; +import SeriesDetailsProvider from './SeriesDetailsProvider'; import SeriesDetailsSeason from './SeriesDetailsSeason'; import SeriesProgressLabel from './SeriesProgressLabel'; import SeriesTags from './SeriesTags'; @@ -98,24 +95,6 @@ function createEpisodesSelector() { ); } -function createEpisodeFilesSelector() { - return createSelector( - (state: AppState) => state.episodeFiles, - (episodeFiles) => { - const { items, isFetching, isPopulated, error } = episodeFiles; - - const hasEpisodeFiles = !!items.length; - - return { - isEpisodeFilesFetching: isFetching, - isEpisodeFilesPopulated: isPopulated, - episodeFilesError: error, - hasEpisodeFiles, - }; - } - ); -} - interface ExpandedState { allExpanded: boolean; allCollapsed: boolean; @@ -139,12 +118,13 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) { hasEpisodes, hasMonitoredEpisodes, } = useSelector(createEpisodesSelector()); + const { - isEpisodeFilesFetching, - isEpisodeFilesPopulated, - episodeFilesError, + isFetching: isEpisodeFilesFetching, + isFetched: isEpisodeFilesFetched, + error: episodeFilesError, hasEpisodeFiles, - } = useSelector(createEpisodeFilesSelector()); + } = useEpisodeFiles({ seriesId }); const commands = useSelector(createCommandsSelector()); @@ -379,7 +359,6 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) { const populate = useCallback(() => { dispatch(fetchEpisodes({ seriesId })); - dispatch(fetchEpisodeFiles({ seriesId })); }, [seriesId, dispatch]); useEffect(() => { @@ -392,7 +371,6 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) { return () => { unregisterPagePopulator(populate); dispatch(clearEpisodes()); - dispatch(clearEpisodeFiles()); }; }, [populate, dispatch]); @@ -461,10 +439,10 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) { const fanartUrl = getFanartUrl(images); const isFetching = isEpisodesFetching || isEpisodeFilesFetching; - const isPopulated = isEpisodesPopulated && isEpisodeFilesPopulated; + const isPopulated = isEpisodesPopulated && isEpisodeFilesFetched; return ( - + @@ -892,7 +870,7 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) { /> - + ); } diff --git a/frontend/src/Series/Details/SeriesDetailsProvider.tsx b/frontend/src/Series/Details/SeriesDetailsProvider.tsx new file mode 100644 index 000000000..f39f8c0ef --- /dev/null +++ b/frontend/src/Series/Details/SeriesDetailsProvider.tsx @@ -0,0 +1,21 @@ +import React, { PropsWithChildren } from 'react'; +import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider'; +import { EpisodeFileContext } from 'EpisodeFile/EpisodeFileProvider'; +import useEpisodeFiles from 'EpisodeFile/useEpisodeFiles'; + +function SeriesDetailsProvider({ + seriesId, + children, +}: PropsWithChildren<{ seriesId: number }>) { + const { data } = useEpisodeFiles({ seriesId }); + + return ( + + + {children} + + + ); +} + +export default SeriesDetailsProvider; diff --git a/frontend/src/Store/Actions/episodeFileActions.js b/frontend/src/Store/Actions/episodeFileActions.js deleted file mode 100644 index c483f770d..000000000 --- a/frontend/src/Store/Actions/episodeFileActions.js +++ /dev/null @@ -1,205 +0,0 @@ -import _ from 'lodash'; -import { createAction } from 'redux-actions'; -import { batchActions } from 'redux-batched-actions'; -import episodeEntities from 'Episode/episodeEntities'; -import { createThunk, handleThunks } from 'Store/thunks'; -import createAjaxRequest from 'Utilities/createAjaxRequest'; -import { removeItem, set, updateItem } from './baseActions'; -import createFetchHandler from './Creators/createFetchHandler'; -import createHandleActions from './Creators/createHandleActions'; -import createRemoveItemHandler from './Creators/createRemoveItemHandler'; - -// -// Variables - -export const section = 'episodeFiles'; - -// -// State - -export const defaultState = { - isFetching: false, - isPopulated: false, - error: null, - isDeleting: false, - deleteError: null, - isSaving: false, - saveError: null, - items: [] -}; - -// -// Actions Types - -export const FETCH_EPISODE_FILE = 'episodeFiles/fetchEpisodeFile'; -export const FETCH_EPISODE_FILES = 'episodeFiles/fetchEpisodeFiles'; -export const DELETE_EPISODE_FILE = 'episodeFiles/deleteEpisodeFile'; -export const DELETE_EPISODE_FILES = 'episodeFiles/deleteEpisodeFiles'; -export const UPDATE_EPISODE_FILES = 'episodeFiles/updateEpisodeFiles'; -export const CLEAR_EPISODE_FILES = 'episodeFiles/clearEpisodeFiles'; - -// -// Action Creators - -export const fetchEpisodeFile = createThunk(FETCH_EPISODE_FILE); -export const fetchEpisodeFiles = createThunk(FETCH_EPISODE_FILES); -export const deleteEpisodeFile = createThunk(DELETE_EPISODE_FILE); -export const deleteEpisodeFiles = createThunk(DELETE_EPISODE_FILES); -export const updateEpisodeFiles = createThunk(UPDATE_EPISODE_FILES); -export const clearEpisodeFiles = createAction(CLEAR_EPISODE_FILES); - -// -// Helpers - -const deleteEpisodeFileHelper = createRemoveItemHandler(section, '/episodeFile'); - -// -// Action Handlers - -export const actionHandlers = handleThunks({ - [FETCH_EPISODE_FILE]: createFetchHandler(section, '/episodeFile'), - [FETCH_EPISODE_FILES]: createFetchHandler(section, '/episodeFile'), - - [DELETE_EPISODE_FILE]: function(getState, payload, dispatch) { - const { - id: episodeFileId, - episodeEntity = episodeEntities.EPISODES - } = payload; - - const episodeSection = _.last(episodeEntity.split('.')); - const deletePromise = deleteEpisodeFileHelper(getState, payload, dispatch); - - deletePromise.done(() => { - const episodes = getState().episodes.items; - const episodesWithRemovedFiles = _.filter(episodes, { episodeFileId }); - - dispatch(batchActions([ - ...episodesWithRemovedFiles.map((episode) => { - return updateItem({ - section: episodeSection, - ...episode, - episodeFileId: 0, - hasFile: false - }); - }) - ])); - }); - }, - - [DELETE_EPISODE_FILES]: function(getState, payload, dispatch) { - const { - episodeFileIds - } = payload; - - dispatch(set({ section, isDeleting: true })); - - const promise = createAjaxRequest({ - url: '/episodeFile/bulk', - method: 'DELETE', - dataType: 'json', - data: JSON.stringify({ episodeFileIds }) - }).request; - - promise.done(() => { - const episodes = getState().episodes.items; - const episodesWithRemovedFiles = episodeFileIds.reduce((acc, episodeFileId) => { - acc.push(..._.filter(episodes, { episodeFileId })); - - return acc; - }, []); - - dispatch(batchActions([ - ...episodeFileIds.map((id) => { - return removeItem({ section, id }); - }), - - ...episodesWithRemovedFiles.map((episode) => { - return updateItem({ - section: 'episodes', - ...episode, - episodeFileId: 0, - hasFile: false - }); - }), - - set({ - section, - isDeleting: false, - deleteError: null - }) - ])); - }); - - promise.fail((xhr) => { - dispatch(set({ - section, - isDeleting: false, - deleteError: xhr - })); - }); - }, - - [UPDATE_EPISODE_FILES]: function(getState, payload, dispatch) { - const { files } = payload; - - dispatch(set({ section, isSaving: true })); - - const requestData = files; - - const promise = createAjaxRequest({ - url: '/episodeFile/bulk', - method: 'PUT', - dataType: 'json', - data: JSON.stringify(requestData) - }).request; - - promise.done((data) => { - dispatch(batchActions([ - ...files.map((file) => { - const id = file.id; - const props = {}; - const episodeFile = data.find((f) => f.id === id); - - props.qualityCutoffNotMet = episodeFile.qualityCutoffNotMet; - props.customFormats = episodeFile.customFormats; - props.customFormatScore = episodeFile.customFormatScore; - props.languages = file.languages; - props.quality = file.quality; - props.releaseGroup = file.releaseGroup; - props.indexerFlags = file.indexerFlags; - - return updateItem({ - section, - id, - ...props - }); - }), - - set({ - section, - isSaving: false, - saveError: null - }) - ])); - }); - - promise.fail((xhr) => { - dispatch(set({ - section, - isSaving: false, - saveError: xhr - })); - }); - } -}); - -// -// Reducers - -export const reducers = createHandleActions({ - - [CLEAR_EPISODE_FILES]: (state) => { - return Object.assign({}, state, defaultState); - } - -}, defaultState, section); diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index e48521739..a84d2cc0f 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -3,7 +3,6 @@ import * as captcha from './captchaActions'; import * as commands from './commandActions'; import * as customFilters from './customFilterActions'; import * as episodes from './episodeActions'; -import * as episodeFiles from './episodeFileActions'; import * as episodeHistory from './episodeHistoryActions'; import * as episodeSelection from './episodeSelectionActions'; import * as importSeries from './importSeriesActions'; @@ -25,7 +24,6 @@ export default [ commands, customFilters, episodes, - episodeFiles, episodeHistory, episodeSelection, importSeries, diff --git a/frontend/src/Store/Selectors/createEpisodeFileSelector.ts b/frontend/src/Store/Selectors/createEpisodeFileSelector.ts deleted file mode 100644 index b8c65e4c4..000000000 --- a/frontend/src/Store/Selectors/createEpisodeFileSelector.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createSelector } from 'reselect'; -import AppState from 'App/State/AppState'; - -function createEpisodeFileSelector() { - return createSelector( - (_: AppState, { episodeFileId }: { episodeFileId: number }) => - episodeFileId, - (state: AppState) => state.episodeFiles, - (episodeFileId, episodeFiles) => { - if (!episodeFileId) { - return; - } - - return episodeFiles.items.find( - (episodeFile) => episodeFile.id === episodeFileId - ); - } - ); -} - -export default createEpisodeFileSelector; diff --git a/frontend/src/Store/Selectors/createEpisodeSelector.js b/frontend/src/Store/Selectors/createEpisodeSelector.js deleted file mode 100644 index 6725cadd9..000000000 --- a/frontend/src/Store/Selectors/createEpisodeSelector.js +++ /dev/null @@ -1,15 +0,0 @@ -import _ from 'lodash'; -import { createSelector } from 'reselect'; -import episodeEntities from 'Episode/episodeEntities'; - -function createEpisodeSelector() { - return createSelector( - (state, { episodeId }) => episodeId, - (state, { episodeEntity = episodeEntities.EPISODES }) => _.get(state, episodeEntity, { items: [] }), - (episodeId, episodes) => { - return _.find(episodes.items, { id: episodeId }); - } - ); -} - -export default createEpisodeSelector; diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.tsx b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.tsx index 76a2a2ca2..7efe35df0 100644 --- a/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.tsx +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { + PropsWithChildren, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import { useDispatch, useSelector } from 'react-redux'; import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider'; import { SelectProvider, useSelect } from 'App/Select/SelectContext'; @@ -20,9 +26,9 @@ import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptions import TablePager from 'Components/Table/TablePager'; import Episode from 'Episode/Episode'; import { useToggleEpisodesMonitored } from 'Episode/useEpisode'; +import EpisodeFileProvider from 'EpisodeFile/EpisodeFileProvider'; import { align, icons, kinds } from 'Helpers/Props'; import { executeCommand } from 'Store/Actions/commandActions'; -import { fetchEpisodeFiles } from 'Store/Actions/episodeFileActions'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import { CheckInputChanged } from 'typings/inputs'; import { TableOptionsChangePayload } from 'typings/Table'; @@ -187,14 +193,11 @@ function CutoffUnmetContent() { }; }, [refetch]); - useEffect(() => { - if (episodeFileIds.length) { - dispatch(fetchEpisodeFiles({ episodeFileIds })); - } - }, [episodeFileIds, dispatch]); - return ( - + @@ -320,7 +323,7 @@ function CutoffUnmetContent() { ) : null} - + ); } @@ -333,3 +336,17 @@ export default function CutoffUnmet() { ); } + +function CutoffUnmetProvider({ + episodeIds, + episodeFileIds, + children, +}: PropsWithChildren<{ episodeIds: number[]; episodeFileIds: number[] }>) { + return ( + + + {children} + + + ); +}