Convert Series Details to TypeScript

pull/7640/head
Mark McDowall 1 month ago
parent d3153685ac
commit 7644cec376
No known key found for this signature in database

@ -8,7 +8,7 @@ import ImportSeries from 'AddSeries/ImportSeries/ImportSeries';
import CalendarPage from 'Calendar/CalendarPage';
import NotFound from 'Components/NotFound';
import Switch from 'Components/Router/Switch';
import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector';
import SeriesDetailsPage from 'Series/Details/SeriesDetailsPage';
import SeriesIndex from 'Series/Index/SeriesIndex';
import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage';
import DownloadClientSettings from 'Settings/DownloadClients/DownloadClientSettings';
@ -66,7 +66,7 @@ function AppRoutes() {
<Route path="/seasonpass" exact={true} render={RedirectWithUrlBase} />
<Route path="/series/:titleSlug" component={SeriesDetailsPageConnector} />
<Route path="/series/:titleSlug" component={SeriesDetailsPage} />
{/*
Calendar

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

@ -16,7 +16,7 @@ import styles from './EpisodeStatus.css';
interface EpisodeStatusProps {
episodeId: number;
episodeEntity?: EpisodeEntity;
episodeFileId: number;
episodeFileId: number | undefined;
}
function EpisodeStatus({

@ -3,7 +3,7 @@ import EpisodeLanguages from 'Episode/EpisodeLanguages';
import useEpisodeFile from './useEpisodeFile';
interface EpisodeFileLanguagesProps {
episodeFileId: number;
episodeFileId: number | undefined;
}
function EpisodeFileLanguages({ episodeFileId }: EpisodeFileLanguagesProps) {

@ -219,7 +219,7 @@ const importModeSelector = createSelector(
}
);
interface InteractiveImportModalContentProps {
export interface InteractiveImportModalContentProps {
downloadId?: string;
seriesId?: number;
seasonNumber?: number;

@ -4,9 +4,12 @@ import usePrevious from 'Helpers/Hooks/usePrevious';
import { sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import InteractiveImportSelectFolderModalContent from './Folder/InteractiveImportSelectFolderModalContent';
import InteractiveImportModalContent from './Interactive/InteractiveImportModalContent';
import InteractiveImportModalContent, {
InteractiveImportModalContentProps,
} from './Interactive/InteractiveImportModalContent';
interface InteractiveImportModalProps {
interface InteractiveImportModalProps
extends Omit<InteractiveImportModalContentProps, 'modalTitle'> {
isOpen: boolean;
folder?: string;
downloadId?: string;

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

@ -28,6 +28,7 @@ function getEpisodeCountKind(
}
interface SeasonProgressLabelProps {
className: string;
seriesId: number;
seasonNumber: number;
monitored: boolean;
@ -36,6 +37,7 @@ interface SeasonProgressLabelProps {
}
function SeasonProgressLabel({
className,
seriesId,
seasonNumber,
monitored,
@ -53,6 +55,7 @@ function SeasonProgressLabel({
return (
<Label
className={className}
kind={getEpisodeCountKind(
monitored,
episodeFileCount,

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

@ -17,17 +17,37 @@
font-size: 24px;
}
.seasonNumber {
.seasonInfo {
margin-right: 10px;
margin-left: 5px;
}
.seasonNumber {
line-height: 24px;
}
.seasonStats {
display: flex;
align-items: flex-end;
flex-direction: column;
margin-left: auto;
gap: 2px;
}
.seasonStatsLabel {
composes: label from '~Components/Label.css';
margin: 0;
width: 100%;
}
.episodeCountTooltip {
display: flex;
align-items: stretch;
width: 100%;
}
.sizeOnDisk {
margin-left: 10px;
color: #777;
font-size: $defaultFontSize;
}

@ -16,7 +16,10 @@ interface CssExports {
'left': string;
'noEpisodes': string;
'season': string;
'seasonInfo': string;
'seasonNumber': string;
'seasonStats': string;
'seasonStatsLabel': string;
'sizeOnDisk': string;
}
export const cssExports: CssExports;

@ -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>&nbsp;</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>&nbsp;</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);

@ -36,6 +36,7 @@ export interface Statistics {
releaseGroups: string[];
sizeOnDisk: number;
totalEpisodeCount: number;
lastAired?: string;
}
export interface Season {

@ -1,6 +1,6 @@
import translate from 'Utilities/String/translate';
function formatRuntime(runtime: number) {
function formatRuntime(runtime: number | undefined) {
if (!runtime) {
return '';
}

Loading…
Cancel
Save