parent
d3153685ac
commit
7644cec376
@ -1,6 +1,9 @@
|
||||
import AppSectionState from 'App/State/AppSectionState';
|
||||
import Column from 'Components/Table/Column';
|
||||
import Episode from 'Episode/Episode';
|
||||
|
||||
type EpisodesAppState = AppSectionState<Episode>;
|
||||
interface EpisodesAppState extends AppSectionState<Episode> {
|
||||
columns: Column[];
|
||||
}
|
||||
|
||||
export default EpisodesAppState;
|
||||
|
@ -1,422 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import MonitorToggleButton from 'Components/MonitorToggleButton';
|
||||
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import Tooltip from 'Components/Tooltip/Tooltip';
|
||||
import EpisodeFormats from 'Episode/EpisodeFormats';
|
||||
import EpisodeNumber from 'Episode/EpisodeNumber';
|
||||
import EpisodeSearchCell from 'Episode/EpisodeSearchCell';
|
||||
import EpisodeStatus from 'Episode/EpisodeStatus';
|
||||
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
|
||||
import IndexerFlags from 'Episode/IndexerFlags';
|
||||
import EpisodeFileLanguages from 'EpisodeFile/EpisodeFileLanguages';
|
||||
import MediaInfo from 'EpisodeFile/MediaInfo';
|
||||
import * as mediaInfoTypes from 'EpisodeFile/mediaInfoTypes';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
import formatRuntime from 'Utilities/Number/formatRuntime';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './EpisodeRow.css';
|
||||
|
||||
class EpisodeRow extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isDetailsModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onManualSearchPress = () => {
|
||||
this.setState({ isDetailsModalOpen: true });
|
||||
};
|
||||
|
||||
onDetailsModalClose = () => {
|
||||
this.setState({ isDetailsModalOpen: false });
|
||||
};
|
||||
|
||||
onMonitorEpisodePress = (monitored, options) => {
|
||||
this.props.onMonitorEpisodePress(this.props.id, monitored, options);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
seriesId,
|
||||
episodeFileId,
|
||||
monitored,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
absoluteEpisodeNumber,
|
||||
sceneSeasonNumber,
|
||||
sceneEpisodeNumber,
|
||||
sceneAbsoluteEpisodeNumber,
|
||||
airDateUtc,
|
||||
runtime,
|
||||
finaleType,
|
||||
title,
|
||||
useSceneNumbering,
|
||||
unverifiedSceneNumbering,
|
||||
isSaving,
|
||||
seriesMonitored,
|
||||
seriesType,
|
||||
episodeFilePath,
|
||||
episodeFileRelativePath,
|
||||
episodeFileSize,
|
||||
releaseGroup,
|
||||
customFormats,
|
||||
customFormatScore,
|
||||
indexerFlags,
|
||||
alternateTitles,
|
||||
columns
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
{
|
||||
columns.map((column) => {
|
||||
const {
|
||||
name,
|
||||
isVisible
|
||||
} = column;
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (name === 'monitored') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.monitored}
|
||||
>
|
||||
<MonitorToggleButton
|
||||
monitored={monitored}
|
||||
isDisabled={!seriesMonitored}
|
||||
isSaving={isSaving}
|
||||
onPress={this.onMonitorEpisodePress}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'episodeNumber') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={seriesType === 'anime' ? styles.episodeNumberAnime : styles.episodeNumber}
|
||||
>
|
||||
<EpisodeNumber
|
||||
seasonNumber={seasonNumber}
|
||||
episodeNumber={episodeNumber}
|
||||
absoluteEpisodeNumber={absoluteEpisodeNumber}
|
||||
useSceneNumbering={useSceneNumbering}
|
||||
unverifiedSceneNumbering={unverifiedSceneNumbering}
|
||||
seriesType={seriesType}
|
||||
sceneSeasonNumber={sceneSeasonNumber}
|
||||
sceneEpisodeNumber={sceneEpisodeNumber}
|
||||
sceneAbsoluteEpisodeNumber={sceneAbsoluteEpisodeNumber}
|
||||
alternateTitles={alternateTitles}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'title') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.title}
|
||||
>
|
||||
<EpisodeTitleLink
|
||||
episodeId={id}
|
||||
seriesId={seriesId}
|
||||
episodeTitle={title}
|
||||
episodeEntity="episodes"
|
||||
finaleType={finaleType}
|
||||
showOpenSeriesButton={false}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'path') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
{
|
||||
episodeFilePath
|
||||
}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'relativePath') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
{
|
||||
episodeFileRelativePath
|
||||
}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'airDateUtc') {
|
||||
return (
|
||||
<RelativeDateCell
|
||||
key={name}
|
||||
date={airDateUtc}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'runtime') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.runtime}
|
||||
>
|
||||
{ formatRuntime(runtime) }
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'customFormats') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
<EpisodeFormats
|
||||
formats={customFormats}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'customFormatScore') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.customFormatScore}
|
||||
>
|
||||
<Tooltip
|
||||
anchor={formatCustomFormatScore(
|
||||
customFormatScore,
|
||||
customFormats.length
|
||||
)}
|
||||
tooltip={<EpisodeFormats formats={customFormats} />}
|
||||
position={tooltipPositions.LEFT}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'languages') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.languages}
|
||||
>
|
||||
<EpisodeFileLanguages
|
||||
episodeFileId={episodeFileId}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'audioInfo') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.audio}
|
||||
>
|
||||
<MediaInfo
|
||||
type={mediaInfoTypes.AUDIO}
|
||||
episodeFileId={episodeFileId}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'audioLanguages') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.audioLanguages}
|
||||
>
|
||||
<MediaInfo
|
||||
type={mediaInfoTypes.AUDIO_LANGUAGES}
|
||||
episodeFileId={episodeFileId}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'subtitleLanguages') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.subtitles}
|
||||
>
|
||||
<MediaInfo
|
||||
type={mediaInfoTypes.SUBTITLES}
|
||||
episodeFileId={episodeFileId}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'videoCodec') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.video}
|
||||
>
|
||||
<MediaInfo
|
||||
type={mediaInfoTypes.VIDEO}
|
||||
episodeFileId={episodeFileId}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'videoDynamicRangeType') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.videoDynamicRangeType}
|
||||
>
|
||||
<MediaInfo
|
||||
type={mediaInfoTypes.VIDEO_DYNAMIC_RANGE_TYPE}
|
||||
episodeFileId={episodeFileId}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'size') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.size}
|
||||
>
|
||||
{!!episodeFileSize && formatBytes(episodeFileSize)}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'releaseGroup') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.releaseGroup}
|
||||
>
|
||||
{releaseGroup}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'indexerFlags') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.indexerFlags}
|
||||
>
|
||||
{indexerFlags ? (
|
||||
<Popover
|
||||
anchor={<Icon name={icons.FLAG} kind={kinds.PRIMARY} />}
|
||||
title={translate('IndexerFlags')}
|
||||
body={<IndexerFlags indexerFlags={indexerFlags} />}
|
||||
position={tooltipPositions.LEFT}
|
||||
/>
|
||||
) : null}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'status') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.status}
|
||||
>
|
||||
<EpisodeStatus
|
||||
episodeId={id}
|
||||
episodeFileId={episodeFileId}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'actions') {
|
||||
return (
|
||||
<EpisodeSearchCell
|
||||
key={name}
|
||||
episodeId={id}
|
||||
episodeEntity='episodes'
|
||||
seriesId={seriesId}
|
||||
episodeTitle={title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeRow.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
seriesId: PropTypes.number.isRequired,
|
||||
episodeFileId: PropTypes.number,
|
||||
monitored: PropTypes.bool.isRequired,
|
||||
seasonNumber: PropTypes.number.isRequired,
|
||||
episodeNumber: PropTypes.number.isRequired,
|
||||
absoluteEpisodeNumber: PropTypes.number,
|
||||
sceneSeasonNumber: PropTypes.number,
|
||||
sceneEpisodeNumber: PropTypes.number,
|
||||
sceneAbsoluteEpisodeNumber: PropTypes.number,
|
||||
airDateUtc: PropTypes.string,
|
||||
runtime: PropTypes.number,
|
||||
finaleType: PropTypes.string,
|
||||
title: PropTypes.string.isRequired,
|
||||
isSaving: PropTypes.bool,
|
||||
useSceneNumbering: PropTypes.bool,
|
||||
unverifiedSceneNumbering: PropTypes.bool,
|
||||
seriesMonitored: PropTypes.bool.isRequired,
|
||||
seriesType: PropTypes.string.isRequired,
|
||||
episodeFilePath: PropTypes.string,
|
||||
episodeFileRelativePath: PropTypes.string,
|
||||
episodeFileSize: PropTypes.number,
|
||||
releaseGroup: PropTypes.string,
|
||||
customFormats: PropTypes.arrayOf(PropTypes.object),
|
||||
customFormatScore: PropTypes.number.isRequired,
|
||||
indexerFlags: PropTypes.number.isRequired,
|
||||
mediaInfo: PropTypes.object,
|
||||
alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onMonitorEpisodePress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
EpisodeRow.defaultProps = {
|
||||
alternateTitles: [],
|
||||
customFormats: [],
|
||||
indexerFlags: 0
|
||||
};
|
||||
|
||||
export default EpisodeRow;
|
@ -0,0 +1,338 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import MonitorToggleButton from 'Components/MonitorToggleButton';
|
||||
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
||||
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 Tooltip from 'Components/Tooltip/Tooltip';
|
||||
import EpisodeFormats from 'Episode/EpisodeFormats';
|
||||
import EpisodeNumber from 'Episode/EpisodeNumber';
|
||||
import EpisodeSearchCell from 'Episode/EpisodeSearchCell';
|
||||
import EpisodeStatus from 'Episode/EpisodeStatus';
|
||||
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
|
||||
import IndexerFlags from 'Episode/IndexerFlags';
|
||||
import EpisodeFileLanguages from 'EpisodeFile/EpisodeFileLanguages';
|
||||
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';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
import formatRuntime from 'Utilities/Number/formatRuntime';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './EpisodeRow.css';
|
||||
|
||||
interface EpisodeRowProps {
|
||||
id: number;
|
||||
seriesId: number;
|
||||
episodeFileId?: number;
|
||||
monitored: boolean;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
absoluteEpisodeNumber?: number;
|
||||
sceneSeasonNumber?: number;
|
||||
sceneEpisodeNumber?: number;
|
||||
sceneAbsoluteEpisodeNumber?: number;
|
||||
airDateUtc?: string;
|
||||
runtime?: number;
|
||||
finaleType?: string;
|
||||
title: string;
|
||||
isSaving?: boolean;
|
||||
unverifiedSceneNumbering?: boolean;
|
||||
// episodeFilePath?: string;
|
||||
// episodeFileRelativePath?: string;
|
||||
// episodeFileSize?: number;
|
||||
// releaseGroup?: string;
|
||||
// customFormats?: CustomFormat[];
|
||||
// customFormatScore: number;
|
||||
// indexerFlags?: number;
|
||||
mediaInfo?: MediaInfoModel;
|
||||
columns: Column[];
|
||||
onMonitorEpisodePress: (
|
||||
episodeId: number,
|
||||
value: boolean,
|
||||
{ shiftKey }: { shiftKey: boolean }
|
||||
) => void;
|
||||
}
|
||||
|
||||
function EpisodeRow({
|
||||
id,
|
||||
seriesId,
|
||||
episodeFileId,
|
||||
monitored,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
absoluteEpisodeNumber,
|
||||
sceneSeasonNumber,
|
||||
sceneEpisodeNumber,
|
||||
sceneAbsoluteEpisodeNumber,
|
||||
airDateUtc,
|
||||
runtime,
|
||||
finaleType,
|
||||
title,
|
||||
unverifiedSceneNumbering,
|
||||
isSaving,
|
||||
// episodeFilePath,
|
||||
// episodeFileRelativePath,
|
||||
// episodeFileSize,
|
||||
// releaseGroup,
|
||||
// customFormats = [],
|
||||
// customFormatScore,
|
||||
// indexerFlags = 0,
|
||||
columns,
|
||||
onMonitorEpisodePress,
|
||||
}: EpisodeRowProps) {
|
||||
const {
|
||||
useSceneNumbering,
|
||||
monitored: seriesMonitored,
|
||||
seriesType,
|
||||
alternateTitles = [],
|
||||
} = useSeries(seriesId)!;
|
||||
const episodeFile = useEpisodeFile(episodeFileId);
|
||||
|
||||
const customFormats = episodeFile?.customFormats ?? [];
|
||||
const customFormatScore = episodeFile?.customFormatScore ?? 0;
|
||||
|
||||
const handleMonitorEpisodePress = useCallback(
|
||||
(monitored: boolean, options: { shiftKey: boolean }) => {
|
||||
onMonitorEpisodePress(id, monitored, options);
|
||||
},
|
||||
[id, onMonitorEpisodePress]
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
{columns.map((column) => {
|
||||
const { name, isVisible } = column;
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (name === 'monitored') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.monitored}>
|
||||
<MonitorToggleButton
|
||||
monitored={monitored}
|
||||
isDisabled={!seriesMonitored}
|
||||
isSaving={isSaving}
|
||||
onPress={handleMonitorEpisodePress}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'episodeNumber') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={
|
||||
seriesType === 'anime'
|
||||
? styles.episodeNumberAnime
|
||||
: styles.episodeNumber
|
||||
}
|
||||
>
|
||||
<EpisodeNumber
|
||||
seasonNumber={seasonNumber}
|
||||
episodeNumber={episodeNumber}
|
||||
absoluteEpisodeNumber={absoluteEpisodeNumber}
|
||||
useSceneNumbering={useSceneNumbering}
|
||||
unverifiedSceneNumbering={unverifiedSceneNumbering}
|
||||
seriesType={seriesType}
|
||||
sceneSeasonNumber={sceneSeasonNumber}
|
||||
sceneEpisodeNumber={sceneEpisodeNumber}
|
||||
sceneAbsoluteEpisodeNumber={sceneAbsoluteEpisodeNumber}
|
||||
alternateTitles={alternateTitles}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'title') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.title}>
|
||||
<EpisodeTitleLink
|
||||
episodeId={id}
|
||||
seriesId={seriesId}
|
||||
episodeTitle={title}
|
||||
episodeEntity="episodes"
|
||||
finaleType={finaleType}
|
||||
showOpenSeriesButton={false}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'path') {
|
||||
return <TableRowCell key={name}>{episodeFile?.path}</TableRowCell>;
|
||||
}
|
||||
|
||||
if (name === 'relativePath') {
|
||||
return (
|
||||
<TableRowCell key={name}>{episodeFile?.relativePath}</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'airDateUtc') {
|
||||
return <RelativeDateCell key={name} date={airDateUtc} />;
|
||||
}
|
||||
|
||||
if (name === 'runtime') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.runtime}>
|
||||
{formatRuntime(runtime)}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'customFormats') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
<EpisodeFormats formats={customFormats} />
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'customFormatScore') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.customFormatScore}>
|
||||
<Tooltip
|
||||
anchor={formatCustomFormatScore(
|
||||
customFormatScore,
|
||||
customFormats.length
|
||||
)}
|
||||
tooltip={<EpisodeFormats formats={customFormats} />}
|
||||
position="left"
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'languages') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.languages}>
|
||||
<EpisodeFileLanguages episodeFileId={episodeFileId} />
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'audioInfo') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.audio}>
|
||||
<MediaInfo
|
||||
type={mediaInfoTypes.AUDIO}
|
||||
episodeFileId={episodeFileId}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'audioLanguages') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.audioLanguages}>
|
||||
<MediaInfo
|
||||
type={mediaInfoTypes.AUDIO_LANGUAGES}
|
||||
episodeFileId={episodeFileId}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'subtitleLanguages') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.subtitles}>
|
||||
<MediaInfo
|
||||
type={mediaInfoTypes.SUBTITLES}
|
||||
episodeFileId={episodeFileId}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'videoCodec') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.video}>
|
||||
<MediaInfo
|
||||
type={mediaInfoTypes.VIDEO}
|
||||
episodeFileId={episodeFileId}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'videoDynamicRangeType') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.videoDynamicRangeType}>
|
||||
<MediaInfo
|
||||
type={mediaInfoTypes.VIDEO_DYNAMIC_RANGE_TYPE}
|
||||
episodeFileId={episodeFileId}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'size') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.size}>
|
||||
{!!episodeFile?.size && formatBytes(episodeFile?.size)}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'releaseGroup') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.releaseGroup}>
|
||||
{episodeFile?.releaseGroup}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'indexerFlags') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.indexerFlags}>
|
||||
{episodeFile?.indexerFlags ? (
|
||||
<Popover
|
||||
anchor={<Icon name={icons.FLAG} kind="default" />}
|
||||
title={translate('IndexerFlags')}
|
||||
body={
|
||||
<IndexerFlags indexerFlags={episodeFile?.indexerFlags} />
|
||||
}
|
||||
position="left"
|
||||
/>
|
||||
) : null}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'status') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.status}>
|
||||
<EpisodeStatus episodeId={id} episodeFileId={episodeFileId} />
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'actions') {
|
||||
return (
|
||||
<EpisodeSearchCell
|
||||
key={name}
|
||||
episodeId={id}
|
||||
episodeEntity="episodes"
|
||||
seriesId={seriesId}
|
||||
episodeTitle={title}
|
||||
showOpenSeriesButton={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default EpisodeRow;
|
@ -1,28 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
|
||||
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
|
||||
import EpisodeRow from './EpisodeRow';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createSeriesSelector(),
|
||||
createEpisodeFileSelector(),
|
||||
(series = {}, episodeFile) => {
|
||||
return {
|
||||
useSceneNumbering: series.useSceneNumbering,
|
||||
seriesMonitored: series.monitored,
|
||||
seriesType: series.seriesType,
|
||||
episodeFilePath: episodeFile ? episodeFile.path : null,
|
||||
episodeFileRelativePath: episodeFile ? episodeFile.relativePath : null,
|
||||
episodeFileSize: episodeFile ? episodeFile.size : null,
|
||||
releaseGroup: episodeFile ? episodeFile.releaseGroup : null,
|
||||
customFormats: episodeFile ? episodeFile.customFormats : [],
|
||||
customFormatScore: episodeFile ? episodeFile.customFormatScore : 0,
|
||||
indexerFlags: episodeFile ? episodeFile.indexerFlags : 0,
|
||||
alternateTitles: series.alternateTitles
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
export default connect(createMapStateToProps)(EpisodeRow);
|
@ -1,32 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import styles from './SeriesAlternateTitles.css';
|
||||
|
||||
function SeriesAlternateTitles({ alternateTitles }) {
|
||||
return (
|
||||
<ul>
|
||||
{
|
||||
alternateTitles.map((alternateTitle) => {
|
||||
return (
|
||||
<li
|
||||
key={alternateTitle.title}
|
||||
className={styles.alternateTitle}
|
||||
>
|
||||
{alternateTitle.title}
|
||||
{
|
||||
alternateTitle.comment &&
|
||||
<span className={styles.comment}> {alternateTitle.comment}</span>
|
||||
}
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
SeriesAlternateTitles.propTypes = {
|
||||
alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired
|
||||
};
|
||||
|
||||
export default SeriesAlternateTitles;
|
@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { AlternateTitle } from 'Series/Series';
|
||||
import styles from './SeriesAlternateTitles.css';
|
||||
|
||||
interface SeriesAlternateTitlesProps {
|
||||
alternateTitles: AlternateTitle[];
|
||||
}
|
||||
|
||||
function SeriesAlternateTitles({
|
||||
alternateTitles,
|
||||
}: SeriesAlternateTitlesProps) {
|
||||
return (
|
||||
<ul>
|
||||
{alternateTitles.map((alternateTitle) => {
|
||||
return (
|
||||
<li key={alternateTitle.title} className={styles.alternateTitle}>
|
||||
{alternateTitle.title}
|
||||
{alternateTitle.comment ? (
|
||||
<span className={styles.comment}> {alternateTitle.comment}</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeriesAlternateTitles;
|
@ -1,786 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import TextTruncate from 'react-text-truncate';
|
||||
import Alert from 'Components/Alert';
|
||||
import HeartRating from 'Components/HeartRating';
|
||||
import Icon from 'Components/Icon';
|
||||
import Label from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import Measure from 'Components/Measure';
|
||||
import MetadataAttribution from 'Components/MetadataAttribution';
|
||||
import MonitorToggleButton from 'Components/MonitorToggleButton';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import Tooltip from 'Components/Tooltip/Tooltip';
|
||||
import { align, icons, kinds, sizes, sortDirections, tooltipPositions } from 'Helpers/Props';
|
||||
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
||||
import OrganizePreviewModal from 'Organize/OrganizePreviewModal';
|
||||
import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
|
||||
import EditSeriesModal from 'Series/Edit/EditSeriesModal';
|
||||
import SeriesHistoryModal from 'Series/History/SeriesHistoryModal';
|
||||
import MonitoringOptionsModal from 'Series/MonitoringOptions/MonitoringOptionsModal';
|
||||
import SeriesGenres from 'Series/SeriesGenres';
|
||||
import SeriesPoster from 'Series/SeriesPoster';
|
||||
import { getSeriesStatusDetails } from 'Series/SeriesStatus';
|
||||
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileName';
|
||||
import fonts from 'Styles/Variables/fonts';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import selectAll from 'Utilities/Table/selectAll';
|
||||
import toggleSelected from 'Utilities/Table/toggleSelected';
|
||||
import SeriesAlternateTitles from './SeriesAlternateTitles';
|
||||
import SeriesDetailsLinks from './SeriesDetailsLinks';
|
||||
import SeriesDetailsSeasonConnector from './SeriesDetailsSeasonConnector';
|
||||
import SeriesTagsConnector from './SeriesTagsConnector';
|
||||
import styles from './SeriesDetails.css';
|
||||
|
||||
const defaultFontSize = parseInt(fonts.defaultFontSize);
|
||||
const lineHeight = parseFloat(fonts.lineHeight);
|
||||
|
||||
function getFanartUrl(images) {
|
||||
return _.find(images, { coverType: 'fanart' })?.url;
|
||||
}
|
||||
|
||||
function getExpandedState(newState) {
|
||||
return {
|
||||
allExpanded: newState.allSelected,
|
||||
allCollapsed: newState.allUnselected,
|
||||
expandedState: newState.selectedState
|
||||
};
|
||||
}
|
||||
|
||||
function getDateYear(date) {
|
||||
const dateDate = moment.utc(date);
|
||||
|
||||
return dateDate.format('YYYY');
|
||||
}
|
||||
|
||||
class SeriesDetails extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isOrganizeModalOpen: false,
|
||||
isManageEpisodesOpen: false,
|
||||
isEditSeriesModalOpen: false,
|
||||
isDeleteSeriesModalOpen: false,
|
||||
isSeriesHistoryModalOpen: false,
|
||||
isMonitorOptionsModalOpen: false,
|
||||
allExpanded: false,
|
||||
allCollapsed: false,
|
||||
expandedState: {},
|
||||
overviewHeight: 0
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onOrganizePress = () => {
|
||||
this.setState({ isOrganizeModalOpen: true });
|
||||
};
|
||||
|
||||
onOrganizeModalClose = () => {
|
||||
this.setState({ isOrganizeModalOpen: false });
|
||||
};
|
||||
|
||||
onManageEpisodesPress = () => {
|
||||
this.setState({ isManageEpisodesOpen: true });
|
||||
};
|
||||
|
||||
onManageEpisodesModalClose = () => {
|
||||
this.setState({ isManageEpisodesOpen: false });
|
||||
};
|
||||
|
||||
onEditSeriesPress = () => {
|
||||
this.setState({ isEditSeriesModalOpen: true });
|
||||
};
|
||||
|
||||
onEditSeriesModalClose = () => {
|
||||
this.setState({ isEditSeriesModalOpen: false });
|
||||
};
|
||||
|
||||
onDeleteSeriesPress = () => {
|
||||
this.setState({
|
||||
isEditSeriesModalOpen: false,
|
||||
isDeleteSeriesModalOpen: true
|
||||
});
|
||||
};
|
||||
|
||||
onDeleteSeriesModalClose = () => {
|
||||
this.setState({ isDeleteSeriesModalOpen: false });
|
||||
};
|
||||
|
||||
onSeriesHistoryPress = () => {
|
||||
this.setState({ isSeriesHistoryModalOpen: true });
|
||||
};
|
||||
|
||||
onSeriesHistoryModalClose = () => {
|
||||
this.setState({ isSeriesHistoryModalOpen: false });
|
||||
};
|
||||
|
||||
onMonitorOptionsPress = () => {
|
||||
this.setState({ isMonitorOptionsModalOpen: true });
|
||||
};
|
||||
|
||||
onMonitorOptionsClose = () => {
|
||||
this.setState({ isMonitorOptionsModalOpen: false });
|
||||
};
|
||||
|
||||
onExpandAllPress = () => {
|
||||
const {
|
||||
allExpanded,
|
||||
expandedState
|
||||
} = this.state;
|
||||
|
||||
this.setState(getExpandedState(selectAll(expandedState, !allExpanded)));
|
||||
};
|
||||
|
||||
onExpandPress = (seasonNumber, isExpanded) => {
|
||||
this.setState((state) => {
|
||||
const convertedState = {
|
||||
allSelected: state.allExpanded,
|
||||
allUnselected: state.allCollapsed,
|
||||
selectedState: state.expandedState
|
||||
};
|
||||
|
||||
const newState = toggleSelected(convertedState, [], seasonNumber, isExpanded, false);
|
||||
|
||||
return getExpandedState(newState);
|
||||
});
|
||||
};
|
||||
|
||||
onMeasure = ({ height }) => {
|
||||
this.setState({ overviewHeight: height });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
tvdbId,
|
||||
tvMazeId,
|
||||
imdbId,
|
||||
tmdbId,
|
||||
title,
|
||||
runtime,
|
||||
ratings,
|
||||
path,
|
||||
statistics,
|
||||
qualityProfileId,
|
||||
monitored,
|
||||
status,
|
||||
network,
|
||||
originalLanguage,
|
||||
overview,
|
||||
images,
|
||||
seasons,
|
||||
alternateTitles,
|
||||
genres,
|
||||
tags,
|
||||
year,
|
||||
lastAired,
|
||||
isSaving,
|
||||
isRefreshing,
|
||||
isSearching,
|
||||
isFetching,
|
||||
isPopulated,
|
||||
episodesError,
|
||||
episodeFilesError,
|
||||
hasEpisodes,
|
||||
hasMonitoredEpisodes,
|
||||
hasEpisodeFiles,
|
||||
previousSeries,
|
||||
nextSeries,
|
||||
onMonitorTogglePress,
|
||||
onRefreshPress,
|
||||
onSearchPress
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
episodeFileCount = 0,
|
||||
sizeOnDisk = 0
|
||||
} = statistics;
|
||||
|
||||
const {
|
||||
isOrganizeModalOpen,
|
||||
isManageEpisodesOpen,
|
||||
isEditSeriesModalOpen,
|
||||
isDeleteSeriesModalOpen,
|
||||
isSeriesHistoryModalOpen,
|
||||
isMonitorOptionsModalOpen,
|
||||
allExpanded,
|
||||
allCollapsed,
|
||||
expandedState,
|
||||
overviewHeight
|
||||
} = this.state;
|
||||
|
||||
const statusDetails = getSeriesStatusDetails(status);
|
||||
const runningYears = status === 'ended' ? `${year}-${getDateYear(lastAired)}` : `${year}-`;
|
||||
|
||||
let episodeFilesCountMessage = translate('SeriesDetailsNoEpisodeFiles');
|
||||
|
||||
if (episodeFileCount === 1) {
|
||||
episodeFilesCountMessage = translate('SeriesDetailsOneEpisodeFile');
|
||||
} else if (episodeFileCount > 1) {
|
||||
episodeFilesCountMessage = translate('SeriesDetailsCountEpisodeFiles', { episodeFileCount });
|
||||
}
|
||||
|
||||
let expandIcon = icons.EXPAND_INDETERMINATE;
|
||||
|
||||
if (allExpanded) {
|
||||
expandIcon = icons.COLLAPSE;
|
||||
} else if (allCollapsed) {
|
||||
expandIcon = icons.EXPAND;
|
||||
}
|
||||
|
||||
const fanartUrl = getFanartUrl(images);
|
||||
|
||||
return (
|
||||
<PageContent title={title}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('RefreshAndScan')}
|
||||
iconName={icons.REFRESH}
|
||||
spinningName={icons.REFRESH}
|
||||
title={translate('RefreshAndScanTooltip')}
|
||||
isSpinning={isRefreshing}
|
||||
onPress={onRefreshPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('SearchMonitored')}
|
||||
iconName={icons.SEARCH}
|
||||
isDisabled={!monitored || !hasMonitoredEpisodes || !hasEpisodes}
|
||||
isSpinning={isSearching}
|
||||
title={hasMonitoredEpisodes ? undefined : translate('NoMonitoredEpisodes')}
|
||||
onPress={onSearchPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('PreviewRename')}
|
||||
iconName={icons.ORGANIZE}
|
||||
isDisabled={!hasEpisodeFiles}
|
||||
onPress={this.onOrganizePress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('ManageEpisodes')}
|
||||
iconName={icons.EPISODE_FILE}
|
||||
onPress={this.onManageEpisodesPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('History')}
|
||||
iconName={icons.HISTORY}
|
||||
isDisabled={!hasEpisodes}
|
||||
onPress={this.onSeriesHistoryPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('SeriesMonitoring')}
|
||||
iconName={icons.MONITORED}
|
||||
onPress={this.onMonitorOptionsPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('Edit')}
|
||||
iconName={icons.EDIT}
|
||||
onPress={this.onEditSeriesPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('Delete')}
|
||||
iconName={icons.DELETE}
|
||||
onPress={this.onDeleteSeriesPress}
|
||||
/>
|
||||
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<PageToolbarButton
|
||||
label={allExpanded ? translate('CollapseAll') : translate('ExpandAll')}
|
||||
iconName={expandIcon}
|
||||
onPress={this.onExpandAllPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
|
||||
<PageContentBody innerClassName={styles.innerContentBody}>
|
||||
<div className={styles.header}>
|
||||
<div
|
||||
className={styles.backdrop}
|
||||
style={
|
||||
fanartUrl ?
|
||||
{ backgroundImage: `url(${fanartUrl})` } :
|
||||
null
|
||||
}
|
||||
>
|
||||
<div className={styles.backdropOverlay} />
|
||||
</div>
|
||||
|
||||
<div className={styles.headerContent}>
|
||||
<SeriesPoster
|
||||
className={styles.poster}
|
||||
images={images}
|
||||
size={500}
|
||||
lazy={false}
|
||||
/>
|
||||
|
||||
<div className={styles.info}>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.titleContainer}>
|
||||
<div className={styles.toggleMonitoredContainer}>
|
||||
<MonitorToggleButton
|
||||
className={styles.monitorToggleButton}
|
||||
monitored={monitored}
|
||||
isSaving={isSaving}
|
||||
size={40}
|
||||
onPress={onMonitorTogglePress}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.title}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{
|
||||
!!alternateTitles.length &&
|
||||
<div className={styles.alternateTitlesIconContainer}>
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon
|
||||
name={icons.ALTERNATE_TITLES}
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
title={translate('AlternateTitles')}
|
||||
body={<SeriesAlternateTitles alternateTitles={alternateTitles} />}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className={styles.seriesNavigationButtons}>
|
||||
<IconButton
|
||||
className={styles.seriesNavigationButton}
|
||||
name={icons.ARROW_LEFT}
|
||||
size={30}
|
||||
title={translate('SeriesDetailsGoTo', { title: previousSeries.title })}
|
||||
to={`/series/${previousSeries.titleSlug}`}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
className={styles.seriesNavigationButton}
|
||||
name={icons.ARROW_RIGHT}
|
||||
size={30}
|
||||
title={translate('SeriesDetailsGoTo', { title: nextSeries.title })}
|
||||
to={`/series/${nextSeries.titleSlug}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.details}>
|
||||
<div>
|
||||
{
|
||||
!!runtime &&
|
||||
<span className={styles.runtime}>
|
||||
{translate('SeriesDetailsRuntime', { runtime })}
|
||||
</span>
|
||||
}
|
||||
|
||||
{
|
||||
ratings.value ?
|
||||
<HeartRating
|
||||
rating={ratings.value}
|
||||
votes={ratings.votes}
|
||||
iconSize={20}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
<SeriesGenres className={styles.genres} genres={genres} />
|
||||
|
||||
<span>
|
||||
{runningYears}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.detailsLabels}>
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<div>
|
||||
<Icon
|
||||
name={icons.FOLDER}
|
||||
size={17}
|
||||
/>
|
||||
<span className={styles.path}>
|
||||
{path}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Tooltip
|
||||
anchor={
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<div>
|
||||
<Icon
|
||||
name={icons.DRIVE}
|
||||
size={17}
|
||||
/>
|
||||
|
||||
<span className={styles.sizeOnDisk}>
|
||||
{formatBytes(sizeOnDisk)}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
}
|
||||
tooltip={
|
||||
<span>
|
||||
{episodeFilesCountMessage}
|
||||
</span>
|
||||
}
|
||||
kind={kinds.INVERSE}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
title={translate('QualityProfile')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<div>
|
||||
<Icon
|
||||
name={icons.PROFILE}
|
||||
size={17}
|
||||
/>
|
||||
<span className={styles.qualityProfileName}>
|
||||
{
|
||||
<QualityProfileNameConnector
|
||||
qualityProfileId={qualityProfileId}
|
||||
/>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<div>
|
||||
<Icon
|
||||
name={monitored ? icons.MONITORED : icons.UNMONITORED}
|
||||
size={17}
|
||||
/>
|
||||
<span className={styles.qualityProfileName}>
|
||||
{monitored ? translate('Monitored') : translate('Unmonitored')}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
title={statusDetails.message}
|
||||
size={sizes.LARGE}
|
||||
kind={status === 'deleted' ? kinds.INVERSE : undefined}
|
||||
>
|
||||
<div>
|
||||
<Icon
|
||||
name={statusDetails.icon}
|
||||
size={17}
|
||||
/>
|
||||
<span className={styles.statusName}>
|
||||
{statusDetails.title}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
{
|
||||
originalLanguage?.name ?
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
title={translate('OriginalLanguage')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<div>
|
||||
<Icon
|
||||
name={icons.LANGUAGE}
|
||||
size={17}
|
||||
/>
|
||||
<span className={styles.originalLanguageName}>
|
||||
{originalLanguage.name}
|
||||
</span>
|
||||
</div>
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
network ?
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
title={translate('Network')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<div>
|
||||
<Icon
|
||||
name={icons.NETWORK}
|
||||
size={17}
|
||||
/>
|
||||
<span className={styles.network}>
|
||||
{network}
|
||||
</span>
|
||||
</div>
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
|
||||
<Tooltip
|
||||
anchor={
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<div>
|
||||
<Icon
|
||||
name={icons.EXTERNAL_LINK}
|
||||
size={17}
|
||||
/>
|
||||
<span className={styles.links}>
|
||||
{translate('Links')}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
}
|
||||
tooltip={
|
||||
<SeriesDetailsLinks
|
||||
tvdbId={tvdbId}
|
||||
tvMazeId={tvMazeId}
|
||||
imdbId={imdbId}
|
||||
tmdbId={tmdbId}
|
||||
/>
|
||||
}
|
||||
kind={kinds.INVERSE}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
|
||||
{
|
||||
!!tags.length &&
|
||||
<Tooltip
|
||||
anchor={
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<Icon
|
||||
name={icons.TAGS}
|
||||
size={17}
|
||||
/>
|
||||
|
||||
<span className={styles.tags}>
|
||||
{translate('Tags')}
|
||||
</span>
|
||||
</Label>
|
||||
}
|
||||
tooltip={<SeriesTagsConnector seriesId={id} />}
|
||||
kind={kinds.INVERSE}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
|
||||
}
|
||||
</div>
|
||||
|
||||
<Measure onMeasure={this.onMeasure}>
|
||||
<div className={styles.overview}>
|
||||
<TextTruncate
|
||||
line={Math.floor(overviewHeight / (defaultFontSize * lineHeight)) - 1}
|
||||
text={overview}
|
||||
/>
|
||||
</div>
|
||||
</Measure>
|
||||
|
||||
<MetadataAttribution />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.contentContainer}>
|
||||
{
|
||||
!isPopulated && !episodesError && !episodeFilesError &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && episodesError ?
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('EpisodesLoadError')}
|
||||
</Alert> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && episodeFilesError ?
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('EpisodeFilesLoadError')}
|
||||
</Alert> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
isPopulated && !!seasons.length &&
|
||||
<div>
|
||||
{
|
||||
seasons.slice(0).reverse().map((season) => {
|
||||
return (
|
||||
<SeriesDetailsSeasonConnector
|
||||
key={season.seasonNumber}
|
||||
seriesId={id}
|
||||
{...season}
|
||||
isExpanded={expandedState[season.seasonNumber]}
|
||||
onExpandPress={this.onExpandPress}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
isPopulated && !seasons.length ?
|
||||
<Alert kind={kinds.WARNING}>
|
||||
{translate('NoEpisodeInformation')}
|
||||
</Alert> :
|
||||
null
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
<OrganizePreviewModal
|
||||
isOpen={isOrganizeModalOpen}
|
||||
seriesId={id}
|
||||
onModalClose={this.onOrganizeModalClose}
|
||||
/>
|
||||
|
||||
<InteractiveImportModal
|
||||
isOpen={isManageEpisodesOpen}
|
||||
seriesId={id}
|
||||
title={title}
|
||||
folder={path}
|
||||
initialSortKey="relativePath"
|
||||
initialSortDirection={sortDirections.DESCENDING}
|
||||
showSeries={false}
|
||||
allowSeriesChange={false}
|
||||
showDelete={true}
|
||||
showImportMode={false}
|
||||
modalTitle={translate('ManageEpisodes')}
|
||||
onModalClose={this.onManageEpisodesModalClose}
|
||||
/>
|
||||
|
||||
<SeriesHistoryModal
|
||||
isOpen={isSeriesHistoryModalOpen}
|
||||
seriesId={id}
|
||||
onModalClose={this.onSeriesHistoryModalClose}
|
||||
/>
|
||||
|
||||
<EditSeriesModal
|
||||
isOpen={isEditSeriesModalOpen}
|
||||
seriesId={id}
|
||||
onModalClose={this.onEditSeriesModalClose}
|
||||
onDeleteSeriesPress={this.onDeleteSeriesPress}
|
||||
/>
|
||||
|
||||
<DeleteSeriesModal
|
||||
isOpen={isDeleteSeriesModalOpen}
|
||||
seriesId={id}
|
||||
onModalClose={this.onDeleteSeriesModalClose}
|
||||
/>
|
||||
|
||||
<MonitoringOptionsModal
|
||||
isOpen={isMonitorOptionsModalOpen}
|
||||
seriesId={id}
|
||||
onModalClose={this.onMonitorOptionsClose}
|
||||
/>
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SeriesDetails.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
tvdbId: PropTypes.number.isRequired,
|
||||
tvMazeId: PropTypes.number,
|
||||
imdbId: PropTypes.string,
|
||||
tmdbId: PropTypes.number,
|
||||
title: PropTypes.string.isRequired,
|
||||
runtime: PropTypes.number.isRequired,
|
||||
ratings: PropTypes.object.isRequired,
|
||||
path: PropTypes.string.isRequired,
|
||||
statistics: PropTypes.object.isRequired,
|
||||
qualityProfileId: PropTypes.number.isRequired,
|
||||
monitored: PropTypes.bool.isRequired,
|
||||
monitor: PropTypes.string,
|
||||
status: PropTypes.string.isRequired,
|
||||
network: PropTypes.string,
|
||||
originalLanguage: PropTypes.object,
|
||||
overview: PropTypes.string.isRequired,
|
||||
images: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
seasons: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
genres: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
|
||||
year: PropTypes.number.isRequired,
|
||||
lastAired: PropTypes.string,
|
||||
previousAiring: PropTypes.string,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
isRefreshing: PropTypes.bool.isRequired,
|
||||
isSearching: PropTypes.bool.isRequired,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
episodesError: PropTypes.object,
|
||||
episodeFilesError: PropTypes.object,
|
||||
hasEpisodes: PropTypes.bool.isRequired,
|
||||
hasMonitoredEpisodes: PropTypes.bool.isRequired,
|
||||
hasEpisodeFiles: PropTypes.bool.isRequired,
|
||||
previousSeries: PropTypes.object.isRequired,
|
||||
nextSeries: PropTypes.object.isRequired,
|
||||
onMonitorTogglePress: PropTypes.func.isRequired,
|
||||
onRefreshPress: PropTypes.func.isRequired,
|
||||
onSearchPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
SeriesDetails.defaultProps = {
|
||||
statistics: {},
|
||||
tags: [],
|
||||
isSaving: false
|
||||
};
|
||||
|
||||
export default SeriesDetails;
|
@ -0,0 +1,891 @@
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import TextTruncate from 'react-text-truncate';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import Alert from 'Components/Alert';
|
||||
import HeartRating from 'Components/HeartRating';
|
||||
import Icon from 'Components/Icon';
|
||||
import Label from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import MetadataAttribution from 'Components/MetadataAttribution';
|
||||
import MonitorToggleButton from 'Components/MonitorToggleButton';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import Tooltip from 'Components/Tooltip/Tooltip';
|
||||
import useMeasure from 'Helpers/Hooks/useMeasure';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import {
|
||||
align,
|
||||
icons,
|
||||
kinds,
|
||||
sizes,
|
||||
sortDirections,
|
||||
tooltipPositions,
|
||||
} from 'Helpers/Props';
|
||||
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
||||
import OrganizePreviewModal from 'Organize/OrganizePreviewModal';
|
||||
import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
|
||||
import EditSeriesModal from 'Series/Edit/EditSeriesModal';
|
||||
import SeriesHistoryModal from 'Series/History/SeriesHistoryModal';
|
||||
import MonitoringOptionsModal from 'Series/MonitoringOptions/MonitoringOptionsModal';
|
||||
import { Image, Statistics } from 'Series/Series';
|
||||
import SeriesGenres from 'Series/SeriesGenres';
|
||||
import SeriesPoster from 'Series/SeriesPoster';
|
||||
import { getSeriesStatusDetails } from 'Series/SeriesStatus';
|
||||
import QualityProfileNameConnector 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 {
|
||||
clearQueueDetails,
|
||||
fetchQueueDetails,
|
||||
} from 'Store/Actions/queueActions';
|
||||
import { toggleSeriesMonitored } from 'Store/Actions/seriesActions';
|
||||
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||
import fonts from 'Styles/Variables/fonts';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import { findCommand, isCommandExecuting } from 'Utilities/Command';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import {
|
||||
registerPagePopulator,
|
||||
unregisterPagePopulator,
|
||||
} from 'Utilities/pagePopulator';
|
||||
import filterAlternateTitles from 'Utilities/Series/filterAlternateTitles';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import selectAll from 'Utilities/Table/selectAll';
|
||||
import toggleSelected from 'Utilities/Table/toggleSelected';
|
||||
import SeriesAlternateTitles from './SeriesAlternateTitles';
|
||||
import SeriesDetailsLinks from './SeriesDetailsLinks';
|
||||
import SeriesDetailsSeason from './SeriesDetailsSeason';
|
||||
import SeriesTags from './SeriesTags';
|
||||
import styles from './SeriesDetails.css';
|
||||
|
||||
const defaultFontSize = parseInt(fonts.defaultFontSize);
|
||||
const lineHeight = parseFloat(fonts.lineHeight);
|
||||
|
||||
function getFanartUrl(images: Image[]) {
|
||||
return images.find((image) => image.coverType === 'fanart')?.url;
|
||||
}
|
||||
|
||||
function getDateYear(date: string | undefined) {
|
||||
const dateDate = moment.utc(date);
|
||||
|
||||
return dateDate.format('YYYY');
|
||||
}
|
||||
|
||||
function createEpisodesSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.episodes,
|
||||
(episodes) => {
|
||||
const { items, isFetching, isPopulated, error } = episodes;
|
||||
|
||||
const hasEpisodes = !!items.length;
|
||||
const hasMonitoredEpisodes = items.some((e) => e.monitored);
|
||||
|
||||
return {
|
||||
isEpisodesFetching: isFetching,
|
||||
isEpisodesPopulated: isPopulated,
|
||||
episodesError: error,
|
||||
hasEpisodes,
|
||||
hasMonitoredEpisodes,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createSeriesSelector(seriesId: number) {
|
||||
return createSelector(createAllSeriesSelector(), (allSeries) => {
|
||||
const sortedSeries = [...allSeries].sort(sortByProp('sortTitle'));
|
||||
const seriesIndex = sortedSeries.findIndex(
|
||||
(series) => series.id === seriesId
|
||||
);
|
||||
|
||||
if (seriesIndex === -1) {
|
||||
return {
|
||||
series: undefined,
|
||||
nextSeries: undefined,
|
||||
previousSeries: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const series = sortedSeries[seriesIndex];
|
||||
const nextSeries = sortedSeries[seriesIndex + 1] ?? sortedSeries[0];
|
||||
const previousSeries =
|
||||
sortedSeries[seriesIndex - 1] ?? sortedSeries[sortedSeries.length - 1];
|
||||
|
||||
return {
|
||||
series,
|
||||
nextSeries: {
|
||||
title: nextSeries.title,
|
||||
titleSlug: nextSeries.titleSlug,
|
||||
},
|
||||
previousSeries: {
|
||||
title: previousSeries.title,
|
||||
titleSlug: previousSeries.titleSlug,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
interface ExpandedState {
|
||||
allExpanded: boolean;
|
||||
allCollapsed: boolean;
|
||||
seasons: Record<number, boolean>;
|
||||
}
|
||||
|
||||
interface SeriesDetailsProps {
|
||||
seriesId: number;
|
||||
}
|
||||
|
||||
function SeriesDetails({ seriesId }: SeriesDetailsProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { series, nextSeries, previousSeries } = useSelector(
|
||||
createSeriesSelector(seriesId)
|
||||
);
|
||||
const {
|
||||
isEpisodesFetching,
|
||||
isEpisodesPopulated,
|
||||
episodesError,
|
||||
hasEpisodes,
|
||||
hasMonitoredEpisodes,
|
||||
} = useSelector(createEpisodesSelector());
|
||||
const {
|
||||
isEpisodeFilesFetching,
|
||||
isEpisodeFilesPopulated,
|
||||
episodeFilesError,
|
||||
hasEpisodeFiles,
|
||||
} = useSelector(createEpisodeFilesSelector());
|
||||
|
||||
const commands = useSelector(createCommandsSelector());
|
||||
const isSaving = useSelector((state: AppState) => state.series.isSaving);
|
||||
|
||||
const { isRefreshing, isRenaming, isSearching } = useMemo(() => {
|
||||
const isSeriesRefreshing = isCommandExecuting(
|
||||
findCommand(commands, {
|
||||
name: commandNames.REFRESH_SERIES,
|
||||
seriesId,
|
||||
})
|
||||
);
|
||||
const seriesRefreshingCommand = findCommand(commands, {
|
||||
name: commandNames.REFRESH_SERIES,
|
||||
});
|
||||
|
||||
const allSeriesRefreshing =
|
||||
isCommandExecuting(seriesRefreshingCommand) &&
|
||||
!seriesRefreshingCommand?.body.seriesId;
|
||||
|
||||
const isSearchingExecuting = isCommandExecuting(
|
||||
findCommand(commands, {
|
||||
name: commandNames.SERIES_SEARCH,
|
||||
seriesId,
|
||||
})
|
||||
);
|
||||
|
||||
const isRenamingFiles = isCommandExecuting(
|
||||
findCommand(commands, {
|
||||
name: commandNames.RENAME_FILES,
|
||||
seriesId,
|
||||
})
|
||||
);
|
||||
|
||||
const isRenamingSeriesCommand = findCommand(commands, {
|
||||
name: commandNames.RENAME_SERIES,
|
||||
});
|
||||
|
||||
const isRenamingSeries =
|
||||
isCommandExecuting(isRenamingSeriesCommand) &&
|
||||
isRenamingSeriesCommand?.body?.seriesIds?.includes(seriesId);
|
||||
|
||||
return {
|
||||
isRefreshing: isSeriesRefreshing || allSeriesRefreshing,
|
||||
isRenaming: isRenamingFiles || isRenamingSeries,
|
||||
isSearching: isSearchingExecuting,
|
||||
};
|
||||
}, [seriesId, commands]);
|
||||
|
||||
const [isOrganizeModalOpen, setIsOrganizeModalOpen] = useState(false);
|
||||
const [isManageEpisodesOpen, setIsManageEpisodesOpen] = useState(false);
|
||||
const [isEditSeriesModalOpen, setIsEditSeriesModalOpen] = useState(false);
|
||||
const [isDeleteSeriesModalOpen, setIsDeleteSeriesModalOpen] = useState(false);
|
||||
const [isSeriesHistoryModalOpen, setIsSeriesHistoryModalOpen] =
|
||||
useState(false);
|
||||
const [isMonitorOptionsModalOpen, setIsMonitorOptionsModalOpen] =
|
||||
useState(false);
|
||||
const [expandedState, setExpandedState] = useState<ExpandedState>({
|
||||
allExpanded: false,
|
||||
allCollapsed: false,
|
||||
seasons: {},
|
||||
});
|
||||
const [overviewRef, { height: overviewHeight }] = useMeasure();
|
||||
const wasRefreshing = usePrevious(isRefreshing);
|
||||
const wasRenaming = usePrevious(isRenaming);
|
||||
|
||||
const alternateTitles = useMemo(() => {
|
||||
if (!series) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return filterAlternateTitles(
|
||||
series.alternateTitles,
|
||||
series.title,
|
||||
series.useSceneNumbering
|
||||
);
|
||||
}, [series]);
|
||||
|
||||
const handleOrganizePress = useCallback(() => {
|
||||
setIsOrganizeModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleOrganizeModalClose = useCallback(() => {
|
||||
setIsOrganizeModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleManageEpisodesPress = useCallback(() => {
|
||||
setIsManageEpisodesOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleManageEpisodesModalClose = useCallback(() => {
|
||||
setIsManageEpisodesOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleEditSeriesPress = useCallback(() => {
|
||||
setIsEditSeriesModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleEditSeriesModalClose = useCallback(() => {
|
||||
setIsEditSeriesModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleDeleteSeriesPress = useCallback(() => {
|
||||
setIsEditSeriesModalOpen(false);
|
||||
setIsDeleteSeriesModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteSeriesModalClose = useCallback(() => {
|
||||
setIsDeleteSeriesModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleSeriesHistoryPress = useCallback(() => {
|
||||
setIsSeriesHistoryModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleSeriesHistoryModalClose = useCallback(() => {
|
||||
setIsSeriesHistoryModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleMonitorOptionsPress = useCallback(() => {
|
||||
setIsMonitorOptionsModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleMonitorOptionsClose = useCallback(() => {
|
||||
setIsMonitorOptionsModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleExpandAllPress = useCallback(() => {
|
||||
const updated = selectAll(
|
||||
expandedState.seasons,
|
||||
!expandedState.allExpanded
|
||||
);
|
||||
|
||||
setExpandedState({
|
||||
allExpanded: updated.allSelected,
|
||||
allCollapsed: updated.allUnselected,
|
||||
seasons: updated.selectedState,
|
||||
});
|
||||
}, [expandedState]);
|
||||
|
||||
const handleExpandPress = useCallback(
|
||||
(seasonNumber: number, isExpanded: boolean) => {
|
||||
setExpandedState((state) => {
|
||||
const { allExpanded, allCollapsed } = state;
|
||||
|
||||
const convertedState = {
|
||||
allSelected: allExpanded,
|
||||
allUnselected: allCollapsed,
|
||||
selectedState: state.seasons,
|
||||
lastToggled: null,
|
||||
};
|
||||
|
||||
const newState = toggleSelected(
|
||||
convertedState,
|
||||
[],
|
||||
seasonNumber,
|
||||
isExpanded,
|
||||
false
|
||||
);
|
||||
|
||||
return {
|
||||
allExpanded: newState.allSelected,
|
||||
allCollapsed: newState.allUnselected,
|
||||
seasons: newState.selectedState,
|
||||
};
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleMonitorTogglePress = useCallback(
|
||||
(value: boolean) => {
|
||||
dispatch(
|
||||
toggleSeriesMonitored({
|
||||
seriesId,
|
||||
monitored: value,
|
||||
})
|
||||
);
|
||||
},
|
||||
[seriesId, dispatch]
|
||||
);
|
||||
|
||||
const handleRefreshPress = useCallback(() => {
|
||||
dispatch(
|
||||
executeCommand({
|
||||
name: commandNames.REFRESH_SERIES,
|
||||
seriesId,
|
||||
})
|
||||
);
|
||||
}, [seriesId, dispatch]);
|
||||
|
||||
const handleSearchPress = useCallback(() => {
|
||||
dispatch(
|
||||
executeCommand({
|
||||
name: commandNames.SERIES_SEARCH,
|
||||
seriesId,
|
||||
})
|
||||
);
|
||||
}, [seriesId, dispatch]);
|
||||
|
||||
const populate = useCallback(() => {
|
||||
dispatch(fetchEpisodes({ seriesId }));
|
||||
dispatch(fetchEpisodeFiles({ seriesId }));
|
||||
dispatch(fetchQueueDetails({ seriesId }));
|
||||
}, [seriesId, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
populate();
|
||||
}, [populate]);
|
||||
|
||||
useEffect(() => {
|
||||
registerPagePopulator(populate);
|
||||
|
||||
return () => {
|
||||
unregisterPagePopulator(populate);
|
||||
dispatch(clearEpisodes());
|
||||
dispatch(clearEpisodeFiles());
|
||||
dispatch(clearQueueDetails());
|
||||
};
|
||||
}, [populate, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if ((!isRefreshing && wasRefreshing) || (!isRenaming && wasRenaming)) {
|
||||
populate();
|
||||
}
|
||||
}, [isRefreshing, wasRefreshing, isRenaming, wasRenaming, populate]);
|
||||
|
||||
if (!series) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
tvdbId,
|
||||
tvMazeId,
|
||||
imdbId,
|
||||
tmdbId,
|
||||
title,
|
||||
runtime,
|
||||
ratings,
|
||||
path,
|
||||
statistics = {} as Statistics,
|
||||
qualityProfileId,
|
||||
monitored,
|
||||
status,
|
||||
network,
|
||||
originalLanguage,
|
||||
overview,
|
||||
images,
|
||||
seasons,
|
||||
genres,
|
||||
tags,
|
||||
year,
|
||||
} = series;
|
||||
|
||||
const { episodeFileCount = 0, sizeOnDisk = 0, lastAired } = statistics;
|
||||
|
||||
const statusDetails = getSeriesStatusDetails(status);
|
||||
const runningYears =
|
||||
status === 'ended' ? `${year}-${getDateYear(lastAired)}` : `${year}-`;
|
||||
|
||||
let episodeFilesCountMessage = translate('SeriesDetailsNoEpisodeFiles');
|
||||
|
||||
if (episodeFileCount === 1) {
|
||||
episodeFilesCountMessage = translate('SeriesDetailsOneEpisodeFile');
|
||||
} else if (episodeFileCount > 1) {
|
||||
episodeFilesCountMessage = translate('SeriesDetailsCountEpisodeFiles', {
|
||||
episodeFileCount,
|
||||
});
|
||||
}
|
||||
|
||||
let expandIcon = icons.EXPAND_INDETERMINATE;
|
||||
|
||||
if (expandedState.allExpanded) {
|
||||
expandIcon = icons.COLLAPSE;
|
||||
} else if (expandedState.allCollapsed) {
|
||||
expandIcon = icons.EXPAND;
|
||||
}
|
||||
|
||||
const fanartUrl = getFanartUrl(images);
|
||||
const isFetching = isEpisodesFetching || isEpisodeFilesFetching;
|
||||
const isPopulated = isEpisodesPopulated && isEpisodeFilesPopulated;
|
||||
|
||||
return (
|
||||
<PageContent title={title}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('RefreshAndScan')}
|
||||
iconName={icons.REFRESH}
|
||||
spinningName={icons.REFRESH}
|
||||
title={translate('RefreshAndScanTooltip')}
|
||||
isSpinning={isRefreshing}
|
||||
onPress={handleRefreshPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('SearchMonitored')}
|
||||
iconName={icons.SEARCH}
|
||||
isDisabled={!monitored || !hasMonitoredEpisodes || !hasEpisodes}
|
||||
isSpinning={isSearching}
|
||||
title={
|
||||
hasMonitoredEpisodes
|
||||
? undefined
|
||||
: translate('NoMonitoredEpisodes')
|
||||
}
|
||||
onPress={handleSearchPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('PreviewRename')}
|
||||
iconName={icons.ORGANIZE}
|
||||
isDisabled={!hasEpisodeFiles}
|
||||
onPress={handleOrganizePress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('ManageEpisodes')}
|
||||
iconName={icons.EPISODE_FILE}
|
||||
onPress={handleManageEpisodesPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('History')}
|
||||
iconName={icons.HISTORY}
|
||||
isDisabled={!hasEpisodes}
|
||||
onPress={handleSeriesHistoryPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('SeriesMonitoring')}
|
||||
iconName={icons.MONITORED}
|
||||
onPress={handleMonitorOptionsPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('Edit')}
|
||||
iconName={icons.EDIT}
|
||||
onPress={handleEditSeriesPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('Delete')}
|
||||
iconName={icons.DELETE}
|
||||
onPress={handleDeleteSeriesPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<PageToolbarButton
|
||||
label={
|
||||
expandedState.allExpanded
|
||||
? translate('CollapseAll')
|
||||
: translate('ExpandAll')
|
||||
}
|
||||
iconName={expandIcon}
|
||||
onPress={handleExpandAllPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
|
||||
<PageContentBody innerClassName={styles.innerContentBody}>
|
||||
<div className={styles.header}>
|
||||
<div
|
||||
className={styles.backdrop}
|
||||
style={
|
||||
fanartUrl ? { backgroundImage: `url(${fanartUrl})` } : undefined
|
||||
}
|
||||
>
|
||||
<div className={styles.backdropOverlay} />
|
||||
</div>
|
||||
|
||||
<div className={styles.headerContent}>
|
||||
<SeriesPoster
|
||||
className={styles.poster}
|
||||
images={images}
|
||||
size={500}
|
||||
lazy={false}
|
||||
/>
|
||||
|
||||
<div className={styles.info}>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.titleContainer}>
|
||||
<div className={styles.toggleMonitoredContainer}>
|
||||
<MonitorToggleButton
|
||||
className={styles.monitorToggleButton}
|
||||
monitored={monitored}
|
||||
isSaving={isSaving}
|
||||
size={40}
|
||||
onPress={handleMonitorTogglePress}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.title}>{title}</div>
|
||||
|
||||
{alternateTitles.length ? (
|
||||
<div className={styles.alternateTitlesIconContainer}>
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon name={icons.ALTERNATE_TITLES} size={20} />
|
||||
}
|
||||
title={translate('AlternateTitles')}
|
||||
body={
|
||||
<SeriesAlternateTitles
|
||||
alternateTitles={alternateTitles}
|
||||
/>
|
||||
}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.seriesNavigationButtons}>
|
||||
<IconButton
|
||||
className={styles.seriesNavigationButton}
|
||||
name={icons.ARROW_LEFT}
|
||||
size={30}
|
||||
title={translate('SeriesDetailsGoTo', {
|
||||
title: previousSeries.title,
|
||||
})}
|
||||
to={`/series/${previousSeries.titleSlug}`}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
className={styles.seriesNavigationButton}
|
||||
name={icons.ARROW_RIGHT}
|
||||
size={30}
|
||||
title={translate('SeriesDetailsGoTo', {
|
||||
title: nextSeries.title,
|
||||
})}
|
||||
to={`/series/${nextSeries.titleSlug}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.details}>
|
||||
<div>
|
||||
{runtime ? (
|
||||
<span className={styles.runtime}>
|
||||
{translate('SeriesDetailsRuntime', { runtime })}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{ratings.value ? (
|
||||
<HeartRating
|
||||
rating={ratings.value}
|
||||
votes={ratings.votes}
|
||||
iconSize={20}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SeriesGenres className={styles.genres} genres={genres} />
|
||||
|
||||
<span>{runningYears}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className={styles.detailsLabel} size={sizes.LARGE}>
|
||||
<div>
|
||||
<Icon name={icons.FOLDER} size={17} />
|
||||
<span className={styles.path}>{path}</span>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Tooltip
|
||||
anchor={
|
||||
<Label className={styles.detailsLabel} size={sizes.LARGE}>
|
||||
<div>
|
||||
<Icon name={icons.DRIVE} size={17} />
|
||||
|
||||
<span className={styles.sizeOnDisk}>
|
||||
{formatBytes(sizeOnDisk)}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
}
|
||||
tooltip={<span>{episodeFilesCountMessage}</span>}
|
||||
kind={kinds.INVERSE}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
title={translate('QualityProfile')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<div>
|
||||
<Icon name={icons.PROFILE} size={17} />
|
||||
<span className={styles.qualityProfileName}>
|
||||
<QualityProfileNameConnector
|
||||
qualityProfileId={qualityProfileId}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Label className={styles.detailsLabel} size={sizes.LARGE}>
|
||||
<div>
|
||||
<Icon
|
||||
name={monitored ? icons.MONITORED : icons.UNMONITORED}
|
||||
size={17}
|
||||
/>
|
||||
<span className={styles.qualityProfileName}>
|
||||
{monitored
|
||||
? translate('Monitored')
|
||||
: translate('Unmonitored')}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
title={statusDetails.message}
|
||||
size={sizes.LARGE}
|
||||
kind={status === 'deleted' ? kinds.INVERSE : undefined}
|
||||
>
|
||||
<div>
|
||||
<Icon name={statusDetails.icon} size={17} />
|
||||
<span className={styles.statusName}>
|
||||
{statusDetails.title}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
{originalLanguage?.name ? (
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
title={translate('OriginalLanguage')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<div>
|
||||
<Icon name={icons.LANGUAGE} size={17} />
|
||||
<span className={styles.originalLanguageName}>
|
||||
{originalLanguage.name}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
) : null}
|
||||
|
||||
{network ? (
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
title={translate('Network')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<div>
|
||||
<Icon name={icons.NETWORK} size={17} />
|
||||
<span className={styles.network}>{network}</span>
|
||||
</div>
|
||||
</Label>
|
||||
) : null}
|
||||
|
||||
<Tooltip
|
||||
anchor={
|
||||
<Label className={styles.detailsLabel} size={sizes.LARGE}>
|
||||
<div>
|
||||
<Icon name={icons.EXTERNAL_LINK} size={17} />
|
||||
<span className={styles.links}>
|
||||
{translate('Links')}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
}
|
||||
tooltip={
|
||||
<SeriesDetailsLinks
|
||||
tvdbId={tvdbId}
|
||||
tvMazeId={tvMazeId}
|
||||
imdbId={imdbId}
|
||||
tmdbId={tmdbId}
|
||||
/>
|
||||
}
|
||||
kind={kinds.INVERSE}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
|
||||
{tags.length ? (
|
||||
<Tooltip
|
||||
anchor={
|
||||
<Label className={styles.detailsLabel} size={sizes.LARGE}>
|
||||
<Icon name={icons.TAGS} size={17} />
|
||||
|
||||
<span className={styles.tags}>{translate('Tags')}</span>
|
||||
</Label>
|
||||
}
|
||||
tooltip={<SeriesTags seriesId={seriesId} />}
|
||||
kind={kinds.INVERSE}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div ref={overviewRef} className={styles.overview}>
|
||||
<TextTruncate
|
||||
line={
|
||||
Math.floor(
|
||||
overviewHeight / (defaultFontSize * lineHeight)
|
||||
) - 1
|
||||
}
|
||||
text={overview}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MetadataAttribution />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.contentContainer}>
|
||||
{!isPopulated && !episodesError && !episodeFilesError ? (
|
||||
<LoadingIndicator />
|
||||
) : null}
|
||||
|
||||
{!isFetching && episodesError ? (
|
||||
<Alert kind={kinds.DANGER}>{translate('EpisodesLoadError')}</Alert>
|
||||
) : null}
|
||||
|
||||
{!isFetching && episodeFilesError ? (
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('EpisodeFilesLoadError')}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{isPopulated && !!seasons.length ? (
|
||||
<div>
|
||||
{seasons
|
||||
.slice(0)
|
||||
.reverse()
|
||||
.map((season) => {
|
||||
return (
|
||||
<SeriesDetailsSeason
|
||||
key={season.seasonNumber}
|
||||
seriesId={seriesId}
|
||||
{...season}
|
||||
isExpanded={expandedState.seasons[season.seasonNumber]}
|
||||
onExpandPress={handleExpandPress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isPopulated && !seasons.length ? (
|
||||
<Alert kind={kinds.WARNING}>
|
||||
{translate('NoEpisodeInformation')}
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<OrganizePreviewModal
|
||||
isOpen={isOrganizeModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleOrganizeModalClose}
|
||||
/>
|
||||
|
||||
<InteractiveImportModal
|
||||
isOpen={isManageEpisodesOpen}
|
||||
seriesId={seriesId}
|
||||
title={title}
|
||||
folder={path}
|
||||
initialSortKey="relativePath"
|
||||
initialSortDirection={sortDirections.DESCENDING}
|
||||
showSeries={false}
|
||||
allowSeriesChange={false}
|
||||
showDelete={true}
|
||||
showImportMode={false}
|
||||
modalTitle={translate('ManageEpisodes')}
|
||||
onModalClose={handleManageEpisodesModalClose}
|
||||
/>
|
||||
|
||||
<SeriesHistoryModal
|
||||
isOpen={isSeriesHistoryModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleSeriesHistoryModalClose}
|
||||
/>
|
||||
|
||||
<EditSeriesModal
|
||||
isOpen={isEditSeriesModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleEditSeriesModalClose}
|
||||
onDeleteSeriesPress={handleDeleteSeriesPress}
|
||||
/>
|
||||
|
||||
<DeleteSeriesModal
|
||||
isOpen={isDeleteSeriesModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleDeleteSeriesModalClose}
|
||||
/>
|
||||
|
||||
<MonitoringOptionsModal
|
||||
isOpen={isMonitorOptionsModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleMonitorOptionsClose}
|
||||
/>
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeriesDetails;
|
@ -1,265 +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 * as commandNames from 'Commands/commandNames';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
|
||||
import { clearEpisodeFiles, fetchEpisodeFiles } from 'Store/Actions/episodeFileActions';
|
||||
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
|
||||
import { toggleSeriesMonitored } from 'Store/Actions/seriesActions';
|
||||
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||
import { findCommand, isCommandExecuting } from 'Utilities/Command';
|
||||
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
|
||||
import filterAlternateTitles from 'Utilities/Series/filterAlternateTitles';
|
||||
import SeriesDetails from './SeriesDetails';
|
||||
|
||||
const selectEpisodes = createSelector(
|
||||
(state) => state.episodes,
|
||||
(episodes) => {
|
||||
const {
|
||||
items,
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error
|
||||
} = episodes;
|
||||
|
||||
const hasEpisodes = !!items.length;
|
||||
const hasMonitoredEpisodes = items.some((e) => e.monitored);
|
||||
|
||||
return {
|
||||
isEpisodesFetching: isFetching,
|
||||
isEpisodesPopulated: isPopulated,
|
||||
episodesError: error,
|
||||
hasEpisodes,
|
||||
hasMonitoredEpisodes
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const selectEpisodeFiles = createSelector(
|
||||
(state) => state.episodeFiles,
|
||||
(episodeFiles) => {
|
||||
const {
|
||||
items,
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error
|
||||
} = episodeFiles;
|
||||
|
||||
const hasEpisodeFiles = !!items.length;
|
||||
|
||||
return {
|
||||
isEpisodeFilesFetching: isFetching,
|
||||
isEpisodeFilesPopulated: isPopulated,
|
||||
episodeFilesError: error,
|
||||
hasEpisodeFiles
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state, { titleSlug }) => titleSlug,
|
||||
selectEpisodes,
|
||||
selectEpisodeFiles,
|
||||
createAllSeriesSelector(),
|
||||
createCommandsSelector(),
|
||||
(titleSlug, episodes, episodeFiles, allSeries, commands) => {
|
||||
const sortedSeries = _.orderBy(allSeries, 'sortTitle');
|
||||
const seriesIndex = _.findIndex(sortedSeries, { titleSlug });
|
||||
const series = sortedSeries[seriesIndex];
|
||||
|
||||
if (!series) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const {
|
||||
isEpisodesFetching,
|
||||
isEpisodesPopulated,
|
||||
episodesError,
|
||||
hasEpisodes,
|
||||
hasMonitoredEpisodes
|
||||
} = episodes;
|
||||
|
||||
const {
|
||||
isEpisodeFilesFetching,
|
||||
isEpisodeFilesPopulated,
|
||||
episodeFilesError,
|
||||
hasEpisodeFiles
|
||||
} = episodeFiles;
|
||||
|
||||
const previousSeries = sortedSeries[seriesIndex - 1] || _.last(sortedSeries);
|
||||
const nextSeries = sortedSeries[seriesIndex + 1] || _.first(sortedSeries);
|
||||
const isSeriesRefreshing = isCommandExecuting(findCommand(commands, { name: commandNames.REFRESH_SERIES, seriesId: series.id }));
|
||||
const seriesRefreshingCommand = findCommand(commands, { name: commandNames.REFRESH_SERIES });
|
||||
const allSeriesRefreshing = (
|
||||
isCommandExecuting(seriesRefreshingCommand) &&
|
||||
!seriesRefreshingCommand.body.seriesId
|
||||
);
|
||||
const isRefreshing = isSeriesRefreshing || allSeriesRefreshing;
|
||||
const isSearching = isCommandExecuting(findCommand(commands, { name: commandNames.SERIES_SEARCH, seriesId: series.id }));
|
||||
const isRenamingFiles = isCommandExecuting(findCommand(commands, { name: commandNames.RENAME_FILES, seriesId: series.id }));
|
||||
const isRenamingSeriesCommand = findCommand(commands, { name: commandNames.RENAME_SERIES });
|
||||
const isRenamingSeries = (
|
||||
isCommandExecuting(isRenamingSeriesCommand) &&
|
||||
isRenamingSeriesCommand.body.seriesIds.indexOf(series.id) > -1
|
||||
);
|
||||
|
||||
const isFetching = isEpisodesFetching || isEpisodeFilesFetching;
|
||||
const isPopulated = isEpisodesPopulated && isEpisodeFilesPopulated;
|
||||
const alternateTitles = filterAlternateTitles(series.alternateTitles, series.title, series.useSceneNumbering);
|
||||
|
||||
return {
|
||||
...series,
|
||||
alternateTitles,
|
||||
isSeriesRefreshing,
|
||||
allSeriesRefreshing,
|
||||
isRefreshing,
|
||||
isSearching,
|
||||
isRenamingFiles,
|
||||
isRenamingSeries,
|
||||
isFetching,
|
||||
isPopulated,
|
||||
episodesError,
|
||||
episodeFilesError,
|
||||
hasEpisodes,
|
||||
hasMonitoredEpisodes,
|
||||
hasEpisodeFiles,
|
||||
previousSeries,
|
||||
nextSeries
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchEpisodes,
|
||||
clearEpisodes,
|
||||
fetchEpisodeFiles,
|
||||
clearEpisodeFiles,
|
||||
toggleSeriesMonitored,
|
||||
fetchQueueDetails,
|
||||
clearQueueDetails,
|
||||
executeCommand
|
||||
};
|
||||
|
||||
class SeriesDetailsConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
registerPagePopulator(this.populate, ['seriesUpdated']);
|
||||
this.populate();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
id,
|
||||
isSeriesRefreshing,
|
||||
allSeriesRefreshing,
|
||||
isRenamingFiles,
|
||||
isRenamingSeries
|
||||
} = this.props;
|
||||
|
||||
if (
|
||||
(prevProps.isSeriesRefreshing && !isSeriesRefreshing) ||
|
||||
(prevProps.allSeriesRefreshing && !allSeriesRefreshing) ||
|
||||
(prevProps.isRenamingFiles && !isRenamingFiles) ||
|
||||
(prevProps.isRenamingSeries && !isRenamingSeries)
|
||||
) {
|
||||
this.populate();
|
||||
}
|
||||
|
||||
// If the id has changed we need to clear the episodes/episode
|
||||
// files and fetch from the server.
|
||||
|
||||
if (prevProps.id !== id) {
|
||||
this.unpopulate();
|
||||
this.populate();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
unregisterPagePopulator(this.populate);
|
||||
this.unpopulate();
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
populate = () => {
|
||||
const seriesId = this.props.id;
|
||||
|
||||
this.props.fetchEpisodes({ seriesId });
|
||||
this.props.fetchEpisodeFiles({ seriesId });
|
||||
this.props.fetchQueueDetails({ seriesId });
|
||||
};
|
||||
|
||||
unpopulate = () => {
|
||||
this.props.clearEpisodes();
|
||||
this.props.clearEpisodeFiles();
|
||||
this.props.clearQueueDetails();
|
||||
};
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onMonitorTogglePress = (monitored) => {
|
||||
this.props.toggleSeriesMonitored({
|
||||
seriesId: this.props.id,
|
||||
monitored
|
||||
});
|
||||
};
|
||||
|
||||
onRefreshPress = () => {
|
||||
this.props.executeCommand({
|
||||
name: commandNames.REFRESH_SERIES,
|
||||
seriesId: this.props.id
|
||||
});
|
||||
};
|
||||
|
||||
onSearchPress = () => {
|
||||
this.props.executeCommand({
|
||||
name: commandNames.SERIES_SEARCH,
|
||||
seriesId: this.props.id
|
||||
});
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SeriesDetails
|
||||
{...this.props}
|
||||
onMonitorTogglePress={this.onMonitorTogglePress}
|
||||
onRefreshPress={this.onRefreshPress}
|
||||
onSearchPress={this.onSearchPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SeriesDetailsConnector.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
titleSlug: PropTypes.string.isRequired,
|
||||
isSeriesRefreshing: PropTypes.bool.isRequired,
|
||||
allSeriesRefreshing: PropTypes.bool.isRequired,
|
||||
isRefreshing: PropTypes.bool.isRequired,
|
||||
isRenamingFiles: PropTypes.bool.isRequired,
|
||||
isRenamingSeries: PropTypes.bool.isRequired,
|
||||
fetchEpisodes: PropTypes.func.isRequired,
|
||||
clearEpisodes: PropTypes.func.isRequired,
|
||||
fetchEpisodeFiles: PropTypes.func.isRequired,
|
||||
clearEpisodeFiles: PropTypes.func.isRequired,
|
||||
toggleSeriesMonitored: PropTypes.func.isRequired,
|
||||
fetchQueueDetails: PropTypes.func.isRequired,
|
||||
clearQueueDetails: PropTypes.func.isRequired,
|
||||
executeCommand: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(SeriesDetailsConnector);
|
@ -0,0 +1,34 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useHistory, useParams } from 'react-router';
|
||||
import NotFound from 'Components/NotFound';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import SeriesDetails from './SeriesDetails';
|
||||
|
||||
function SeriesDetailsPage() {
|
||||
const allSeries = useSelector(createAllSeriesSelector());
|
||||
const { titleSlug } = useParams<{ titleSlug: string }>();
|
||||
const history = useHistory();
|
||||
|
||||
const seriesIndex = allSeries.findIndex(
|
||||
(series) => series.titleSlug === titleSlug
|
||||
);
|
||||
|
||||
const previousIndex = usePrevious(seriesIndex);
|
||||
|
||||
useEffect(() => {
|
||||
if (seriesIndex === -1 && previousIndex !== -1) {
|
||||
history.push(`${window.Sonarr.urlBase}/`);
|
||||
}
|
||||
}, [seriesIndex, previousIndex, history]);
|
||||
|
||||
if (seriesIndex === -1) {
|
||||
return <NotFound message={translate('SeriesCannotBeFound')} />;
|
||||
}
|
||||
|
||||
return <SeriesDetails seriesId={allSeries[seriesIndex].id} />;
|
||||
}
|
||||
|
||||
export default SeriesDetailsPage;
|
@ -1,77 +0,0 @@
|
||||
import { push } from 'connected-react-router';
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import NotFound from 'Components/NotFound';
|
||||
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import SeriesDetailsConnector from './SeriesDetailsConnector';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state, { match }) => match,
|
||||
createAllSeriesSelector(),
|
||||
(match, allSeries) => {
|
||||
const titleSlug = match.params.titleSlug;
|
||||
const seriesIndex = _.findIndex(allSeries, { titleSlug });
|
||||
|
||||
if (seriesIndex > -1) {
|
||||
return {
|
||||
titleSlug
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
push
|
||||
};
|
||||
|
||||
class SeriesDetailsPageConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (!this.props.titleSlug) {
|
||||
this.props.push(`${window.Sonarr.urlBase}/`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
titleSlug
|
||||
} = this.props;
|
||||
|
||||
if (!titleSlug) {
|
||||
return (
|
||||
<NotFound
|
||||
message={translate('SeriesCannotBeFound')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SeriesDetailsConnector
|
||||
titleSlug={titleSlug}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SeriesDetailsPageConnector.propTypes = {
|
||||
titleSlug: PropTypes.string,
|
||||
match: PropTypes.shape({ params: PropTypes.shape({ titleSlug: PropTypes.string.isRequired }).isRequired }).isRequired,
|
||||
push: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(SeriesDetailsPageConnector);
|
@ -1,546 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import Link from 'Components/Link/Link';
|
||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||
import Menu from 'Components/Menu/Menu';
|
||||
import MenuButton from 'Components/Menu/MenuButton';
|
||||
import MenuContent from 'Components/Menu/MenuContent';
|
||||
import MenuItem from 'Components/Menu/MenuItem';
|
||||
import MonitorToggleButton from 'Components/MonitorToggleButton';
|
||||
import SpinnerIcon from 'Components/SpinnerIcon';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { align, icons, sortDirections, tooltipPositions } from 'Helpers/Props';
|
||||
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
||||
import OrganizePreviewModal from 'Organize/OrganizePreviewModal';
|
||||
import SeriesHistoryModal from 'Series/History/SeriesHistoryModal';
|
||||
import SeasonInteractiveSearchModal from 'Series/Search/SeasonInteractiveSearchModal';
|
||||
import isAfter from 'Utilities/Date/isAfter';
|
||||
import isBefore from 'Utilities/Date/isBefore';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import getToggledRange from 'Utilities/Table/getToggledRange';
|
||||
import EpisodeRowConnector from './EpisodeRowConnector';
|
||||
import SeasonInfo from './SeasonInfo';
|
||||
import SeasonProgressLabel from './SeasonProgressLabel';
|
||||
import styles from './SeriesDetailsSeason.css';
|
||||
|
||||
function getSeasonStatistics(episodes) {
|
||||
let episodeCount = 0;
|
||||
let episodeFileCount = 0;
|
||||
let totalEpisodeCount = 0;
|
||||
let monitoredEpisodeCount = 0;
|
||||
let hasMonitoredEpisodes = false;
|
||||
const sizeOnDisk = 0;
|
||||
|
||||
episodes.forEach((episode) => {
|
||||
if (episode.episodeFileId || (episode.monitored && isBefore(episode.airDateUtc))) {
|
||||
episodeCount++;
|
||||
}
|
||||
|
||||
if (episode.episodeFileId) {
|
||||
episodeFileCount++;
|
||||
}
|
||||
|
||||
if (episode.monitored) {
|
||||
monitoredEpisodeCount++;
|
||||
hasMonitoredEpisodes = true;
|
||||
}
|
||||
|
||||
totalEpisodeCount++;
|
||||
});
|
||||
|
||||
return {
|
||||
episodeCount,
|
||||
episodeFileCount,
|
||||
totalEpisodeCount,
|
||||
monitoredEpisodeCount,
|
||||
hasMonitoredEpisodes,
|
||||
sizeOnDisk
|
||||
};
|
||||
}
|
||||
|
||||
class SeriesDetailsSeason extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isOrganizeModalOpen: false,
|
||||
isManageEpisodesOpen: false,
|
||||
isHistoryModalOpen: false,
|
||||
isInteractiveSearchModalOpen: false,
|
||||
lastToggledEpisode: null
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._expandByDefault();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
seriesId,
|
||||
items
|
||||
} = this.props;
|
||||
|
||||
if (prevProps.seriesId !== seriesId) {
|
||||
this._expandByDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
getSeasonStatistics(prevProps.items).episodeFileCount > 0 &&
|
||||
getSeasonStatistics(items).episodeFileCount === 0
|
||||
) {
|
||||
this.setState({
|
||||
isOrganizeModalOpen: false,
|
||||
isManageEpisodesOpen: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
_expandByDefault() {
|
||||
const {
|
||||
seasonNumber,
|
||||
onExpandPress,
|
||||
items
|
||||
} = this.props;
|
||||
|
||||
const expand = _.some(items, (item) => isAfter(item.airDateUtc) || isAfter(item.airDateUtc, { days: -30 })) ||
|
||||
items.every((item) => !item.airDateUtc);
|
||||
|
||||
onExpandPress(seasonNumber, expand && seasonNumber > 0);
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onOrganizePress = () => {
|
||||
this.setState({ isOrganizeModalOpen: true });
|
||||
};
|
||||
|
||||
onOrganizeModalClose = () => {
|
||||
this.setState({ isOrganizeModalOpen: false });
|
||||
};
|
||||
|
||||
onManageEpisodesPress = () => {
|
||||
this.setState({ isManageEpisodesOpen: true });
|
||||
};
|
||||
|
||||
onManageEpisodesModalClose = () => {
|
||||
this.setState({ isManageEpisodesOpen: false });
|
||||
};
|
||||
|
||||
onHistoryPress = () => {
|
||||
this.setState({ isHistoryModalOpen: true });
|
||||
};
|
||||
|
||||
onHistoryModalClose = () => {
|
||||
this.setState({ isHistoryModalOpen: false });
|
||||
};
|
||||
|
||||
onInteractiveSearchPress = () => {
|
||||
this.setState({ isInteractiveSearchModalOpen: true });
|
||||
};
|
||||
|
||||
onInteractiveSearchModalClose = () => {
|
||||
this.setState({ isInteractiveSearchModalOpen: false });
|
||||
};
|
||||
|
||||
onExpandPress = () => {
|
||||
const {
|
||||
seasonNumber,
|
||||
isExpanded
|
||||
} = this.props;
|
||||
|
||||
this.props.onExpandPress(seasonNumber, !isExpanded);
|
||||
};
|
||||
|
||||
onMonitorEpisodePress = (episodeId, monitored, { shiftKey }) => {
|
||||
const lastToggled = this.state.lastToggledEpisode;
|
||||
const episodeIds = [episodeId];
|
||||
|
||||
if (shiftKey && lastToggled) {
|
||||
const { lower, upper } = getToggledRange(this.props.items, episodeId, lastToggled);
|
||||
const items = this.props.items;
|
||||
|
||||
for (let i = lower; i < upper; i++) {
|
||||
episodeIds.push(items[i].id);
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({ lastToggledEpisode: episodeId });
|
||||
|
||||
this.props.onMonitorEpisodePress(_.uniq(episodeIds), monitored);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
seriesId,
|
||||
path,
|
||||
monitored,
|
||||
seasonNumber,
|
||||
items,
|
||||
columns,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
statistics,
|
||||
isSaving,
|
||||
isExpanded,
|
||||
isSearching,
|
||||
seriesMonitored,
|
||||
isSmallScreen,
|
||||
onSortPress,
|
||||
onTableOptionChange,
|
||||
onMonitorSeasonPress,
|
||||
onSearchPress
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
sizeOnDisk = 0
|
||||
} = statistics;
|
||||
|
||||
const {
|
||||
episodeCount,
|
||||
episodeFileCount,
|
||||
totalEpisodeCount,
|
||||
monitoredEpisodeCount,
|
||||
hasMonitoredEpisodes
|
||||
} = getSeasonStatistics(items);
|
||||
|
||||
const {
|
||||
isOrganizeModalOpen,
|
||||
isManageEpisodesOpen,
|
||||
isHistoryModalOpen,
|
||||
isInteractiveSearchModalOpen
|
||||
} = this.state;
|
||||
|
||||
const title = seasonNumber === 0 ? translate('Specials') : translate('SeasonNumberToken', { seasonNumber });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.season}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.left}>
|
||||
<MonitorToggleButton
|
||||
monitored={monitored}
|
||||
isDisabled={!seriesMonitored}
|
||||
isSaving={isSaving}
|
||||
size={24}
|
||||
onPress={onMonitorSeasonPress}
|
||||
/>
|
||||
|
||||
<span className={styles.seasonNumber}>
|
||||
{title}
|
||||
</span>
|
||||
|
||||
<Popover
|
||||
className={styles.episodeCountTooltip}
|
||||
canFlip={true}
|
||||
anchor={
|
||||
<SeasonProgressLabel
|
||||
seriesId={seriesId}
|
||||
seasonNumber={seasonNumber}
|
||||
monitored={monitored}
|
||||
episodeCount={episodeCount}
|
||||
episodeFileCount={episodeFileCount}
|
||||
/>
|
||||
}
|
||||
title={translate('SeasonInformation')}
|
||||
body={
|
||||
<div>
|
||||
<SeasonInfo
|
||||
totalEpisodeCount={totalEpisodeCount}
|
||||
monitoredEpisodeCount={monitoredEpisodeCount}
|
||||
episodeFileCount={episodeFileCount}
|
||||
sizeOnDisk={sizeOnDisk}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
|
||||
{
|
||||
sizeOnDisk ?
|
||||
<div className={styles.sizeOnDisk}>
|
||||
{formatBytes(sizeOnDisk)}
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
className={styles.expandButton}
|
||||
onPress={this.onExpandPress}
|
||||
>
|
||||
<Icon
|
||||
className={styles.expandButtonIcon}
|
||||
name={isExpanded ? icons.COLLAPSE : icons.EXPAND}
|
||||
title={isExpanded ? translate('HideEpisodes') : translate('ShowEpisodes')}
|
||||
size={24}
|
||||
/>
|
||||
{
|
||||
!isSmallScreen &&
|
||||
<span> </span>
|
||||
}
|
||||
</Link>
|
||||
|
||||
{
|
||||
isSmallScreen ?
|
||||
<Menu
|
||||
className={styles.actionsMenu}
|
||||
alignMenu={align.RIGHT}
|
||||
enforceMaxHeight={false}
|
||||
>
|
||||
<MenuButton>
|
||||
<Icon
|
||||
name={icons.ACTIONS}
|
||||
size={22}
|
||||
/>
|
||||
</MenuButton>
|
||||
|
||||
<MenuContent className={styles.actionsMenuContent}>
|
||||
<MenuItem
|
||||
isDisabled={isSearching || !hasMonitoredEpisodes || !seriesMonitored}
|
||||
onPress={onSearchPress}
|
||||
>
|
||||
<SpinnerIcon
|
||||
className={styles.actionMenuIcon}
|
||||
name={icons.SEARCH}
|
||||
isSpinning={isSearching}
|
||||
/>
|
||||
|
||||
{translate('Search')}
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
onPress={this.onInteractiveSearchPress}
|
||||
isDisabled={!totalEpisodeCount}
|
||||
>
|
||||
<Icon
|
||||
className={styles.actionMenuIcon}
|
||||
name={icons.INTERACTIVE}
|
||||
/>
|
||||
|
||||
{translate('InteractiveSearch')}
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
onPress={this.onOrganizePress}
|
||||
isDisabled={!episodeFileCount}
|
||||
>
|
||||
<Icon
|
||||
className={styles.actionMenuIcon}
|
||||
name={icons.ORGANIZE}
|
||||
/>
|
||||
|
||||
{translate('PreviewRename')}
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
onPress={this.onManageEpisodesPress}
|
||||
isDisabled={!episodeFileCount}
|
||||
>
|
||||
<Icon
|
||||
className={styles.actionMenuIcon}
|
||||
name={icons.EPISODE_FILE}
|
||||
/>
|
||||
|
||||
{translate('ManageEpisodes')}
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
onPress={this.onHistoryPress}
|
||||
isDisabled={!totalEpisodeCount}
|
||||
>
|
||||
<Icon
|
||||
className={styles.actionMenuIcon}
|
||||
name={icons.HISTORY}
|
||||
/>
|
||||
|
||||
{translate('History')}
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</Menu> :
|
||||
|
||||
<div className={styles.actions}>
|
||||
<SpinnerIconButton
|
||||
className={styles.actionButton}
|
||||
name={icons.SEARCH}
|
||||
title={hasMonitoredEpisodes && seriesMonitored ? translate('SearchForMonitoredEpisodesSeason') : translate('NoMonitoredEpisodesSeason')}
|
||||
size={24}
|
||||
isSpinning={isSearching}
|
||||
isDisabled={isSearching || !hasMonitoredEpisodes || !seriesMonitored}
|
||||
onPress={onSearchPress}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
className={styles.actionButton}
|
||||
name={icons.INTERACTIVE}
|
||||
title={translate('InteractiveSearchSeason')}
|
||||
size={24}
|
||||
isDisabled={!totalEpisodeCount}
|
||||
onPress={this.onInteractiveSearchPress}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
className={styles.actionButton}
|
||||
name={icons.ORGANIZE}
|
||||
title={translate('PreviewRenameSeason')}
|
||||
size={24}
|
||||
isDisabled={!episodeFileCount}
|
||||
onPress={this.onOrganizePress}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
className={styles.actionButton}
|
||||
name={icons.EPISODE_FILE}
|
||||
title={translate('ManageEpisodesSeason')}
|
||||
size={24}
|
||||
isDisabled={!episodeFileCount}
|
||||
onPress={this.onManageEpisodesPress}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
className={styles.actionButton}
|
||||
name={icons.HISTORY}
|
||||
title={translate('HistorySeason')}
|
||||
size={24}
|
||||
isDisabled={!totalEpisodeCount}
|
||||
onPress={this.onHistoryPress}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{
|
||||
isExpanded &&
|
||||
<div className={styles.episodes}>
|
||||
{
|
||||
items.length ?
|
||||
<Table
|
||||
columns={columns}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
onSortPress={onSortPress}
|
||||
onTableOptionChange={onTableOptionChange}
|
||||
>
|
||||
<TableBody>
|
||||
{
|
||||
items.map((item) => {
|
||||
return (
|
||||
<EpisodeRowConnector
|
||||
key={item.id}
|
||||
columns={columns}
|
||||
{...item}
|
||||
onMonitorEpisodePress={this.onMonitorEpisodePress}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</TableBody>
|
||||
</Table> :
|
||||
|
||||
<div className={styles.noEpisodes}>
|
||||
{translate('NoEpisodesInThisSeason')}
|
||||
</div>
|
||||
}
|
||||
<div className={styles.collapseButtonContainer}>
|
||||
<IconButton
|
||||
iconClassName={styles.collapseButtonIcon}
|
||||
name={icons.COLLAPSE}
|
||||
size={20}
|
||||
title={translate('HideEpisodes')}
|
||||
onPress={this.onExpandPress}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<OrganizePreviewModal
|
||||
isOpen={isOrganizeModalOpen}
|
||||
seriesId={seriesId}
|
||||
seasonNumber={seasonNumber}
|
||||
onModalClose={this.onOrganizeModalClose}
|
||||
/>
|
||||
|
||||
<InteractiveImportModal
|
||||
isOpen={isManageEpisodesOpen}
|
||||
seriesId={seriesId}
|
||||
seasonNumber={seasonNumber}
|
||||
title={title}
|
||||
folder={path}
|
||||
initialSortKey="relativePath"
|
||||
initialSortDirection={sortDirections.DESCENDING}
|
||||
showSeries={false}
|
||||
allowSeriesChange={false}
|
||||
showDelete={true}
|
||||
showImportMode={false}
|
||||
modalTitle={translate('ManageEpisodes')}
|
||||
onModalClose={this.onManageEpisodesModalClose}
|
||||
/>
|
||||
|
||||
<SeriesHistoryModal
|
||||
isOpen={isHistoryModalOpen}
|
||||
seriesId={seriesId}
|
||||
seasonNumber={seasonNumber}
|
||||
onModalClose={this.onHistoryModalClose}
|
||||
/>
|
||||
|
||||
<SeasonInteractiveSearchModal
|
||||
isOpen={isInteractiveSearchModalOpen}
|
||||
seriesId={seriesId}
|
||||
seasonNumber={seasonNumber}
|
||||
onModalClose={this.onInteractiveSearchModalClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SeriesDetailsSeason.propTypes = {
|
||||
seriesId: PropTypes.number.isRequired,
|
||||
path: PropTypes.string.isRequired,
|
||||
monitored: PropTypes.bool.isRequired,
|
||||
seasonNumber: PropTypes.number.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
sortKey: PropTypes.string.isRequired,
|
||||
sortDirection: PropTypes.oneOf(sortDirections.all),
|
||||
statistics: PropTypes.object.isRequired,
|
||||
isSaving: PropTypes.bool,
|
||||
isExpanded: PropTypes.bool,
|
||||
isSearching: PropTypes.bool.isRequired,
|
||||
seriesMonitored: PropTypes.bool.isRequired,
|
||||
isSmallScreen: PropTypes.bool.isRequired,
|
||||
onTableOptionChange: PropTypes.func.isRequired,
|
||||
onSortPress: PropTypes.func.isRequired,
|
||||
onMonitorSeasonPress: PropTypes.func.isRequired,
|
||||
onExpandPress: PropTypes.func.isRequired,
|
||||
onMonitorEpisodePress: PropTypes.func.isRequired,
|
||||
onSearchPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
SeriesDetailsSeason.defaultProps = {
|
||||
statistics: {}
|
||||
};
|
||||
|
||||
export default SeriesDetailsSeason;
|
@ -0,0 +1,573 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import EpisodesAppState from 'App/State/EpisodesAppState';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import Icon from 'Components/Icon';
|
||||
import Label from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import Link from 'Components/Link/Link';
|
||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||
import Menu from 'Components/Menu/Menu';
|
||||
import MenuButton from 'Components/Menu/MenuButton';
|
||||
import MenuContent from 'Components/Menu/MenuContent';
|
||||
import MenuItem from 'Components/Menu/MenuItem';
|
||||
import MonitorToggleButton from 'Components/MonitorToggleButton';
|
||||
import SpinnerIcon from 'Components/SpinnerIcon';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import Episode from 'Episode/Episode';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { align, icons, sortDirections, tooltipPositions } from 'Helpers/Props';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
||||
import OrganizePreviewModal from 'Organize/OrganizePreviewModal';
|
||||
import SeriesHistoryModal from 'Series/History/SeriesHistoryModal';
|
||||
import SeasonInteractiveSearchModal from 'Series/Search/SeasonInteractiveSearchModal';
|
||||
import { Statistics } from 'Series/Series';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import {
|
||||
setEpisodesSort,
|
||||
setEpisodesTableOption,
|
||||
toggleEpisodesMonitored,
|
||||
} from 'Store/Actions/episodeActions';
|
||||
import { toggleSeasonMonitored } from 'Store/Actions/seriesActions';
|
||||
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||
import { TableOptionsChangePayload } from 'typings/Table';
|
||||
import { findCommand, isCommandExecuting } from 'Utilities/Command';
|
||||
import isAfter from 'Utilities/Date/isAfter';
|
||||
import isBefore from 'Utilities/Date/isBefore';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import getToggledRange from 'Utilities/Table/getToggledRange';
|
||||
import EpisodeRow from './EpisodeRow';
|
||||
import SeasonInfo from './SeasonInfo';
|
||||
import SeasonProgressLabel from './SeasonProgressLabel';
|
||||
import styles from './SeriesDetailsSeason.css';
|
||||
|
||||
function getSeasonStatistics(episodes: Episode[]) {
|
||||
let episodeCount = 0;
|
||||
let episodeFileCount = 0;
|
||||
let totalEpisodeCount = 0;
|
||||
let monitoredEpisodeCount = 0;
|
||||
let hasMonitoredEpisodes = false;
|
||||
const sizeOnDisk = 0;
|
||||
|
||||
episodes.forEach((episode) => {
|
||||
if (
|
||||
episode.episodeFileId ||
|
||||
(episode.monitored && isBefore(episode.airDateUtc))
|
||||
) {
|
||||
episodeCount++;
|
||||
}
|
||||
|
||||
if (episode.episodeFileId) {
|
||||
episodeFileCount++;
|
||||
}
|
||||
|
||||
if (episode.monitored) {
|
||||
monitoredEpisodeCount++;
|
||||
hasMonitoredEpisodes = true;
|
||||
}
|
||||
|
||||
totalEpisodeCount++;
|
||||
});
|
||||
|
||||
return {
|
||||
episodeCount,
|
||||
episodeFileCount,
|
||||
totalEpisodeCount,
|
||||
monitoredEpisodeCount,
|
||||
hasMonitoredEpisodes,
|
||||
sizeOnDisk,
|
||||
};
|
||||
}
|
||||
|
||||
function createEpisodesSelector(seasonNumber: number) {
|
||||
return createSelector(
|
||||
createClientSideCollectionSelector('episodes'),
|
||||
(episodes: EpisodesAppState) => {
|
||||
const { items, columns, sortKey, sortDirection } = episodes;
|
||||
|
||||
const episodesInSeason = items.filter(
|
||||
(episode) => episode.seasonNumber === seasonNumber
|
||||
);
|
||||
|
||||
return { items: episodesInSeason, columns, sortKey, sortDirection };
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createIsSearchingSelector(seriesId: number, seasonNumber: number) {
|
||||
return createSelector(createCommandsSelector(), (commands) => {
|
||||
return isCommandExecuting(
|
||||
findCommand(commands, {
|
||||
name: commandNames.SEASON_SEARCH,
|
||||
seriesId,
|
||||
seasonNumber,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
interface SeriesDetailsSeasonProps {
|
||||
seriesId: number;
|
||||
monitored: boolean;
|
||||
seasonNumber: number;
|
||||
statistics?: Statistics;
|
||||
isSaving?: boolean;
|
||||
isExpanded?: boolean;
|
||||
onExpandPress: (seasonNumber: number, isExpanded: boolean) => void;
|
||||
}
|
||||
|
||||
function SeriesDetailsSeason({
|
||||
seriesId,
|
||||
monitored,
|
||||
seasonNumber,
|
||||
statistics = {} as Statistics,
|
||||
isSaving,
|
||||
isExpanded,
|
||||
onExpandPress,
|
||||
}: SeriesDetailsSeasonProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { monitored: seriesMonitored, path } = useSeries(seriesId)!;
|
||||
|
||||
const { items, columns, sortKey, sortDirection } = useSelector(
|
||||
createEpisodesSelector(seasonNumber)
|
||||
);
|
||||
|
||||
const { isSmallScreen } = useSelector(createDimensionsSelector());
|
||||
const isSearching = useSelector(
|
||||
createIsSearchingSelector(seriesId, seasonNumber)
|
||||
);
|
||||
|
||||
const { sizeOnDisk = 0 } = statistics;
|
||||
|
||||
const {
|
||||
episodeCount,
|
||||
episodeFileCount,
|
||||
totalEpisodeCount,
|
||||
monitoredEpisodeCount,
|
||||
hasMonitoredEpisodes,
|
||||
} = getSeasonStatistics(items);
|
||||
|
||||
const previousEpisodeFileCount = usePrevious(episodeFileCount);
|
||||
|
||||
const [isOrganizeModalOpen, setIsOrganizeModalOpen] = useState(false);
|
||||
const [isManageEpisodesOpen, setIsManageEpisodesOpen] = useState(false);
|
||||
const [isHistoryModalOpen, setIsHistoryModalOpen] = useState(false);
|
||||
const [isInteractiveSearchModalOpen, setIsInteractiveSearchModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const lastToggledEpisode = useRef<number | null>(null);
|
||||
const itemsRef = useRef(items);
|
||||
|
||||
itemsRef.current = items;
|
||||
|
||||
const seasonNumberTitle =
|
||||
seasonNumber === 0
|
||||
? translate('Specials')
|
||||
: translate('SeasonNumberToken', { seasonNumber });
|
||||
|
||||
const handleMonitorSeasonPress = useCallback(
|
||||
(value: boolean) => {
|
||||
dispatch(
|
||||
toggleSeasonMonitored({
|
||||
seriesId,
|
||||
seasonNumber,
|
||||
monitored: value,
|
||||
})
|
||||
);
|
||||
},
|
||||
[seriesId, seasonNumber, dispatch]
|
||||
);
|
||||
|
||||
const handleExpandPress = useCallback(() => {
|
||||
onExpandPress(seasonNumber, !isExpanded);
|
||||
}, [seasonNumber, isExpanded, onExpandPress]);
|
||||
|
||||
const handleMonitorEpisodePress = useCallback(
|
||||
(
|
||||
episodeId: number,
|
||||
value: boolean,
|
||||
{ shiftKey }: { shiftKey: boolean }
|
||||
) => {
|
||||
const lastToggled = lastToggledEpisode.current;
|
||||
const episodeIds = [episodeId];
|
||||
|
||||
if (shiftKey && lastToggled) {
|
||||
const { lower, upper } = getToggledRange(items, episodeId, lastToggled);
|
||||
for (let i = lower; i < upper; i++) {
|
||||
episodeIds.push(items[i].id);
|
||||
}
|
||||
}
|
||||
|
||||
lastToggledEpisode.current = episodeId;
|
||||
|
||||
dispatch(
|
||||
toggleEpisodesMonitored({
|
||||
episodeIds,
|
||||
value,
|
||||
})
|
||||
);
|
||||
},
|
||||
[items, dispatch]
|
||||
);
|
||||
|
||||
const handleSearchPress = useCallback(() => {
|
||||
dispatch({
|
||||
name: commandNames.SEASON_SEARCH,
|
||||
seriesId,
|
||||
seasonNumber,
|
||||
});
|
||||
}, [seriesId, seasonNumber, dispatch]);
|
||||
|
||||
const handleOrganizePress = useCallback(() => {
|
||||
setIsOrganizeModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleOrganizeModalClose = useCallback(() => {
|
||||
setIsOrganizeModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleManageEpisodesPress = useCallback(() => {
|
||||
setIsManageEpisodesOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleManageEpisodesModalClose = useCallback(() => {
|
||||
setIsManageEpisodesOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleHistoryPress = useCallback(() => {
|
||||
setIsHistoryModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleHistoryModalClose = useCallback(() => {
|
||||
setIsHistoryModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleInteractiveSearchPress = useCallback(() => {
|
||||
setIsInteractiveSearchModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleInteractiveSearchModalClose = useCallback(() => {
|
||||
setIsInteractiveSearchModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleSortPress = useCallback(
|
||||
(sortKey: string, sortDirection?: SortDirection) => {
|
||||
dispatch(
|
||||
setEpisodesSort({
|
||||
sortKey,
|
||||
sortDirection,
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleTableOptionChange = useCallback(
|
||||
(payload: TableOptionsChangePayload) => {
|
||||
dispatch(setEpisodesTableOption(payload));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const expand =
|
||||
itemsRef.current.some(
|
||||
(item) =>
|
||||
isAfter(item.airDateUtc) || isAfter(item.airDateUtc, { days: -30 })
|
||||
) || itemsRef.current.every((item) => !item.airDateUtc);
|
||||
|
||||
onExpandPress(seasonNumber, expand && seasonNumber > 0);
|
||||
}, [seriesId, seasonNumber, onExpandPress]);
|
||||
|
||||
useEffect(() => {
|
||||
if ((previousEpisodeFileCount ?? 0) > 0 && episodeFileCount === 0) {
|
||||
setIsOrganizeModalOpen(false);
|
||||
setIsManageEpisodesOpen(false);
|
||||
}
|
||||
}, [episodeFileCount, previousEpisodeFileCount]);
|
||||
|
||||
return (
|
||||
<div className={styles.season}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.left}>
|
||||
<MonitorToggleButton
|
||||
monitored={monitored}
|
||||
isDisabled={!seriesMonitored}
|
||||
isSaving={isSaving}
|
||||
size={24}
|
||||
onPress={handleMonitorSeasonPress}
|
||||
/>
|
||||
|
||||
<div className={styles.seasonInfo}>
|
||||
<div className={styles.seasonNumber}>{seasonNumberTitle}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.seasonStats}>
|
||||
<Popover
|
||||
className={styles.episodeCountTooltip}
|
||||
canFlip={true}
|
||||
anchor={
|
||||
<SeasonProgressLabel
|
||||
className={styles.seasonStatsLabel}
|
||||
seriesId={seriesId}
|
||||
seasonNumber={seasonNumber}
|
||||
monitored={monitored}
|
||||
episodeCount={episodeCount}
|
||||
episodeFileCount={episodeFileCount}
|
||||
/>
|
||||
}
|
||||
title={translate('SeasonInformation')}
|
||||
body={
|
||||
<div>
|
||||
<SeasonInfo
|
||||
totalEpisodeCount={totalEpisodeCount}
|
||||
monitoredEpisodeCount={monitoredEpisodeCount}
|
||||
episodeFileCount={episodeFileCount}
|
||||
sizeOnDisk={sizeOnDisk}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
|
||||
{sizeOnDisk ? (
|
||||
<Label
|
||||
className={styles.seasonStatsLabel}
|
||||
kind="default"
|
||||
size="large"
|
||||
>
|
||||
{formatBytes(sizeOnDisk)}
|
||||
</Label>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link className={styles.expandButton} onPress={handleExpandPress}>
|
||||
<Icon
|
||||
className={styles.expandButtonIcon}
|
||||
name={isExpanded ? icons.COLLAPSE : icons.EXPAND}
|
||||
title={
|
||||
isExpanded ? translate('HideEpisodes') : translate('ShowEpisodes')
|
||||
}
|
||||
size={24}
|
||||
/>
|
||||
{isSmallScreen ? null : <span> </span>}
|
||||
</Link>
|
||||
|
||||
{isSmallScreen ? (
|
||||
<Menu
|
||||
className={styles.actionsMenu}
|
||||
alignMenu={align.RIGHT}
|
||||
enforceMaxHeight={false}
|
||||
>
|
||||
<MenuButton>
|
||||
<Icon name={icons.ACTIONS} size={22} />
|
||||
</MenuButton>
|
||||
|
||||
<MenuContent className={styles.actionsMenuContent}>
|
||||
<MenuItem
|
||||
isDisabled={
|
||||
isSearching || !hasMonitoredEpisodes || !seriesMonitored
|
||||
}
|
||||
onPress={handleSearchPress}
|
||||
>
|
||||
<SpinnerIcon
|
||||
className={styles.actionMenuIcon}
|
||||
name={icons.SEARCH}
|
||||
isSpinning={isSearching}
|
||||
/>
|
||||
|
||||
{translate('Search')}
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
isDisabled={!totalEpisodeCount}
|
||||
onPress={handleInteractiveSearchPress}
|
||||
>
|
||||
<Icon
|
||||
className={styles.actionMenuIcon}
|
||||
name={icons.INTERACTIVE}
|
||||
/>
|
||||
|
||||
{translate('InteractiveSearch')}
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
isDisabled={!episodeFileCount}
|
||||
onPress={handleOrganizePress}
|
||||
>
|
||||
<Icon className={styles.actionMenuIcon} name={icons.ORGANIZE} />
|
||||
|
||||
{translate('PreviewRename')}
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
isDisabled={!episodeFileCount}
|
||||
onPress={handleManageEpisodesPress}
|
||||
>
|
||||
<Icon
|
||||
className={styles.actionMenuIcon}
|
||||
name={icons.EPISODE_FILE}
|
||||
/>
|
||||
|
||||
{translate('ManageEpisodes')}
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
isDisabled={!totalEpisodeCount}
|
||||
onPress={handleHistoryPress}
|
||||
>
|
||||
<Icon className={styles.actionMenuIcon} name={icons.HISTORY} />
|
||||
|
||||
{translate('History')}
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
) : (
|
||||
<div className={styles.actions}>
|
||||
<SpinnerIconButton
|
||||
className={styles.actionButton}
|
||||
name={icons.SEARCH}
|
||||
title={
|
||||
hasMonitoredEpisodes && seriesMonitored
|
||||
? translate('SearchForMonitoredEpisodesSeason')
|
||||
: translate('NoMonitoredEpisodesSeason')
|
||||
}
|
||||
size={24}
|
||||
isSpinning={isSearching}
|
||||
isDisabled={
|
||||
isSearching || !hasMonitoredEpisodes || !seriesMonitored
|
||||
}
|
||||
onPress={handleSearchPress}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
className={styles.actionButton}
|
||||
name={icons.INTERACTIVE}
|
||||
title={translate('InteractiveSearchSeason')}
|
||||
size={24}
|
||||
isDisabled={!totalEpisodeCount}
|
||||
onPress={handleInteractiveSearchPress}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
className={styles.actionButton}
|
||||
name={icons.ORGANIZE}
|
||||
title={translate('PreviewRenameSeason')}
|
||||
size={24}
|
||||
isDisabled={!episodeFileCount}
|
||||
onPress={handleOrganizePress}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
className={styles.actionButton}
|
||||
name={icons.EPISODE_FILE}
|
||||
title={translate('ManageEpisodesSeason')}
|
||||
size={24}
|
||||
isDisabled={!episodeFileCount}
|
||||
onPress={handleManageEpisodesPress}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
className={styles.actionButton}
|
||||
name={icons.HISTORY}
|
||||
title={translate('HistorySeason')}
|
||||
size={24}
|
||||
isDisabled={!totalEpisodeCount}
|
||||
onPress={handleHistoryPress}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isExpanded ? (
|
||||
<div className={styles.episodes}>
|
||||
{items.length ? (
|
||||
<Table
|
||||
columns={columns}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
onSortPress={handleSortPress}
|
||||
onTableOptionChange={handleTableOptionChange}
|
||||
>
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<EpisodeRow
|
||||
key={item.id}
|
||||
columns={columns}
|
||||
{...item}
|
||||
onMonitorEpisodePress={handleMonitorEpisodePress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className={styles.noEpisodes}>
|
||||
{translate('NoEpisodesInThisSeason')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.collapseButtonContainer}>
|
||||
<IconButton
|
||||
iconClassName={styles.collapseButtonIcon}
|
||||
name={icons.COLLAPSE}
|
||||
size={20}
|
||||
title={translate('HideEpisodes')}
|
||||
onPress={handleExpandPress}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<OrganizePreviewModal
|
||||
isOpen={isOrganizeModalOpen}
|
||||
seriesId={seriesId}
|
||||
seasonNumber={seasonNumber}
|
||||
onModalClose={handleOrganizeModalClose}
|
||||
/>
|
||||
|
||||
<InteractiveImportModal
|
||||
isOpen={isManageEpisodesOpen}
|
||||
seriesId={seriesId}
|
||||
seasonNumber={seasonNumber}
|
||||
title={seasonNumberTitle}
|
||||
folder={path}
|
||||
initialSortKey="relativePath"
|
||||
initialSortDirection={sortDirections.DESCENDING}
|
||||
showSeries={false}
|
||||
allowSeriesChange={false}
|
||||
showDelete={true}
|
||||
showImportMode={false}
|
||||
modalTitle={translate('ManageEpisodes')}
|
||||
onModalClose={handleManageEpisodesModalClose}
|
||||
/>
|
||||
|
||||
<SeriesHistoryModal
|
||||
isOpen={isHistoryModalOpen}
|
||||
seriesId={seriesId}
|
||||
seasonNumber={seasonNumber}
|
||||
onModalClose={handleHistoryModalClose}
|
||||
/>
|
||||
|
||||
<SeasonInteractiveSearchModal
|
||||
isOpen={isInteractiveSearchModalOpen}
|
||||
seriesId={seriesId}
|
||||
seasonNumber={seasonNumber}
|
||||
onModalClose={handleInteractiveSearchModalClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeriesDetailsSeason;
|
@ -1,130 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { setEpisodesSort, setEpisodesTableOption, toggleEpisodesMonitored } from 'Store/Actions/episodeActions';
|
||||
import { toggleSeasonMonitored } from 'Store/Actions/seriesActions';
|
||||
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
|
||||
import { findCommand, isCommandExecuting } from 'Utilities/Command';
|
||||
import SeriesDetailsSeason from './SeriesDetailsSeason';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state, { seasonNumber }) => seasonNumber,
|
||||
createClientSideCollectionSelector('episodes'),
|
||||
createSeriesSelector(),
|
||||
createCommandsSelector(),
|
||||
createDimensionsSelector(),
|
||||
(seasonNumber, episodes, series, commands, dimensions) => {
|
||||
const isSearching = isCommandExecuting(findCommand(commands, {
|
||||
name: commandNames.SEASON_SEARCH,
|
||||
seriesId: series.id,
|
||||
seasonNumber
|
||||
}));
|
||||
|
||||
const episodesInSeason = episodes.items.filter((episode) => episode.seasonNumber === seasonNumber);
|
||||
|
||||
return {
|
||||
items: episodesInSeason,
|
||||
columns: episodes.columns,
|
||||
sortKey: episodes.sortKey,
|
||||
sortDirection: episodes.sortDirection,
|
||||
isSearching,
|
||||
seriesMonitored: series.monitored,
|
||||
path: series.path,
|
||||
isSmallScreen: dimensions.isSmallScreen
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
toggleSeasonMonitored,
|
||||
toggleEpisodesMonitored,
|
||||
setEpisodesTableOption,
|
||||
setEpisodesSort,
|
||||
executeCommand
|
||||
};
|
||||
|
||||
class SeriesDetailsSeasonConnector extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onTableOptionChange = (payload) => {
|
||||
this.props.setEpisodesTableOption(payload);
|
||||
};
|
||||
|
||||
onMonitorSeasonPress = (monitored) => {
|
||||
const {
|
||||
seriesId,
|
||||
seasonNumber
|
||||
} = this.props;
|
||||
|
||||
this.props.toggleSeasonMonitored({
|
||||
seriesId,
|
||||
seasonNumber,
|
||||
monitored
|
||||
});
|
||||
};
|
||||
|
||||
onSearchPress = () => {
|
||||
const {
|
||||
seriesId,
|
||||
seasonNumber
|
||||
} = this.props;
|
||||
|
||||
this.props.executeCommand({
|
||||
name: commandNames.SEASON_SEARCH,
|
||||
seriesId,
|
||||
seasonNumber
|
||||
});
|
||||
};
|
||||
|
||||
onMonitorEpisodePress = (episodeIds, monitored) => {
|
||||
this.props.toggleEpisodesMonitored({
|
||||
episodeIds,
|
||||
monitored
|
||||
});
|
||||
};
|
||||
|
||||
onSortPress = (sortKey, sortDirection) => {
|
||||
this.props.setEpisodesSort({
|
||||
sortKey,
|
||||
sortDirection
|
||||
});
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SeriesDetailsSeason
|
||||
{...this.props}
|
||||
onTableOptionChange={this.onTableOptionChange}
|
||||
onSortPress={this.onSortPress}
|
||||
onMonitorSeasonPress={this.onMonitorSeasonPress}
|
||||
onSearchPress={this.onSearchPress}
|
||||
onMonitorEpisodePress={this.onMonitorEpisodePress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SeriesDetailsSeasonConnector.propTypes = {
|
||||
seriesId: PropTypes.number.isRequired,
|
||||
seasonNumber: PropTypes.number.isRequired,
|
||||
toggleSeasonMonitored: PropTypes.func.isRequired,
|
||||
toggleEpisodesMonitored: PropTypes.func.isRequired,
|
||||
setEpisodesTableOption: PropTypes.func.isRequired,
|
||||
setEpisodesSort: PropTypes.func.isRequired,
|
||||
executeCommand: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(SeriesDetailsSeasonConnector);
|
@ -1,30 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
|
||||
function SeriesTags({ tags }) {
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
tags.map((tag) => {
|
||||
return (
|
||||
<Label
|
||||
key={tag}
|
||||
kind={kinds.INFO}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
{tag}
|
||||
</Label>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SeriesTags.propTypes = {
|
||||
tags: PropTypes.arrayOf(PropTypes.string).isRequired
|
||||
};
|
||||
|
||||
export default SeriesTags;
|
@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import useTags from 'Tags/useTags';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
|
||||
interface SeriesTagsProps {
|
||||
seriesId: number;
|
||||
}
|
||||
|
||||
function SeriesTags({ seriesId }: SeriesTagsProps) {
|
||||
const series = useSeries(seriesId)!;
|
||||
const tagList = useTags();
|
||||
|
||||
const tags = series.tags
|
||||
.map((tagId) => tagList.find((tag) => tag.id === tagId))
|
||||
.filter((tag) => !!tag)
|
||||
.sort(sortByProp('label'))
|
||||
.map((tag) => tag.label);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{tags.map((tag) => {
|
||||
return (
|
||||
<Label key={tag} kind={kinds.INFO} size={sizes.LARGE}>
|
||||
{tag}
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeriesTags;
|
@ -1,26 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import SeriesTags from './SeriesTags';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createSeriesSelector(),
|
||||
createTagsSelector(),
|
||||
(series, tagList) => {
|
||||
const tags = series.tags
|
||||
.map((tagId) => tagList.find((tag) => tag.id === tagId))
|
||||
.filter((tag) => !!tag)
|
||||
.sort(sortByProp('label'))
|
||||
.map((tag) => tag.label);
|
||||
|
||||
return {
|
||||
tags
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(SeriesTags);
|
Loading…
Reference in new issue