1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-04-18 21:35:27 -04:00
This commit is contained in:
Mark McDowall
2018-01-12 18:01:27 -08:00
committed by Taloth Saldono
parent 99feff549d
commit 5894b4fd95
1183 changed files with 91622 additions and 4978 deletions
@@ -0,0 +1,39 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { sizes } from 'Helpers/Props';
import Modal from 'Components/Modal/Modal';
import EpisodeDetailsModalContentConnector from './EpisodeDetailsModalContentConnector';
class EpisodeDetailsModal extends Component {
//
// Render
render() {
const {
isOpen,
onModalClose,
...otherProps
} = this.props;
return (
<Modal
isOpen={isOpen}
size={sizes.EXTRA_LARGE}
onModalClose={onModalClose}
>
<EpisodeDetailsModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
}
EpisodeDetailsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EpisodeDetailsModal;
@@ -0,0 +1,44 @@
.seriesTitle {
margin-left: 5px;
}
.separator {
margin: 0 5px;
}
.tabs {
margin-top: -32px;
}
.tabList {
margin: 0;
padding: 0;
}
.tab {
position: relative;
bottom: -1px;
display: inline-block;
padding: 6px 12px;
border: 1px solid transparent;
border-top: none;
list-style: none;
cursor: pointer;
}
.selectedTab {
border-color: $borderColor;
border-radius: 0 0 5px 5px;
background-color: rgba(239, 239, 239, 0.4);
color: $black;
}
.tabContent {
margin-top: 20px;
}
.openSeriesButton {
composes: button from 'Components/Link/Button.css';
margin-right: auto;
}
@@ -0,0 +1,218 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
import episodeEntities from 'Episode/episodeEntities';
import Button from 'Components/Link/Button';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import EpisodeSummaryConnector from './Summary/EpisodeSummaryConnector';
import EpisodeHistoryConnector from './History/EpisodeHistoryConnector';
import EpisodeSearchConnector from './Search/EpisodeSearchConnector';
import SeasonEpisodeNumber from './SeasonEpisodeNumber';
import styles from './EpisodeDetailsModalContent.css';
const tabs = [
'details',
'history',
'search'
];
class EpisodeDetailsModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
selectedTab: props.selectedTab
};
}
//
// Listeners
onTabSelect = (index, lastIndex) => {
this.setState({ selectedTab: tabs[index] });
}
//
// Render
render() {
const {
episodeId,
episodeEntity,
episodeFileId,
seriesId,
seriesTitle,
titleSlug,
seriesMonitored,
seriesType,
seasonNumber,
episodeNumber,
absoluteEpisodeNumber,
episodeTitle,
airDate,
monitored,
isSaving,
showOpenSeriesButton,
startInteractiveSearch,
onMonitorEpisodePress,
onModalClose
} = this.props;
const seriesLink = `/series/${titleSlug}`;
return (
<ModalContent
onModalClose={onModalClose}
>
<ModalHeader>
<MonitorToggleButton
className={styles.toggleButton}
id={episodeId}
monitored={monitored}
size={18}
isDisabled={!seriesMonitored}
isSaving={isSaving}
onPress={onMonitorEpisodePress}
/>
<span className={styles.seriesTitle}>
{seriesTitle}
</span>
<span className={styles.separator}>-</span>
<SeasonEpisodeNumber
seasonNumber={seasonNumber}
episodeNumber={episodeNumber}
absoluteEpisodeNumber={absoluteEpisodeNumber}
airDate={airDate}
seriesType={seriesType}
/>
<span className={styles.separator}>-</span>
{episodeTitle}
</ModalHeader>
<ModalBody>
<Tabs
className={styles.tabs}
selectedIndex={tabs.indexOf(this.state.selectedTab)}
onSelect={this.onTabSelect}
>
<TabList
className={styles.tabList}
>
<Tab
className={styles.tab}
selectedClassName={styles.selectedTab}
>
Details
</Tab>
<Tab
className={styles.tab}
selectedClassName={styles.selectedTab}
>
History
</Tab>
<Tab
className={styles.tab}
selectedClassName={styles.selectedTab}
>
Search
</Tab>
</TabList>
<TabPanel>
<div className={styles.tabContent}>
<EpisodeSummaryConnector
episodeId={episodeId}
episodeEntity={episodeEntity}
episodeFileId={episodeFileId}
seriesId={seriesId}
/>
</div>
</TabPanel>
<TabPanel>
<div className={styles.tabContent}>
<EpisodeHistoryConnector
episodeId={episodeId}
/>
</div>
</TabPanel>
<TabPanel>
{/* Don't wrap in tabContent so we not have a top margin */}
<EpisodeSearchConnector
episodeId={episodeId}
startInteractiveSearch={startInteractiveSearch}
onModalClose={onModalClose}
/>
</TabPanel>
</Tabs>
</ModalBody>
<ModalFooter>
{
showOpenSeriesButton &&
<Button
className={styles.openSeriesButton}
to={seriesLink}
onPress={onModalClose}
>
Open Series
</Button>
}
<Button
onPress={onModalClose}
>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
}
EpisodeDetailsModalContent.propTypes = {
episodeId: PropTypes.number.isRequired,
episodeEntity: PropTypes.string.isRequired,
episodeFileId: PropTypes.number,
seriesId: PropTypes.number.isRequired,
seriesTitle: PropTypes.string.isRequired,
titleSlug: PropTypes.string.isRequired,
seriesMonitored: PropTypes.bool.isRequired,
seriesType: PropTypes.string.isRequired,
seasonNumber: PropTypes.number.isRequired,
episodeNumber: PropTypes.number.isRequired,
absoluteEpisodeNumber: PropTypes.number,
airDate: PropTypes.string.isRequired,
episodeTitle: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
isSaving: PropTypes.bool,
showOpenSeriesButton: PropTypes.bool,
selectedTab: PropTypes.string.isRequired,
startInteractiveSearch: PropTypes.bool.isRequired,
onMonitorEpisodePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
EpisodeDetailsModalContent.defaultProps = {
selectedTab: 'details',
episodeEntity: episodeEntities.EPISODES,
startInteractiveSearch: false
};
export default EpisodeDetailsModalContent;
@@ -0,0 +1,100 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions';
import { toggleEpisodeMonitored } from 'Store/Actions/episodeActions';
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import episodeEntities from 'Episode/episodeEntities';
import EpisodeDetailsModalContent from './EpisodeDetailsModalContent';
function createMapStateToProps() {
return createSelector(
createEpisodeSelector(),
createSeriesSelector(),
(episode, series) => {
const {
title: seriesTitle,
titleSlug,
monitored: seriesMonitored,
seriesType
} = series;
return {
seriesTitle,
titleSlug,
seriesMonitored,
seriesType,
...episode
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
dispatchCancelFetchReleases() {
dispatch(cancelFetchReleases());
},
dispatchClearReleases() {
dispatch(clearReleases());
},
onMonitorEpisodePress(monitored) {
const {
episodeId,
episodeEntity
} = props;
dispatch(toggleEpisodeMonitored({
episodeEntity,
episodeId,
monitored
}));
}
};
}
class EpisodeDetailsModalContentConnector extends Component {
//
// Lifecycle
componentWillUnmount() {
// Clear pending releases here so we can reshow the search
// results even after switching tabs.
this.props.dispatchCancelFetchReleases();
this.props.dispatchClearReleases();
}
//
// Render
render() {
const {
dispatchClearReleases,
...otherProps
} = this.props;
return (
<EpisodeDetailsModalContent {...otherProps} />
);
}
}
EpisodeDetailsModalContentConnector.propTypes = {
episodeId: PropTypes.number.isRequired,
episodeEntity: PropTypes.string.isRequired,
seriesId: PropTypes.number.isRequired,
dispatchCancelFetchReleases: PropTypes.func.isRequired,
dispatchClearReleases: PropTypes.func.isRequired
};
EpisodeDetailsModalContentConnector.defaultProps = {
episodeEntity: episodeEntities.EPISODES
};
export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeDetailsModalContentConnector);
+37
View File
@@ -0,0 +1,37 @@
import PropTypes from 'prop-types';
import React from 'react';
import Label from 'Components/Label';
import { kinds } from 'Helpers/Props';
function EpisodeLanguage(props) {
const {
className,
language,
isCutoffNotMet
} = props;
if (!language) {
return null;
}
return (
<Label
className={className}
kind={isCutoffNotMet ? kinds.INVERSE : kinds.DEFAULT}
>
{language.name}
</Label>
);
}
EpisodeLanguage.propTypes = {
className: PropTypes.string,
language: PropTypes.object,
isCutoffNotMet: PropTypes.bool
};
EpisodeLanguage.defaultProps = {
isCutoffNotMet: true
};
export default EpisodeLanguage;
+7
View File
@@ -0,0 +1,7 @@
.absoluteEpisodeNumber {
margin-left: 5px;
}
.warning {
margin-left: 8px;
}
+138
View File
@@ -0,0 +1,138 @@
import PropTypes from 'prop-types';
import React, { Fragment } from 'react';
import padNumber from 'Utilities/Number/padNumber';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover';
import SceneInfo from './SceneInfo';
import styles from './EpisodeNumber.css';
function getAlternateTitles(seasonNumber, sceneSeasonNumber, alternateTitles) {
return alternateTitles.filter((alternateTitle) => {
if (sceneSeasonNumber && sceneSeasonNumber === alternateTitle.sceneSeasonNumber) {
return true;
}
return seasonNumber === alternateTitle.seasonNumber;
});
}
function EpisodeNumber(props) {
const {
seasonNumber,
episodeNumber,
absoluteEpisodeNumber,
sceneSeasonNumber,
sceneEpisodeNumber,
sceneAbsoluteEpisodeNumber,
unverifiedSceneNumbering,
alternateTitles: seriesAlternateTitles,
seriesType,
showSeasonNumber
} = props;
const alternateTitles = getAlternateTitles(seasonNumber, sceneSeasonNumber, seriesAlternateTitles);
const hasSceneInformation = sceneSeasonNumber !== undefined ||
sceneEpisodeNumber !== undefined ||
(seriesType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined) ||
!!alternateTitles.length;
return (
<span>
{
hasSceneInformation ?
<Popover
anchor={
<span>
{
showSeasonNumber && seasonNumber != null &&
<Fragment>
{seasonNumber}x
</Fragment>
}
{showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber}
{
seriesType === 'anime' && !!absoluteEpisodeNumber &&
<span className={styles.absoluteEpisodeNumber}>
({absoluteEpisodeNumber})
</span>
}
</span>
}
title="Scene Information"
body={
<SceneInfo
sceneSeasonNumber={sceneSeasonNumber}
sceneEpisodeNumber={sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={sceneAbsoluteEpisodeNumber}
alternateTitles={alternateTitles}
seriesType={seriesType}
/>
}
position={tooltipPositions.RIGHT}
/> :
<span>
{
showSeasonNumber && seasonNumber != null &&
<Fragment>
{seasonNumber}x
</Fragment>
}
{showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber}
{
seriesType === 'anime' && !!absoluteEpisodeNumber &&
<span className={styles.absoluteEpisodeNumber}>
({absoluteEpisodeNumber})
</span>
}
</span>
}
{
unverifiedSceneNumbering &&
<Icon
className={styles.warning}
name={icons.WARNING}
kind={kinds.WARNING}
title="Scene number hasn't been verified yet"
/>
}
{
seriesType === 'anime' && !absoluteEpisodeNumber &&
<Icon
className={styles.warning}
name={icons.WARNING}
kind={kinds.WARNING}
title="Episode does not have an absolute episode number"
/>
}
</span>
);
}
EpisodeNumber.propTypes = {
seasonNumber: PropTypes.number.isRequired,
episodeNumber: PropTypes.number.isRequired,
absoluteEpisodeNumber: PropTypes.number,
sceneSeasonNumber: PropTypes.number,
sceneEpisodeNumber: PropTypes.number,
sceneAbsoluteEpisodeNumber: PropTypes.number,
unverifiedSceneNumbering: PropTypes.bool.isRequired,
alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
seriesType: PropTypes.string,
showSeasonNumber: PropTypes.bool.isRequired
};
EpisodeNumber.defaultProps = {
unverifiedSceneNumbering: false,
alternateTitles: [],
showSeasonNumber: false
};
export default EpisodeNumber;
+57
View File
@@ -0,0 +1,57 @@
import PropTypes from 'prop-types';
import React from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
import { kinds } from 'Helpers/Props';
import Label from 'Components/Label';
function getTooltip(title, quality, size) {
const revision = quality.revision;
if (revision.real && revision.real > 0) {
title += ' [REAL]';
}
if (revision.version && revision.version > 1) {
title += ' [PROPER]';
}
if (size) {
title += ` - ${formatBytes(size)}`;
}
return title;
}
function EpisodeQuality(props) {
const {
className,
title,
quality,
size,
isCutoffNotMet
} = props;
return (
<Label
className={className}
kind={isCutoffNotMet ? kinds.INVERSE : kinds.DEFAULT}
title={getTooltip(title, quality, size)}
>
{quality.quality.name}
</Label>
);
}
EpisodeQuality.propTypes = {
className: PropTypes.string,
title: PropTypes.string,
quality: PropTypes.object.isRequired,
size: PropTypes.number,
isCutoffNotMet: PropTypes.bool
};
EpisodeQuality.defaultProps = {
title: ''
};
export default EpisodeQuality;
@@ -0,0 +1,6 @@
.episodeSearchCell {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 70px;
white-space: nowrap;
}
+83
View File
@@ -0,0 +1,83 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import EpisodeDetailsModal from './EpisodeDetailsModal';
import styles from './EpisodeSearchCell.css';
class EpisodeSearchCell extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isDetailsModalOpen: false
};
}
//
// Listeners
onManualSearchPress = () => {
this.setState({ isDetailsModalOpen: true });
}
onDetailsModalClose = () => {
this.setState({ isDetailsModalOpen: false });
}
//
// Render
render() {
const {
episodeId,
seriesId,
episodeTitle,
isSearching,
onSearchPress,
...otherProps
} = this.props;
return (
<TableRowCell className={styles.episodeSearchCell}>
<SpinnerIconButton
name={icons.SEARCH}
isSpinning={isSearching}
onPress={onSearchPress}
/>
<IconButton
name={icons.INTERACTIVE}
onPress={this.onManualSearchPress}
/>
<EpisodeDetailsModal
isOpen={this.state.isDetailsModalOpen}
episodeId={episodeId}
seriesId={seriesId}
episodeTitle={episodeTitle}
selectedTab="search"
startInteractiveSearch={true}
onModalClose={this.onDetailsModalClose}
{...otherProps}
/>
</TableRowCell>
);
}
}
EpisodeSearchCell.propTypes = {
episodeId: PropTypes.number.isRequired,
seriesId: PropTypes.number.isRequired,
episodeTitle: PropTypes.string.isRequired,
isSearching: PropTypes.bool.isRequired,
onSearchPress: PropTypes.func.isRequired
};
export default EpisodeSearchCell;
@@ -0,0 +1,50 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { isCommandExecuting } from 'Utilities/Command';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import { executeCommand } from 'Store/Actions/commandActions';
import * as commandNames from 'Commands/commandNames';
import EpisodeSearchCell from './EpisodeSearchCell';
function createMapStateToProps() {
return createSelector(
(state, { episodeId }) => episodeId,
(state, { sceneSeasonNumber }) => sceneSeasonNumber,
createSeriesSelector(),
createCommandsSelector(),
(episodeId, sceneSeasonNumber, series, commands) => {
const isSearching = commands.some((command) => {
const episodeSearch = command.name === commandNames.EPISODE_SEARCH;
if (!episodeSearch) {
return false;
}
return (
isCommandExecuting(command) &&
command.body.episodeIds.indexOf(episodeId) > -1
);
});
return {
seriesMonitored: series.monitored,
seriesType: series.seriesType,
isSearching
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onSearchPress(name, path) {
dispatch(executeCommand({
name: commandNames.EPISODE_SEARCH,
episodeIds: [props.episodeId]
}));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeSearchCell);
+4
View File
@@ -0,0 +1,4 @@
.center {
display: flex;
justify-content: center;
}
+128
View File
@@ -0,0 +1,128 @@
import PropTypes from 'prop-types';
import React from 'react';
import isBefore from 'Utilities/Date/isBefore';
import { icons, kinds, sizes } from 'Helpers/Props';
import Icon from 'Components/Icon';
import ProgressBar from 'Components/ProgressBar';
import QueueDetails from 'Activity/Queue/QueueDetails';
import EpisodeQuality from './EpisodeQuality';
import styles from './EpisodeStatus.css';
function EpisodeStatus(props) {
const {
airDateUtc,
monitored,
grabbed,
queueItem,
episodeFile
} = props;
const hasEpisodeFile = !!episodeFile;
const isQueued = !!queueItem;
const hasAired = isBefore(airDateUtc);
if (isQueued) {
const {
sizeleft,
size
} = queueItem;
const progress = (100 - sizeleft / size * 100);
return (
<div className={styles.center}>
<QueueDetails
{...queueItem}
progressBar={
<ProgressBar
title={`Episode is downloading - ${progress.toFixed(1)}% ${queueItem.title}`}
progress={progress}
kind={kinds.PURPLE}
size={sizes.MEDIUM}
/>
}
/>
</div>
);
}
if (grabbed) {
return (
<div className={styles.center}>
<Icon
name={icons.DOWNLOADING}
title="Episode is downloading"
/>
</div>
);
}
if (hasEpisodeFile) {
const quality = episodeFile.quality;
const isCutoffNotMet = episodeFile.qualityCutoffNotMet;
return (
<div className={styles.center}>
<EpisodeQuality
quality={quality}
size={episodeFile.size}
isCutoffNotMet={isCutoffNotMet}
title="Episode Downloaded"
/>
</div>
);
}
if (!airDateUtc) {
return (
<div className={styles.center}>
<Icon
name={icons.TBA}
title="TBA"
/>
</div>
);
}
if (!monitored) {
return (
<div className={styles.center}>
<Icon
name={icons.UNMONITORED}
kind={kinds.DISABLED}
title="Episode is not monitored"
/>
</div>
);
}
if (hasAired) {
return (
<div className={styles.center}>
<Icon
name={icons.MISSING}
title="Episode missing from disk"
/>
</div>
);
}
return (
<div className={styles.center}>
<Icon
name={icons.NOT_AIRED}
title="Episode has not aired"
/>
</div>
);
}
EpisodeStatus.propTypes = {
airDateUtc: PropTypes.string,
monitored: PropTypes.bool.isRequired,
grabbed: PropTypes.bool,
queueItem: PropTypes.object,
episodeFile: PropTypes.object
};
export default EpisodeStatus;
@@ -0,0 +1,53 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
import EpisodeStatus from './EpisodeStatus';
function createMapStateToProps() {
return createSelector(
createEpisodeSelector(),
createQueueItemSelector(),
createEpisodeFileSelector(),
(episode, queueItem, episodeFile) => {
const result = _.pick(episode, [
'airDateUtc',
'monitored',
'grabbed'
]);
result.queueItem = queueItem;
result.episodeFile = episodeFile;
return result;
}
);
}
const mapDispatchToProps = {
};
class EpisodeStatusConnector extends Component {
//
// Render
render() {
return (
<EpisodeStatus
{...this.props}
/>
);
}
}
EpisodeStatusConnector.propTypes = {
episodeId: PropTypes.number.isRequired,
episodeFileId: PropTypes.number.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeStatusConnector);
@@ -0,0 +1,8 @@
.link {
composes: link from 'Components/Link/Link.css';
&:hover {
color: $linkHoverColor;
text-decoration: underline;
}
}
+68
View File
@@ -0,0 +1,68 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Link from 'Components/Link/Link';
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
import styles from './EpisodeTitleLink.css';
class EpisodeTitleLink extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isDetailsModalOpen: false
};
}
//
// Listeners
onLinkPress = () => {
this.setState({ isDetailsModalOpen: true });
}
onModalClose = () => {
this.setState({ isDetailsModalOpen: false });
}
//
// Render
render() {
const {
episodeTitle,
...otherProps
} = this.props;
return (
<div>
<Link
className={styles.link}
onPress={this.onLinkPress}
>
{episodeTitle}
</Link>
<EpisodeDetailsModal
isOpen={this.state.isDetailsModalOpen}
episodeTitle={episodeTitle}
{...otherProps}
onModalClose={this.onModalClose}
/>
</div>
);
}
}
EpisodeTitleLink.propTypes = {
episodeTitle: PropTypes.string.isRequired
};
EpisodeTitleLink.defaultProps = {
showSeriesButton: false
};
export default EpisodeTitleLink;
@@ -0,0 +1,117 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import EpisodeHistoryRow from './EpisodeHistoryRow';
const columns = [
{
name: 'eventType',
isVisible: true
},
{
name: 'sourceTitle',
label: 'Source Title',
isVisible: true
},
{
name: 'language',
label: 'Language',
isVisible: true
},
{
name: 'quality',
label: 'Quality',
isVisible: true
},
{
name: 'date',
label: 'Date',
isVisible: true
},
{
name: 'details',
label: 'Details',
isVisible: true
},
{
name: 'actions',
label: 'Actions',
isVisible: true
}
];
class EpisodeHistory extends Component {
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items,
onMarkAsFailedPress
} = this.props;
const hasItems = !!items.length;
if (isFetching) {
return (
<LoadingIndicator />
);
}
if (!isFetching && !!error) {
return (
<div>Unable to load episode history.</div>
);
}
if (isPopulated && !hasItems && !error) {
return (
<div>No episode history.</div>
);
}
if (isPopulated && hasItems && !error) {
return (
<Table
columns={columns}
>
<TableBody>
{
items.map((item) => {
return (
<EpisodeHistoryRow
key={item.id}
{...item}
onMarkAsFailedPress={onMarkAsFailedPress}
/>
);
})
}
</TableBody>
</Table>
);
}
return null;
}
}
EpisodeHistory.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onMarkAsFailedPress: PropTypes.func.isRequired
};
EpisodeHistory.defaultProps = {
selectedTab: 'details'
};
export default EpisodeHistory;
@@ -0,0 +1,63 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchEpisodeHistory, clearEpisodeHistory, episodeHistoryMarkAsFailed } from 'Store/Actions/episodeHistoryActions';
import EpisodeHistory from './EpisodeHistory';
function createMapStateToProps() {
return createSelector(
(state) => state.episodeHistory,
(episodeHistory) => {
return episodeHistory;
}
);
}
const mapDispatchToProps = {
fetchEpisodeHistory,
clearEpisodeHistory,
episodeHistoryMarkAsFailed
};
class EpisodeHistoryConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchEpisodeHistory({ episodeId: this.props.episodeId });
}
componentWillUnmount() {
this.props.clearEpisodeHistory();
}
//
// Listeners
onMarkAsFailedPress = (historyId) => {
this.props.episodeHistoryMarkAsFailed({ historyId, episodeId: this.props.episodeId });
}
//
// Render
render() {
return (
<EpisodeHistory
{...this.props}
onMarkAsFailedPress={this.onMarkAsFailedPress}
/>
);
}
}
EpisodeHistoryConnector.propTypes = {
episodeId: PropTypes.number.isRequired,
fetchEpisodeHistory: PropTypes.func.isRequired,
clearEpisodeHistory: PropTypes.func.isRequired,
episodeHistoryMarkAsFailed: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeHistoryConnector);
@@ -0,0 +1,6 @@
.details,
.actions {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 65px;
}
@@ -0,0 +1,163 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import Popover from 'Components/Tooltip/Popover';
import EpisodeLanguage from 'Episode/EpisodeLanguage';
import EpisodeQuality from 'Episode/EpisodeQuality';
import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector';
import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
import styles from './EpisodeHistoryRow.css';
function getTitle(eventType) {
switch (eventType) {
case 'grabbed': return 'Grabbed';
case 'seriesFolderImported': return 'Series Folder Imported';
case 'downloadFolderImported': return 'Download Folder Imported';
case 'downloadFailed': return 'Download Failed';
case 'episodeFileDeleted': return 'Episode File Deleted';
case 'episodeFileRenamed': return 'Episode File Renamed';
default: return 'Unknown';
}
}
class EpisodeHistoryRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isMarkAsFailedModalOpen: false
};
}
//
// Listeners
onMarkAsFailedPress = () => {
this.setState({ isMarkAsFailedModalOpen: true });
}
onConfirmMarkAsFailed = () => {
this.props.onMarkAsFailedPress(this.props.id);
this.setState({ isMarkAsFailedModalOpen: false });
}
onMarkAsFailedModalClose = () => {
this.setState({ isMarkAsFailedModalOpen: false });
}
//
// Render
render() {
const {
eventType,
sourceTitle,
language,
languageCutoffNotMet,
quality,
qualityCutoffNotMet,
date,
data
} = this.props;
const {
isMarkAsFailedModalOpen
} = this.state;
return (
<TableRow>
<HistoryEventTypeCell
eventType={eventType}
data={data}
/>
<TableRowCell>
{sourceTitle}
</TableRowCell>
<TableRowCell>
<EpisodeLanguage
language={language}
isCutoffNotMet={languageCutoffNotMet}
/>
</TableRowCell>
<TableRowCell>
<EpisodeQuality
quality={quality}
isCutoffNotMet={qualityCutoffNotMet}
/>
</TableRowCell>
<RelativeDateCellConnector
date={date}
/>
<TableRowCell className={styles.details}>
<Popover
anchor={
<Icon
name={icons.INFO}
/>
}
title={getTitle(eventType)}
body={
<HistoryDetailsConnector
eventType={eventType}
sourceTitle={sourceTitle}
data={data}
/>
}
position={tooltipPositions.LEFT}
/>
</TableRowCell>
<TableRowCell className={styles.actions}>
{
eventType === 'grabbed' &&
<IconButton
title="Mark as failed"
name={icons.REMOVE}
onPress={this.onMarkAsFailedPress}
/>
}
</TableRowCell>
<ConfirmModal
isOpen={isMarkAsFailedModalOpen}
kind={kinds.DANGER}
title="Mark as Failed"
message={`Are you sure you want to mark '${sourceTitle}' as failed?`}
confirmLabel="Mark as Failed"
onConfirm={this.onConfirmMarkAsFailed}
onCancel={this.onMarkAsFailedModalClose}
/>
</TableRow>
);
}
}
EpisodeHistoryRow.propTypes = {
id: PropTypes.number.isRequired,
eventType: PropTypes.string.isRequired,
sourceTitle: PropTypes.string.isRequired,
language: PropTypes.object.isRequired,
languageCutoffNotMet: PropTypes.bool.isRequired,
quality: PropTypes.object.isRequired,
qualityCutoffNotMet: PropTypes.bool.isRequired,
date: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
onMarkAsFailedPress: PropTypes.func.isRequired
};
export default EpisodeHistoryRow;
+17
View File
@@ -0,0 +1,17 @@
.descriptionList {
composes: descriptionList from 'Components/DescriptionList/DescriptionList.css';
margin-right: 10px;
}
.title {
composes: title from 'Components/DescriptionList/DescriptionListItemTitle.css';
width: 80px;
}
.description {
composes: title from 'Components/DescriptionList/DescriptionListItemDescription.css';
margin-left: 100px;
}
+83
View File
@@ -0,0 +1,83 @@
import PropTypes from 'prop-types';
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import styles from './SceneInfo.css';
function SceneInfo(props) {
const {
sceneSeasonNumber,
sceneEpisodeNumber,
sceneAbsoluteEpisodeNumber,
alternateTitles,
seriesType
} = props;
return (
<DescriptionList className={styles.descriptionList}>
{
sceneSeasonNumber !== undefined &&
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title="Season"
data={sceneSeasonNumber}
/>
}
{
sceneEpisodeNumber !== undefined &&
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title="Episode"
data={sceneEpisodeNumber}
/>
}
{
seriesType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined &&
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title="Absolute"
data={sceneAbsoluteEpisodeNumber}
/>
}
{
!!alternateTitles.length &&
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title={alternateTitles.length === 1 ? 'Title' : 'Titles'}
data={
<div>
{
alternateTitles.map((alternateTitle) => {
return (
<div
key={alternateTitle.title}
>
{alternateTitle.title}
</div>
);
})
}
</div>
}
/>
}
</DescriptionList>
);
}
SceneInfo.propTypes = {
sceneSeasonNumber: PropTypes.number,
sceneEpisodeNumber: PropTypes.number,
sceneAbsoluteEpisodeNumber: PropTypes.number,
alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
seriesType: PropTypes.string
};
export default SceneInfo;
@@ -0,0 +1,16 @@
.buttonContainer {
display: flex;
justify-content: center;
margin-top: 10px;
}
.button {
composes: button from 'Components/Link/Button.css';
width: 300px;
}
.buttonIcon {
margin-right: 5px;
}
@@ -0,0 +1,55 @@
import PropTypes from 'prop-types';
import React from 'react';
import { icons, kinds, sizes } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import Icon from 'Components/Icon';
import styles from './EpisodeSearch.css';
function EpisodeSearch(props) {
const {
onQuickSearchPress,
onInteractiveSearchPress
} = props;
return (
<div>
<div className={styles.buttonContainer}>
<Button
className={styles.button}
size={sizes.LARGE}
onPress={onQuickSearchPress}
>
<Icon
className={styles.buttonIcon}
name={icons.QUICK}
/>
Quick Search
</Button>
</div>
<div className={styles.buttonContainer}>
<Button
className={styles.button}
kind={kinds.PRIMARY}
size={sizes.LARGE}
onPress={onInteractiveSearchPress}
>
<Icon
className={styles.buttonIcon}
name={icons.INTERACTIVE}
/>
Interactive Search
</Button>
</div>
</div>
);
}
EpisodeSearch.propTypes = {
onQuickSearchPress: PropTypes.func.isRequired,
onInteractiveSearchPress: PropTypes.func.isRequired
};
export default EpisodeSearch;
@@ -0,0 +1,93 @@
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 InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector';
import { executeCommand } from 'Store/Actions/commandActions';
import EpisodeSearch from './EpisodeSearch';
function createMapStateToProps() {
return createSelector(
(state) => state.releases,
(releases) => {
return {
isPopulated: releases.isPopulated
};
}
);
}
const mapDispatchToProps = {
executeCommand
};
class EpisodeSearchConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isInteractiveSearchOpen: props.startInteractiveSearch
};
}
componentDidMount() {
if (this.props.isPopulated) {
this.setState({ isInteractiveSearchOpen: true });
}
}
//
// Listeners
onQuickSearchPress = () => {
this.props.executeCommand({
name: commandNames.EPISODE_SEARCH,
episodeIds: [this.props.episodeId]
});
this.props.onModalClose();
}
onInteractiveSearchPress = () => {
this.setState({ isInteractiveSearchOpen: true });
}
//
// Render
render() {
const { episodeId } = this.props;
if (this.state.isInteractiveSearchOpen) {
return (
<InteractiveSearchConnector
type="episode"
searchPayload={{ episodeId }}
/>
);
}
return (
<EpisodeSearch
{...this.props}
onQuickSearchPress={this.onQuickSearchPress}
onInteractiveSearchPress={this.onInteractiveSearchPress}
/>
);
}
}
EpisodeSearchConnector.propTypes = {
episodeId: PropTypes.number.isRequired,
isPopulated: PropTypes.bool.isRequired,
startInteractiveSearch: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeSearchConnector);
@@ -0,0 +1,32 @@
import PropTypes from 'prop-types';
import React from 'react';
import EpisodeNumber from './EpisodeNumber';
function SeasonEpisodeNumber(props) {
const {
airDate,
seriesType,
...otherProps
} = props;
if (seriesType === 'daily' && airDate) {
return (
<span>{airDate}</span>
);
}
return (
<EpisodeNumber
seriesType={seriesType}
showSeasonNumber={true}
{...otherProps}
/>
);
}
SeasonEpisodeNumber.propTypes = {
airDate: PropTypes.string,
seriesType: PropTypes.string
};
export default SeasonEpisodeNumber;
@@ -0,0 +1,86 @@
import moment from 'moment';
import PropTypes from 'prop-types';
import React from 'react';
import formatTime from 'Utilities/Date/formatTime';
import isInNextWeek from 'Utilities/Date/isInNextWeek';
import isToday from 'Utilities/Date/isToday';
import isTomorrow from 'Utilities/Date/isTomorrow';
import { kinds, sizes } from 'Helpers/Props';
import Label from 'Components/Label';
function EpisodeAiring(props) {
const {
airDateUtc,
network,
shortDateFormat,
showRelativeDates,
timeFormat
} = props;
const networkLabel = (
<Label
kind={kinds.INFO}
size={sizes.MEDIUM}
>
{network}
</Label>
);
if (!airDateUtc) {
return (
<span>
TBA on {networkLabel}
</span>
);
}
const time = formatTime(airDateUtc, timeFormat);
if (!showRelativeDates) {
return (
<span>
{moment(airDateUtc).format(shortDateFormat)} at {time} on {networkLabel}
</span>
);
}
if (isToday(airDateUtc)) {
return (
<span>
{time} on {networkLabel}
</span>
);
}
if (isTomorrow(airDateUtc)) {
return (
<span>
Tomorrow at {time} on {networkLabel}
</span>
);
}
if (isInNextWeek(airDateUtc)) {
return (
<span>
{moment(airDateUtc).format('dddd')} at {time} on {networkLabel}
</span>
);
}
return (
<span>
{moment(airDateUtc).format(shortDateFormat)} at {time} on {networkLabel}
</span>
);
}
EpisodeAiring.propTypes = {
airDateUtc: PropTypes.string.isRequired,
network: PropTypes.string.isRequired,
shortDateFormat: PropTypes.string.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
timeFormat: PropTypes.string.isRequired
};
export default EpisodeAiring;
@@ -0,0 +1,20 @@
import _ from 'lodash';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import EpisodeAiring from './EpisodeAiring';
function createMapStateToProps() {
return createSelector(
createUISettingsSelector(),
(uiSettings) => {
return _.pick(uiSettings, [
'shortDateFormat',
'showRelativeDates',
'timeFormat'
]);
}
);
}
export default connect(createMapStateToProps)(EpisodeAiring);
@@ -0,0 +1,48 @@
.infoTitle {
display: inline-block;
width: 100px;
font-weight: bold;
}
.overview,
.files {
margin-top: 20px;
}
.filesHeader {
display: flex;
font-weight: bold;
}
.filesHeader {
display: flex;
margin-bottom: 10px;
border-bottom: 1px solid $borderColor;
}
.fileRow {
display: flex;
}
.path {
@add-mixin truncate;
flex: 1 0 1px;
}
.size,
.quality {
flex: 0 0 125px;
}
.actions {
flex: 0 0 40px;
text-align: center;
}
@media only screen and (max-width: $breakpointMedium) {
.size,
.quality {
flex: 0 0 80px;
}
}
@@ -0,0 +1,183 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
import { icons, kinds, sizes, tooltipPositions } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import Popover from 'Components/Tooltip/Popover';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
import EpisodeQuality from 'Episode/EpisodeQuality';
import EpisodeAiringConnector from './EpisodeAiringConnector';
import MediaInfo from './MediaInfo';
import styles from './EpisodeSummary.css';
class EpisodeSummary extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isRemoveEpisodeFileModalOpen: false
};
}
//
// Listeners
onRemoveEpisodeFilePress = () => {
this.setState({ isRemoveEpisodeFileModalOpen: true });
}
onConfirmRemoveEpisodeFile = () => {
this.props.onDeleteEpisodeFile();
this.setState({ isRemoveEpisodeFileModalOpen: false });
}
onRemoveEpisodeFileModalClose = () => {
this.setState({ isRemoveEpisodeFileModalOpen: false });
}
//
// Render
render() {
const {
qualityProfileId,
network,
overview,
airDateUtc,
mediaInfo,
path,
size,
quality,
qualityCutoffNotMet
} = this.props;
const hasOverview = !!overview;
return (
<div>
<div>
<span className={styles.infoTitle}>Airs</span>
<EpisodeAiringConnector
airDateUtc={airDateUtc}
network={network}
/>
</div>
<div>
<span className={styles.infoTitle}>Quality Profile</span>
<Label
kind={kinds.PRIMARY}
size={sizes.MEDIUM}
>
<QualityProfileNameConnector
qualityProfileId={qualityProfileId}
/>
</Label>
</div>
<div className={styles.overview}>
{
hasOverview ?
overview :
'No episode overview.'
}
</div>
{
path &&
<div className={styles.files}>
<div className={styles.filesHeader}>
<div className={styles.path}>
Path
</div>
<div className={styles.size}>
Size
</div>
<div className={styles.quality}>
Quality
</div>
<div className={styles.actions} />
</div>
<div className={styles.fileRow}>
<div
className={styles.path}
title={path}
>
{path}
</div>
<div className={styles.size}>
{formatBytes(size)}
</div>
<div className={styles.quality}>
<EpisodeQuality
quality={quality}
isCutoffNotMet={qualityCutoffNotMet}
/>
</div>
<div className={styles.actions}>
<Popover
anchor={
<Icon
name={icons.MEDIA_INFO}
/>
}
title="Media Info"
body={<MediaInfo {...mediaInfo} />}
position={tooltipPositions.LEFT}
/>
<IconButton
title="Delete episode from disk"
name={icons.REMOVE}
onPress={this.onRemoveEpisodeFilePress}
/>
</div>
</div>
</div>
}
<ConfirmModal
isOpen={this.state.isRemoveEpisodeFileModalOpen}
kind={kinds.DANGER}
title="Delete Episode File"
message={`Are you sure you want to delete '${path}'?`}
confirmLabel="Delete"
onConfirm={this.onConfirmRemoveEpisodeFile}
onCancel={this.onRemoveEpisodeFileModalClose}
/>
</div>
);
}
}
EpisodeSummary.propTypes = {
episodeFileId: PropTypes.number.isRequired,
qualityProfileId: PropTypes.number.isRequired,
network: PropTypes.string.isRequired,
overview: PropTypes.string,
airDateUtc: PropTypes.string.isRequired,
mediaInfo: PropTypes.object,
path: PropTypes.string,
size: PropTypes.number,
quality: PropTypes.object,
qualityCutoffNotMet: PropTypes.bool,
onDeleteEpisodeFile: PropTypes.func.isRequired
};
export default EpisodeSummary;
@@ -0,0 +1,59 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { deleteEpisodeFile } from 'Store/Actions/episodeFileActions';
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import EpisodeSummary from './EpisodeSummary';
function createMapStateToProps() {
return createSelector(
createSeriesSelector(),
createEpisodeSelector(),
createEpisodeFileSelector(),
(series, episode, episodeFile = {}) => {
const {
qualityProfileId,
network
} = series;
const {
airDateUtc,
overview
} = episode;
const {
mediaInfo,
path,
size,
quality,
qualityCutoffNotMet
} = episodeFile;
return {
network,
qualityProfileId,
airDateUtc,
overview,
mediaInfo,
path,
size,
quality,
qualityCutoffNotMet
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onDeleteEpisodeFile() {
dispatch(deleteEpisodeFile({
id: props.episodeFileId,
episodeEntity: props.episodeEntity
}));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeSummary);
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
function MediaInfo(props) {
return (
<DescriptionList>
{
Object.keys(props).map((key) => {
const title = key
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase());
const value = props[key];
if (!value) {
return null;
}
return (
<DescriptionListItem
key={key}
title={title}
data={props[key]}
/>
);
})
}
</DescriptionList>
);
}
export default MediaInfo;
+13
View File
@@ -0,0 +1,13 @@
export const CALENDAR = 'calendar';
export const EPISODES = 'episodes';
export const INTERACTIVE_IMPORT = 'interactiveImport.episodes';
export const WANTED_CUTOFF_UNMET = 'wanted.cutoffUnmet';
export const WANTED_MISSING = 'wanted.missing';
export default {
CALENDAR,
EPISODES,
INTERACTIVE_IMPORT,
WANTED_CUTOFF_UNMET,
WANTED_MISSING
};