1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2024-10-29 23:12:39 +01:00

Convert Episode and Season search to TypeScript

Co-authored-by: Mark McDowall <markus.mcd5@gmail.com>
This commit is contained in:
Bogdan 2024-08-20 05:59:32 +03:00 committed by Mark McDowall
parent 4548dcdf97
commit 041fdd3929
47 changed files with 1082 additions and 1475 deletions

View File

@ -1,10 +1,10 @@
import InteractiveImportAppState from 'App/State/InteractiveImportAppState';
import BlocklistAppState from './BlocklistAppState';
import CalendarAppState from './CalendarAppState';
import CommandAppState from './CommandAppState';
import EpisodeFilesAppState from './EpisodeFilesAppState';
import EpisodesAppState from './EpisodesAppState';
import HistoryAppState from './HistoryAppState';
import InteractiveImportAppState from './InteractiveImportAppState';
import ParseAppState from './ParseAppState';
import QueueAppState from './QueueAppState';
import RootFolderAppState from './RootFolderAppState';
@ -12,6 +12,7 @@ import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
import SettingsAppState from './SettingsAppState';
import SystemAppState from './SystemAppState';
import TagsAppState from './TagsAppState';
import WantedAppState from './WantedAppState';
interface FilterBuilderPropOption {
id: string;
@ -62,8 +63,8 @@ interface AppState {
blocklist: BlocklistAppState;
calendar: CalendarAppState;
commands: CommandAppState;
episodes: EpisodesAppState;
episodeFiles: EpisodeFilesAppState;
episodes: EpisodesAppState;
episodesSelection: EpisodesAppState;
history: HistoryAppState;
interactiveImport: InteractiveImportAppState;
@ -75,6 +76,7 @@ interface AppState {
settings: SettingsAppState;
system: SystemAppState;
tags: TagsAppState;
wanted: WantedAppState;
}
export default AppState;

View File

@ -0,0 +1,13 @@
import AppSectionState from 'App/State/AppSectionState';
import Episode from 'Episode/Episode';
interface WantedCutoffUnmetAppState extends AppSectionState<Episode> {}
interface WantedMissingAppState extends AppSectionState<Episode> {}
interface WantedAppState {
cutoffUnmet: WantedCutoffUnmetAppState;
missing: WantedMissingAppState;
}
export default WantedAppState;

View File

@ -26,6 +26,7 @@ export interface CommandBody {
seriesId?: number;
seriesIds?: number[];
seasonNumber?: number;
episodeIds?: number[];
[key: string]: string | number | boolean | number[] | undefined;
}

View File

@ -19,6 +19,7 @@ interface Episode extends ModelBase {
episodeFile?: object;
hasFile: boolean;
monitored: boolean;
grabbed?: boolean;
unverifiedSceneNumbering: boolean;
endTime?: string;
grabDate?: string;

View File

@ -1,60 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import EpisodeDetailsModalContentConnector from './EpisodeDetailsModalContentConnector';
class EpisodeDetailsModal extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
closeOnBackgroundClick: props.selectedTab !== 'search'
};
}
//
// Listeners
onTabChange = (isSearch) => {
this.setState({ closeOnBackgroundClick: !isSearch });
};
//
// Render
render() {
const {
isOpen,
onModalClose,
...otherProps
} = this.props;
return (
<Modal
isOpen={isOpen}
size={sizes.EXTRA_EXTRA_LARGE}
closeOnBackgroundClick={this.state.closeOnBackgroundClick}
onModalClose={onModalClose}
>
<EpisodeDetailsModalContentConnector
{...otherProps}
onTabChange={this.onTabChange}
onModalClose={onModalClose}
/>
</Modal>
);
}
}
EpisodeDetailsModal.propTypes = {
selectedTab: PropTypes.string,
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EpisodeDetailsModal;

View File

@ -0,0 +1,52 @@
import React, { useCallback, useState } from 'react';
import Modal from 'Components/Modal/Modal';
import EpisodeDetailsTab from 'Episode/EpisodeDetailsTab';
import { EpisodeEntities } from 'Episode/useEpisode';
import { sizes } from 'Helpers/Props';
import EpisodeDetailsModalContent from './EpisodeDetailsModalContent';
interface EpisodeDetailsModalProps {
isOpen: boolean;
episodeId: number;
episodeEntity: EpisodeEntities;
seriesId: number;
episodeTitle: string;
isSaving?: boolean;
showOpenSeriesButton?: boolean;
selectedTab?: EpisodeDetailsTab;
startInteractiveSearch?: boolean;
onModalClose(): void;
}
function EpisodeDetailsModal(props: EpisodeDetailsModalProps) {
const { selectedTab, isOpen, onModalClose, ...otherProps } = props;
const [closeOnBackgroundClick, setCloseOnBackgroundClick] = useState(
selectedTab !== 'search'
);
const handleTabChange = useCallback(
(isSearch: boolean) => {
setCloseOnBackgroundClick(!isSearch);
},
[setCloseOnBackgroundClick]
);
return (
<Modal
isOpen={isOpen}
size={sizes.EXTRA_EXTRA_LARGE}
closeOnBackgroundClick={closeOnBackgroundClick}
onModalClose={onModalClose}
>
<EpisodeDetailsModalContent
{...otherProps}
selectedTab={selectedTab}
onTabChange={handleTabChange}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default EpisodeDetailsModal;

View File

@ -1,222 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import episodeEntities from 'Episode/episodeEntities';
import translate from 'Utilities/String/translate';
import EpisodeHistoryConnector from './History/EpisodeHistoryConnector';
import EpisodeSearchConnector from './Search/EpisodeSearchConnector';
import SeasonEpisodeNumber from './SeasonEpisodeNumber';
import EpisodeSummaryConnector from './Summary/EpisodeSummaryConnector';
import styles from './EpisodeDetailsModalContent.css';
const tabs = [
'details',
'history',
'search'
];
class EpisodeDetailsModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
selectedTab: props.selectedTab
};
}
//
// Listeners
onTabSelect = (index, lastIndex) => {
const selectedTab = tabs[index];
this.props.onTabChange(selectedTab === 'search');
this.setState({ selectedTab });
};
//
// Render
render() {
const {
episodeId,
episodeEntity,
episodeFileId,
seriesId,
seriesTitle,
titleSlug,
seriesMonitored,
seriesType,
seasonNumber,
episodeNumber,
absoluteEpisodeNumber,
episodeTitle,
airDate,
monitored,
isSaving,
showOpenSeriesButton,
startInteractiveSearch,
onMonitorEpisodePress,
onModalClose
} = this.props;
const seriesLink = `/series/${titleSlug}`;
return (
<ModalContent
onModalClose={onModalClose}
>
<ModalHeader>
<MonitorToggleButton
className={styles.toggleButton}
id={episodeId}
monitored={monitored}
size={18}
isDisabled={!seriesMonitored}
isSaving={isSaving}
onPress={onMonitorEpisodePress}
/>
<span className={styles.seriesTitle}>
{seriesTitle}
</span>
<span className={styles.separator}>-</span>
<SeasonEpisodeNumber
seasonNumber={seasonNumber}
episodeNumber={episodeNumber}
absoluteEpisodeNumber={absoluteEpisodeNumber}
airDate={airDate}
seriesType={seriesType}
/>
<span className={styles.separator}>-</span>
{episodeTitle}
</ModalHeader>
<ModalBody>
<Tabs
className={styles.tabs}
selectedIndex={tabs.indexOf(this.state.selectedTab)}
onSelect={this.onTabSelect}
>
<TabList
className={styles.tabList}
>
<Tab
className={styles.tab}
selectedClassName={styles.selectedTab}
>
{translate('Details')}
</Tab>
<Tab
className={styles.tab}
selectedClassName={styles.selectedTab}
>
{translate('History')}
</Tab>
<Tab
className={styles.tab}
selectedClassName={styles.selectedTab}
>
{translate('Search')}
</Tab>
</TabList>
<TabPanel>
<div className={styles.tabContent}>
<EpisodeSummaryConnector
episodeId={episodeId}
episodeEntity={episodeEntity}
episodeFileId={episodeFileId}
seriesId={seriesId}
/>
</div>
</TabPanel>
<TabPanel>
<div className={styles.tabContent}>
<EpisodeHistoryConnector
episodeId={episodeId}
/>
</div>
</TabPanel>
<TabPanel>
{/* Don't wrap in tabContent so we not have a top margin */}
<EpisodeSearchConnector
episodeId={episodeId}
startInteractiveSearch={startInteractiveSearch}
onModalClose={onModalClose}
/>
</TabPanel>
</Tabs>
</ModalBody>
<ModalFooter>
{
showOpenSeriesButton &&
<Button
className={styles.openSeriesButton}
to={seriesLink}
onPress={onModalClose}
>
{translate('OpenSeries')}
</Button>
}
<Button
onPress={onModalClose}
>
{translate('Close')}
</Button>
</ModalFooter>
</ModalContent>
);
}
}
EpisodeDetailsModalContent.propTypes = {
episodeId: PropTypes.number.isRequired,
episodeEntity: PropTypes.string.isRequired,
episodeFileId: PropTypes.number,
seriesId: PropTypes.number.isRequired,
seriesTitle: PropTypes.string.isRequired,
titleSlug: PropTypes.string.isRequired,
seriesMonitored: PropTypes.bool.isRequired,
seriesType: PropTypes.string.isRequired,
seasonNumber: PropTypes.number.isRequired,
episodeNumber: PropTypes.number.isRequired,
absoluteEpisodeNumber: PropTypes.number,
airDate: PropTypes.string.isRequired,
episodeTitle: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
isSaving: PropTypes.bool,
showOpenSeriesButton: PropTypes.bool,
selectedTab: PropTypes.string.isRequired,
startInteractiveSearch: PropTypes.bool.isRequired,
onMonitorEpisodePress: PropTypes.func.isRequired,
onTabChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
EpisodeDetailsModalContent.defaultProps = {
selectedTab: 'details',
episodeEntity: episodeEntities.EPISODES,
startInteractiveSearch: false
};
export default EpisodeDetailsModalContent;

View File

@ -0,0 +1,204 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import Episode from 'Episode/Episode';
import EpisodeDetailsTab from 'Episode/EpisodeDetailsTab';
import episodeEntities from 'Episode/episodeEntities';
import useEpisode, { EpisodeEntities } from 'Episode/useEpisode';
import Series from 'Series/Series';
import useSeries from 'Series/useSeries';
import { toggleEpisodeMonitored } from 'Store/Actions/episodeActions';
import {
cancelFetchReleases,
clearReleases,
} from 'Store/Actions/releaseActions';
import translate from 'Utilities/String/translate';
import EpisodeHistoryConnector from './History/EpisodeHistoryConnector';
import EpisodeSearchConnector from './Search/EpisodeSearchConnector';
import SeasonEpisodeNumber from './SeasonEpisodeNumber';
import EpisodeSummary from './Summary/EpisodeSummary';
import styles from './EpisodeDetailsModalContent.css';
const TABS: EpisodeDetailsTab[] = ['details', 'history', 'search'];
export interface EpisodeDetailsModalContentProps {
episodeId: number;
episodeEntity: EpisodeEntities;
seriesId: number;
episodeTitle: string;
isSaving?: boolean;
showOpenSeriesButton?: boolean;
selectedTab?: EpisodeDetailsTab;
startInteractiveSearch?: boolean;
onTabChange(isSearch: boolean): void;
onModalClose(): void;
}
function EpisodeDetailsModalContent(props: EpisodeDetailsModalContentProps) {
const {
episodeId,
episodeEntity = episodeEntities.EPISODES,
seriesId,
episodeTitle,
isSaving = false,
showOpenSeriesButton = false,
startInteractiveSearch = false,
selectedTab = 'details',
onTabChange,
onModalClose,
} = props;
const dispatch = useDispatch();
const [currentlySelectedTab, setCurrentlySelectedTab] = useState(selectedTab);
const {
title: seriesTitle,
titleSlug,
monitored: seriesMonitored,
seriesType,
} = useSeries(seriesId) as Series;
const {
episodeFileId,
seasonNumber,
episodeNumber,
absoluteEpisodeNumber,
airDate,
monitored,
} = useEpisode(episodeId, episodeEntity) as Episode;
const handleTabSelect = useCallback(
(selectedIndex: number) => {
const tab = TABS[selectedIndex];
onTabChange(tab === 'search');
setCurrentlySelectedTab(tab);
},
[onTabChange]
);
const handleMonitorEpisodePress = useCallback(
(monitored: boolean) => {
dispatch(
toggleEpisodeMonitored({
episodeEntity,
episodeId,
monitored,
})
);
},
[episodeEntity, episodeId, dispatch]
);
useEffect(() => {
return () => {
// Clear pending releases here, so we can reshow the search
// results even after switching tabs.
dispatch(cancelFetchReleases());
dispatch(clearReleases());
};
}, [dispatch]);
const seriesLink = `/series/${titleSlug}`;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
<MonitorToggleButton
id={episodeId}
monitored={monitored}
size={18}
isDisabled={!seriesMonitored}
isSaving={isSaving}
onPress={handleMonitorEpisodePress}
/>
<span className={styles.seriesTitle}>{seriesTitle}</span>
<span className={styles.separator}>-</span>
<SeasonEpisodeNumber
seasonNumber={seasonNumber}
episodeNumber={episodeNumber}
absoluteEpisodeNumber={absoluteEpisodeNumber}
airDate={airDate}
seriesType={seriesType}
/>
<span className={styles.separator}>-</span>
{episodeTitle}
</ModalHeader>
<ModalBody>
<Tabs
className={styles.tabs}
selectedIndex={TABS.indexOf(currentlySelectedTab)}
onSelect={handleTabSelect}
>
<TabList className={styles.tabList}>
<Tab className={styles.tab} selectedClassName={styles.selectedTab}>
{translate('Details')}
</Tab>
<Tab className={styles.tab} selectedClassName={styles.selectedTab}>
{translate('History')}
</Tab>
<Tab className={styles.tab} selectedClassName={styles.selectedTab}>
{translate('Search')}
</Tab>
</TabList>
<TabPanel>
<div className={styles.tabContent}>
<EpisodeSummary
episodeId={episodeId}
episodeEntity={episodeEntity}
episodeFileId={episodeFileId}
seriesId={seriesId}
/>
</div>
</TabPanel>
<TabPanel>
<div className={styles.tabContent}>
<EpisodeHistoryConnector episodeId={episodeId} />
</div>
</TabPanel>
<TabPanel>
{/* Don't wrap in tabContent so we not have a top margin */}
<EpisodeSearchConnector
episodeId={episodeId}
startInteractiveSearch={startInteractiveSearch}
onModalClose={onModalClose}
/>
</TabPanel>
</Tabs>
</ModalBody>
<ModalFooter>
{showOpenSeriesButton && (
<Button
className={styles.openSeriesButton}
to={seriesLink}
onPress={onModalClose}
>
{translate('OpenSeries')}
</Button>
)}
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default EpisodeDetailsModalContent;

View File

@ -1,101 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import episodeEntities from 'Episode/episodeEntities';
import { toggleEpisodeMonitored } from 'Store/Actions/episodeActions';
import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions';
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import EpisodeDetailsModalContent from './EpisodeDetailsModalContent';
function createMapStateToProps() {
return createSelector(
createEpisodeSelector(),
createSeriesSelector(),
(episode, series) => {
const {
title: seriesTitle,
titleSlug,
monitored: seriesMonitored,
seriesType
} = series;
return {
seriesTitle,
titleSlug,
seriesMonitored,
seriesType,
...episode
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
dispatchCancelFetchReleases() {
dispatch(cancelFetchReleases());
},
dispatchClearReleases() {
dispatch(clearReleases());
},
onMonitorEpisodePress(monitored) {
const {
episodeId,
episodeEntity
} = props;
dispatch(toggleEpisodeMonitored({
episodeEntity,
episodeId,
monitored
}));
}
};
}
class EpisodeDetailsModalContentConnector extends Component {
//
// Lifecycle
componentWillUnmount() {
// Clear pending releases here, so we can reshow the search
// results even after switching tabs.
this.props.dispatchCancelFetchReleases();
this.props.dispatchClearReleases();
}
//
// Render
render() {
const {
dispatchCancelFetchReleases,
dispatchClearReleases,
...otherProps
} = this.props;
return (
<EpisodeDetailsModalContent {...otherProps} />
);
}
}
EpisodeDetailsModalContentConnector.propTypes = {
episodeId: PropTypes.number.isRequired,
episodeEntity: PropTypes.string.isRequired,
seriesId: PropTypes.number.isRequired,
dispatchCancelFetchReleases: PropTypes.func.isRequired,
dispatchClearReleases: PropTypes.func.isRequired
};
EpisodeDetailsModalContentConnector.defaultProps = {
episodeEntity: episodeEntities.EPISODES
};
export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeDetailsModalContentConnector);

View File

@ -0,0 +1,3 @@
type EpisodeDetailsTab = 'details' | 'history' | 'search';
export default EpisodeDetailsTab;

View File

@ -1,33 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Label from 'Components/Label';
import { kinds } from 'Helpers/Props';
function EpisodeFormats({ formats }) {
return (
<div>
{
formats.map((format) => {
return (
<Label
key={format.id}
kind={kinds.INFO}
>
{format.name}
</Label>
);
})
}
</div>
);
}
EpisodeFormats.propTypes = {
formats: PropTypes.arrayOf(PropTypes.object).isRequired
};
EpisodeFormats.defaultProps = {
formats: []
};
export default EpisodeFormats;

View File

@ -0,0 +1,22 @@
import React from 'react';
import Label from 'Components/Label';
import { kinds } from 'Helpers/Props';
import CustomFormat from 'typings/CustomFormat';
interface EpisodeFormatsProps {
formats: CustomFormat[];
}
function EpisodeFormats({ formats }: EpisodeFormatsProps) {
return (
<div>
{formats.map(({ id, name }) => (
<Label key={id} kind={kinds.INFO}>
{name}
</Label>
))}
</div>
);
}
export default EpisodeFormats;

View File

@ -1,86 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import EpisodeDetailsModal from './EpisodeDetailsModal';
import styles from './EpisodeSearchCell.css';
class EpisodeSearchCell extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isDetailsModalOpen: false
};
}
//
// Listeners
onManualSearchPress = () => {
this.setState({ isDetailsModalOpen: true });
};
onDetailsModalClose = () => {
this.setState({ isDetailsModalOpen: false });
};
//
// Render
render() {
const {
episodeId,
seriesId,
episodeTitle,
isSearching,
onSearchPress,
...otherProps
} = this.props;
return (
<TableRowCell className={styles.episodeSearchCell}>
<SpinnerIconButton
name={icons.SEARCH}
isSpinning={isSearching}
onPress={onSearchPress}
title={translate('AutomaticSearch')}
/>
<IconButton
name={icons.INTERACTIVE}
onPress={this.onManualSearchPress}
title={translate('InteractiveSearch')}
/>
<EpisodeDetailsModal
isOpen={this.state.isDetailsModalOpen}
episodeId={episodeId}
seriesId={seriesId}
episodeTitle={episodeTitle}
selectedTab="search"
startInteractiveSearch={true}
onModalClose={this.onDetailsModalClose}
{...otherProps}
/>
</TableRowCell>
);
}
}
EpisodeSearchCell.propTypes = {
episodeId: PropTypes.number.isRequired,
seriesId: PropTypes.number.isRequired,
episodeTitle: PropTypes.string.isRequired,
isSearching: PropTypes.bool.isRequired,
onSearchPress: PropTypes.func.isRequired
};
export default EpisodeSearchCell;

