Use react-query for episode files

This commit is contained in:
Mark McDowall 2025-11-17 21:13:09 -08:00
parent 324d477376
commit 7daee3f63d
No known key found for this signature in database
27 changed files with 343 additions and 424 deletions

View file

@ -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]);

View file

@ -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<Queue[] | undefined>(undefined);
export default function QueueDetailsProvider({
children,
...filter
}: QueueDetailsProps & QueueDetailsFilter) {
}: PropsWithChildren<QueueDetailsFilter>) {
const { data } = useApiQuery<Queue[]>({
path: '/queue/details',
queryParams: { ...filter },

View file

@ -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;

View file

@ -1,10 +0,0 @@
import AppSectionState, {
AppSectionDeleteState,
} from 'App/State/AppSectionState';
import { EpisodeFile } from 'EpisodeFile/EpisodeFile';
interface EpisodeFilesAppState
extends AppSectionState<EpisodeFile>,
AppSectionDeleteState {}
export default EpisodeFilesAppState;

View file

@ -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';

View file

@ -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<ReturnType<typeof setTimeout>>();
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<Episode, number>(
data,
'episodeFileId'
);
if (episodeFileIds.length) {
dispatch(fetchEpisodeFiles({ episodeFileIds }));
}
}, [data, dispatch]);
return (
<div className={styles.calendar}>
{isLoading ? <LoadingIndicator /> : null}

View file

@ -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<Episode, number>(data, 'id');
}, [data]);
const episodeFileIds = useMemo(() => {
return selectUniqueIds<Episode, number>(data, 'episodeFileId');
}, [data]);
useEffect(() => {
if (width === 0) {
return;
@ -102,7 +113,10 @@ function CalendarPage() {
}, [width]);
return (
<QueueDetails episodeIds={episodeIds}>
<CalendarPageProvider
episodeIds={episodeIds}
episodeFileIds={episodeFileIds}
>
<PageContent title={translate('Calendar')}>
<PageToolbar>
<PageToolbarSection>
@ -162,8 +176,22 @@ function CalendarPage() {
onModalClose={handleOptionsModalClose}
/>
</PageContent>
</QueueDetails>
</CalendarPageProvider>
);
}
export default CalendarPage;
function CalendarPageProvider({
episodeIds,
episodeFileIds,
children,
}: PropsWithChildren<{ episodeIds: number[]; episodeFileIds: number[] }>) {
return (
<QueueDetailsProvider episodeIds={episodeIds}>
<EpisodeFileProvider episodeFileIds={episodeFileIds}>
{children}
</EpisodeFileProvider>
</QueueDetailsProvider>
);
}

View file

@ -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';

View file

@ -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');
}

View file

@ -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';

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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<EpisodeFile[] | undefined>(
undefined
);
export default function EpisodeFileProvider({
children,
...filter
}: PropsWithChildren<EpisodeFileFilter>) {
const { data } = useEpisodeFiles(filter);
return (
<EpisodeFileContext.Provider value={data}>
{children}
</EpisodeFileContext.Provider>
);
}
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;
}

View file

@ -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) {

View file

@ -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';

View file

@ -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;

View file

@ -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<EpisodeFile[]>({
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<unknown, void>({
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<unknown, EpisodeFileIds>({
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<EpisodeFile>[]
>({
path: '/episodeFile/bulk',
method: 'PUT',
mutationOptions: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/episodeFile'] });
},
},
});
return {
updateEpisodeFiles: mutate,
isUpdating: isPending,
updateError: error,
};
};

View file

@ -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<number[]>([]);
@ -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<SortCallback>(

View file

@ -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 (
<TableRowCell key={name} className={styles.audio}>
<MediaInfo
type={mediaInfoTypes.AUDIO}
episodeFileId={episodeFileId}
/>
<MediaInfo type="audio" episodeFileId={episodeFileId} />
</TableRowCell>
);
}
@ -234,10 +230,7 @@ function EpisodeRow({
if (name === 'audioLanguages') {
return (
<TableRowCell key={name} className={styles.audioLanguages}>
<MediaInfo
type={mediaInfoTypes.AUDIO_LANGUAGES}
episodeFileId={episodeFileId}
/>
<MediaInfo type="audioLanguages" episodeFileId={episodeFileId} />
</TableRowCell>
);
}
@ -245,10 +238,7 @@ function EpisodeRow({
if (name === 'subtitleLanguages') {
return (
<TableRowCell key={name} className={styles.subtitles}>
<MediaInfo
type={mediaInfoTypes.SUBTITLES}
episodeFileId={episodeFileId}
/>
<MediaInfo type="subtitles" episodeFileId={episodeFileId} />
</TableRowCell>
);
}
@ -256,10 +246,7 @@ function EpisodeRow({
if (name === 'videoCodec') {
return (
<TableRowCell key={name} className={styles.video}>
<MediaInfo
type={mediaInfoTypes.VIDEO}
episodeFileId={episodeFileId}
/>
<MediaInfo type="video" episodeFileId={episodeFileId} />
</TableRowCell>
);
}
@ -268,7 +255,7 @@ function EpisodeRow({
return (
<TableRowCell key={name} className={styles.videoDynamicRangeType}>
<MediaInfo
type={mediaInfoTypes.VIDEO_DYNAMIC_RANGE_TYPE}
type="videoDynamicRangeType"
episodeFileId={episodeFileId}
/>
</TableRowCell>

View file

@ -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 (
<QueueDetailsProvider seriesId={seriesId}>
<SeriesDetailsProvider seriesId={seriesId}>
<PageContent title={title}>
<PageToolbar>
<PageToolbarSection>
@ -892,7 +870,7 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
/>
</PageContentBody>
</PageContent>
</QueueDetailsProvider>
</SeriesDetailsProvider>
);
}

View file

@ -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 (
<QueueDetailsProvider seriesId={seriesId}>
<EpisodeFileContext.Provider value={data}>
{children}
</EpisodeFileContext.Provider>
</QueueDetailsProvider>
);
}
export default SeriesDetailsProvider;

View file

@ -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);

View file

@ -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,

View file

@ -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;

View file

@ -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;

View file

@ -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 (
<QueueDetailsProvider episodeIds={episodeIds}>
<CutoffUnmetProvider
episodeIds={episodeIds}
episodeFileIds={episodeFileIds}
>
<PageContent title={translate('CutoffUnmet')}>
<PageToolbar>
<PageToolbarSection>
@ -320,7 +323,7 @@ function CutoffUnmetContent() {
) : null}
</PageContentBody>
</PageContent>
</QueueDetailsProvider>
</CutoffUnmetProvider>
);
}
@ -333,3 +336,17 @@ export default function CutoffUnmet() {
</SelectProvider>
);
}
function CutoffUnmetProvider({
episodeIds,
episodeFileIds,
children,
}: PropsWithChildren<{ episodeIds: number[]; episodeFileIds: number[] }>) {
return (
<QueueDetailsProvider episodeIds={episodeIds}>
<EpisodeFileProvider episodeFileIds={episodeFileIds}>
{children}
</EpisodeFileProvider>
</QueueDetailsProvider>
);
}