View File

@ -0,0 +1,75 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { EPISODE_SEARCH } from 'Commands/commandNames';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import { EpisodeEntities } from 'Episode/useEpisode';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { icons } from 'Helpers/Props';
import { executeCommand } from 'Store/Actions/commandActions';
import createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector';
import translate from 'Utilities/String/translate';
import EpisodeDetailsModal from './EpisodeDetailsModal';
import styles from './EpisodeSearchCell.css';
interface EpisodeSearchCellProps {
episodeId: number;
episodeEntity: EpisodeEntities;
seriesId: number;
episodeTitle: string;
}
function EpisodeSearchCell(props: EpisodeSearchCellProps) {
const { episodeId, episodeEntity, seriesId, episodeTitle } = props;
const executingCommands = useSelector(createExecutingCommandsSelector());
const isSearching = executingCommands.some(({ name, body }) => {
const { episodeIds = [] } = body;
return name === EPISODE_SEARCH && episodeIds.indexOf(episodeId) > -1;
});
const dispatch = useDispatch();
const [isDetailsModalOpen, setDetailsModalOpen, setDetailsModalClosed] =
useModalOpenState(false);
const handleSearchPress = useCallback(() => {
dispatch(
executeCommand({
name: EPISODE_SEARCH,
episodeIds: [episodeId],
})
);
}, [episodeId, dispatch]);
return (
<TableRowCell className={styles.episodeSearchCell}>
<SpinnerIconButton
name={icons.SEARCH}
isSpinning={isSearching}
title={translate('AutomaticSearch')}
onPress={handleSearchPress}
/>
<IconButton
name={icons.INTERACTIVE}
title={translate('InteractiveSearch')}
onPress={setDetailsModalOpen}
/>
<EpisodeDetailsModal
isOpen={isDetailsModalOpen}
episodeId={episodeId}
episodeEntity={episodeEntity}
seriesId={seriesId}
episodeTitle={episodeTitle}
selectedTab="search"
startInteractiveSearch={true}
onModalClose={setDetailsModalClosed}
/>
</TableRowCell>
);
}
export default EpisodeSearchCell;

View File

@ -1,50 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import { executeCommand } from 'Store/Actions/commandActions';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import { isCommandExecuting } from 'Utilities/Command';
import EpisodeSearchCell from './EpisodeSearchCell';
function createMapStateToProps() {
return createSelector(
(state, { episodeId }) => episodeId,
(state, { sceneSeasonNumber }) => sceneSeasonNumber,
createSeriesSelector(),
createCommandsSelector(),
(episodeId, sceneSeasonNumber, series, commands) => {
const isSearching = commands.some((command) => {
const episodeSearch = command.name === commandNames.EPISODE_SEARCH;
if (!episodeSearch) {
return false;
}
return (
isCommandExecuting(command) &&
command.body.episodeIds.indexOf(episodeId) > -1
);
});
return {
seriesMonitored: series.monitored,
seriesType: series.seriesType,
isSearching
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onSearchPress(name, path) {
dispatch(executeCommand({
name: commandNames.EPISODE_SEARCH,
episodeIds: [props.episodeId]
}));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeSearchCell);

View File

@ -1,34 +1,44 @@
import PropTypes from 'prop-types';
import React from 'react';
import { useSelector } from 'react-redux';
import QueueDetails from 'Activity/Queue/QueueDetails';
import Icon from 'Components/Icon';
import ProgressBar from 'Components/ProgressBar';
import Episode from 'Episode/Episode';
import useEpisode, { EpisodeEntities } 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';
import styles from './EpisodeStatus.css';
function EpisodeStatus(props) {
interface EpisodeStatusProps {
episodeId: number;
episodeEntity?: EpisodeEntities;
episodeFileId: number;
}
function EpisodeStatus(props: EpisodeStatusProps) {
const { episodeId, episodeEntity = 'episodes', episodeFileId } = props;
const {
airDateUtc,
monitored,
grabbed,
queueItem,
episodeFile
} = props;
grabbed = false,
} = useEpisode(episodeId, episodeEntity) as Episode;
const queueItem = useSelector(createQueueItemSelectorForHook(episodeId));
const episodeFile = useEpisodeFile(episodeFileId);
const hasEpisodeFile = !!episodeFile;
const isQueued = !!queueItem;
const hasAired = isBefore(airDateUtc);
if (isQueued) {
const {
sizeleft,
size
} = queueItem;
const { sizeleft, size } = queueItem;
const progress = size ? (100 - sizeleft / size * 100) : 0;
const progress = size ? 100 - (sizeleft / size) * 100 : 0;
return (
<div className={styles.center}>
@ -76,10 +86,7 @@ function EpisodeStatus(props) {
if (!airDateUtc) {
return (
<div className={styles.center}>
<Icon
name={icons.TBA}
title={translate('Tba')}
/>
<Icon name={icons.TBA} title={translate('Tba')} />
</div>
);
}
@ -109,20 +116,9 @@ function EpisodeStatus(props) {
return (
<div className={styles.center}>
<Icon
name={icons.NOT_AIRED}
title={translate('EpisodeHasNotAired')}
/>
<Icon name={icons.NOT_AIRED} title={translate('EpisodeHasNotAired')} />
</div>
);
}
EpisodeStatus.propTypes = {
airDateUtc: PropTypes.string,
monitored: PropTypes.bool.isRequired,
grabbed: PropTypes.bool,
queueItem: PropTypes.object,
episodeFile: PropTypes.object
};
export default EpisodeStatus;

View File

@ -1,53 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
import EpisodeStatus from './EpisodeStatus';
function createMapStateToProps() {
return createSelector(
createEpisodeSelector(),
createQueueItemSelector(),
createEpisodeFileSelector(),
(episode, queueItem, episodeFile) => {
const result = _.pick(episode, [
'airDateUtc',
'monitored',
'grabbed'
]);
result.queueItem = queueItem;
result.episodeFile = episodeFile;
return result;
}
);
}
const mapDispatchToProps = {
};
class EpisodeStatusConnector extends Component {
//
// Render
render() {
return (
<EpisodeStatus
{...this.props}
/>
);
}
}
EpisodeStatusConnector.propTypes = {
episodeId: PropTypes.number.isRequired,
episodeFileId: PropTypes.number.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeStatusConnector);

View File

@ -1,13 +1,14 @@
import React, { useCallback, useState } from 'react';
import Link from 'Components/Link/Link';
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
import { EpisodeEntities } from 'Episode/useEpisode';
import FinaleType from './FinaleType';
import styles from './EpisodeTitleLink.css';
interface EpisodeTitleLinkProps {
episodeId: number;
seriesId: number;
episodeEntity: string;
episodeEntity: EpisodeEntities;
episodeTitle: string;
finaleType?: string;
showOpenSeriesButton: boolean;

View File

@ -1,130 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import padNumber from 'Utilities/Number/padNumber';
import translate from 'Utilities/String/translate';
import styles from './SceneInfo.css';
function SceneInfo(props) {
const {
seasonNumber,
episodeNumber,
sceneSeasonNumber,
sceneEpisodeNumber,
sceneAbsoluteEpisodeNumber,
alternateTitles,
seriesType
} = props;
const reducedAlternateTitles = alternateTitles.map((alternateTitle) => {
let suffix = '';
const altSceneSeasonNumber = sceneSeasonNumber === undefined ? seasonNumber : sceneSeasonNumber;
const altSceneEpisodeNumber = sceneEpisodeNumber === undefined ? episodeNumber : sceneEpisodeNumber;
const mappingSeasonNumber = alternateTitle.sceneOrigin === 'tvdb' ? seasonNumber : altSceneSeasonNumber;
const altSeasonNumber = (alternateTitle.sceneSeasonNumber !== -1 && alternateTitle.sceneSeasonNumber !== undefined) ? alternateTitle.sceneSeasonNumber : mappingSeasonNumber;
const altEpisodeNumber = alternateTitle.sceneOrigin === 'tvdb' ? episodeNumber : altSceneEpisodeNumber;
if (altEpisodeNumber !== altSceneEpisodeNumber) {
suffix = `S${padNumber(altSeasonNumber, 2)}E${padNumber(altEpisodeNumber, 2)}`;
} else if (altSeasonNumber !== altSceneSeasonNumber) {
suffix = `S${padNumber(altSeasonNumber, 2)}`;
}
return {
alternateTitle,
title: alternateTitle.title,
suffix,
comment: alternateTitle.comment
};
});
const groupedAlternateTitles = _.map(_.groupBy(reducedAlternateTitles, (item) => `${item.title} ${item.suffix}`), (group) => {
return {
title: group[0].title,
suffix: group[0].suffix,
comment: _.uniq(group.map((item) => item.comment)).join('/')
};
});
return (
<DescriptionList className={styles.descriptionList}>
{
sceneSeasonNumber !== undefined &&
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title={translate('Season')}
data={sceneSeasonNumber}
/>
}
{
sceneEpisodeNumber !== undefined &&
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title={translate('Episode')}
data={sceneEpisodeNumber}
/>
}
{
seriesType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined &&
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title={translate('Absolute')}
data={sceneAbsoluteEpisodeNumber}
/>
}
{
!!alternateTitles.length &&
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title={groupedAlternateTitles.length === 1 ? translate('Title') : translate('Titles')}
data={
<div>
{
groupedAlternateTitles.map(({ title, suffix, comment }) => {
return (
<div
key={`${title} ${suffix}`}
>
{title}
{
suffix &&
<span> ({suffix})</span>
}
{
comment &&
<span className={styles.comment}> {comment}</span>
}
</div>
);
})
}
</div>
}
/>
}
</DescriptionList>
);
}
SceneInfo.propTypes = {
seasonNumber: PropTypes.number,
episodeNumber: PropTypes.number,
sceneSeasonNumber: PropTypes.number,
sceneEpisodeNumber: PropTypes.number,
sceneAbsoluteEpisodeNumber: PropTypes.number,
alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
seriesType: PropTypes.string
};
export default SceneInfo;

View File

@ -0,0 +1,168 @@
import React, { useMemo } from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import { AlternateTitle } from 'Series/Series';
import padNumber from 'Utilities/Number/padNumber';
import translate from 'Utilities/String/translate';
import styles from './SceneInfo.css';
interface SceneInfoProps {
seasonNumber?: number;
episodeNumber?: number;
sceneSeasonNumber?: number;
sceneEpisodeNumber?: number;
sceneAbsoluteEpisodeNumber?: number;
alternateTitles: AlternateTitle[];
seriesType?: string;
}
function SceneInfo(props: SceneInfoProps) {
const {
seasonNumber,
episodeNumber,
sceneSeasonNumber,
sceneEpisodeNumber,
sceneAbsoluteEpisodeNumber,
alternateTitles,
seriesType,
} = props;
const groupedAlternateTitles = useMemo(() => {
const reducedAlternateTitles = alternateTitles.map((alternateTitle) => {
let suffix = '';
const altSceneSeasonNumber =
sceneSeasonNumber === undefined ? seasonNumber : sceneSeasonNumber;
const altSceneEpisodeNumber =
sceneEpisodeNumber === undefined ? episodeNumber : sceneEpisodeNumber;
const mappingSeasonNumber =
alternateTitle.sceneOrigin === 'tvdb'
? seasonNumber
: altSceneSeasonNumber;
const altSeasonNumber =
alternateTitle.sceneSeasonNumber !== -1 &&
alternateTitle.sceneSeasonNumber !== undefined
? alternateTitle.sceneSeasonNumber
: mappingSeasonNumber;
const altEpisodeNumber =
alternateTitle.sceneOrigin === 'tvdb'
? episodeNumber
: altSceneEpisodeNumber;
if (altEpisodeNumber !== altSceneEpisodeNumber) {
suffix = `S${padNumber(altSeasonNumber as number, 2)}E${padNumber(
altEpisodeNumber as number,
2
)}`;
} else if (altSeasonNumber !== altSceneSeasonNumber) {
suffix = `S${padNumber(altSeasonNumber as number, 2)}`;
}
return {
alternateTitle,
title: alternateTitle.title,
suffix,
comment: alternateTitle.comment,
};
});
return Object.values(
reducedAlternateTitles.reduce(
(
acc: Record<
string,
{ title: string; suffix: string; comment: string }
>,
alternateTitle
) => {
const key = alternateTitle.suffix
? `${alternateTitle.title} ${alternateTitle.suffix}`
: alternateTitle.title;
const item = acc[key];
if (item) {
item.comment = alternateTitle.comment
? `${item.comment}/${alternateTitle.comment}`
: item.comment;
} else {
acc[key] = {
title: alternateTitle.title,
suffix: alternateTitle.suffix,
comment: alternateTitle.comment ?? '',
};
}
return acc;
},
{}
)
);
}, [
alternateTitles,
seasonNumber,
episodeNumber,
sceneSeasonNumber,
sceneEpisodeNumber,
]);
return (
<DescriptionList className={styles.descriptionList}>
{sceneSeasonNumber === undefined ? null : (
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title={translate('Season')}
data={sceneSeasonNumber}
/>
)}
{sceneEpisodeNumber === undefined ? null : (
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title={translate('Episode')}
data={sceneEpisodeNumber}
/>
)}
{seriesType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined ? (
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title={translate('Absolute')}
data={sceneAbsoluteEpisodeNumber}
/>
) : null}
{alternateTitles.length ? (
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title={
groupedAlternateTitles.length === 1
? translate('Title')
: translate('Titles')
}
data={
<div>
{groupedAlternateTitles.map(({ title, suffix, comment }) => {
return (
<div key={`${title} ${suffix}`}>
{title}
{suffix && <span> ({suffix})</span>}
{comment ? (
<span className={styles.comment}> {comment}</span>
) : null}
</div>
);
})}
</div>
}
/>
) : null}
</DescriptionList>
);
}
export default SceneInfo;

View File

@ -1,28 +1,29 @@
import moment from 'moment';
import PropTypes from 'prop-types';
import React from 'react';
import { useSelector } from 'react-redux';
import Label from 'Components/Label';
import { kinds, sizes } from 'Helpers/Props';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatTime from 'Utilities/Date/formatTime';
import isInNextWeek from 'Utilities/Date/isInNextWeek';
import isToday from 'Utilities/Date/isToday';
import isTomorrow from 'Utilities/Date/isTomorrow';
import translate from 'Utilities/String/translate';
function EpisodeAiring(props) {
const {
airDateUtc,
network,
shortDateFormat,
showRelativeDates,
timeFormat
} = props;
interface EpisodeAiringProps {
airDateUtc?: string;
network: string;
}
function EpisodeAiring(props: EpisodeAiringProps) {
const { airDateUtc, network } = props;
const { shortDateFormat, showRelativeDates, timeFormat } = useSelector(
createUISettingsSelector()
);
const networkLabel = (
<Label
kind={kinds.INFO}
size={sizes.MEDIUM}
>
<Label kind={kinds.INFO} size={sizes.MEDIUM}>
{network}
</Label>
);
@ -31,7 +32,8 @@ function EpisodeAiring(props) {
if (!airDateUtc) {
return (
<span>
{translate('AirsTbaOn', { networkLabel: '' })}{networkLabel}
{translate('AirsTbaOn', { networkLabel: '' })}
{networkLabel}
</span>
);
}
@ -41,7 +43,12 @@ function EpisodeAiring(props) {
if (!showRelativeDates) {
return (
<span>
{translate('AirsDateAtTimeOn', { date: moment(airDateUtc).format(shortDateFormat), time, networkLabel: '' })}{networkLabel}
{translate('AirsDateAtTimeOn', {
date: moment(airDateUtc).format(shortDateFormat),
time,
networkLabel: '',
})}
{networkLabel}
</span>
);
}
@ -49,7 +56,8 @@ function EpisodeAiring(props) {
if (isToday(airDateUtc)) {
return (
<span>
{translate('AirsTimeOn', { time, networkLabel: '' })}{networkLabel}
{translate('AirsTimeOn', { time, networkLabel: '' })}
{networkLabel}
</span>
);
}
@ -57,7 +65,8 @@ function EpisodeAiring(props) {
if (isTomorrow(airDateUtc)) {
return (
<span>
{translate('AirsTomorrowOn', { time, networkLabel: '' })}{networkLabel}
{translate('AirsTomorrowOn', { time, networkLabel: '' })}
{networkLabel}
</span>
);
}
@ -65,24 +74,26 @@ function EpisodeAiring(props) {
if (isInNextWeek(airDateUtc)) {
return (
<span>
{translate('AirsDateAtTimeOn', { date: moment(airDateUtc).format('dddd'), time, networkLabel: '' })}{networkLabel}
{translate('AirsDateAtTimeOn', {
date: moment(airDateUtc).format('dddd'),
time,
networkLabel: '',
})}
{networkLabel}
</span>
);
}
return (
<span>
{translate('AirsDateAtTimeOn', { date: moment(airDateUtc).format(shortDateFormat), time, networkLabel: '' })}{networkLabel}
{translate('AirsDateAtTimeOn', {
date: moment(airDateUtc).format(shortDateFormat),
time,
networkLabel: '',
})}
{networkLabel}
</span>
);
}
EpisodeAiring.propTypes = {
airDateUtc: PropTypes.string.isRequired,
network: PropTypes.string.isRequired,
shortDateFormat: PropTypes.string.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
timeFormat: PropTypes.string.isRequired
};
export default EpisodeAiring;

View File

@ -1,20 +0,0 @@
import _ from 'lodash';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import EpisodeAiring from './EpisodeAiring';
function createMapStateToProps() {
return createSelector(
createUISettingsSelector(),
(uiSettings) => {
return _.pick(uiSettings, [
'shortDateFormat',
'showRelativeDates',
'timeFormat'
]);
}
);
}
export default connect(createMapStateToProps)(EpisodeAiring);

View File

@ -1,205 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import formatBytes from 'Utilities/Number/formatBytes';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import MediaInfo from './MediaInfo';
import styles from './EpisodeFileRow.css';
class EpisodeFileRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isRemoveEpisodeFileModalOpen: false
};
}
//
// Listeners
onRemoveEpisodeFilePress = () => {
this.setState({ isRemoveEpisodeFileModalOpen: true });
};
onConfirmRemoveEpisodeFile = () => {
this.props.onDeleteEpisodeFile();
this.setState({ isRemoveEpisodeFileModalOpen: false });
};
onRemoveEpisodeFileModalClose = () => {
this.setState({ isRemoveEpisodeFileModalOpen: false });
};
//
// Render
render() {
const {
path,
size,
languages,
quality,
customFormats,
customFormatScore,
qualityCutoffNotMet,
mediaInfo,
columns
} = this.props;
return (
<TableRow>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'path') {
return (
<TableRowCell key={name}>
{path}
</TableRowCell>
);
}
if (name === 'size') {
return (
<TableRowCell key={name}>
{formatBytes(size)}
</TableRowCell>
);
}
if (name === 'languages') {
return (
<TableRowCell
key={name}
className={styles.languages}
>
<EpisodeLanguages languages={languages} />
</TableRowCell>
);
}
if (name === 'quality') {
return (
<TableRowCell
key={name}
className={styles.quality}
>
<EpisodeQuality
quality={quality}
isCutoffNotMet={qualityCutoffNotMet}
/>
</TableRowCell>
);
}
if (name === 'customFormats') {
return (
<TableRowCell
key={name}
className={styles.customFormats}
>
<EpisodeFormats
formats={customFormats}
/>
</TableRowCell>
);
}
if (name === 'customFormatScore') {
return (
<TableRowCell
key={name}
className={styles.customFormatScore}
>
{formatCustomFormatScore(customFormatScore, customFormats.length)}
</TableRowCell>
);
}
if (name === 'actions') {
return (
<TableRowCell
key={name}
className={styles.actions}
>
{
mediaInfo ?
<Popover
anchor={
<Icon
name={icons.MEDIA_INFO}
/>
}
title={translate('MediaInfo')}
body={<MediaInfo {...mediaInfo} />}
position={tooltipPositions.LEFT}
/> :
null
}
<IconButton
title={translate('DeleteEpisodeFromDisk')}
name={icons.REMOVE}
onPress={this.onRemoveEpisodeFilePress}
/>
</TableRowCell>
);
}
return null;
})
}
<ConfirmModal
isOpen={this.state.isRemoveEpisodeFileModalOpen}
kind={kinds.DANGER}
title={translate('DeleteEpisodeFile')}
message={translate('DeleteEpisodeFileMessage', { path })}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmRemoveEpisodeFile}
onCancel={this.onRemoveEpisodeFileModalClose}
/>
</TableRow>
);
}
}
EpisodeFileRow.propTypes = {
path: PropTypes.string.isRequired,
size: PropTypes.number.isRequired,
languages: PropTypes.arrayOf(PropTypes.object).isRequired,
quality: PropTypes.object.isRequired,
qualityCutoffNotMet: PropTypes.bool.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object),
customFormatScore: PropTypes.number.isRequired,
mediaInfo: PropTypes.object,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onDeleteEpisodeFile: PropTypes.func.isRequired
};
export default EpisodeFileRow;

View File

@ -0,0 +1,149 @@
import React, { useCallback } from 'react';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import Column from 'Components/Table/Column';
import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import CustomFormat from 'typings/CustomFormat';
import formatBytes from 'Utilities/Number/formatBytes';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import MediaInfo from './MediaInfo';
import styles from './EpisodeFileRow.css';
interface EpisodeFileRowProps {
path: string;
size: number;
languages: Language[];
quality: QualityModel;
qualityCutoffNotMet: boolean;
customFormats: CustomFormat[];
customFormatScore: number;
mediaInfo: object;
columns: Column[];
onDeleteEpisodeFile(): void;
}
function EpisodeFileRow(props: EpisodeFileRowProps) {
const {
path,
size,
languages,
quality,
customFormats,
customFormatScore,
qualityCutoffNotMet,
mediaInfo,
columns,
onDeleteEpisodeFile,
} = props;
const [
isRemoveEpisodeFileModalOpen,
setRemoveEpisodeFileModalOpen,
setRemoveEpisodeFileModalClosed,
] = useModalOpenState(false);
const handleRemoveEpisodeFilePress = useCallback(() => {
onDeleteEpisodeFile();
setRemoveEpisodeFileModalClosed();
}, [onDeleteEpisodeFile, setRemoveEpisodeFileModalClosed]);
return (
<TableRow>
{columns.map(({ name, isVisible }) => {
if (!isVisible) {
return null;
}
if (name === 'path') {
return <TableRowCell key={name}>{path}</TableRowCell>;
}
if (name === 'size') {
return <TableRowCell key={name}>{formatBytes(size)}</TableRowCell>;
}
if (name === 'languages') {
return (
<TableRowCell key={name} className={styles.languages}>
<EpisodeLanguages languages={languages} />
</TableRowCell>
);
}
if (name === 'quality') {
return (
<TableRowCell key={name} className={styles.quality}>
<EpisodeQuality
quality={quality}
isCutoffNotMet={qualityCutoffNotMet}
/>
</TableRowCell>
);
}
if (name === 'customFormats') {
return (
<TableRowCell key={name} className={styles.customFormats}>
<EpisodeFormats formats={customFormats} />
</TableRowCell>
);
}
if (name === 'customFormatScore') {
return (
<TableRowCell key={name} className={styles.customFormatScore}>
{formatCustomFormatScore(customFormatScore, customFormats.length)}
</TableRowCell>
);
}
if (name === 'actions') {
return (
<TableRowCell key={name} className={styles.actions}>
{mediaInfo ? (
<Popover
anchor={<Icon name={icons.MEDIA_INFO} />}
title={translate('MediaInfo')}
body={<MediaInfo {...mediaInfo} />}
position={tooltipPositions.LEFT}
/>
) : null}
<IconButton
title={translate('DeleteEpisodeFromDisk')}
name={icons.REMOVE}
onPress={setRemoveEpisodeFileModalOpen}
/>
</TableRowCell>
);
}
return null;
})}
<ConfirmModal
isOpen={isRemoveEpisodeFileModalOpen}
kind={kinds.DANGER}
title={translate('DeleteEpisodeFile')}
message={translate('DeleteEpisodeFileMessage', { path })}
confirmLabel={translate('Delete')}
onConfirm={handleRemoveEpisodeFilePress}
onCancel={setRemoveEpisodeFileModalClosed}
/>
</TableRow>
);
}
export default EpisodeFileRow;

View File

@ -1,198 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { icons, kinds, sizes } from 'Helpers/Props';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
import translate from 'Utilities/String/translate';
import EpisodeAiringConnector from './EpisodeAiringConnector';
import EpisodeFileRow from './EpisodeFileRow';
import styles from './EpisodeSummary.css';
const columns = [
{
name: 'path',
label: () => translate('Path'),
isSortable: false,
isVisible: true
},
{
name: 'size',
label: () => translate('Size'),
isSortable: false,
isVisible: true
},
{
name: 'languages',
label: () => translate('Languages'),
isSortable: false,
isVisible: true
},
{
name: 'quality',
label: () => translate('Quality'),
isSortable: false,
isVisible: true
},
{
name: 'customFormats',
label: () => translate('Formats'),
isSortable: false,
isVisible: true
},
{
name: 'customFormatScore',
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore')
}),
isSortable: true,
isVisible: true
},
{
name: 'actions',
label: '',
isSortable: false,
isVisible: true
}
];
class EpisodeSummary extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isRemoveEpisodeFileModalOpen: false
};
}
//
// Listeners
onRemoveEpisodeFilePress = () => {
this.setState({ isRemoveEpisodeFileModalOpen: true });
};
onConfirmRemoveEpisodeFile = () => {
this.props.onDeleteEpisodeFile();
this.setState({ isRemoveEpisodeFileModalOpen: false });
};
onRemoveEpisodeFileModalClose = () => {
this.setState({ isRemoveEpisodeFileModalOpen: false });
};
//
// Render
render() {
const {
qualityProfileId,
network,
overview,
airDateUtc,
mediaInfo,
path,
size,
languages,
quality,
customFormats,
customFormatScore,
qualityCutoffNotMet,
onDeleteEpisodeFile
} = this.props;
const hasOverview = !!overview;
return (
<div>
<div>
<span className={styles.infoTitle}>{translate('Airs')}</span>
<EpisodeAiringConnector
airDateUtc={airDateUtc}
network={network}
/>
</div>
<div>
<span className={styles.infoTitle}>{translate('QualityProfile')}</span>
<Label
kind={kinds.PRIMARY}
size={sizes.MEDIUM}
>
<QualityProfileNameConnector
qualityProfileId={qualityProfileId}
/>
</Label>
</div>
<div className={styles.overview}>
{
hasOverview ?
overview :
translate('NoEpisodeOverview')
}
</div>
{
path ?
<Table columns={columns}>
<TableBody>
<EpisodeFileRow
path={path}
size={size}
languages={languages}
quality={quality}
qualityCutoffNotMet={qualityCutoffNotMet}
customFormats={customFormats}
customFormatScore={customFormatScore}
mediaInfo={mediaInfo}
columns={columns}
onDeleteEpisodeFile={onDeleteEpisodeFile}
/>
</TableBody>
</Table> :
null
}
<ConfirmModal
isOpen={this.state.isRemoveEpisodeFileModalOpen}
kind={kinds.DANGER}
title={translate('DeleteEpisodeFile')}
message={translate('DeleteEpisodeFileMessage', { path })}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmRemoveEpisodeFile}
onCancel={this.onRemoveEpisodeFileModalClose}
/>
</div>
);
}
}
EpisodeSummary.propTypes = {
episodeFileId: PropTypes.number.isRequired,
qualityProfileId: PropTypes.number.isRequired,
network: PropTypes.string.isRequired,
overview: PropTypes.string,
airDateUtc: PropTypes.string.isRequired,
mediaInfo: PropTypes.object,
path: PropTypes.string,
size: PropTypes.number,
languages: PropTypes.arrayOf(PropTypes.object),
quality: PropTypes.object,
qualityCutoffNotMet: PropTypes.bool,
customFormats: PropTypes.arrayOf(PropTypes.object),
customFormatScore: PropTypes.number.isRequired,
onDeleteEpisodeFile: PropTypes.func.isRequired
};
export default EpisodeSummary;

View File

@ -0,0 +1,161 @@
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';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import Episode from 'Episode/Episode';
import useEpisode, { EpisodeEntities } from 'Episode/useEpisode';
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds, sizes } from 'Helpers/Props';
import Series from 'Series/Series';
import useSeries from 'Series/useSeries';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
import {
deleteEpisodeFile,
fetchEpisodeFile,
} from 'Store/Actions/episodeFileActions';
import translate from 'Utilities/String/translate';
import EpisodeAiring from './EpisodeAiring';
import EpisodeFileRow from './EpisodeFileRow';
import styles from './EpisodeSummary.css';
const COLUMNS: Column[] = [
{
name: 'path',
label: () => translate('Path'),
isSortable: false,
isVisible: true,
},
{
name: 'size',
label: () => translate('Size'),
isSortable: false,
isVisible: true,
},
{
name: 'languages',
label: () => translate('Languages'),
isSortable: false,
isVisible: true,
},
{
name: 'quality',
label: () => translate('Quality'),
isSortable: false,
isVisible: true,
},
{
name: 'customFormats',
label: () => translate('Formats'),
isSortable: false,
isVisible: true,
},
{
name: 'customFormatScore',
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore'),
}),
isSortable: true,
isVisible: true,
},
{
name: 'actions',
label: '',
isSortable: false,
isVisible: true,
},
];
interface EpisodeSummaryProps {
seriesId: number;
episodeId: number;
episodeEntity: EpisodeEntities;
episodeFileId?: number;
}
function EpisodeSummary(props: EpisodeSummaryProps) {
const { seriesId, episodeId, episodeEntity, episodeFileId } = props;
const dispatch = useDispatch();
const { qualityProfileId, network } = useSeries(seriesId) as Series;
const { airDateUtc, overview } = useEpisode(
episodeId,
episodeEntity
) as Episode;
const {
path,
mediaInfo,
size,
languages,
quality,
qualityCutoffNotMet,
customFormats,
customFormatScore,
} = useEpisodeFile(episodeFileId) || {};
const handleDeleteEpisodeFile = useCallback(() => {
dispatch(
deleteEpisodeFile({
id: episodeFileId,
episodeEntity,
})
);
}, [episodeFileId, episodeEntity, dispatch]);
useEffect(() => {
if (episodeFileId && !path) {
dispatch(fetchEpisodeFile({ id: episodeFileId }));
}
}, [episodeFileId, path, dispatch]);
const hasOverview = !!overview;
return (
<div>
<div>
<span className={styles.infoTitle}>{translate('Airs')}</span>
<EpisodeAiring airDateUtc={airDateUtc} network={network} />
</div>
<div>
<span className={styles.infoTitle}>{translate('QualityProfile')}</span>
<Label kind={kinds.PRIMARY} size={sizes.MEDIUM}>
<QualityProfileNameConnector qualityProfileId={qualityProfileId} />
</Label>
</div>
<div className={styles.overview}>
{hasOverview ? overview : translate('NoEpisodeOverview')}
</div>
{path ? (
<Table columns={COLUMNS}>
<TableBody>
<EpisodeFileRow
path={path}
size={size!}
languages={languages!}
quality={quality!}
qualityCutoffNotMet={qualityCutoffNotMet!}
customFormats={customFormats!}
customFormatScore={customFormatScore!}
mediaInfo={mediaInfo!}
columns={COLUMNS}
onDeleteEpisodeFile={handleDeleteEpisodeFile}
/>
</TableBody>
</Table>
) : null}
</div>
);
}
export default EpisodeSummary;

View File

@ -1,109 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { deleteEpisodeFile, fetchEpisodeFile } from 'Store/Actions/episodeFileActions';
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import EpisodeSummary from './EpisodeSummary';
function createMapStateToProps() {
return createSelector(
createSeriesSelector(),
createEpisodeSelector(),
createEpisodeFileSelector(),
(series, episode, episodeFile = {}) => {
const {
qualityProfileId,
network
} = series;
const {
airDateUtc,
overview
} = episode;
const {
mediaInfo,
path,
size,
languages,
quality,
qualityCutoffNotMet,
customFormats,
customFormatScore
} = episodeFile;
return {
network,
qualityProfileId,
airDateUtc,
overview,
mediaInfo,
path,
size,
languages,
quality,
qualityCutoffNotMet,
customFormats,
customFormatScore
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onDeleteEpisodeFile() {
dispatch(deleteEpisodeFile({
id: props.episodeFileId,
episodeEntity: props.episodeEntity
}));
},
dispatchFetchEpisodeFile() {
dispatch(fetchEpisodeFile({
id: props.episodeFileId
}));
}
};
}
class EpisodeSummaryConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
episodeFileId,
path,
dispatchFetchEpisodeFile
} = this.props;
if (episodeFileId && !path) {
dispatchFetchEpisodeFile({ id: episodeFileId });
}
}
//
// Render
render() {
const {
dispatchFetchEpisodeFile,
...otherProps
} = this.props;
return <EpisodeSummary {...otherProps} />;
}
}
EpisodeSummaryConnector.propTypes = {
episodeFileId: PropTypes.number,
path: PropTypes.string,
dispatchFetchEpisodeFile: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeSummaryConnector);

View File

@ -9,5 +9,5 @@ export default {
EPISODES,
INTERACTIVE_IMPORT,
WANTED_CUTOFF_UNMET,
WANTED_MISSING
};
WANTED_MISSING,
} as const;

View File

@ -5,15 +5,15 @@ import AppState from 'App/State/AppState';
export type EpisodeEntities =
| 'calendar'
| 'episodes'
| 'interactiveImport'
| 'cutoffUnmet'
| 'missing';
| 'interactiveImport.episodes'
| 'wanted.cutoffUnmet'
| 'wanted.missing';
function createEpisodeSelector(episodeId?: number) {
return createSelector(
(state: AppState) => state.episodes.items,
(episodes) => {
return episodes.find((e) => e.id === episodeId);
return episodes.find(({ id }) => id === episodeId);
}
);
}
@ -22,7 +22,25 @@ function createCalendarEpisodeSelector(episodeId?: number) {
return createSelector(
(state: AppState) => state.calendar.items,
(episodes) => {
return episodes.find((e) => e.id === episodeId);
return episodes.find(({ id }) => id === episodeId);
}
);
}
function createWantedCutoffUnmetEpisodeSelector(episodeId?: number) {
return createSelector(
(state: AppState) => state.wanted.cutoffUnmet.items,
(episodes) => {
return episodes.find(({ id }) => id === episodeId);
}
);
}
function createWantedMissingEpisodeSelector(episodeId?: number) {
return createSelector(
(state: AppState) => state.wanted.missing.items,
(episodes) => {
return episodes.find(({ id }) => id === episodeId);
}
);
}
@ -37,6 +55,12 @@ function useEpisode(
case 'calendar':
selector = createCalendarEpisodeSelector;
break;
case 'wanted.cutoffUnmet':
selector = createWantedCutoffUnmetEpisodeSelector;
break;
case 'wanted.missing':
selector = createWantedMissingEpisodeSelector;
break;
default:
break;
}

View File

@ -17,6 +17,7 @@ export interface EpisodeFile extends ModelBase {
languages: Language[];
quality: QualityModel;
customFormats: CustomFormat[];
customFormatScore: number;
indexerFlags: number;
releaseType: ReleaseType;
mediaInfo: MediaInfo;

View File

@ -0,0 +1,18 @@
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

@ -32,6 +32,7 @@ import {
reprocessInteractiveImportItems,
updateInteractiveImportItem,
} from 'Store/Actions/interactiveImportActions';
import CustomFormat from 'typings/CustomFormat';
import { SelectStateInputProps } from 'typings/props';
import Rejection from 'typings/Rejection';
import formatBytes from 'Utilities/Number/formatBytes';
@ -66,7 +67,7 @@ interface InteractiveImportRowProps {
languages?: Language[];
size: number;
releaseType: ReleaseType;
customFormats?: object[];
customFormats?: CustomFormat[];
customFormatScore?: number;
indexerFlags: number;
rejections: Rejection[];
@ -92,7 +93,7 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
releaseGroup,
size,
releaseType,
customFormats,
customFormats = [],
customFormatScore,
indexerFlags,
rejections,

View File

@ -4,6 +4,7 @@ import ReleaseType from 'InteractiveImport/ReleaseType';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import Series from 'Series/Series';
import CustomFormat from 'typings/CustomFormat';
import Rejection from 'typings/Rejection';
export interface InteractiveImportCommandOptions {
@ -33,7 +34,7 @@ interface InteractiveImport extends ModelBase {
seasonNumber: number;
episodes: Episode[];
qualityWeight: number;
customFormats: object[];
customFormats: CustomFormat[];
indexerFlags: number;
releaseType: ReleaseType;
rejections: Rejection[];

View File

@ -86,9 +86,10 @@ class InteractiveSearchConnector extends Component {
}
InteractiveSearchConnector.propTypes = {
type: PropTypes.string.isRequired,
searchPayload: PropTypes.object.isRequired,
isPopulated: PropTypes.bool.isRequired,
dispatchFetchReleases: PropTypes.func.isRequired
isPopulated: PropTypes.bool,
dispatchFetchReleases: PropTypes.func
};
export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveSearchConnector);

View File

@ -9,8 +9,8 @@ import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeNumber from 'Episode/EpisodeNumber';
import EpisodeSearchCellConnector from 'Episode/EpisodeSearchCellConnector';
import EpisodeStatusConnector from 'Episode/EpisodeStatusConnector';
import EpisodeSearchCell from 'Episode/EpisodeSearchCell';
import EpisodeStatus from 'Episode/EpisodeStatus';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import IndexerFlags from 'Episode/IndexerFlags';
import EpisodeFileLanguageConnector from 'EpisodeFile/EpisodeFileLanguageConnector';
@ -147,6 +147,7 @@ class EpisodeRow extends Component {
episodeId={id}
seriesId={seriesId}
episodeTitle={title}
episodeEntity="episodes"
finaleType={finaleType}
showOpenSeriesButton={false}
/>
@ -351,7 +352,7 @@ class EpisodeRow extends Component {
key={name}
className={styles.status}
>
<EpisodeStatusConnector
<EpisodeStatus
episodeId={id}
episodeFileId={episodeFileId}
/>
@ -361,9 +362,10 @@ class EpisodeRow extends Component {
if (name === 'actions') {
return (
<EpisodeSearchCellConnector
<EpisodeSearchCell
key={name}
episodeId={id}
episodeEntity='episodes'
seriesId={seriesId}
episodeTitle={title}
/>

View File

@ -18,7 +18,7 @@ import { align, icons, sortDirections, tooltipPositions } from 'Helpers/Props';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
import SeriesHistoryModal from 'Series/History/SeriesHistoryModal';
import SeasonInteractiveSearchModalConnector from 'Series/Search/SeasonInteractiveSearchModalConnector';
import SeasonInteractiveSearchModal from 'Series/Search/SeasonInteractiveSearchModal';
import isAfter from 'Utilities/Date/isAfter';
import isBefore from 'Utilities/Date/isBefore';
import formatBytes from 'Utilities/Number/formatBytes';
@ -505,7 +505,7 @@ class SeriesDetailsSeason extends Component {
onModalClose={this.onHistoryModalClose}
/>
<SeasonInteractiveSearchModalConnector
<SeasonInteractiveSearchModal
isOpen={isInteractiveSearchModalOpen}
seriesId={seriesId}
seasonNumber={seasonNumber}

View File

@ -1,38 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import SeasonInteractiveSearchModalContent from './SeasonInteractiveSearchModalContent';
function SeasonInteractiveSearchModal(props) {
const {
isOpen,
seriesId,
seasonNumber,
onModalClose
} = props;
return (
<Modal
isOpen={isOpen}
size={sizes.EXTRA_EXTRA_LARGE}
closeOnBackgroundClick={false}
onModalClose={onModalClose}
>
<SeasonInteractiveSearchModalContent
seriesId={seriesId}
seasonNumber={seasonNumber}
onModalClose={onModalClose}
/>
</Modal>
);
}
SeasonInteractiveSearchModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
seriesId: PropTypes.number.isRequired,
seasonNumber: PropTypes.number.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SeasonInteractiveSearchModal;

View File

@ -0,0 +1,55 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import {
cancelFetchReleases,
clearReleases,
} from 'Store/Actions/releaseActions';
import SeasonInteractiveSearchModalContent from './SeasonInteractiveSearchModalContent';
interface SeasonInteractiveSearchModalProps {
isOpen: boolean;
seriesId: number;
seasonNumber: number;
onModalClose(): void;
}
function SeasonInteractiveSearchModal(
props: SeasonInteractiveSearchModalProps
) {
const { isOpen, seriesId, seasonNumber, onModalClose } = props;
const dispatch = useDispatch();
const handleModalClose = useCallback(() => {
dispatch(cancelFetchReleases());
dispatch(clearReleases());
onModalClose();
}, [dispatch, onModalClose]);
useEffect(() => {
return () => {
dispatch(cancelFetchReleases());
dispatch(clearReleases());
};
}, [dispatch]);
return (
<Modal
isOpen={isOpen}
size={sizes.EXTRA_EXTRA_LARGE}
closeOnBackgroundClick={false}
onModalClose={handleModalClose}
>
<SeasonInteractiveSearchModalContent
seriesId={seriesId}
seasonNumber={seasonNumber}
onModalClose={handleModalClose}
/>
</Modal>
);
}
export default SeasonInteractiveSearchModal;

View File

@ -1,59 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions';
import SeasonInteractiveSearchModal from './SeasonInteractiveSearchModal';
function createMapDispatchToProps(dispatch, props) {
return {
dispatchCancelFetchReleases() {
dispatch(cancelFetchReleases());
},
dispatchClearReleases() {
dispatch(clearReleases());
},
onModalClose() {
dispatch(cancelFetchReleases());
dispatch(clearReleases());
props.onModalClose();
}
};
}
class SeasonInteractiveSearchModalConnector extends Component {
//
// Lifecycle
componentWillUnmount() {
this.props.dispatchCancelFetchReleases();
this.props.dispatchClearReleases();
}
//
// Render
render() {
const {
dispatchCancelFetchReleases,
dispatchClearReleases,
...otherProps
} = this.props;
return (
<SeasonInteractiveSearchModal
{...otherProps}
/>
);
}
}
SeasonInteractiveSearchModalConnector.propTypes = {
...SeasonInteractiveSearchModal.propTypes,
dispatchCancelFetchReleases: PropTypes.func.isRequired,
dispatchClearReleases: PropTypes.func.isRequired
};
export default connect(null, createMapDispatchToProps)(SeasonInteractiveSearchModalConnector);

View File

@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import React from 'react';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
@ -10,20 +9,25 @@ import InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConne
import formatSeason from 'Season/formatSeason';
import translate from 'Utilities/String/translate';
function SeasonInteractiveSearchModalContent(props) {
const {
seriesId,
seasonNumber,
onModalClose
} = props;
interface SeasonInteractiveSearchModalContentProps {
seriesId: number;
seasonNumber: number;
onModalClose(): void;
}
function SeasonInteractiveSearchModalContent(
props: SeasonInteractiveSearchModalContentProps
) {
const { seriesId, seasonNumber, onModalClose } = props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{seasonNumber === null ?
translate('InteractiveSearchModalHeader') :
translate('InteractiveSearchModalHeaderSeason', { season: formatSeason(seasonNumber) })
}
{seasonNumber === null
? translate('InteractiveSearchModalHeader')
: translate('InteractiveSearchModalHeaderSeason', {
season: formatSeason(seasonNumber) as string,
})}
</ModalHeader>
<ModalBody scrollDirection={scrollDirections.BOTH}>
@ -31,24 +35,16 @@ function SeasonInteractiveSearchModalContent(props) {
type="season"
searchPayload={{
seriesId,
seasonNumber
seasonNumber,
}}
/>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
{translate('Close')}
</Button>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
);
}
SeasonInteractiveSearchModalContent.propTypes = {
seriesId: PropTypes.number.isRequired,
seasonNumber: PropTypes.number.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SeasonInteractiveSearchModalContent;

View File

@ -53,6 +53,7 @@ export interface AlternateTitle {
sceneSeasonNumber?: number;
title: string;
sceneOrigin: 'unknown' | 'unknown:tvdb' | 'mixed' | 'tvdb';
comment?: string;
}
export interface SeriesAddOptions {

View File

@ -1,6 +1,19 @@
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,

View File

@ -5,7 +5,6 @@ export function createSeriesSelectorForHook(seriesId) {
(state) => state.series.itemMap,
(state) => state.series.items,
(itemMap, allSeries) => {
return seriesId ? allSeries[itemMap[seriesId]] : undefined;
}
);

View File

@ -5,8 +5,8 @@ import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import TableRow from 'Components/Table/TableRow';
import episodeEntities from 'Episode/episodeEntities';
import EpisodeSearchCellConnector from 'Episode/EpisodeSearchCellConnector';
import EpisodeStatusConnector from 'Episode/EpisodeStatusConnector';
import EpisodeSearchCell from 'Episode/EpisodeSearchCell';
import EpisodeStatus from 'Episode/EpisodeStatus';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import EpisodeFileLanguageConnector from 'EpisodeFile/EpisodeFileLanguageConnector';
@ -125,7 +125,7 @@ function CutoffUnmetRow(props) {
key={name}
className={styles.status}
>
<EpisodeStatusConnector
<EpisodeStatus
episodeId={id}
episodeFileId={episodeFileId}
episodeEntity={episodeEntities.WANTED_CUTOFF_UNMET}
@ -136,7 +136,7 @@ function CutoffUnmetRow(props) {
if (name === 'actions') {
return (
<EpisodeSearchCellConnector
<EpisodeSearchCell
key={name}
episodeId={id}
seriesId={series.id}

View File

@ -5,8 +5,8 @@ import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import TableRow from 'Components/Table/TableRow';
import episodeEntities from 'Episode/episodeEntities';
import EpisodeSearchCellConnector from 'Episode/EpisodeSearchCellConnector';
import EpisodeStatusConnector from 'Episode/EpisodeStatusConnector';
import EpisodeSearchCell from 'Episode/EpisodeSearchCell';
import EpisodeStatus from 'Episode/EpisodeStatus';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import SeriesTitleLink from 'Series/SeriesTitleLink';
@ -115,7 +115,7 @@ function MissingRow(props) {
key={name}
className={styles.status}
>
<EpisodeStatusConnector
<EpisodeStatus
episodeId={id}
episodeFileId={episodeFileId}
episodeEntity={episodeEntities.WANTED_MISSING}
@ -126,7 +126,7 @@ function MissingRow(props) {
if (name === 'actions') {
return (
<EpisodeSearchCellConnector
<EpisodeSearchCell
key={name}
episodeId={id}
seriesId={series.id}

View File

@ -69,7 +69,7 @@
"react-router": "5.2.0",
"react-router-dom": "5.2.0",
"react-slider": "1.1.4",
"react-tabs": "3.2.2",
"react-tabs": "4.3.0",
"react-text-truncate": "0.18.0",
"react-use-measure": "2.1.1",
"react-virtualized": "9.21.1",

View File

@ -5817,10 +5817,10 @@ react-slider@1.1.4:
resolved "https://registry.yarnpkg.com/react-slider/-/react-slider-1.1.4.tgz#08b55f9be3e04cc10ae00cc3aedb6891dffe9bf3"
integrity sha512-lL/MvzFcDue0ztdJItwLqas2lOy8Gg46eCDGJc4cJGldThmBHcHfGQePgBgyY1SEN95OwsWAakd3SuI8RyixDQ==
react-tabs@3.2.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/react-tabs/-/react-tabs-3.2.2.tgz#07bdc3cdb17bdffedd02627f32a93cd4b3d6e4d0"
integrity sha512-/o52eGKxFHRa+ssuTEgSM8qORnV4+k7ibW+aNQzKe+5gifeVz8nLxCrsI9xdRhfb0wCLdgIambIpb1qCxaMN+A==
react-tabs@4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/react-tabs/-/react-tabs-4.3.0.tgz#9f4db0fd209ba4ab2c1e78993ff964435f84af62"
integrity sha512-2GfoG+f41kiBIIyd3gF+/GRCCYtamC8/2zlAcD8cqQmqI9Q+YVz7fJLHMmU9pXDVYYHpJeCgUSBJju85vu5q8Q==
dependencies:
clsx "^1.1.0"
prop-types "^15.5.0"