Initial Commit Rework

This commit is contained in:
Qstick 2017-09-03 22:20:56 -04:00
parent 74a4cc048c
commit 95051cbd63
2483 changed files with 101351 additions and 111396 deletions

4
frontend/src/.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,4 @@
// Place your settings in this file to overwrite default and user settings.
{
"files.insertFinalNewline": true
}

View file

@ -0,0 +1,110 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TablePager from 'Components/Table/TablePager';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import BlacklistRowConnector from './BlacklistRowConnector';
class Blacklist extends Component {
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items,
columns,
totalRecords,
isClearingBlacklistExecuting,
onClearBlacklistPress,
...otherProps
} = this.props;
return (
<PageContent title="Blacklist">
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label="Clear"
iconName={icons.CLEAR}
isSpinning={isClearingBlacklistExecuting}
onPress={onClearBlacklistPress}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBodyConnector>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>Unable to load blacklist</div>
}
{
isPopulated && !error && !items.length &&
<div>
No history blacklist
</div>
}
{
isPopulated && !error && !!items.length &&
<div>
<Table
columns={columns}
{...otherProps}
>
<TableBody>
{
items.map((item) => {
return (
<BlacklistRowConnector
key={item.id}
columns={columns}
{...item}
/>
);
})
}
</TableBody>
</Table>
<TablePager
totalRecords={totalRecords}
isFetching={isFetching}
{...otherProps}
/>
</div>
}
</PageContentBodyConnector>
</PageContent>
);
}
}
Blacklist.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
totalRecords: PropTypes.number,
isClearingBlacklistExecuting: PropTypes.bool.isRequired,
onClearBlacklistPress: PropTypes.func.isRequired
};
export default Blacklist;

View file

@ -0,0 +1,120 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import * as blacklistActions from 'Store/Actions/blacklistActions';
import { executeCommand } from 'Store/Actions/commandActions';
import * as commandNames from 'Commands/commandNames';
import Blacklist from './Blacklist';
function createMapStateToProps() {
return createSelector(
(state) => state.blacklist,
createCommandsSelector(),
(blacklist, commands) => {
const isClearingBlacklistExecuting = _.some(commands, { name: commandNames.CLEAR_BLACKLIST });
return {
isClearingBlacklistExecuting,
...blacklist
};
}
);
}
const mapDispatchToProps = {
...blacklistActions,
executeCommand
};
class BlacklistConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.gotoBlacklistFirstPage();
}
componentDidUpdate(prevProps) {
if (prevProps.isClearingBlacklistExecuting && !this.props.isClearingBlacklistExecuting) {
this.props.gotoBlacklistFirstPage();
}
}
//
// Listeners
onFirstPagePress = () => {
this.props.gotoBlacklistFirstPage();
}
onPreviousPagePress = () => {
this.props.gotoBlacklistPreviousPage();
}
onNextPagePress = () => {
this.props.gotoBlacklistNextPage();
}
onLastPagePress = () => {
this.props.gotoBlacklistLastPage();
}
onPageSelect = (page) => {
this.props.gotoBlacklistPage({ page });
}
onSortPress = (sortKey) => {
this.props.setBlacklistSort({ sortKey });
}
onTableOptionChange = (payload) => {
this.props.setBlacklistTableOption(payload);
if (payload.pageSize) {
this.props.gotoBlacklistFirstPage();
}
}
onClearBlacklistPress = () => {
this.props.executeCommand({ name: commandNames.CLEAR_BLACKLIST });
}
//
// Render
render() {
return (
<Blacklist
onFirstPagePress={this.onFirstPagePress}
onPreviousPagePress={this.onPreviousPagePress}
onNextPagePress={this.onNextPagePress}
onLastPagePress={this.onLastPagePress}
onPageSelect={this.onPageSelect}
onSortPress={this.onSortPress}
onTableOptionChange={this.onTableOptionChange}
onClearBlacklistPress={this.onClearBlacklistPress}
{...this.props}
/>
);
}
}
BlacklistConnector.propTypes = {
isClearingBlacklistExecuting: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchBlacklist: PropTypes.func.isRequired,
gotoBlacklistFirstPage: PropTypes.func.isRequired,
gotoBlacklistPreviousPage: PropTypes.func.isRequired,
gotoBlacklistNextPage: PropTypes.func.isRequired,
gotoBlacklistLastPage: PropTypes.func.isRequired,
gotoBlacklistPage: PropTypes.func.isRequired,
setBlacklistSort: PropTypes.func.isRequired,
setBlacklistTableOption: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(BlacklistConnector);

View file

@ -0,0 +1,89 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Button from 'Components/Link/Button';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import Modal from 'Components/Modal/Modal';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
class BlacklistDetailsModal extends Component {
//
// Render
render() {
const {
isOpen,
sourceTitle,
protocol,
indexer,
message,
onModalClose
} = this.props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<ModalContent
onModalClose={onModalClose}
>
<ModalHeader>
Details
</ModalHeader>
<ModalBody>
<DescriptionList>
<DescriptionListItem
title="Name"
data={sourceTitle}
/>
<DescriptionListItem
title="Protocol"
data={protocol}
/>
{
!!message &&
<DescriptionListItem
title="Indexer"
data={indexer}
/>
}
{
!!message &&
<DescriptionListItem
title="Message"
data={message}
/>
}
</DescriptionList>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
}
BlacklistDetailsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
sourceTitle: PropTypes.string.isRequired,
protocol: PropTypes.string.isRequired,
indexer: PropTypes.string,
message: PropTypes.string,
onModalClose: PropTypes.func.isRequired
};
export default BlacklistDetailsModal;

View file

@ -0,0 +1,18 @@
.language,
.quality {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 100px;
}
.indexer {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 80px;
}
.details {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 30px;
}

View file

@ -0,0 +1,175 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import EpisodeLanguage from 'Episode/EpisodeLanguage';
import EpisodeQuality from 'Episode/EpisodeQuality';
import ArtistNameLink from 'Artist/ArtistNameLink';
import BlacklistDetailsModal from './BlacklistDetailsModal';
import styles from './BlacklistRow.css';
class BlacklistRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isDetailsModalOpen: false
};
}
//
// Listeners
onDetailsPress = () => {
this.setState({ isDetailsModalOpen: true });
}
onDetailsModalClose = () => {
this.setState({ isDetailsModalOpen: false });
}
//
// Render
render() {
const {
series,
sourceTitle,
language,
quality,
date,
protocol,
indexer,
message,
columns
} = this.props;
return (
<TableRow>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'series.sortTitle') {
return (
<TableRowCell key={name}>
<ArtistNameLink
titleSlug={series.titleSlug}
title={series.title}
/>
</TableRowCell>
);
}
if (name === 'sourceTitle') {
return (
<TableRowCell key={name}>
{sourceTitle}
</TableRowCell>
);
}
if (name === 'language') {
return (
<TableRowCell
key={name}
className={styles.language}
>
<EpisodeLanguage
language={language}
/>
</TableRowCell>
);
}
if (name === 'quality') {
return (
<TableRowCell
key={name}
className={styles.quality}
>
<EpisodeQuality
quality={quality}
/>
</TableRowCell>
);
}
if (name === 'date') {
return (
<RelativeDateCellConnector
key={name}
date={date}
/>
);
}
if (name === 'indexer') {
return (
<TableRowCell
key={name}
className={styles.indexer}
>
{indexer}
</TableRowCell>
);
}
if (name === 'details') {
return (
<TableRowCell
key={name}
className={styles.details}
>
<IconButton
name={icons.INFO}
onPress={this.onDetailsPress}
/>
</TableRowCell>
);
}
})
}
<BlacklistDetailsModal
isOpen={this.state.isDetailsModalOpen}
sourceTitle={sourceTitle}
protocol={protocol}
indexer={indexer}
message={message}
onModalClose={this.onDetailsModalClose}
/>
</TableRow>
);
}
}
BlacklistRow.propTypes = {
id: PropTypes.number.isRequired,
series: PropTypes.object.isRequired,
sourceTitle: PropTypes.string.isRequired,
language: PropTypes.object.isRequired,
quality: PropTypes.object.isRequired,
date: PropTypes.string.isRequired,
protocol: PropTypes.string.isRequired,
indexer: PropTypes.string,
message: PropTypes.string,
columns: PropTypes.arrayOf(PropTypes.object).isRequired
};
export default BlacklistRow;

View file

@ -0,0 +1,17 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createArtistSelector from 'Store/Selectors/createArtistSelector';
import BlacklistRow from './BlacklistRow';
function createMapStateToProps() {
return createSelector(
createArtistSelector(),
(series) => {
return {
series
};
}
);
}
export default connect(createMapStateToProps)(BlacklistRow);

View file

@ -0,0 +1,237 @@
import PropTypes from 'prop-types';
import React from 'react';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatAge from 'Utilities/Number/formatAge';
import Link from 'Components/Link/Link';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
function HistoryDetails(props) {
const {
eventType,
sourceTitle,
data,
shortDateFormat,
timeFormat
} = props;
if (eventType === 'grabbed') {
const {
indexer,
releaseGroup,
nzbInfoUrl,
downloadClient,
downloadId,
age,
ageHours,
ageMinutes,
publishedDate
} = data;
return (
<DescriptionList>
<DescriptionListItem
title="Name"
data={sourceTitle}
/>
{
!!indexer &&
<DescriptionListItem
title="Indexer"
data={indexer}
/>
}
{
!!releaseGroup &&
<DescriptionListItem
title="Release Group"
data={releaseGroup}
/>
}
{
!!nzbInfoUrl &&
<span>
<DescriptionListItemTitle>
Info URL
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to={nzbInfoUrl}>{nzbInfoUrl}</Link>
</DescriptionListItemDescription>
</span>
}
{
!!downloadClient &&
<DescriptionListItem
title="Download Client"
data={downloadClient}
/>
}
{
!!downloadId &&
<DescriptionListItem
title="Grab ID"
data={downloadId}
/>
}
{
!!indexer &&
<DescriptionListItem
title="Age (when grabbed)"
data={formatAge(age, ageHours, ageMinutes)}
/>
}
{
!!publishedDate &&
<DescriptionListItem
title="Published Date"
data={formatDateTime(publishedDate, shortDateFormat, timeFormat, { includeSeconds: true })}
/>
}
</DescriptionList>
);
}
if (eventType === 'downloadFailed') {
const {
message
} = data;
return (
<DescriptionList>
<DescriptionListItem
title="Name"
data={sourceTitle}
/>
{
!!message &&
<DescriptionListItem
title="Message"
data={message}
/>
}
</DescriptionList>
);
}
if (eventType === 'downloadFolderImported') {
const {
droppedPath,
importedPath
} = data;
return (
<DescriptionList>
<DescriptionListItem
title="Name"
data={sourceTitle}
/>
{
!!droppedPath &&
<DescriptionListItem
title="Source"
data={droppedPath}
/>
}
{
!!importedPath &&
<DescriptionListItem
title="Imported To"
data={importedPath}
/>
}
</DescriptionList>
);
}
if (eventType === 'episodeFileDeleted') {
const {
reason
} = data;
let reasonMessage = '';
switch (reason) {
case 'Manual':
reasonMessage = 'File was deleted by via UI';
break;
case 'MissingFromDisk':
reasonMessage = 'Sonarr was unable to find the file on disk so it was removed';
break;
case 'Upgrade':
reasonMessage = 'File was deleted to import an upgrade';
break;
default:
reasonMessage = '';
}
return (
<DescriptionList>
<DescriptionListItem
title="Name"
data={sourceTitle}
/>
<DescriptionListItem
title="Reason"
data={reasonMessage}
/>
</DescriptionList>
);
}
if (eventType === 'episodeFileRenamed') {
const {
sourcePath,
sourceRelativePath,
path,
relativePath
} = data;
return (
<DescriptionList>
<DescriptionListItem
title="Source Path"
data={sourcePath}
/>
<DescriptionListItem
title="Source Relative Path"
data={sourceRelativePath}
/>
<DescriptionListItem
title="Destination Path"
data={path}
/>
<DescriptionListItem
title="Destination Relative Path"
data={relativePath}
/>
</DescriptionList>
);
}
}
HistoryDetails.propTypes = {
eventType: PropTypes.string.isRequired,
sourceTitle: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
shortDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired
};
export default HistoryDetails;

View file

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

View file

@ -0,0 +1,5 @@
.markAsFailedButton {
composes: button from 'Components/Link/Button.css';
margin-right: auto;
}

View file

@ -0,0 +1,104 @@
import PropTypes from 'prop-types';
import React from 'react';
import { kinds } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import Modal from 'Components/Modal/Modal';
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 HistoryDetails from './HistoryDetails';
import styles from './HistoryDetailsModal.css';
function getHeaderTitle(eventType) {
switch (eventType) {
case 'grabbed':
return 'Grabbed';
case 'downloadFailed':
return 'Download Failed';
case 'downloadFolderImported':
return 'Episode Imported';
case 'episodeFileDeleted':
return 'Episode File Deleted';
case 'episodeFileRenamed':
return 'Episode File Renamed';
default:
return 'Unknown';
}
}
function HistoryDetailsModal(props) {
const {
isOpen,
eventType,
sourceTitle,
data,
isMarkingAsFailed,
shortDateFormat,
timeFormat,
onMarkAsFailedPress,
onModalClose
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{getHeaderTitle(eventType)}
</ModalHeader>
<ModalBody>
<HistoryDetails
eventType={eventType}
sourceTitle={sourceTitle}
data={data}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
/>
</ModalBody>
<ModalFooter>
{
eventType === 'grabbed' &&
<SpinnerButton
className={styles.markAsFailedButton}
kind={kinds.DANGER}
isSpinning={isMarkingAsFailed}
onPress={onMarkAsFailedPress}
>
Mark as Failed
</SpinnerButton>
}
<Button
onPress={onModalClose}
>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
HistoryDetailsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
eventType: PropTypes.string.isRequired,
sourceTitle: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
isMarkingAsFailed: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
onMarkAsFailedPress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
HistoryDetailsModal.defaultProps = {
isMarkingAsFailed: false
};
export default HistoryDetailsModal;

View file

@ -0,0 +1,195 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { align, icons } from 'Helpers/Props';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TablePager from 'Components/Table/TablePager';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import FilterMenu from 'Components/Menu/FilterMenu';
import MenuContent from 'Components/Menu/MenuContent';
import FilterMenuItem from 'Components/Menu/FilterMenuItem';
import HistoryRowConnector from './HistoryRowConnector';
class History extends Component {
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items,
columns,
filterKey,
filterValue,
totalRecords,
isEpisodesFetching,
isEpisodesPopulated,
episodesError,
onFilterSelect,
onFirstPagePress,
...otherProps
} = this.props;
const isFetchingAny = isFetching || isEpisodesFetching;
const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length);
const hasError = error || episodesError;
return (
<PageContent title="History">
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label="Refresh"
iconName={icons.REFRESH}
isSpinning={isFetching}
onPress={onFirstPagePress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<FilterMenu alignMenu={align.RIGHT}>
<MenuContent>
<FilterMenuItem
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
All
</FilterMenuItem>
<FilterMenuItem
name="eventType"
value="1"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Grabbed
</FilterMenuItem>
<FilterMenuItem
name="eventType"
value="3"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Imported
</FilterMenuItem>
<FilterMenuItem
name="eventType"
value="4"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Failed
</FilterMenuItem>
<FilterMenuItem
name="eventType"
value="5"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Deleted
</FilterMenuItem>
<FilterMenuItem
name="eventType"
value="6"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Renamed
</FilterMenuItem>
</MenuContent>
</FilterMenu>
</PageToolbarSection>
</PageToolbar>
<PageContentBodyConnector>
{
isFetchingAny && !isAllPopulated &&
<LoadingIndicator />
}
{
!isFetchingAny && hasError &&
<div>Unable to load history</div>
}
{
// If history isPopulated and it's empty show no history found and don't
// wait for the episodes to populate because they are never coming.
isPopulated && !hasError && !items.length &&
<div>
No history found
</div>
}
{
isAllPopulated && !hasError && !!items.length &&
<div>
<Table
columns={columns}
{...otherProps}
>
<TableBody>
{
items.map((item) => {
return (
<HistoryRowConnector
key={item.id}
columns={columns}
{...item}
/>
);
})
}
</TableBody>
</Table>
<TablePager
totalRecords={totalRecords}
isFetching={isFetchingAny}
onFirstPagePress={onFirstPagePress}
{...otherProps}
/>
</div>
}
</PageContentBodyConnector>
</PageContent>
);
}
}
History.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
filterKey: PropTypes.string,
filterValue: PropTypes.string,
totalRecords: PropTypes.number,
isEpisodesFetching: PropTypes.bool.isRequired,
isEpisodesPopulated: PropTypes.bool.isRequired,
episodesError: PropTypes.object,
onFilterSelect: PropTypes.func.isRequired,
onFirstPagePress: PropTypes.func.isRequired
};
export default History;

View file

@ -0,0 +1,128 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import * as historyActions from 'Store/Actions/historyActions';
import { fetchEpisodes, clearEpisodes } from 'Store/Actions/episodeActions';
import History from './History';
function createMapStateToProps() {
return createSelector(
(state) => state.history,
(state) => state.episodes,
(history, episodes) => {
return {
isEpisodesFetching: episodes.isFetching,
isEpisodesPopulated: episodes.isPopulated,
episodesError: episodes.error,
...history
};
}
);
}
const mapDispatchToProps = {
...historyActions,
fetchEpisodes,
clearEpisodes
};
class HistoryConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.gotoHistoryFirstPage();
}
componentDidUpdate(prevProps) {
if (hasDifferentItems(prevProps.items, this.props.items)) {
const episodeIds = selectUniqueIds(this.props.items, 'episodeId');
this.props.fetchEpisodes({ episodeIds });
}
}
componentWillUnmount() {
this.props.clearHistory();
this.props.clearEpisodes();
}
//
// Listeners
onFirstPagePress = () => {
this.props.gotoHistoryFirstPage();
}
onPreviousPagePress = () => {
this.props.gotoHistoryPreviousPage();
}
onNextPagePress = () => {
this.props.gotoHistoryNextPage();
}
onLastPagePress = () => {
this.props.gotoHistoryLastPage();
}
onPageSelect = (page) => {
this.props.gotoHistoryPage({ page });
}
onSortPress = (sortKey) => {
this.props.setHistorySort({ sortKey });
}
onFilterSelect = (filterKey, filterValue) => {
this.props.setHistoryFilter({ filterKey, filterValue });
}
onTableOptionChange = (payload) => {
this.props.setHistoryTableOption(payload);
if (payload.pageSize) {
this.props.gotoHistoryFirstPage();
}
}
//
// Render
render() {
return (
<History
onFirstPagePress={this.onFirstPagePress}
onPreviousPagePress={this.onPreviousPagePress}
onNextPagePress={this.onNextPagePress}
onLastPagePress={this.onLastPagePress}
onPageSelect={this.onPageSelect}
onSortPress={this.onSortPress}
onFilterSelect={this.onFilterSelect}
onTableOptionChange={this.onTableOptionChange}
{...this.props}
/>
);
}
}
HistoryConnector.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchHistory: PropTypes.func.isRequired,
gotoHistoryFirstPage: PropTypes.func.isRequired,
gotoHistoryPreviousPage: PropTypes.func.isRequired,
gotoHistoryNextPage: PropTypes.func.isRequired,
gotoHistoryLastPage: PropTypes.func.isRequired,
gotoHistoryPage: PropTypes.func.isRequired,
setHistorySort: PropTypes.func.isRequired,
setHistoryFilter: PropTypes.func.isRequired,
setHistoryTableOption: PropTypes.func.isRequired,
clearHistory: PropTypes.func.isRequired,
fetchEpisodes: PropTypes.func.isRequired,
clearEpisodes: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(HistoryConnector);

View file

@ -0,0 +1,3 @@
.cell {
width: 35px;
}

View file

@ -0,0 +1,82 @@
import PropTypes from 'prop-types';
import React from 'react';
import { icons, kinds } from 'Helpers/Props';
import Icon from 'Components/Icon';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import styles from './HistoryEventTypeCell.css';
function getIconName(eventType) {
switch (eventType) {
case 'grabbed':
return icons.DOWNLOADING;
case 'seriesFolderImported':
return icons.DRIVE;
case 'downloadFolderImported':
return icons.DOWNLOADED;
case 'downloadFailed':
return icons.DOWNLOADING;
case 'episodeFileDeleted':
return icons.DELETE;
case 'episodeFileRenamed':
return icons.ORGANIZE;
default:
return icons.UNKNOWN;
}
}
function getIconKind(eventType) {
switch (eventType) {
case 'downloadFailed':
return kinds.DANGER;
default:
return kinds.DEFAULT;
}
}
function getTooltip(eventType, data) {
switch (eventType) {
case 'grabbed':
return `Episode grabbed from ${data.indexer} and sent to ${data.downloadClient}`;
case 'seriesFolderImported':
return 'Episode imported from series folder';
case 'downloadFolderImported':
return 'Episode downloaded successfully and picked up from download client';
case 'downloadFailed':
return 'Episode download failed';
case 'episodeFileDeleted':
return 'Episode file deleted';
case 'episodeFileRenamed':
return 'Episode file renamed';
default:
return 'Unknown event';
}
}
function HistoryEventTypeCell({ eventType, data }) {
const iconName = getIconName(eventType);
const iconKind = getIconKind(eventType);
const tooltip = getTooltip(eventType, data);
return (
<TableRowCell
className={styles.cell}
title={tooltip}
>
<Icon
name={iconName}
kind={iconKind}
/>
</TableRowCell>
);
}
HistoryEventTypeCell.propTypes = {
eventType: PropTypes.string.isRequired,
data: PropTypes.object
};
HistoryEventTypeCell.defaultProps = {
data: {}
};
export default HistoryEventTypeCell;

View file

@ -0,0 +1,23 @@
.downloadClient {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 120px;
}
.indexer {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 80px;
}
.releaseGroup {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 110px;
}
.details {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 30px;
}

View file

@ -0,0 +1,254 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import episodeEntities from 'Episode/episodeEntities';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import EpisodeLanguage from 'Episode/EpisodeLanguage';
import EpisodeQuality from 'Episode/EpisodeQuality';
import ArtistNameLink from 'Artist/ArtistNameLink';
import HistoryEventTypeCell from './HistoryEventTypeCell';
import HistoryDetailsModal from './Details/HistoryDetailsModal';
import styles from './HistoryRow.css';
class HistoryRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isDetailsModalOpen: false
};
}
componentDidUpdate(prevProps) {
if (
prevProps.isMarkingAsFailed &&
!this.props.isMarkingAsFailed &&
!this.props.markAsFailedError
) {
this.setState({ isDetailsModalOpen: false });
}
}
//
// Listeners
onDetailsPress = () => {
this.setState({ isDetailsModalOpen: true });
}
onDetailsModalClose = () => {
this.setState({ isDetailsModalOpen: false });
}
//
// Render
render() {
const {
episodeId,
series,
episode,
language,
quality,
eventType,
sourceTitle,
date,
data,
isMarkingAsFailed,
columns,
shortDateFormat,
timeFormat,
onMarkAsFailedPress
} = this.props;
if (!episode) {
return null;
}
return (
<TableRow>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'eventType') {
return (
<HistoryEventTypeCell
key={name}
eventType={eventType}
data={data}
/>
);
}
if (name === 'series.sortTitle') {
return (
<TableRowCell key={name}>
<ArtistNameLink
titleSlug={series.titleSlug}
title={series.title}
/>
</TableRowCell>
);
}
if (name === 'episode') {
return (
<TableRowCell key={name}>
<SeasonEpisodeNumber
seasonNumber={episode.seasonNumber}
episodeNumber={episode.episodeNumber}
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
seriesType={series.seriesType}
sceneSeasonNumber={episode.sceneSeasonNumber}
sceneEpisodeNumber={episode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={episode.sceneAbsoluteEpisodeNumber}
/>
</TableRowCell>
);
}
if (name === 'episodeTitle') {
return (
<TableRowCell key={name}>
<EpisodeTitleLink
episodeId={episodeId}
episodeEntity={episodeEntities.EPISODES}
artistId={series.id}
episodeTitle={episode.title}
showOpenSeriesButton={true}
/>
</TableRowCell>
);
}
if (name === 'language') {
return (
<TableRowCell key={name}>
<EpisodeLanguage
language={language}
/>
</TableRowCell>
);
}
if (name === 'quality') {
return (
<TableRowCell key={name}>
<EpisodeQuality
quality={quality}
/>
</TableRowCell>
);
}
if (name === 'date') {
return (
<RelativeDateCellConnector
key={name}
date={date}
/>
);
}
if (name === 'downloadClient') {
return (
<TableRowCell
key={name}
className={styles.downloadClient}
>
{data.downloadClient}
</TableRowCell>
);
}
if (name === 'indexer') {
return (
<TableRowCell
key={name}
className={styles.indexer}
>
{data.indexer}
</TableRowCell>
);
}
if (name === 'releaseGroup') {
return (
<TableRowCell
key={name}
className={styles.releaseGroup}
>
{data.releaseGroup}
</TableRowCell>
);
}
if (name === 'details') {
return (
<TableRowCell
key={name}
className={styles.details}
>
<IconButton
name={icons.INFO}
onPress={this.onDetailsPress}
/>
</TableRowCell>
);
}
})
}
<HistoryDetailsModal
isOpen={this.state.isDetailsModalOpen}
eventType={eventType}
sourceTitle={sourceTitle}
data={data}
isMarkingAsFailed={isMarkingAsFailed}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
onMarkAsFailedPress={onMarkAsFailedPress}
onModalClose={this.onDetailsModalClose}
/>
</TableRow>
);
}
}
HistoryRow.propTypes = {
episodeId: PropTypes.number,
series: PropTypes.object.isRequired,
episode: PropTypes.object,
language: PropTypes.object.isRequired,
quality: PropTypes.object.isRequired,
eventType: PropTypes.string.isRequired,
sourceTitle: PropTypes.string.isRequired,
date: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
isMarkingAsFailed: PropTypes.bool,
markAsFailedError: PropTypes.object,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
shortDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
onMarkAsFailedPress: PropTypes.func.isRequired
};
export default HistoryRow;

View file

@ -0,0 +1,69 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions';
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import HistoryRow from './HistoryRow';
function createMapStateToProps() {
return createSelector(
createEpisodeSelector(),
createUISettingsSelector(),
(episode, uiSettings) => {
return {
episode,
shortDateFormat: uiSettings.shortDateFormat,
timeFormat: uiSettings.timeFormat
};
}
);
}
const mapDispatchToProps = {
fetchHistory,
markAsFailed
};
class HistoryRowConnector extends Component {
componentDidUpdate(prevProps) {
if (
prevProps.isMarkingAsFailed &&
!this.props.isMarkingAsFailed &&
!this.props.markAsFailedError
) {
this.props.fetchHistory();
}
}
//
// Listeners
onMarkAsFailedPress = () => {
this.props.markAsFailed({ id: this.props.id });
}
//
// Render
render() {
return (
<HistoryRow
{...this.props}
onMarkAsFailedPress={this.onMarkAsFailedPress}
/>
);
}
}
HistoryRowConnector.propTypes = {
id: PropTypes.number.isRequired,
isMarkingAsFailed: PropTypes.bool,
markAsFailedError: PropTypes.object,
fetchHistory: PropTypes.func.isRequired,
markAsFailed: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(HistoryRowConnector);

View file

@ -0,0 +1,13 @@
.torrent {
composes: label from 'Components/Label.css';
border-color: $torrentColor;
background-color: $torrentColor;
}
.usenet {
composes: label from 'Components/Label.css';
border-color: $usenetColor;
background-color: $usenetColor;
}

View file

@ -0,0 +1,20 @@
import PropTypes from 'prop-types';
import React from 'react';
import Label from 'Components/Label';
import styles from './ProtocolLabel.css';
function ProtocolLabel({ protocol }) {
const protocolName = protocol === 'usenet' ? 'nzb' : protocol;
return (
<Label className={styles[protocol]}>
{protocolName}
</Label>
);
}
ProtocolLabel.propTypes = {
protocol: PropTypes.string.isRequired
};
export default ProtocolLabel;

View file

@ -0,0 +1,243 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import { icons } from 'Helpers/Props';
import episodeEntities from 'Episode/episodeEntities';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TablePager from 'Components/Table/TablePager';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import RemoveQueueItemsModal from './RemoveQueueItemsModal';
import QueueRowConnector from './QueueRowConnector';
class Queue extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {},
isPendingSelected: false,
isConfirmRemoveModalOpen: false
};
}
componentDidUpdate(prevProps) {
if (hasDifferentItems(prevProps.items, this.props.items)) {
this.setState({ selectedState: {} });
return;
}
const selectedIds = this.getSelectedIds();
const isPendingSelected = _.some(this.props.items, (item) => {
return selectedIds.indexOf(item.id) > -1 && item.status === 'Delay';
});
if (isPendingSelected !== this.state.isPendingSelected) {
this.setState({ isPendingSelected });
}
}
//
// Control
getSelectedIds = () => {
return getSelectedIds(this.state.selectedState);
}
//
// Listeners
onSelectAllChange = ({ value }) => {
this.setState(selectAll(this.state.selectedState, value));
}
onSelectedChange = ({ id, value, shiftKey = false }) => {
this.setState((state) => {
return toggleSelected(state, this.props.items, id, value, shiftKey);
});
}
onGrabSelectedPress = () => {
this.props.onGrabSelectedPress(this.getSelectedIds());
}
onRemoveSelectedPress = () => {
this.setState({ isConfirmRemoveModalOpen: true });
}
onRemoveSelectedConfirmed = (blacklist) => {
this.props.onRemoveSelectedPress(this.getSelectedIds(), blacklist);
this.setState({ isConfirmRemoveModalOpen: false });
}
onConfirmRemoveModalClose = () => {
this.setState({ isConfirmRemoveModalOpen: false });
}
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items,
isEpisodesPopulated,
columns,
totalRecords,
isGrabbing,
isRemoving,
isCheckForFinishedDownloadExecuting,
onRefreshPress,
...otherProps
} = this.props;
const {
allSelected,
allUnselected,
selectedState,
isConfirmRemoveModalOpen,
isPendingSelected
} = this.state;
const isRefreshing = isFetching || isCheckForFinishedDownloadExecuting;
const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length);
const selectedCount = this.getSelectedIds().length;
const disableSelectedActions = selectedCount === 0;
return (
<PageContent title="Queue">
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label="Refresh"
iconName={icons.REFRESH}
isSpinning={isRefreshing}
onPress={onRefreshPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label="Grab Selected"
iconName={icons.DOWNLOAD}
isDisabled={disableSelectedActions || !isPendingSelected}
isSpinning={isGrabbing}
onPress={this.onGrabSelectedPress}
/>
<PageToolbarButton
label="Remove Selected"
iconName={icons.REMOVE}
isDisabled={disableSelectedActions}
isSpinning={isRemoving}
onPress={this.onRemoveSelectedPress}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBodyConnector>
{
isRefreshing && !isAllPopulated &&
<LoadingIndicator />
}
{
!isRefreshing && error &&
<div>
Failed to load Queue
</div>
}
{
isAllPopulated && !error && !items.length &&
<div>
Queue is empty
</div>
}
{
isAllPopulated && !error && !!items.length &&
<div>
<Table
columns={columns}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
{...otherProps}
onSelectAllChange={this.onSelectAllChange}
>
<TableBody>
{
items.map((item) => {
return (
<QueueRowConnector
key={item.id}
episodeId={item.episode.id}
episodeEntity={episodeEntities.QUEUE_EPISODES}
isSelected={selectedState[item.id]}
columns={columns}
{...item}
onSelectedChange={this.onSelectedChange}
/>
);
})
}
</TableBody>
</Table>
<TablePager
totalRecords={totalRecords}
isFetching={isRefreshing}
{...otherProps}
/>
</div>
}
</PageContentBodyConnector>
<RemoveQueueItemsModal
isOpen={isConfirmRemoveModalOpen}
selectedCount={selectedCount}
onRemovePress={this.onRemoveSelectedConfirmed}
onModalClose={this.onConfirmRemoveModalClose}
/>
</PageContent>
);
}
}
Queue.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
isEpisodesPopulated: PropTypes.bool.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
totalRecords: PropTypes.number,
isGrabbing: PropTypes.bool.isRequired,
isRemoving: PropTypes.bool.isRequired,
isCheckForFinishedDownloadExecuting: PropTypes.bool.isRequired,
onRefreshPress: PropTypes.func.isRequired,
onGrabSelectedPress: PropTypes.func.isRequired,
onRemoveSelectedPress: PropTypes.func.isRequired
};
export default Queue;

View file

@ -0,0 +1,153 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import { executeCommand } from 'Store/Actions/commandActions';
import * as queueActions from 'Store/Actions/queueActions';
import { clearEpisodes } from 'Store/Actions/episodeActions';
import * as commandNames from 'Commands/commandNames';
import Queue from './Queue';
function createMapStateToProps() {
return createSelector(
(state) => state.queue.paged,
(state) => state.queue.queueEpisodes,
createCommandsSelector(),
(queue, queueEpisodes, commands) => {
const isCheckForFinishedDownloadExecuting = _.some(commands, { name: commandNames.CHECK_FOR_FINISHED_DOWNLOAD });
return {
isCheckForFinishedDownloadExecuting,
isEpisodesPopulated: queueEpisodes.isPopulated,
...queue
};
}
);
}
const mapDispatchToProps = {
...queueActions,
clearEpisodes,
executeCommand
};
class QueueConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.gotoQueueFirstPage();
}
componentDidUpdate(prevProps) {
if (hasDifferentItems(prevProps.items, this.props.items)) {
const episodes = _.uniqBy(_.reduce(this.props.items, (result, item) => {
result.push(item.episode);
return result;
}, []), ({ id }) => id);
this.props.clearEpisodes();
this.props.setQueueEpisodes({ episodes });
}
}
componentWillUnmount() {
this.props.clearQueue();
this.props.clearEpisodes();
}
//
// Listeners
onFirstPagePress = () => {
this.props.gotoQueueFirstPage();
}
onPreviousPagePress = () => {
this.props.gotoQueuePreviousPage();
}
onNextPagePress = () => {
this.props.gotoQueueNextPage();
}
onLastPagePress = () => {
this.props.gotoQueueLastPage();
}
onPageSelect = (page) => {
this.props.gotoQueuePage({ page });
}
onSortPress = (sortKey) => {
this.props.setQueueSort({ sortKey });
}
onTableOptionChange = (payload) => {
this.props.setQueueTableOption(payload);
if (payload.pageSize) {
this.props.gotoQueueFirstPage();
}
}
onRefreshPress = () => {
this.props.executeCommand({
name: commandNames.CHECK_FOR_FINISHED_DOWNLOAD
});
}
onGrabSelectedPress = (ids) => {
this.props.grabQueueItems({ ids });
}
onRemoveSelectedPress = (ids, blacklist) => {
this.props.removeQueueItems({ ids, blacklist });
}
//
// Render
render() {
return (
<Queue
onFirstPagePress={this.onFirstPagePress}
onPreviousPagePress={this.onPreviousPagePress}
onNextPagePress={this.onNextPagePress}
onLastPagePress={this.onLastPagePress}
onPageSelect={this.onPageSelect}
onSortPress={this.onSortPress}
onTableOptionChange={this.onTableOptionChange}
onRefreshPress={this.onRefreshPress}
onGrabSelectedPress={this.onGrabSelectedPress}
onRemoveSelectedPress={this.onRemoveSelectedPress}
{...this.props}
/>
);
}
}
QueueConnector.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchQueue: PropTypes.func.isRequired,
gotoQueueFirstPage: PropTypes.func.isRequired,
gotoQueuePreviousPage: PropTypes.func.isRequired,
gotoQueueNextPage: PropTypes.func.isRequired,
gotoQueueLastPage: PropTypes.func.isRequired,
gotoQueuePage: PropTypes.func.isRequired,
setQueueSort: PropTypes.func.isRequired,
setQueueTableOption: PropTypes.func.isRequired,
clearQueue: PropTypes.func.isRequired,
setQueueEpisodes: PropTypes.func.isRequired,
grabQueueItems: PropTypes.func.isRequired,
removeQueueItems: PropTypes.func.isRequired,
clearEpisodes: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(QueueConnector);

View file

@ -0,0 +1,97 @@
import moment from 'moment';
import PropTypes from 'prop-types';
import React from 'react';
import { icons, kinds } from 'Helpers/Props';
import Icon from 'Components/Icon';
function QueueDetails(props) {
const {
title,
size,
sizeleft,
estimatedCompletionTime,
status: queueStatus,
errorMessage,
progressBar
} = props;
const status = queueStatus.toLowerCase();
const progress = (100 - sizeleft / size * 100);
if (status === 'pending') {
return (
<Icon
name={icons.PENDING}
title={`Release will be processed ${moment(estimatedCompletionTime).fromNow()}`}
/>
);
}
if (status === 'completed') {
if (errorMessage) {
return (
<Icon
name={icons.DOWNLOAD}
kind={kinds.DANGER}
title={`Import failed: ${errorMessage}`}
/>
);
}
// TODO: show an icon when download is complete, but not imported yet?
}
if (errorMessage) {
return (
<Icon
name={icons.DOWNLOADING}
kind={kinds.DANGER}
title={`Download failed: ${errorMessage}`}
/>
);
}
if (status === 'failed') {
return (
<Icon
name={icons.DOWNLOADING}
kind={kinds.DANGER}
title="Download failed: check download client for more details"
/>
);
}
if (status === 'warning') {
return (
<Icon
name={icons.DOWNLOADING}
kind={kinds.WARNING}
title="Download warning: check download client for more details"
/>
);
}
if (progress < 5) {
return (
<Icon
name={icons.DOWNLOADING}
title={`Episode is downloading - ${progress.toFixed(1)}% ${title}`}
/>
);
}
return progressBar;
}
QueueDetails.propTypes = {
title: PropTypes.string.isRequired,
size: PropTypes.number.isRequired,
sizeleft: PropTypes.number.isRequired,
estimatedCompletionTime: PropTypes.string,
status: PropTypes.string.isRequired,
errorMessage: PropTypes.string,
progressBar: PropTypes.node.isRequired
};
export default QueueDetails;

View file

@ -0,0 +1,23 @@
.quality {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 150px;
}
.protocol {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 100px;
}
.progress {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 150px;
}
.actions {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 70px;
}

View file

@ -0,0 +1,348 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons, kinds } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import ProgressBar from 'Components/ProgressBar';
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import EpisodeQuality from 'Episode/EpisodeQuality';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import ArtistNameLink from 'Artist/ArtistNameLink';
import QueueStatusCell from './QueueStatusCell';
import TimeleftCell from './TimeleftCell';
import RemoveQueueItemModal from './RemoveQueueItemModal';
import styles from './QueueRow.css';
class QueueRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isRemoveQueueItemModalOpen: false,
isInteractiveImportModalOpen: false
};
}
//
// Listeners
onRemoveQueueItemPress = () => {
this.setState({ isRemoveQueueItemModalOpen: true });
}
onRemoveQueueItemModalConfirmed = (blacklist) => {
this.props.onRemoveQueueItemPress(blacklist);
this.setState({ isRemoveQueueItemModalOpen: false });
}
onRemoveQueueItemModalClose = () => {
this.setState({ isRemoveQueueItemModalOpen: false });
}
onInteractiveImportPress = () => {
this.setState({ isInteractiveImportModalOpen: true });
}
onInteractiveImportModalClose = () => {
this.setState({ isInteractiveImportModalOpen: false });
}
//
// Render
render() {
const {
id,
downloadId,
episodeEntity,
title,
status,
trackedDownloadStatus,
statusMessages,
errorMessage,
series,
episode,
quality,
protocol,
indexer,
downloadClient,
estimatedCompletionTime,
timeleft,
size,
sizeleft,
showRelativeDates,
shortDateFormat,
timeFormat,
isGrabbing,
grabError,
isRemoving,
isSelected,
columns,
onSelectedChange,
onGrabPress
} = this.props;
const {
isRemoveQueueItemModalOpen,
isInteractiveImportModalOpen
} = this.state;
const progress = 100 - (sizeleft / size * 100);
const showInteractiveImport = status === 'Completed' && trackedDownloadStatus === 'Warning';
const isPending = status === 'Delay' || status === 'DownloadClientUnavailable';
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
/>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'status') {
return (
<QueueStatusCell
key={name}
sourceTitle={title}
status={status}
trackedDownloadStatus={trackedDownloadStatus}
statusMessages={statusMessages}
errorMessage={errorMessage}
/>
);
}
if (name === 'series.sortTitle') {
return (
<TableRowCell key={name}>
<ArtistNameLink
titleSlug={series.titleSlug}
title={series.title}
/>
</TableRowCell>
);
}
if (name === 'series') {
return (
<TableRowCell key={name}>
<ArtistNameLink
titleSlug={series.titleSlug}
title={series.title}
/>
</TableRowCell>
);
}
if (name === 'episode') {
return (
<TableRowCell key={name}>
<SeasonEpisodeNumber
seasonNumber={episode.seasonNumber}
episodeNumber={episode.episodeNumber}
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
seriesType={series.seriesType}
sceneSeasonNumber={episode.sceneSeasonNumber}
sceneEpisodeNumber={episode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={episode.sceneAbsoluteEpisodeNumber}
/>
</TableRowCell>
);
}
if (name === 'episodeTitle') {
return (
<TableRowCell key={name}>
<EpisodeTitleLink
episodeId={episode.id}
artistId={series.id}
episodeFileId={episode.episodeFileId}
episodeEntity={episodeEntity}
episodeTitle={episode.title}
showOpenSeriesButton={true}
/>
</TableRowCell>
);
}
if (name === 'quality') {
return (
<TableRowCell key={name}>
<EpisodeQuality
quality={quality}
/>
</TableRowCell>
);
}
if (name === 'protocol') {
return (
<TableRowCell key={name}>
<ProtocolLabel
protocol={protocol}
/>
</TableRowCell>
);
}
if (name === 'indexer') {
return (
<TableRowCell key={name}>
{indexer}
</TableRowCell>
);
}
if (name === 'downloadClient') {
return (
<TableRowCell key={name}>
{downloadClient}
</TableRowCell>
);
}
if (name === 'estimatedCompletionTime') {
return (
<TimeleftCell
key={name}
status={status}
estimatedCompletionTime={estimatedCompletionTime}
timeleft={timeleft}
size={size}
sizeleft={sizeleft}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
/>
);
}
if (name === 'progress') {
return (
<TableRowCell
key={name}
className={styles.progress}
>
{
!!progress &&
<ProgressBar
progress={progress}
title={`${progress.toFixed(1)}%`}
/>
}
</TableRowCell>
);
}
if (name === 'actions') {
return (
<TableRowCell
key={name}
className={styles.actions}
>
{
showInteractiveImport &&
<IconButton
name={icons.INTERACTIVE}
onPress={this.onInteractiveImportPress}
/>
}
{
isPending &&
<SpinnerIconButton
name={icons.DOWNLOAD}
kind={grabError ? kinds.DANGER : kinds.DEFAULT}
isSpinning={isGrabbing}
onPress={onGrabPress}
/>
}
<SpinnerIconButton
name={icons.REMOVE}
isSpinning={isRemoving}
onPress={this.onRemoveQueueItemPress}
/>
</TableRowCell>
);
}
})
}
<InteractiveImportModal
isOpen={isInteractiveImportModalOpen}
downloadId={downloadId}
title={title}
onModalClose={this.onInteractiveImportModalClose}
/>
<RemoveQueueItemModal
isOpen={isRemoveQueueItemModalOpen}
sourceTitle={title}
onRemovePress={this.onRemoveQueueItemModalConfirmed}
onModalClose={this.onRemoveQueueItemModalClose}
/>
</TableRow>
);
}
}
QueueRow.propTypes = {
id: PropTypes.number.isRequired,
downloadId: PropTypes.string,
episodeEntity: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
trackedDownloadStatus: PropTypes.string,
statusMessages: PropTypes.arrayOf(PropTypes.object),
errorMessage: PropTypes.string.isRequired,
series: PropTypes.object.isRequired,
episode: PropTypes.object.isRequired,
quality: PropTypes.object.isRequired,
protocol: PropTypes.string.isRequired,
indexer: PropTypes.string,
downloadClient: PropTypes.string,
estimatedCompletionTime: PropTypes.string,
timeleft: PropTypes.string,
size: PropTypes.number,
sizeleft: PropTypes.number,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
isGrabbing: PropTypes.bool.isRequired,
grabError: PropTypes.object,
isRemoving: PropTypes.bool.isRequired,
isSelected: PropTypes.bool,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onSelectedChange: PropTypes.func.isRequired,
onGrabPress: PropTypes.func.isRequired,
onRemoveQueueItemPress: PropTypes.func.isRequired
};
QueueRow.defaultProps = {
isGrabbing: false,
isRemoving: false
};
export default QueueRow;

View file

@ -0,0 +1,76 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions';
import createArtistSelector from 'Store/Selectors/createArtistSelector';
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import QueueRow from './QueueRow';
function createMapStateToProps() {
return createSelector(
createArtistSelector(),
createEpisodeSelector(),
createUISettingsSelector(),
(series, episode, uiSettings) => {
const result = _.pick(uiSettings, [
'showRelativeDates',
'shortDateFormat',
'timeFormat'
]);
result.series = series;
result.episode = episode;
return result;
}
);
}
const mapDispatchToProps = {
grabQueueItem,
removeQueueItem
};
class QueueRowConnector extends Component {
//
// Listeners
onGrabPress = () => {
this.props.grabQueueItem({ id: this.props.id });
}
onRemoveQueueItemPress = (blacklist) => {
this.props.removeQueueItem({ id: this.props.id, blacklist });
}
//
// Render
render() {
if (!this.props.episode) {
return null;
}
return (
<QueueRow
{...this.props}
onGrabPress={this.onGrabPress}
onRemoveQueueItemPress={this.onRemoveQueueItemPress}
/>
);
}
}
QueueRowConnector.propTypes = {
id: PropTypes.number.isRequired,
episodeEntity: PropTypes.string.isRequired,
episode: PropTypes.object,
grabQueueItem: PropTypes.func.isRequired,
removeQueueItem: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(QueueRowConnector);

View file

@ -0,0 +1,5 @@
.status {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 30px;
}

View file

@ -0,0 +1,132 @@
import PropTypes from 'prop-types';
import React from 'react';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import Icon from 'Components/Icon';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import Popover from 'Components/Tooltip/Popover';
import styles from './QueueStatusCell.css';
function getDetailedPopoverBody(statusMessages) {
return (
<div>
{
statusMessages.map(({ title, messages }) => {
return (
<div key={title}>
{title}
<ul>
{
messages.map((message) => {
return (
<li key={message}>
{message}
</li>
);
})
}
</ul>
</div>
);
})
}
</div>
);
}
function QueueStatusCell(props) {
const {
sourceTitle,
status,
trackedDownloadStatus = 'Ok',
statusMessages,
errorMessage
} = props;
const hasWarning = trackedDownloadStatus === 'Warning';
const hasError = trackedDownloadStatus === 'Error';
// status === 'downloading'
let iconName = icons.DOWNLOADING;
let iconKind = kinds.DEFAULT;
let title = 'Downloading';
if (hasWarning) {
iconKind = kinds.WARNING;
}
if (status === 'Paused') {
iconName = icons.PAUSED;
title = 'Paused';
}
if (status === 'Queued') {
iconName = icons.QUEUED;
title = 'Queued';
}
if (status === 'Completed') {
iconName = icons.DOWNLOADED;
title = 'Downloaded';
}
if (status === 'Delay') {
iconName = icons.PENDING;
title = 'Pending';
}
if (status === 'DownloadClientUnavailable') {
iconName = icons.PENDING;
iconKind = kinds.WARNING;
title = 'Pending - Download client is unavailable';
}
if (status === 'Failed') {
iconName = icons.DOWNLOADING;
iconKind = kinds.DANGER;
title = 'Download failed';
}
if (status === 'Warning') {
iconName = icons.DOWNLOADING;
iconKind = kinds.WARNING;
title = `Download warning: ${errorMessage || 'check download client for more details'}`;
}
if (hasError) {
if (status === 'Completed') {
iconName = icons.DOWNLOAD;
iconKind = kinds.DANGER;
title = `Import failed: ${sourceTitle}`;
} else {
iconName = icons.DOWNLOADING;
iconKind = kinds.DANGER;
title = 'Download failed';
}
}
return (
<TableRowCell className={styles.status}>
<Popover
anchor={
<Icon
name={iconName}
kind={iconKind}
/>
}
title={title}
body={hasWarning || hasError ? getDetailedPopoverBody(statusMessages) : sourceTitle}
position={tooltipPositions.RIGHT}
/>
</TableRowCell>
);
}
QueueStatusCell.propTypes = {
sourceTitle: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
trackedDownloadStatus: PropTypes.string,
statusMessages: PropTypes.arrayOf(PropTypes.object),
errorMessage: PropTypes.string
};
export default QueueStatusCell;

View file

@ -0,0 +1,3 @@
.message {
margin-bottom: 30px;
}

View file

@ -0,0 +1,114 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import Modal from 'Components/Modal/Modal';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputGroup from 'Components/Form/FormInputGroup';
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 styles from './RemoveQueueItemModal.css';
class RemoveQueueItemModal extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
blacklist: false
};
}
//
// Listeners
onBlacklistChange = ({ value }) => {
this.setState({ blacklist: value });
}
onRemoveQueueItemConfirmed = () => {
const blacklist = this.state.blacklist;
this.setState({ blacklist: false });
this.props.onRemovePress(blacklist);
}
onModalClose = () => {
this.setState({ blacklist: false });
this.props.onModalClose();
}
//
// Render
render() {
const {
isOpen,
sourceTitle
} = this.props;
const blacklist = this.state.blacklist;
return (
<Modal
isOpen={isOpen}
size={sizes.MEDIUM}
onModalClose={this.onModalClose}
>
<ModalContent
onModalClose={this.onModalClose}
>
<ModalHeader>
Remove - {sourceTitle}
</ModalHeader>
<ModalBody>
<div className={styles.message}>
Are you sure you want to remove '{sourceTitle}' from the queue?
</div>
<FormGroup>
<FormLabel>Blacklist Release</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="blacklist"
value={blacklist}
helpText="Prevents Sonarr from automatically grabbing this episode again"
onChange={this.onBlacklistChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter>
<Button onPress={this.onModalClose}>
Close
</Button>
<Button
kind={kinds.DANGER}
onPress={this.onRemoveQueueItemConfirmed}
>
Remove
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
}
RemoveQueueItemModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
sourceTitle: PropTypes.string.isRequired,
onRemovePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default RemoveQueueItemModal;

View file

@ -0,0 +1,3 @@
.message {
margin-bottom: 30px;
}

View file

@ -0,0 +1,114 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import Modal from 'Components/Modal/Modal';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputGroup from 'Components/Form/FormInputGroup';
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 styles from './RemoveQueueItemsModal.css';
class RemoveQueueItemsModal extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
blacklist: false
};
}
//
// Listeners
onBlacklistChange = ({ value }) => {
this.setState({ blacklist: value });
}
onRemoveQueueItemConfirmed = () => {
const blacklist = this.state.blacklist;
this.setState({ blacklist: false });
this.props.onRemovePress(blacklist);
}
onModalClose = () => {
this.setState({ blacklist: false });
this.props.onModalClose();
}
//
// Render
render() {
const {
isOpen,
selectedCount
} = this.props;
const blacklist = this.state.blacklist;
return (
<Modal
isOpen={isOpen}
size={sizes.MEDIUM}
onModalClose={this.onModalClose}
>
<ModalContent
onModalClose={this.onModalClose}
>
<ModalHeader>
Remove Selected Item{selectedCount > 1 ? 's' : ''}
</ModalHeader>
<ModalBody>
<div className={styles.message}>
Are you sure you want to remove {selectedCount} item{selectedCount > 1 ? 's' : ''} from the queue?
</div>
<FormGroup>
<FormLabel>Blacklist Release</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="blacklist"
value={blacklist}
helpText="Prevents Sonarr from automatically grabbing this episode again"
onChange={this.onBlacklistChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter>
<Button onPress={this.onModalClose}>
Close
</Button>
<Button
kind={kinds.DANGER}
onPress={this.onRemoveQueueItemConfirmed}
>
Remove
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
}
RemoveQueueItemsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
selectedCount: PropTypes.number.isRequired,
onRemovePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default RemoveQueueItemsModal;

View file

@ -0,0 +1,63 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchQueueStatus } from 'Store/Actions/queueActions';
import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
function createMapStateToProps() {
return createSelector(
(state) => state.app,
(state) => state.queue.queueStatus,
(app, status) => {
return {
isConnected: app.isConnected,
isReconnecting: app.isReconnecting,
isPopulated: status.isPopulated,
...status.item
};
}
);
}
const mapDispatchToProps = {
fetchQueueStatus
};
class QueueStatusConnector extends Component {
//
// Lifecycle
componentDidMount() {
if (!this.props.isPopulated) {
this.props.fetchQueueStatus();
}
}
componentDidUpdate(prevProps) {
if (this.props.isConnected && prevProps.isReconnecting) {
this.props.fetchQueueStatus();
}
}
//
// Render
render() {
return (
<PageSidebarStatus
{...this.props}
/>
);
}
}
QueueStatusConnector.propTypes = {
isConnected: PropTypes.bool.isRequired,
isReconnecting: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
fetchQueueStatus: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(QueueStatusConnector);

View file

@ -0,0 +1,5 @@
.timeleft {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 100px;
}

View file

@ -0,0 +1,82 @@
import PropTypes from 'prop-types';
import React from 'react';
import formatTime from 'Utilities/Date/formatTime';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import formatBytes from 'Utilities/Number/formatBytes';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import styles from './TimeleftCell.css';
function TimeleftCell(props) {
const {
estimatedCompletionTime,
timeleft,
status,
size,
sizeleft,
showRelativeDates,
shortDateFormat,
timeFormat
} = props;
if (status === 'Delay') {
const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates);
const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true });
return (
<TableRowCell
className={styles.timeleft}
title={`Delaying download until ${date} at ${time}`}
>
-
</TableRowCell>
);
}
if (status === 'DownloadClientUnavailable') {
const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates);
const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true });
return (
<TableRowCell
className={styles.timeleft}
title={`Retrying download ${date} at ${time}`}
>
-
</TableRowCell>
);
}
if (!timeleft) {
return (
<TableRowCell className={styles.timeleft}>
-
</TableRowCell>
);
}
const totalSize = formatBytes(size);
const remainingSize = formatBytes(sizeleft);
return (
<TableRowCell
className={styles.timeleft}
title={`${remainingSize} / ${totalSize}`}
>
{formatTimeSpan(timeleft)}
</TableRowCell>
);
}
TimeleftCell.propTypes = {
estimatedCompletionTime: PropTypes.string,
timeleft: PropTypes.string,
status: PropTypes.string.isRequired,
size: PropTypes.number.isRequired,
sizeleft: PropTypes.number.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired
};
export default TimeleftCell;

View file

@ -0,0 +1,27 @@
.queue-status-cell .popover {
max-width: 800px;
}
.queue {
.protocol-cell {
text-align: center;
width: 80px;
}
.episode-number-cell {
min-width: 90px;
}
}
.remove-from-queue-modal {
.form-horizontal {
margin-top: 20px;
}
}
.history-detail-modal {
.info {
word-wrap: break-word;
}
}

View file

@ -0,0 +1,54 @@
.searchContainer {
display: flex;
margin-bottom: 10px;
}
.searchIconContainer {
width: 58px;
height: 46px;
border: 1px solid $inputBorderColor;
border-right: none;
border-radius: 4px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
background-color: #edf1f2;
text-align: center;
line-height: 46px;
}
.searchInput {
composes: text from 'Components/Form/TextInput.css';
height: 46px;
border-radius: 0;
font-size: 18px;
}
.clearLookupButton {
border: 1px solid $inputBorderColor;
border-left: none;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
}
.message {
margin-top: 30px;
text-align: center;
}
.helpText {
margin-bottom: 10px;
font-weight: 300;
font-size: 24px;
}
.noResults {
margin-bottom: 10px;
font-weight: 300;
font-size: 30px;
}
.searchResults {
margin-top: 30px;
}

View file

@ -0,0 +1,184 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import TextInput from 'Components/Form/TextInput';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import AddNewSeriesSearchResultConnector from './AddNewSeriesSearchResultConnector';
import styles from './AddNewSeries.css';
class AddNewSeries extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
term: props.term || '',
isFetching: false
};
}
componentDidMount() {
const term = this.state.term;
if (term) {
this.props.onSeriesLookupChange(term);
}
}
componentDidUpdate(prevProps) {
const {
term,
isFetching
} = this.props;
if (term && term !== prevProps.term) {
this.setState({
term,
isFetching: true
});
this.props.onSeriesLookupChange(term);
} else if (isFetching !== prevProps.isFetching) {
this.setState({
isFetching
});
}
}
//
// Listeners
onSearchInputChange = ({ value }) => {
const hasValue = !!value.trim();
this.setState({ term: value, isFetching: hasValue }, () => {
if (hasValue) {
this.props.onSeriesLookupChange(value);
} else {
this.props.onClearSeriesLookup();
}
});
}
onClearSeriesLookupPress = () => {
this.setState({ term: '' });
this.props.onClearSeriesLookup();
}
//
// Render
render() {
const {
error,
items
} = this.props;
const term = this.state.term;
const isFetching = this.state.isFetching;
return (
<PageContent title="Add New Artist">
<PageContentBodyConnector>
<div className={styles.searchContainer}>
<div className={styles.searchIconContainer}>
<Icon
name={icons.SEARCH}
size={20}
/>
</div>
<TextInput
className={styles.searchInput}
name="seriesLookup"
value={term}
placeholder="eg. Breaking Benjamin, lidarr:####"
onChange={this.onSearchInputChange}
/>
<Button
className={styles.clearLookupButton}
onPress={this.onClearSeriesLookupPress}
>
<Icon
name={icons.REMOVE}
size={20}
/>
</Button>
</div>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>Failed to load search results, please try again.</div>
}
{
!isFetching && !error && !!items.length &&
<div className={styles.searchResults}>
{
items.map((item) => {
return (
<AddNewSeriesSearchResultConnector
key={item.foreignArtistId}
{...item}
/>
);
})
}
</div>
}
{
!isFetching && !error && !items.length && !!term &&
<div className={styles.message}>
<div className={styles.noResults}>Couldn't find any results for '{term}'</div>
<div>You can also search using MusicBrainz ID of a show. eg. lidarr:71663</div>
<div>
<Link to="https://github.com/Sonarr/Sonarr/wiki/FAQ#why-cant-i-add-a-new-series-when-i-know-the-tvdb-id">
Why can't I find my artist?
</Link>
</div>
</div>
}
{
!term &&
<div className={styles.message}>
<div className={styles.helpText}>It's easy to add a new artist, just start typing the name the artist you want to add.</div>
<div>You can also search using MusicBrainz ID of a show. eg. lidarr:71663</div>
</div>
}
<div>
</div>
</PageContentBodyConnector>
</PageContent>
);
}
}
AddNewSeries.propTypes = {
term: PropTypes.string,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isAdding: PropTypes.bool.isRequired,
addError: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onSeriesLookupChange: PropTypes.func.isRequired,
onClearSeriesLookup: PropTypes.func.isRequired
};
export default AddNewSeries;

View file

@ -0,0 +1,102 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import queryString from 'query-string';
import { lookupSeries, clearAddSeries } from 'Store/Actions/addSeriesActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import AddNewSeries from './AddNewSeries';
function createMapStateToProps() {
return createSelector(
(state) => state.addSeries,
(state) => state.routing.location,
(addSeries, location) => {
const query = queryString.parse(location.search);
return {
term: query.term,
...addSeries
};
}
);
}
const mapDispatchToProps = {
lookupSeries,
clearAddSeries,
fetchRootFolders
};
class AddNewSeriesConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._seriesLookupTimeout = null;
}
componentDidMount() {
this.props.fetchRootFolders();
}
componentWillUnmount() {
if (this._seriesLookupTimeout) {
clearTimeout(this._seriesLookupTimeout);
}
this.props.clearAddSeries();
}
//
// Listeners
onSeriesLookupChange = (term) => {
if (this._seriesLookupTimeout) {
clearTimeout(this._seriesLookupTimeout);
}
if (term.trim() === '') {
this.props.clearAddSeries();
} else {
this._seriesLookupTimeout = setTimeout(() => {
this.props.lookupSeries({ term });
}, 300);
}
}
onClearSeriesLookup = () => {
this.props.clearAddSeries();
}
//
// Render
render() {
const {
term,
...otherProps
} = this.props;
return (
<AddNewSeries
term={term}
{...otherProps}
onSeriesLookupChange={this.onSeriesLookupChange}
onClearSeriesLookup={this.onClearSeriesLookup}
/>
);
}
}
AddNewSeriesConnector.propTypes = {
term: PropTypes.string,
lookupSeries: PropTypes.func.isRequired,
clearAddSeries: PropTypes.func.isRequired,
fetchRootFolders: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AddNewSeriesConnector);

View file

@ -0,0 +1,31 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import AddNewSeriesModalContentConnector from './AddNewSeriesModalContentConnector';
function AddNewSeriesModal(props) {
const {
isOpen,
onModalClose,
...otherProps
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<AddNewSeriesModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
AddNewSeriesModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AddNewSeriesModal;

View file

@ -0,0 +1,74 @@
.container {
display: flex;
}
.year {
margin-left: 5px;
color: $disabledColor;
}
.poster {
flex: 0 0 170px;
margin-right: 20px;
height: 250px;
}
.info {
flex-grow: 1;
}
.overview {
margin-bottom: 30px;
}
.labelIcon {
margin-left: 8px;
}
.searchForMissingEpisodesLabelContainer {
display: flex;
margin-top: 2px;
}
.searchForMissingEpisodesLabel {
margin-right: 8px;
font-weight: normal;
}
.searchForMissingEpisodesContainer {
composes: container from 'Components/Form/CheckInput.css';
flex: 0 1 0;
}
.searchForMissingEpisodesInput {
composes: input from 'Components/Form/CheckInput.css';
margin-top: 0;
}
.modalFooter {
composes: modalFooter from 'Components/Modal/ModalFooter.css';
}
.addButton {
composes: button from 'Components/Link/SpinnerButton.css';
composes: truncate from 'Styles/mixins/truncate.css';
}
.hideLanguageProfile {
composes: group from 'Components/Form/FormGroup.css';
display: none;
}
@media only screen and (max-width: $breakpointSmall) {
.modalFooter {
display: block;
text-align: center;
}
.addButton {
margin-top: 10px;
}
}

View file

@ -0,0 +1,265 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons, kinds, inputTypes, tooltipPositions } from 'Helpers/Props';
import Icon from 'Components/Icon';
import SpinnerButton from 'Components/Link/SpinnerButton';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputGroup from 'Components/Form/FormInputGroup';
import CheckInput from 'Components/Form/CheckInput';
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 Popover from 'Components/Tooltip/Popover';
import ArtistPoster from 'Artist/ArtistPoster';
import SeriesMonitoringOptionsPopoverContent from 'AddArtist/SeriesMonitoringOptionsPopoverContent';
import SeriesTypePopoverContent from 'AddArtist/SeriesTypePopoverContent';
import styles from './AddNewSeriesModalContent.css';
class AddNewSeriesModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
searchForMissingEpisodes: false
};
}
//
// Listeners
onSearchForMissingEpisodesChange = ({ value }) => {
this.setState({ searchForMissingEpisodes: value });
}
onQualityProfileIdChange = ({ value }) => {
this.props.onInputChange({ name: 'qualityProfileId', value: parseInt(value) });
}
onLanguageProfileIdChange = ({ value }) => {
this.props.onInputChange({ name: 'languageProfileId', value: parseInt(value) });
}
onAddSeriesPress = () => {
this.props.onAddSeriesPress(this.state.searchForMissingEpisodes);
}
//
// Render
render() {
const {
artistName,
year,
overview,
images,
isAdding,
rootFolderPath,
monitor,
qualityProfileId,
languageProfileId,
seriesType,
albumFolder,
tags,
showLanguageProfile,
isSmallScreen,
onModalClose,
onInputChange
} = this.props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{artistName}
{
!name.contains(year) &&
<span className={styles.year}>({year})</span>
}
</ModalHeader>
<ModalBody>
<div className={styles.container}>
{
!isSmallScreen &&
<div className={styles.poster}>
<ArtistPoster
className={styles.poster}
images={images}
size={250}
/>
</div>
}
<div className={styles.info}>
<div className={styles.overview}>
{overview}
</div>
<Form>
<FormGroup>
<FormLabel>Root Folder</FormLabel>
<FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath"
onChange={onInputChange}
{...rootFolderPath}
/>
</FormGroup>
<FormGroup>
<FormLabel>
Monitor
<Popover
anchor={
<Icon
className={styles.labelIcon}
name={icons.INFO}
/>
}
title="Monitoring Options"
body={<SeriesMonitoringOptionsPopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.MONITOR_EPISODES_SELECT}
name="monitor"
onChange={onInputChange}
{...monitor}
/>
</FormGroup>
<FormGroup>
<FormLabel>Quality Profile</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
onChange={this.onQualityProfileIdChange}
{...qualityProfileId}
/>
</FormGroup>
<FormGroup className={showLanguageProfile ? null : styles.hideLanguageProfile}>
<FormLabel>Language Profile</FormLabel>
<FormInputGroup
type={inputTypes.LANGUAGE_PROFILE_SELECT}
name="languageProfileId"
onChange={this.onLanguageProfileIdChange}
{...languageProfileId}
/>
</FormGroup>
<FormGroup>
<FormLabel>
Series Type
<Popover
anchor={
<Icon
className={styles.labelIcon}
name={icons.INFO}
/>
}
title="Series Types"
body={<SeriesTypePopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.SERIES_TYPE_SELECT}
name="seriesType"
onChange={onInputChange}
{...seriesType}
/>
</FormGroup>
<FormGroup>
<FormLabel>Album Folder</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="albumFolder"
onChange={onInputChange}
{...albumFolder}
/>
</FormGroup>
<FormGroup>
<FormLabel>Tags</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
onChange={onInputChange}
{...tags}
/>
</FormGroup>
</Form>
</div>
</div>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<label className={styles.searchForMissingEpisodesLabelContainer}>
<span className={styles.searchForMissingEpisodesLabel}>
Start search for missing episodes
</span>
<CheckInput
containerClassName={styles.searchForMissingEpisodesContainer}
className={styles.searchForMissingEpisodesInput}
name="searchForMissingEpisodes"
value={this.state.searchForMissingEpisodes}
onChange={this.onSearchForMissingEpisodesChange}
/>
</label>
<SpinnerButton
className={styles.addButton}
kind={kinds.SUCCESS}
isSpinning={isAdding}
onPress={this.onAddSeriesPress}
>
Add {artistName}
</SpinnerButton>
</ModalFooter>
</ModalContent>
);
}
}
AddNewSeriesModalContent.propTypes = {
artistName: PropTypes.string.isRequired,
year: PropTypes.number.isRequired,
overview: PropTypes.string,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
isAdding: PropTypes.bool.isRequired,
addError: PropTypes.object,
rootFolderPath: PropTypes.object,
monitor: PropTypes.object.isRequired,
qualityProfileId: PropTypes.object,
languageProfileId: PropTypes.object,
seriesType: PropTypes.object.isRequired,
albumFolder: PropTypes.object.isRequired,
tags: PropTypes.object.isRequired,
showLanguageProfile: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired,
onInputChange: PropTypes.func.isRequired,
onAddSeriesPress: PropTypes.func.isRequired
};
export default AddNewSeriesModalContent;

View file

@ -0,0 +1,105 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setAddSeriesDefault, addSeries } from 'Store/Actions/addSeriesActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import selectSettings from 'Store/Selectors/selectSettings';
import AddNewSeriesModalContent from './AddNewSeriesModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.addSeries,
(state) => state.settings.languageProfiles,
createDimensionsSelector(),
(addSeriesState, languageProfiles, dimensions) => {
const {
isAdding,
addError,
defaults
} = addSeriesState;
const {
settings,
validationErrors,
validationWarnings
} = selectSettings(defaults, {}, addError);
return {
isAdding,
addError,
showLanguageProfile: languageProfiles.length > 1,
isSmallScreen: dimensions.isSmallScreen,
validationErrors,
validationWarnings,
...settings
};
}
);
}
const mapDispatchToProps = {
setAddSeriesDefault,
addSeries
};
class AddNewSeriesModalContentConnector extends Component {
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setAddSeriesDefault({ [name]: value });
}
onAddSeriesPress = (searchForMissingEpisodes) => {
const {
foreignArtistId,
rootFolderPath,
monitor,
qualityProfileId,
languageProfileId,
albumFolder,
tags
} = this.props;
this.props.addSeries({
foreignArtistId,
rootFolderPath: rootFolderPath.value,
monitor: monitor.value,
qualityProfileId: qualityProfileId.value,
languageProfileId: languageProfileId.value,
albumFolder: albumFolder.value,
tags: tags.value,
searchForMissingEpisodes
});
}
//
// Render
render() {
return (
<AddNewSeriesModalContent
{...this.props}
onInputChange={this.onInputChange}
onAddSeriesPress={this.onAddSeriesPress}
/>
);
}
}
AddNewSeriesModalContentConnector.propTypes = {
foreignArtistId: PropTypes.string.isRequired,
rootFolderPath: PropTypes.object,
monitor: PropTypes.object.isRequired,
qualityProfileId: PropTypes.object,
languageProfileId: PropTypes.object,
albumFolder: PropTypes.object.isRequired,
tags: PropTypes.object.isRequired,
onModalClose: PropTypes.func.isRequired,
setAddSeriesDefault: PropTypes.func.isRequired,
addSeries: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AddNewSeriesModalContentConnector);

View file

@ -0,0 +1,40 @@
.searchResult {
display: flex;
margin: 20px 0;
padding: 20px;
width: 100%;
background-color: $white;
color: inherit;
transition: background 500ms;
&:hover {
background-color: #eaf2ff;
color: inherit;
text-decoration: none;
}
}
.poster {
flex: 0 0 170px;
margin-right: 20px;
height: 250px;
}
.title {
font-weight: 300;
font-size: 36px;
}
.year {
margin-left: 10px;
color: $disabledColor;
}
.alreadyExistsIcon {
margin-left: 10px;
color: #37bc9b;
}
.overview {
margin-top: 20px;
}

View file

@ -0,0 +1,169 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons, kinds, sizes } from 'Helpers/Props';
import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import Link from 'Components/Link/Link';
import ArtistPoster from 'Artist/ArtistPoster';
import AddNewSeriesModal from './AddNewSeriesModal';
import styles from './AddNewSeriesSearchResult.css';
class AddNewSeriesSearchResult extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isNewAddSeriesModalOpen: false
};
}
componentDidUpdate(prevProps) {
if (!prevProps.isExistingSeries && this.props.isExistingSeries) {
this.onAddSerisModalClose();
}
}
//
// Listeners
onPress = () => {
this.setState({ isNewAddSeriesModalOpen: true });
}
onAddSerisModalClose = () => {
this.setState({ isNewAddSeriesModalOpen: false });
}
//
// Render
render() {
const {
foreignArtistId,
artistName,
nameSlug,
year,
network,
status,
overview,
seasonCount,
ratings,
images,
isExistingSeries,
isSmallScreen
} = this.props;
const linkProps = isExistingSeries ? { to: `/series/${nameSlug}` } : { onPress: this.onPress };
let seasons = '1 Season';
if (seasonCount > 1) {
seasons = `${seasonCount} Seasons`;
}
return (
<Link
className={styles.searchResult}
{...linkProps}
>
{
!isSmallScreen &&
<ArtistPoster
className={styles.poster}
images={images}
size={250}
/>
}
<div>
<div className={styles.name}>
{artistName}
{
!name.contains(year) && !!year &&
<span className={styles.year}>({year})</span>
}
{
isExistingSeries &&
<Icon
className={styles.alreadyExistsIcon}
name={icons.CHECK_CIRCLE}
size={36}
artistName="Already in your library"
/>
}
</div>
<div>
<Label size={sizes.LARGE}>
<HeartRating
rating={ratings.value}
iconSize={13}
/>
</Label>
{
!!network &&
<Label size={sizes.LARGE}>
{network}
</Label>
}
{
!!seasonCount &&
<Label size={sizes.LARGE}>
{seasons}
</Label>
}
{
status === 'ended' &&
<Label
kind={kinds.DANGER}
size={sizes.LARGE}
>
Ended
</Label>
}
</div>
<div className={styles.overview}>
{overview}
</div>
</div>
<AddNewSeriesModal
isOpen={this.state.isNewAddSeriesModalOpen && !isExistingSeries}
foreignArtistId={foreignArtistId}
artistName={artistName}
year={year}
overview={overview}
images={images}
onModalClose={this.onAddSerisModalClose}
/>
</Link>
);
}
}
AddNewSeriesSearchResult.propTypes = {
foreignArtistId: PropTypes.string.isRequired,
artistName: PropTypes.string.isRequired,
nameSlug: PropTypes.string.isRequired,
year: PropTypes.number,
network: PropTypes.string,
status: PropTypes.string.isRequired,
overview: PropTypes.string,
seasonCount: PropTypes.number,
ratings: PropTypes.object.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
isExistingSeries: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired
};
export default AddNewSeriesSearchResult;

View file

@ -0,0 +1,20 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import AddNewSeriesSearchResult from './AddNewSeriesSearchResult';
function createMapStateToProps() {
return createSelector(
createExistingSeriesSelector(),
createDimensionsSelector(),
(isExistingSeries, dimensions) => {
return {
isExistingSeries,
isSmallScreen: dimensions.isSmallScreen
};
}
);
}
export default connect(createMapStateToProps)(AddNewSeriesSearchResult);

View file

@ -0,0 +1,173 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import ImportSeriesTableConnector from './ImportSeriesTableConnector';
import ImportSeriesFooterConnector from './ImportSeriesFooterConnector';
class ImportSeries extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {},
contentBody: null,
scrollTop: 0
};
}
//
// Control
setContentBodyRef = (ref) => {
this.setState({ contentBody: ref });
}
//
// Listeners
getSelectedIds = () => {
return getSelectedIds(this.state.selectedState, { parseIds: false });
}
onSelectAllChange = ({ value }) => {
// Only select non-dupes
this.setState(selectAll(this.state.selectedState, value));
}
onSelectedChange = ({ id, value, shiftKey = false }) => {
this.setState((state) => {
return toggleSelected(state, this.props.items, id, value, shiftKey);
});
}
onRemoveSelectedStateItem = (id) => {
this.setState((state) => {
const selectedState = Object.assign({}, state.selectedState);
delete selectedState[id];
return {
...state,
selectedState
};
});
}
onInputChange = ({ name, value }) => {
this.props.onInputChange(this.getSelectedIds(), name, value);
}
onImportPress = () => {
this.props.onImportPress(this.getSelectedIds());
}
onScroll = ({ scrollTop }) => {
this.setState({ scrollTop });
}
//
// Render
render() {
const {
rootFolderId,
path,
rootFoldersFetching,
rootFoldersPopulated,
rootFoldersError,
unmappedFolders,
showLanguageProfile
} = this.props;
const {
allSelected,
allUnselected,
selectedState,
contentBody
} = this.state;
return (
<PageContent title="Import Series">
<PageContentBodyConnector
ref={this.setContentBodyRef}
onScroll={this.onScroll}
>
{
rootFoldersFetching && !rootFoldersPopulated &&
<LoadingIndicator />
}
{
!rootFoldersFetching && !!rootFoldersError &&
<div>Unable to load root folders</div>
}
{
!rootFoldersError && rootFoldersPopulated && !unmappedFolders.length &&
<div>
All series in {path} have been imported
</div>
}
{
!rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length && contentBody &&
<ImportSeriesTableConnector
rootFolderId={rootFolderId}
unmappedFolders={unmappedFolders}
allSelected={allSelected}
allUnselected={allUnselected}
selectedState={selectedState}
contentBody={contentBody}
showLanguageProfile={showLanguageProfile}
scrollTop={this.state.scrollTop}
onSelectAllChange={this.onSelectAllChange}
onSelectedChange={this.onSelectedChange}
onRemoveSelectedStateItem={this.onRemoveSelectedStateItem}
onScroll={this.onScroll}
/>
}
</PageContentBodyConnector>
{
!rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length &&
<ImportSeriesFooterConnector
selectedIds={this.getSelectedIds()}
showLanguageProfile={showLanguageProfile}
onInputChange={this.onInputChange}
onImportPress={this.onImportPress}
/>
}
</PageContent>
);
}
}
ImportSeries.propTypes = {
rootFolderId: PropTypes.number.isRequired,
path: PropTypes.string,
rootFoldersFetching: PropTypes.bool.isRequired,
rootFoldersPopulated: PropTypes.bool.isRequired,
rootFoldersError: PropTypes.object,
unmappedFolders: PropTypes.arrayOf(PropTypes.object),
items: PropTypes.arrayOf(PropTypes.object),
showLanguageProfile: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired,
onImportPress: PropTypes.func.isRequired
};
ImportSeries.defaultProps = {
unmappedFolders: []
};
export default ImportSeries;

View file

@ -0,0 +1,121 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setImportSeriesValue, importSeries, clearImportSeries } from 'Store/Actions/importSeriesActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import { setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
import createRouteMatchShape from 'Helpers/Props/Shapes/createRouteMatchShape';
import ImportSeries from './ImportSeries';
function createMapStateToProps() {
return createSelector(
(state, { match }) => match,
(state) => state.rootFolders,
(state) => state.addSeries,
(state) => state.importSeries,
(state) => state.settings.languageProfiles,
(match, rootFolders, addSeries, importSeriesState, languageProfiles) => {
const {
isFetching: rootFoldersFetching,
isPopulated: rootFoldersPopulated,
error: rootFoldersError,
items
} = rootFolders;
const rootFolderId = parseInt(match.params.rootFolderId);
const result = {
rootFolderId,
rootFoldersFetching,
rootFoldersPopulated,
rootFoldersError,
showLanguageProfile: languageProfiles.items.length > 1
};
if (items.length) {
const rootFolder = _.find(items, { id: rootFolderId });
return {
...result,
...rootFolder,
items: importSeriesState.items
};
}
return result;
}
);
}
const mapDispatchToProps = {
setImportSeriesValue,
importSeries,
clearImportSeries,
fetchRootFolders,
setAddSeriesDefault
};
class ImportSeriesConnector extends Component {
//
// Lifecycle
componentDidMount() {
if (!this.props.rootFoldersPopulated) {
this.props.fetchRootFolders();
}
}
componentWillUnmount() {
this.props.clearImportSeries();
}
//
// Listeners
onInputChange = (ids, name, value) => {
this.props.setAddSeriesDefault({ [name]: value });
ids.forEach((id) => {
this.props.setImportSeriesValue({
id,
[name]: value
});
});
}
onImportPress = (ids) => {
this.props.importSeries({ ids });
}
//
// Render
render() {
return (
<ImportSeries
{...this.props}
onInputChange={this.onInputChange}
onImportPress={this.onImportPress}
/>
);
}
}
const routeMatchShape = createRouteMatchShape({
rootFolderId: PropTypes.string.isRequired
});
ImportSeriesConnector.propTypes = {
match: routeMatchShape.isRequired,
rootFoldersPopulated: PropTypes.bool.isRequired,
setImportSeriesValue: PropTypes.func.isRequired,
importSeries: PropTypes.func.isRequired,
clearImportSeries: PropTypes.func.isRequired,
fetchRootFolders: PropTypes.func.isRequired,
setAddSeriesDefault: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesConnector);

View file

@ -0,0 +1,27 @@
.inputContainer {
margin-right: 20px;
min-width: 150px;
}
.label {
margin-bottom: 3px;
font-weight: bold;
}
.importButtonContainer {
display: flex;
align-items: center;
}
.importButton {
composes: button from 'Components/Link/SpinnerButton.css';
height: 35px;
}
.loading {
composes: loading from 'Components/Loading/LoadingIndicator.css';
margin: 0 10px 0 12px;
text-align: left;
}

View file

@ -0,0 +1,263 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { inputTypes, kinds } from 'Helpers/Props';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import CheckInput from 'Components/Form/CheckInput';
import FormInputGroup from 'Components/Form/FormInputGroup';
import PageContentFooter from 'Components/Page/PageContentFooter';
import styles from './ImportSeriesFooter.css';
const MIXED = 'mixed';
class ImportSeriesFooter extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const {
defaultMonitor,
defaultQualityProfileId,
defaultLanguageProfileId,
defaultSeasonFolder,
defaultSeriesType
} = props;
this.state = {
monitor: defaultMonitor,
qualityProfileId: defaultQualityProfileId,
languageProfileId: defaultLanguageProfileId,
seriesType: defaultSeriesType,
seasonFolder: defaultSeasonFolder
};
}
componentDidUpdate(prevProps, prevState) {
const {
defaultMonitor,
defaultQualityProfileId,
defaultLanguageProfileId,
defaultSeriesType,
defaultSeasonFolder,
isMonitorMixed,
isQualityProfileIdMixed,
isLanguageProfileIdMixed,
isSeriesTypeMixed,
isSeasonFolderMixed
} = this.props;
const {
monitor,
qualityProfileId,
languageProfileId,
seriesType,
seasonFolder
} = this.state;
const newState = {};
if (isMonitorMixed && monitor !== MIXED) {
newState.monitor = MIXED;
} else if (!isMonitorMixed && monitor !== defaultMonitor) {
newState.monitor = defaultMonitor;
}
if (isQualityProfileIdMixed && qualityProfileId !== MIXED) {
newState.qualityProfileId = MIXED;
} else if (!isQualityProfileIdMixed && qualityProfileId !== defaultQualityProfileId) {
newState.qualityProfileId = defaultQualityProfileId;
}
if (isLanguageProfileIdMixed && languageProfileId !== MIXED) {
newState.languageProfileId = MIXED;
} else if (!isLanguageProfileIdMixed && languageProfileId !== defaultLanguageProfileId) {
newState.languageProfileId = defaultLanguageProfileId;
}
if (isSeriesTypeMixed && seriesType !== MIXED) {
newState.seriesType = MIXED;
} else if (!isSeriesTypeMixed && seriesType !== defaultSeriesType) {
newState.seriesType = defaultSeriesType;
}
if (isSeasonFolderMixed && seasonFolder != null) {
newState.seasonFolder = null;
} else if (!isSeasonFolderMixed && seasonFolder !== defaultSeasonFolder) {
newState.seasonFolder = defaultSeasonFolder;
}
if (!_.isEmpty(newState)) {
this.setState(newState);
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.setState({ [name]: value });
this.props.onInputChange({ name, value });
}
//
// Render
render() {
const {
selectedCount,
isImporting,
isLookingUpSeries,
isMonitorMixed,
isQualityProfileIdMixed,
isLanguageProfileIdMixed,
isSeriesTypeMixed,
showLanguageProfile,
onImportPress
} = this.props;
const {
monitor,
qualityProfileId,
languageProfileId,
seriesType,
seasonFolder
} = this.state;
return (
<PageContentFooter>
<div className={styles.inputContainer}>
<div className={styles.label}>
Monitor
</div>
<FormInputGroup
type={inputTypes.MONITOR_EPISODES_SELECT}
name="monitor"
value={monitor}
isDisabled={!selectedCount}
includeMixed={isMonitorMixed}
onChange={this.onInputChange}
/>
</div>
<div className={styles.inputContainer}>
<div className={styles.label}>
Quality Profile
</div>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
value={qualityProfileId}
isDisabled={!selectedCount}
includeMixed={isQualityProfileIdMixed}
onChange={this.onInputChange}
/>
</div>
{
showLanguageProfile &&
<div className={styles.inputContainer}>
<div className={styles.label}>
Language Profile
</div>
<FormInputGroup
type={inputTypes.LANGUAGE_PROFILE_SELECT}
name="languageProfileId"
value={languageProfileId}
isDisabled={!selectedCount}
includeMixed={isLanguageProfileIdMixed}
onChange={this.onInputChange}
/>
</div>
}
<div className={styles.inputContainer}>
<div className={styles.label}>
Series Type
</div>
<FormInputGroup
type={inputTypes.SERIES_TYPE_SELECT}
name="seriesType"
value={seriesType}
isDisabled={!selectedCount}
includeMixed={isSeriesTypeMixed}
onChange={this.onInputChange}
/>
</div>
<div className={styles.inputContainer}>
<div className={styles.label}>
Season Folder
</div>
<CheckInput
name="seasonFolder"
value={seasonFolder}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>
</div>
<div>
<div className={styles.label}>
&nbsp;
</div>
<div className={styles.importButtonContainer}>
<SpinnerButton
className={styles.importButton}
kind={kinds.PRIMARY}
isSpinning={isImporting}
isDisabled={!selectedCount || isLookingUpSeries}
onPress={onImportPress}
>
Import {selectedCount} Series
</SpinnerButton>
{
isLookingUpSeries &&
<LoadingIndicator
className={styles.loading}
size={24}
/>
}
{
isLookingUpSeries &&
'Processing Folders'
}
</div>
</div>
</PageContentFooter>
);
}
}
ImportSeriesFooter.propTypes = {
selectedCount: PropTypes.number.isRequired,
isImporting: PropTypes.bool.isRequired,
isLookingUpSeries: PropTypes.bool.isRequired,
defaultMonitor: PropTypes.string.isRequired,
defaultQualityProfileId: PropTypes.number,
defaultLanguageProfileId: PropTypes.number,
defaultSeriesType: PropTypes.string.isRequired,
defaultSeasonFolder: PropTypes.bool.isRequired,
isMonitorMixed: PropTypes.bool.isRequired,
isQualityProfileIdMixed: PropTypes.bool.isRequired,
isLanguageProfileIdMixed: PropTypes.bool.isRequired,
isSeriesTypeMixed: PropTypes.bool.isRequired,
isSeasonFolderMixed: PropTypes.bool.isRequired,
showLanguageProfile: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired,
onImportPress: PropTypes.func.isRequired
};
export default ImportSeriesFooter;

View file

@ -0,0 +1,57 @@
import _ from 'lodash';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import ImportSeriesFooter from './ImportSeriesFooter';
function isMixed(items, selectedIds, defaultValue, key) {
return _.some(items, (series) => {
return selectedIds.indexOf(series.id) > -1 && series[key] !== defaultValue;
});
}
function createMapStateToProps() {
return createSelector(
(state) => state.addSeries,
(state) => state.importSeries,
(state, { selectedIds }) => selectedIds,
(addSeries, importSeries, selectedIds) => {
const {
monitor: defaultMonitor,
qualityProfileId: defaultQualityProfileId,
languageProfileId: defaultLanguageProfileId,
seriesType: defaultSeriesType,
seasonFolder: defaultSeasonFolder
} = addSeries.defaults;
const items = importSeries.items;
const isLookingUpSeries = _.some(importSeries.items, (series) => {
return !series.isPopulated && series.error == null;
});
const isMonitorMixed = isMixed(items, selectedIds, defaultMonitor, 'monitor');
const isQualityProfileIdMixed = isMixed(items, selectedIds, defaultQualityProfileId, 'qualityProfileId');
const isLanguageProfileIdMixed = isMixed(items, selectedIds, defaultLanguageProfileId, 'languageProfileId');
const isSeriesTypeMixed = isMixed(items, selectedIds, defaultSeriesType, 'seriesType');
const isSeasonFolderMixed = isMixed(items, selectedIds, defaultSeasonFolder, 'seasonFolder');
return {
selectedCount: selectedIds.length,
isImporting: importSeries.isImporting,
isLookingUpSeries,
defaultMonitor,
defaultQualityProfileId,
defaultLanguageProfileId,
defaultSeriesType,
defaultSeasonFolder,
isMonitorMixed,
isQualityProfileIdMixed,
isLanguageProfileIdMixed,
isSeriesTypeMixed,
isSeasonFolderMixed
};
}
);
}
export default connect(createMapStateToProps)(ImportSeriesFooter);

View file

@ -0,0 +1,45 @@
.folder {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 1 0 200px;
}
.monitor {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 1 200px;
min-width: 185px;
}
.qualityProfile,
.languageProfile {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 1 250px;
min-width: 170px;
}
.seriesType {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 1 200px;
min-width: 120px;
}
.seasonFolder {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 1 150px;
min-width: 120px;
}
.series {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 1 400px;
min-width: 300px;
}
.detailsIcon {
margin-left: 8px;
}

View file

@ -0,0 +1,115 @@
import PropTypes from 'prop-types';
import React from 'react';
import { icons, tooltipPositions } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover';
import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell';
import SeriesMonitoringOptionsPopoverContent from 'AddArtist/SeriesMonitoringOptionsPopoverContent';
import SeriesTypePopoverContent from 'AddArtist/SeriesTypePopoverContent';
import styles from './ImportSeriesHeader.css';
function ImportSeriesHeader(props) {
const {
showLanguageProfile,
allSelected,
allUnselected,
onSelectAllChange
} = props;
return (
<VirtualTableHeader>
<VirtualTableSelectAllHeaderCell
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
/>
<VirtualTableHeaderCell
className={styles.folder}
name="folder"
>
Folder
</VirtualTableHeaderCell>
<VirtualTableHeaderCell
className={styles.monitor}
name="monitor"
>
Monitor
<Popover
anchor={
<Icon
className={styles.detailsIcon}
name={icons.INFO}
/>
}
title="Monitoring Options"
body={<SeriesMonitoringOptionsPopoverContent />}
position={tooltipPositions.RIGHT}
/>
</VirtualTableHeaderCell>
<VirtualTableHeaderCell
className={styles.qualityProfile}
name="qualityProfileId"
>
Quality Profile
</VirtualTableHeaderCell>
{
showLanguageProfile &&
<VirtualTableHeaderCell
className={styles.languageProfile}
name="languageProfileId"
>
Language Profile
</VirtualTableHeaderCell>
}
<VirtualTableHeaderCell
className={styles.seriesType}
name="seriesType"
>
Series Type
<Popover
anchor={
<Icon
className={styles.detailsIcon}
name={icons.INFO}
/>
}
title="Series Type"
body={<SeriesTypePopoverContent />}
position={tooltipPositions.RIGHT}
/>
</VirtualTableHeaderCell>
<VirtualTableHeaderCell
className={styles.seasonFolder}
name="seasonFolder"
>
Season Folder
</VirtualTableHeaderCell>
<VirtualTableHeaderCell
className={styles.series}
name="series"
>
Series
</VirtualTableHeaderCell>
</VirtualTableHeader>
);
}
ImportSeriesHeader.propTypes = {
showLanguageProfile: PropTypes.bool.isRequired,
allSelected: PropTypes.bool.isRequired,
allUnselected: PropTypes.bool.isRequired,
onSelectAllChange: PropTypes.func.isRequired
};
export default ImportSeriesHeader;

View file

@ -0,0 +1,52 @@
.selectInput {
composes: input from 'Components/Form/CheckInput.css';
}
.folder {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 1 0 200px;
line-height: 36px;
}
.monitor {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 0 1 200px;
min-width: 185px;
}
.qualityProfile,
.languageProfile {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 0 1 250px;
min-width: 170px;
}
.seriesType {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 0 1 200px;
min-width: 120px;
}
.seasonFolder {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 0 1 150px;
min-width: 120px;
}
.series {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 0 1 400px;
min-width: 300px;
}
.hideLanguageProfile {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
display: none;
}

View file

@ -0,0 +1,121 @@
import PropTypes from 'prop-types';
import React from 'react';
import { inputTypes } from 'Helpers/Props';
import FormInputGroup from 'Components/Form/FormInputGroup';
import VirtualTableRow from 'Components/Table/VirtualTableRow';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
import ImportSeriesSelectSeriesConnector from './SelectSeries/ImportSeriesSelectSeriesConnector';
import styles from './ImportSeriesRow.css';
function ImportSeriesRow(props) {
const {
style,
id,
monitor,
qualityProfileId,
languageProfileId,
seasonFolder,
seriesType,
selectedSeries,
isExistingSeries,
showLanguageProfile,
isSelected,
onSelectedChange,
onInputChange
} = props;
return (
<VirtualTableRow style={style}>
<VirtualTableSelectCell
inputClassName={styles.selectInput}
id={id}
isSelected={isSelected}
isDisabled={!selectedSeries || isExistingSeries}
onSelectedChange={onSelectedChange}
/>
<VirtualTableRowCell className={styles.folder}>
{id}
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.monitor}>
<FormInputGroup
type={inputTypes.MONITOR_EPISODES_SELECT}
name="monitor"
value={monitor}
onChange={onInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.qualityProfile}>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
value={qualityProfileId}
onChange={onInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell
className={showLanguageProfile ? styles.languageProfile : styles.hideLanguageProfile}
>
<FormInputGroup
type={inputTypes.LANGUAGE_PROFILE_SELECT}
name="languageProfileId"
value={languageProfileId}
onChange={onInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.seriesType}>
<FormInputGroup
type={inputTypes.SERIES_TYPE_SELECT}
name="seriesType"
value={seriesType}
onChange={onInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.seasonFolder}>
<FormInputGroup
type={inputTypes.CHECK}
name="seasonFolder"
value={seasonFolder}
onChange={onInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.series}>
<ImportSeriesSelectSeriesConnector
id={id}
isExistingSeries={isExistingSeries}
/>
</VirtualTableRowCell>
</VirtualTableRow>
);
}
ImportSeriesRow.propTypes = {
style: PropTypes.object.isRequired,
id: PropTypes.string.isRequired,
monitor: PropTypes.string.isRequired,
qualityProfileId: PropTypes.number.isRequired,
languageProfileId: PropTypes.number.isRequired,
seriesType: PropTypes.string.isRequired,
seasonFolder: PropTypes.bool.isRequired,
selectedSeries: PropTypes.object,
isExistingSeries: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
queued: PropTypes.bool.isRequired,
showLanguageProfile: PropTypes.bool.isRequired,
isSelected: PropTypes.bool,
onSelectedChange: PropTypes.func.isRequired,
onInputChange: PropTypes.func.isRequired
};
ImportSeriesRow.defaultsProps = {
items: []
};
export default ImportSeriesRow;

View file

@ -0,0 +1,91 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { queueLookupSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import ImportSeriesRow from './ImportSeriesRow';
function createImportSeriesItemSelector() {
return createSelector(
(state, { id }) => id,
(state) => state.importSeries.items,
(id, items) => {
return _.find(items, { id }) || {};
}
);
}
function createMapStateToProps() {
return createSelector(
createImportSeriesItemSelector(),
createAllSeriesSelector(),
(item, series) => {
const selectedSeries = item && item.selectedSeries;
const isExistingSeries = !!selectedSeries && _.some(series, { tvdbId: selectedSeries.tvdbId });
return {
...item,
isExistingSeries
};
}
);
}
const mapDispatchToProps = {
queueLookupSeries,
setImportSeriesValue
};
class ImportSeriesRowConnector extends Component {
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setImportSeriesValue({
id: this.props.id,
[name]: value
});
}
//
// Render
render() {
// Don't show the row until we have the information we require for it.
const {
items,
monitor,
seriesType,
seasonFolder
} = this.props;
if (!items || !monitor || !seriesType || !seasonFolder == null) {
return null;
}
return (
<ImportSeriesRow
{...this.props}
onInputChange={this.onInputChange}
onSeriesSelect={this.onSeriesSelect}
/>
);
}
}
ImportSeriesRowConnector.propTypes = {
rootFolderId: PropTypes.number.isRequired,
id: PropTypes.string.isRequired,
monitor: PropTypes.string,
seriesType: PropTypes.string,
seasonFolder: PropTypes.bool,
items: PropTypes.arrayOf(PropTypes.object),
queueLookupSeries: PropTypes.func.isRequired,
setImportSeriesValue: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesRowConnector);

View file

@ -0,0 +1,3 @@
.input {
composes: input from 'Components/Form/CheckInput.css';
}

View file

@ -0,0 +1,213 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import VirtualTable from 'Components/Table/VirtualTable';
import ImportSeriesHeader from './ImportSeriesHeader';
import ImportSeriesRowConnector from './ImportSeriesRowConnector';
class ImportSeriesTable extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._table = null;
}
componentDidMount() {
const {
unmappedFolders,
defaultMonitor,
defaultQualityProfileId,
defaultLanguageProfileId,
defaultSeriesType,
defaultSeasonFolder,
onSeriesLookup,
onSetImportSeriesValue
} = this.props;
const values = {
monitor: defaultMonitor,
qualityProfileId: defaultQualityProfileId,
languageProfileId: defaultLanguageProfileId,
seriesType: defaultSeriesType,
seasonFolder: defaultSeasonFolder
};
unmappedFolders.forEach((unmappedFolder) => {
const id = unmappedFolder.name;
onSeriesLookup(id, unmappedFolder.path);
onSetImportSeriesValue({
id,
...values
});
});
}
// This isn't great, but it's the most reliable way to ensure the items
// are checked off even if they aren't actually visible since the cells
// are virtualized.
componentDidUpdate(prevProps) {
const {
items,
selectedState,
onSelectedChange,
onRemoveSelectedStateItem
} = this.props;
prevProps.items.forEach((prevItem) => {
const {
id
} = prevItem;
const item = _.find(items, { id });
if (!item) {
onRemoveSelectedStateItem(id);
return;
}
const selectedSeries = item.selectedSeries;
const isSelected = selectedState[id];
const isExistingSeries = !!selectedSeries &&
_.some(prevProps.allSeries, { tvdbId: selectedSeries.tvdbId });
// Props doesn't have a selected series or
// the selected series is an existing series.
if ((selectedSeries && !prevItem.selectedSeries) || (isExistingSeries && !prevItem.selectedSeries)) {
onSelectedChange({ id, value: false });
return;
}
// State is selected, but a series isn't selected or
// the selected series is an existing series.
if (isSelected && (!selectedSeries || isExistingSeries)) {
onSelectedChange({ id, value: false });
return;
}
// A series is being selected that wasn't previously selected.
if (selectedSeries && selectedSeries !== prevItem.selectedSeries) {
onSelectedChange({ id, value: true });
return;
}
});
// Forces the table to re-render if the selected state
// has changed otherwise it will be stale.
if (prevProps.selectedState !== selectedState && this._table) {
this._table.forceUpdateGrid();
}
}
//
// Control
setTableRef = (ref) => {
this._table = ref;
}
rowRenderer = ({ key, rowIndex, style }) => {
const {
rootFolderId,
items,
selectedState,
showLanguageProfile,
onSelectedChange
} = this.props;
const item = items[rowIndex];
return (
<ImportSeriesRowConnector
key={key}
style={style}
rootFolderId={rootFolderId}
showLanguageProfile={showLanguageProfile}
isSelected={selectedState[item.id]}
onSelectedChange={onSelectedChange}
id={item.id}
/>
);
}
//
// Render
render() {
const {
items,
allSelected,
allUnselected,
isSmallScreen,
contentBody,
showLanguageProfile,
scrollTop,
onSelectAllChange,
onScroll
} = this.props;
if (!items.length) {
return null;
}
return (
<VirtualTable
ref={this.setTableRef}
items={items}
contentBody={contentBody}
isSmallScreen={isSmallScreen}
rowHeight={52}
scrollTop={scrollTop}
overscanRowCount={2}
rowRenderer={this.rowRenderer}
header={
<ImportSeriesHeader
showLanguageProfile={showLanguageProfile}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
/>
}
onScroll={onScroll}
/>
);
}
}
ImportSeriesTable.propTypes = {
rootFolderId: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object),
unmappedFolders: PropTypes.arrayOf(PropTypes.object),
defaultMonitor: PropTypes.string.isRequired,
defaultQualityProfileId: PropTypes.number,
defaultLanguageProfileId: PropTypes.number,
defaultSeriesType: PropTypes.string.isRequired,
defaultSeasonFolder: PropTypes.bool.isRequired,
allSelected: PropTypes.bool.isRequired,
allUnselected: PropTypes.bool.isRequired,
selectedState: PropTypes.object.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
allSeries: PropTypes.arrayOf(PropTypes.object),
contentBody: PropTypes.object.isRequired,
showLanguageProfile: PropTypes.bool.isRequired,
scrollTop: PropTypes.number.isRequired,
onSelectAllChange: PropTypes.func.isRequired,
onSelectedChange: PropTypes.func.isRequired,
onRemoveSelectedStateItem: PropTypes.func.isRequired,
onSeriesLookup: PropTypes.func.isRequired,
onSetImportSeriesValue: PropTypes.func.isRequired,
onScroll: PropTypes.func.isRequired
};
export default ImportSeriesTable;

View file

@ -0,0 +1,44 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { queueLookupSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import ImportSeriesTable from './ImportSeriesTable';
function createMapStateToProps() {
return createSelector(
(state) => state.addSeries,
(state) => state.importSeries,
(state) => state.app.dimensions,
createAllSeriesSelector(),
(addSeries, importSeries, dimensions, allSeries) => {
return {
defaultMonitor: addSeries.defaults.monitor,
defaultQualityProfileId: addSeries.defaults.qualityProfileId,
defaultLanguageProfileId: addSeries.defaults.languageProfileId,
defaultSeriesType: addSeries.defaults.seriesType,
defaultSeasonFolder: addSeries.defaults.seasonFolder,
items: importSeries.items,
isSmallScreen: dimensions.isSmallScreen,
allSeries
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onSeriesLookup(name, path) {
dispatch(queueLookupSeries({
name,
path,
term: name
}));
},
onSetImportSeriesValue(values) {
dispatch(setImportSeriesValue(values));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(ImportSeriesTable);

View file

@ -0,0 +1,8 @@
.series {
padding: 10px 20px;
width: 100%;
&:hover {
background-color: $menuItemHoverColor;
}
}

View file

@ -0,0 +1,52 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Link from 'Components/Link/Link';
import ImportSeriesTitle from './ImportSeriesTitle';
import styles from './ImportSeriesSearchResult.css';
class ImportSeriesSearchResult extends Component {
//
// Listeners
onPress = () => {
this.props.onPress(this.props.tvdbId);
}
//
// Render
render() {
const {
title,
year,
network,
isExistingSeries
} = this.props;
return (
<Link
className={styles.series}
onPress={this.onPress}
>
<ImportSeriesTitle
title={title}
year={year}
network={network}
isExistingSeries={isExistingSeries}
/>
</Link>
);
}
}
ImportSeriesSearchResult.propTypes = {
tvdbId: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
year: PropTypes.number.isRequired,
network: PropTypes.string,
isExistingSeries: PropTypes.bool.isRequired,
onPress: PropTypes.func.isRequired
};
export default ImportSeriesSearchResult;

View file

@ -0,0 +1,17 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
import ImportSeriesSearchResult from './ImportSeriesSearchResult';
function createMapStateToProps() {
return createSelector(
createExistingSeriesSelector(),
(isExistingSeries) => {
return {
isExistingSeries
};
}
);
}
export default connect(createMapStateToProps)(ImportSeriesSearchResult);

View file

@ -0,0 +1,72 @@
.tether {
z-index: 2000;
}
.button {
composes: link from 'Components/Link/Link.css';
position: relative;
display: flex;
align-items: center;
padding: 6px 16px;
width: 100%;
height: 35px;
border: 1px solid $inputBorderColor;
border-radius: 4px;
background-color: $white;
box-shadow: inset 0 1px 1px $inputBoxShadowColor;
}
.loading {
display: inline-block;
}
.warningIcon {
margin-right: 8px;
}
.existing {
margin-left: 5px;
}
.dropdownArrowContainer {
position: absolute;
right: 16px;
}
.contentContainer {
margin-top: 4px;
padding: 0 8px;
width: 400px;
}
.content {
padding: 4px;
border: 1px solid $inputBorderColor;
border-radius: 4px;
background-color: $white;
}
.searchContainer {
display: flex;
}
.searchIconContainer {
width: 58px;
border: 1px solid $inputBorderColor;
border-right: none;
border-radius: 4px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
background-color: #edf1f2;
text-align: center;
line-height: 33px;
}
.searchInput {
composes: text from 'Components/Form/TextInput.css';
/*border-left: 0;*/
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}

View file

@ -0,0 +1,268 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import TetherComponent from 'react-tether';
import { icons, kinds } from 'Helpers/Props';
import Icon from 'Components/Icon';
import SpinnerIcon from 'Components/SpinnerIcon';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import TextInput from 'Components/Form/TextInput';
import ImportSeriesSearchResultConnector from './ImportSeriesSearchResultConnector';
import ImportSeriesTitle from './ImportSeriesTitle';
import styles from './ImportSeriesSelectSeries.css';
const tetherOptions = {
skipMoveElement: true,
constraints: [
{
to: 'window',
attachment: 'together',
pin: true
}
],
attachment: 'top center',
targetAttachment: 'bottom center'
};
class ImportSeriesSelectSeries extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._seriesLookupTimeout = null;
this.state = {
term: props.id,
isOpen: false
};
}
//
// Control
_setButtonRef = (ref) => {
this._buttonRef = ref;
}
_setContentRef = (ref) => {
this._contentRef = ref;
}
_addListener() {
window.addEventListener('click', this.onWindowClick);
}
_removeListener() {
window.removeEventListener('click', this.onWindowClick);
}
//
// Listeners
onWindowClick = (event) => {
const button = ReactDOM.findDOMNode(this._buttonRef);
const content = ReactDOM.findDOMNode(this._contentRef);
if (!button) {
return;
}
if (!button.contains(event.target) && content && !content.contains(event.target) && this.state.isOpen) {
this.setState({ isOpen: false });
this._removeListener();
}
}
onPress = () => {
if (this.state.isOpen) {
this._removeListener();
} else {
this._addListener();
}
this.setState({ isOpen: !this.state.isOpen });
}
onSearchInputChange = ({ value }) => {
if (this._seriesLookupTimeout) {
clearTimeout(this._seriesLookupTimeout);
}
this.setState({ term: value }, () => {
this._seriesLookupTimeout = setTimeout(() => {
this.props.onSearchInputChange(value);
}, 200);
});
}
onSeriesSelect = (tvdbId) => {
this.setState({ isOpen: false });
this.props.onSeriesSelect(tvdbId);
}
//
// Render
render() {
const {
selectedSeries,
isExistingSeries,
isFetching,
isPopulated,
error,
items,
queued,
onSeriesSelect
} = this.props;
const errorMessage = error &&
error.responseJSON &&
error.responseJSON.message;
return (
<TetherComponent
classes={{
element: styles.tether
}}
{...tetherOptions}
>
<Link
ref={this._setButtonRef}
className={styles.button}
component="div"
onPress={this.onPress}
>
{
queued && !isPopulated &&
<LoadingIndicator
className={styles.loading}
size={20}
/>
}
{
isPopulated && selectedSeries && isExistingSeries &&
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/>
}
{
isPopulated && selectedSeries &&
<ImportSeriesTitle
title={selectedSeries.title}
year={selectedSeries.year}
network={selectedSeries.network}
isExistingSeries={isExistingSeries}
/>
}
{
isPopulated && !selectedSeries &&
<div>
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/>
No match found!
</div>
}
{
!isFetching && !!error &&
<div>
<Icon
className={styles.warningIcon}
title={errorMessage}
name={icons.WARNING}
kind={kinds.WARNING}
/>
Search failed, please try again later.
</div>
}
<div className={styles.dropdownArrowContainer}>
<Icon
name={icons.CARET_DOWN}
/>
</div>
</Link>
{
this.state.isOpen &&
<div
ref={this._setContentRef}
className={styles.contentContainer}
>
<div className={styles.content}>
<div className={styles.searchContainer}>
<div className={styles.searchIconContainer}>
<SpinnerIcon
name={icons.SEARCH}
isSpinning={isFetching}
/>
</div>
<TextInput
className={styles.searchInput}
name={`${name}_textInput`}
value={this.state.term}
onChange={this.onSearchInputChange}
/>
</div>
<div className={styles.results}>
{
items.map((item) => {
return (
<ImportSeriesSearchResultConnector
key={item.tvdbId}
tvdbId={item.tvdbId}
title={item.title}
year={item.year}
network={item.network}
onPress={this.onSeriesSelect}
/>
);
})
}
</div>
</div>
</div>
}
</TetherComponent>
);
}
}
ImportSeriesSelectSeries.propTypes = {
id: PropTypes.string.isRequired,
selectedSeries: PropTypes.object,
isExistingSeries: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
queued: PropTypes.bool.isRequired,
onSearchInputChange: PropTypes.func.isRequired,
onSeriesSelect: PropTypes.func.isRequired
};
ImportSeriesSelectSeries.defaultProps = {
isFetching: true,
isPopulated: false,
items: [],
queued: true
};
export default ImportSeriesSelectSeries;

View file

@ -0,0 +1,71 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { queueLookupSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions';
import createImportSeriesItemSelector from 'Store/Selectors/createImportSeriesItemSelector';
import ImportSeriesSelectSeries from './ImportSeriesSelectSeries';
function createMapStateToProps() {
return createSelector(
createImportSeriesItemSelector(),
(item) => {
return item;
}
);
}
const mapDispatchToProps = {
queueLookupSeries,
setImportSeriesValue
};
class ImportSeriesSelectSeriesConnector extends Component {
//
// Listeners
onSearchInputChange = (term) => {
this.props.queueLookupSeries({
name: this.props.id,
term
});
}
onSeriesSelect = (tvdbId) => {
const {
id,
items
} = this.props;
this.props.setImportSeriesValue({
id,
selectedSeries: _.find(items, { tvdbId })
});
}
//
// Render
render() {
return (
<ImportSeriesSelectSeries
{...this.props}
onSearchInputChange={this.onSearchInputChange}
onSeriesSelect={this.onSeriesSelect}
/>
);
}
}
ImportSeriesSelectSeriesConnector.propTypes = {
id: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.object),
selectedSeries: PropTypes.object,
isSelected: PropTypes.bool,
queueLookupSeries: PropTypes.func.isRequired,
setImportSeriesValue: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesSelectSeriesConnector);

View file

@ -0,0 +1,17 @@
.titleContainer {
display: flex;
align-items: center;
}
.title {
margin-right: 5px;
}
.year {
margin-left: 5px;
color: $disabledColor;
}
.existing {
margin-left: 5px;
}

View file

@ -0,0 +1,50 @@
import PropTypes from 'prop-types';
import React from 'react';
import { kinds } from 'Helpers/Props';
import Label from 'Components/Label';
import styles from './ImportSeriesTitle.css';
function ImportSeriesTitle(props) {
const {
title,
year,
network,
isExistingSeries
} = props;
return (
<div className={styles.titleContainer}>
<div className={styles.title}>
{title}
{
!title.contains(year) &&
<span className={styles.year}>({year})</span>
}
</div>
{
!!network &&
<Label>{network}</Label>
}
{
isExistingSeries &&
<Label
kind={kinds.WARNING}
>
Existing
</Label>
}
</div>
);
}
ImportSeriesTitle.propTypes = {
title: PropTypes.string.isRequired,
year: PropTypes.number.isRequired,
network: PropTypes.string,
isExistingSeries: PropTypes.bool.isRequired
};
export default ImportSeriesTitle;

View file

@ -0,0 +1,30 @@
import React, { Component } from 'react';
import { Route } from 'react-router-dom';
import Switch from 'Components/Router/Switch';
import ImportSeriesSelectFolderConnector from 'AddArtist/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector';
import ImportSeriesConnector from 'AddArtist/ImportSeries/Import/ImportSeriesConnector';
class ImportSeries extends Component {
//
// Render
render() {
return (
<Switch>
<Route
exact={true}
path="/add/import"
component={ImportSeriesSelectFolderConnector}
/>
<Route
path="/add/import/:rootFolderId"
component={ImportSeriesConnector}
/>
</Switch>
);
}
}
export default ImportSeries;

View file

@ -0,0 +1,18 @@
.link {
composes: link from 'Components/Link/Link.css';
display: block;
}
.freeSpace,
.unmappedFolders {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 150px;
}
.actions {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 45px;
}

View file

@ -0,0 +1,64 @@
import PropTypes from 'prop-types';
import React from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
import { icons } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import styles from './ImportSeriesRootFolderRow.css';
function ImportSeriesRootFolderRow(props) {
const {
id,
path,
freeSpace,
unmappedFolders,
onDeletePress
} = props;
const unmappedFoldersCount = unmappedFolders.length || '-';
return (
<TableRow>
<TableRowCell>
<Link
className={styles.link}
to={`/add/import/${id}`}
>
{path}
</Link>
</TableRowCell>
<TableRowCell className={styles.freeSpace}>
{formatBytes(freeSpace) || '-'}
</TableRowCell>
<TableRowCell className={styles.unmappedFolders}>
{unmappedFoldersCount}
</TableRowCell>
<TableRowCell className={styles.actions}>
<IconButton
name={icons.REMOVE}
onPress={onDeletePress}
/>
</TableRowCell>
</TableRow>
);
}
ImportSeriesRootFolderRow.propTypes = {
id: PropTypes.number.isRequired,
path: PropTypes.string.isRequired,
freeSpace: PropTypes.number.isRequired,
unmappedFolders: PropTypes.arrayOf(PropTypes.object).isRequired,
onDeletePress: PropTypes.func.isRequired
};
ImportSeriesRootFolderRow.defaultProps = {
freeSpace: 0,
unmappedFolders: []
};
export default ImportSeriesRootFolderRow;

View file

@ -0,0 +1,48 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { deleteRootFolder } from 'Store/Actions/rootFolderActions';
import ImportSeriesRootFolderRow from './ImportSeriesRootFolderRow';
function createMapStateToProps() {
return createSelector(
() => {
return {
};
}
);
}
const mapDispatchToProps = {
deleteRootFolder
};
class ImportSeriesRootFolderRowConnector extends Component {
//
// Listeners
onDeletePress = () => {
this.props.deleteRootFolder({ id: this.props.id });
}
//
// Render
render() {
return (
<ImportSeriesRootFolderRow
{...this.props}
onDeletePress={this.onDeletePress}
/>
);
}
}
ImportSeriesRootFolderRowConnector.propTypes = {
id: PropTypes.number.isRequired,
deleteRootFolder: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesRootFolderRowConnector);

View file

@ -0,0 +1,32 @@
.header {
margin-bottom: 40px;
text-align: center;
font-weight: 300;
font-size: 36px;
}
.tips {
font-size: 20px;
}
.tip {
font-size: $defaultFontSize;
}
.code {
font-size: 12px;
font-family: $monoSpaceFontFamily;
}
.recentFolders {
margin-top: 40px;
}
.startImport {
margin-top: 40px;
text-align: center;
}
.importButtonIcon {
margin-right: 8px;
}

View file

@ -0,0 +1,188 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { kinds, sizes } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import ImportSeriesRootFolderRowConnector from './ImportSeriesRootFolderRowConnector';
import styles from './ImportSeriesSelectFolder.css';
const rootFolderColumns = [
{
name: 'path',
label: 'Path',
isVisible: true
},
{
name: 'freeSpace',
label: 'Free Space',
isVisible: true
},
{
name: 'unmappedFolders',
label: 'Unmapped Folders',
isVisible: true
},
{
name: 'actions',
isVisible: true
}
];
class ImportSeriesSelectFolder extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isAddNewRootFolderModalOpen: false
};
}
//
// Lifecycle
onAddNewRootFolderPress = () => {
this.setState({ isAddNewRootFolderModalOpen: true });
}
onNewRootFolderSelect = ({ value }) => {
this.props.onNewRootFolderSelect(value);
}
onAddRootFolderModalClose = () => {
this.setState({ isAddNewRootFolderModalOpen: false });
}
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items
} = this.props;
return (
<PageContent title="Import Series">
<PageContentBodyConnector>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>Unable to load root folders</div>
}
{
!error && isPopulated &&
<div>
<div className={styles.header}>
Import series you already have
</div>
<div className={styles.tips}>
Some tips to ensure the import goes smoothly:
<ul>
<li className={styles.tip}>
Make sure your files include the quality in the name. eg. <span className={styles.code}>episode.s02e15.bluray.mkv</span>
</li>
<li className={styles.tip}>
Point Sonarr to the folder containing all of your tv shows not a specific one. eg. <span className={styles.code}>"\tv shows\"</span> and not <span className={styles.code}>"\tv shows\the simpsons\"</span>
</li>
</ul>
</div>
{
items.length > 0 ?
<div className={styles.recentFolders}>
<FieldSet
legend="Recent Folders"
>
<Table
columns={rootFolderColumns}
>
<TableBody>
{
items.map((rootFolder) => {
return (
<ImportSeriesRootFolderRowConnector
key={rootFolder.id}
id={rootFolder.id}
path={rootFolder.path}
freeSpace={rootFolder.freeSpace}
unmappedFolders={rootFolder.unmappedFolders}
/>
);
})
}
</TableBody>
</Table>
</FieldSet>
<Button
kind={kinds.PRIMARY}
size={sizes.LARGE}
onPress={this.onAddNewRootFolderPress}
>
<Icon
className={styles.importButtonIcon}
name="fa fa-hdd-o"
/>
Choose another folder
</Button>
</div> :
<div className={styles.startImport}>
<Button
kind={kinds.PRIMARY}
size={sizes.LARGE}
onPress={this.onAddNewRootFolderPress}
>
<Icon
className={styles.importButtonIcon}
name="fa fa-hdd-o"
/>
Start Import
</Button>
</div>
}
<FileBrowserModal
isOpen={this.state.isAddNewRootFolderModalOpen}
name="rootFolderPath"
value=""
onChange={this.onNewRootFolderSelect}
onModalClose={this.onAddRootFolderModalClose}
/>
</div>
}
</PageContentBodyConnector>
</PageContent>
);
}
}
ImportSeriesSelectFolder.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onNewRootFolderSelect: PropTypes.func.isRequired,
onDeleteRootFolderPress: PropTypes.func.isRequired
};
export default ImportSeriesSelectFolder;

View file

@ -0,0 +1,87 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { push } from 'react-router-redux';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import { fetchRootFolders, addRootFolder, deleteRootFolder } from 'Store/Actions/rootFolderActions';
import ImportSeriesSelectFolder from './ImportSeriesSelectFolder';
function createMapStateToProps() {
return createSelector(
(state) => state.rootFolders,
(rootFolders) => {
return rootFolders;
}
);
}
const mapDispatchToProps = {
fetchRootFolders,
addRootFolder,
deleteRootFolder,
push
};
class ImportSeriesSelectFolderConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchRootFolders();
}
componentDidUpdate(prevProps) {
const {
items,
isSaving,
saveError
} = this.props;
if (prevProps.isSaving && !isSaving && !saveError) {
const newRootFolders = _.differenceBy(items, prevProps.items, (item) => item.id);
if (newRootFolders.length === 1) {
this.props.push(`${window.Sonarr.urlBase}/add/import/${newRootFolders[0].id}`);
}
}
}
//
// Listeners
onNewRootFolderSelect = (path) => {
this.props.addRootFolder({ path });
}
onDeleteRootFolderPress = (id) => {
this.props.deleteRootFolder({ id });
}
//
// Render
render() {
return (
<ImportSeriesSelectFolder
{...this.props}
onNewRootFolderSelect={this.onNewRootFolderSelect}
onDeleteRootFolderPress={this.onDeleteRootFolderPress}
/>
);
}
}
ImportSeriesSelectFolderConnector.propTypes = {
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchRootFolders: PropTypes.func.isRequired,
addRootFolder: PropTypes.func.isRequired,
deleteRootFolder: PropTypes.func.isRequired,
push: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesSelectFolderConnector);

View file

@ -0,0 +1,46 @@
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
function SeriesMonitoringOptionsPopoverContent() {
return (
<DescriptionList>
<DescriptionListItem
title="All Episodes"
data="Monitor all episodes except specials"
/>
<DescriptionListItem
title="Future Episodes"
data="Monitor episodes that have not aired yet"
/>
<DescriptionListItem
title="Missing Episodes"
data="Monitor episodes that do not have files or have not aired yet"
/>
<DescriptionListItem
title="Existing Episodes"
data="Monitor episodes that have files or have not aired yet"
/>
<DescriptionListItem
title="First Season"
data="Monitor all episodes of the first season. All other seasons will be ignored"
/>
<DescriptionListItem
title="Latest Season"
data="Monitor all episodes of the latest season and future seasons"
/>
<DescriptionListItem
title="None"
data="No episodes will be monitored."
/>
</DescriptionList>
);
}
export default SeriesMonitoringOptionsPopoverContent;

View file

@ -0,0 +1,26 @@
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
function SeriesTypePopoverContent() {
return (
<DescriptionList>
<DescriptionListItem
title="Anime"
data="Episodes released using an absolute episode number"
/>
<DescriptionListItem
title="Daily"
data="Episodes released daily or less frequently that use year-month-day (2017-05-25)"
/>
<DescriptionListItem
title="Standard"
data="Episodes released with SxxEyy pattern"
/>
</DescriptionList>
);
}
export default SeriesTypePopoverContent;

245
frontend/src/App/App.js Normal file
View file

@ -0,0 +1,245 @@
import PropTypes from 'prop-types';
import React from 'react';
import DocumentTitle from 'react-document-title';
import { Provider } from 'react-redux';
import { Route, Redirect } from 'react-router-dom';
import { ConnectedRouter } from 'react-router-redux';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
import NotFound from 'Components/NotFound';
import Switch from 'Components/Router/Switch';
import PageConnector from 'Components/Page/PageConnector';
import ArtistIndexConnector from 'Artist/Index/ArtistIndexConnector';
import AddNewSeriesConnector from 'AddArtist/AddNewSeries/AddNewSeriesConnector';
import ImportSeries from 'AddArtist/ImportSeries/ImportSeries';
import SeriesEditorConnector from 'Artist/Editor/SeriesEditorConnector';
import SeasonPassConnector from 'SeasonPass/SeasonPassConnector';
import SeriesDetailsPageConnector from 'Artist/Details/SeriesDetailsPageConnector';
import CalendarPageConnector from 'Calendar/CalendarPageConnector';
import HistoryConnector from 'Activity/History/HistoryConnector';
import QueueConnector from 'Activity/Queue/QueueConnector';
import BlacklistConnector from 'Activity/Blacklist/BlacklistConnector';
import MissingConnector from 'Wanted/Missing/MissingConnector';
import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
import UISettingsConnector from 'Settings/UI/UISettingsConnector';
import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector';
import Profiles from 'Settings/Profiles/Profiles';
import Quality from 'Settings/Quality/Quality';
import IndexerSettings from 'Settings/Indexers/IndexerSettings';
import DownloadClientSettings from 'Settings/DownloadClients/DownloadClientSettings';
import NotificationSettings from 'Settings/Notifications/NotificationSettings';
import MetadataSettings from 'Settings/Metadata/MetadataSettings';
import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
import Status from 'System/Status/Status';
import TasksConnector from 'System/Tasks/TasksConnector';
import BackupsConnector from 'System/Backup/BackupsConnector';
import UpdatesConnector from 'System/Updates/UpdatesConnector';
import LogsTableConnector from 'System/Events/LogsTableConnector';
import Logs from 'System/Logs/Logs';
function App({ store, history }) {
return (
<DocumentTitle title="Lidarr">
<Provider store={store}>
<ConnectedRouter history={history}>
<PageConnector>
<Switch>
{/*
Series
*/}
<Route
exact={true}
path="/"
component={ArtistIndexConnector}
/>
{
window.Sonarr.urlBase &&
<Route
exact={true}
path="/"
addUrlBase={false}
render={() => {
return (
<Redirect
to={getPathWithUrlBase('/')}
component={App}
/>
);
}}
/>
}
<Route
path="/add/new"
component={AddNewSeriesConnector}
/>
<Route
path="/add/import"
component={ImportSeries}
/>
<Route
path="/serieseditor"
component={SeriesEditorConnector}
/>
<Route
path="/seasonpass"
component={SeasonPassConnector}
/>
<Route
path="/series/:titleSlug"
component={SeriesDetailsPageConnector}
/>
{/*
Calendar
*/}
<Route
path="/calendar"
component={CalendarPageConnector}
/>
{/*
Activity
*/}
<Route
path="/activity/history"
component={HistoryConnector}
/>
<Route
path="/activity/queue"
component={QueueConnector}
/>
<Route
path="/activity/blacklist"
component={BlacklistConnector}
/>
{/*
Wanted
*/}
<Route
path="/wanted/missing"
component={MissingConnector}
/>
<Route
path="/wanted/cutoffunmet"
component={CutoffUnmetConnector}
/>
{/*
Settings
*/}
<Route
path="/settings/ui"
component={UISettingsConnector}
/>
<Route
path="/settings/mediamanagement"
component={MediaManagementConnector}
/>
<Route
path="/settings/profiles"
component={Profiles}
/>
<Route
path="/settings/quality"
component={Quality}
/>
<Route
path="/settings/indexers"
component={IndexerSettings}
/>
<Route
path="/settings/downloadclients"
component={DownloadClientSettings}
/>
<Route
path="/settings/connect"
component={NotificationSettings}
/>
<Route
path="/settings/metadata"
component={MetadataSettings}
/>
<Route
path="/settings/general"
component={GeneralSettingsConnector}
/>
{/*
System
*/}
<Route
path="/system/status"
component={Status}
/>
<Route
path="/system/tasks"
component={TasksConnector}
/>
<Route
path="/system/backup"
component={BackupsConnector}
/>
<Route
path="/system/updates"
component={UpdatesConnector}
/>
<Route
path="/system/events"
component={LogsTableConnector}
/>
<Route
path="/system/logs/files"
component={Logs}
/>
{/*
Not Found
*/}
<Route
path="*"
component={NotFound}
/>
</Switch>
</PageConnector>
</ConnectedRouter>
</Provider>
</DocumentTitle>
);
}
App.propTypes = {
store: PropTypes.object.isRequired,
history: PropTypes.object.isRequired
};
export default App;

View file

@ -0,0 +1,30 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import AppUpdatedModalContentConnector from './AppUpdatedModalContentConnector';
function AppUpdatedModal(props) {
const {
isOpen,
onModalClose
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<AppUpdatedModalContentConnector
onModalClose={onModalClose}
/>
</Modal>
);
}
AppUpdatedModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
version: PropTypes.string.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AppUpdatedModal;

View file

@ -0,0 +1,24 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import AppUpdatedModal from './AppUpdatedModal';
function createMapStateToProps() {
return createSelector(
(state) => state.app.version,
(version) => {
return {
version
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onModalClose() {
location.reload();
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(AppUpdatedModal);

View file

@ -0,0 +1,15 @@
.version {
margin: 0 3px;
font-weight: bold;
}
.maintenance {
margin-top: 20px;
}
.changes {
margin-top: 20px;
padding-bottom: 5px;
font-size: 18px;
border-bottom: 1px solid #e5e5e5;
}

View file

@ -0,0 +1,100 @@
import PropTypes from 'prop-types';
import React from 'react';
import { kinds } from 'Helpers/Props';
import FieldSet from 'Components/FieldSet';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
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 UpdateChanges from 'System/Updates/UpdateChanges';
import styles from './AppUpdatedModalContent.css';
function AppUpdatedModalContent(props) {
const {
version,
isPopulated,
error,
items,
onSeeChangesPress,
onModalClose
} = props;
const update = items[0];
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Sonarr Updated
</ModalHeader>
<ModalBody>
<div>
Version <span className={styles.version}>{version}</span> of Sonarr has been installed, in order to get the latest changes you'll need to reload Sonarr.
</div>
{
isPopulated && !error && !!update &&
<div>
{
!update.changes &&
<div className={styles.maintenance}>Maintenance release</div>
}
{
!!update.changes &&
<div>
<div className={styles.changes}>
What's new?
</div>
<UpdateChanges
title="New"
changes={update.changes.new}
/>
<UpdateChanges
title="Fixed"
changes={update.changes.fixed}
/>
</div>
}
</div>
}
{
!isPopulated && !error &&
<LoadingIndicator />
}
</ModalBody>
<ModalFooter>
<Button
onPress={onSeeChangesPress}
>
Recent Changes
</Button>
<Button
kind={kinds.PRIMARY}
onPress={onModalClose}
>
Reload
</Button>
</ModalFooter>
</ModalContent>
);
}
AppUpdatedModalContent.propTypes = {
isOpen: PropTypes.bool.isRequired,
version: PropTypes.string.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onSeeChangesPress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AppUpdatedModalContent;

View file

@ -0,0 +1,69 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchUpdates } from 'Store/Actions/systemActions';
import AppUpdatedModalContent from './AppUpdatedModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.app.version,
(state) => state.system.updates,
(version, updates) => {
const {
isPopulated,
error,
items
} = updates;
return {
version,
isPopulated,
error,
items
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
dispatchFetchUpdates() {
dispatch(fetchUpdates());
},
onSeeChangesPress() {
window.location = `${window.Sonarr.urlBase}/system/updates`;
}
};
}
class AppUpdatedModalContentConnector extends Component {
//
// Lifecycle
componentDidUpdate() {
this.props.dispatchFetchUpdates();
}
//
// Render
render() {
const {
dispatchFetchUpdates,
...otherProps
} = this.props;
return (
<AppUpdatedModalContent {...otherProps} />
);
}
}
AppUpdatedModalContentConnector.propTypes = {
dispatchFetchUpdates: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, createMapDispatchToProps)(AppUpdatedModalContentConnector);

View file

@ -0,0 +1,3 @@
.automatic {
margin-top: 20px;
}

View file

@ -0,0 +1,55 @@
import PropTypes from 'prop-types';
import React from 'react';
import { kinds } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import Modal from 'Components/Modal/Modal';
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 styles from './ConnectionLostModal.css';
function ConnectionLostModal(props) {
const {
isOpen,
onModalClose
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Connnection Lost
</ModalHeader>
<ModalBody>
<div>
Sonarr has lost it's connection to the backend and will need to be reloaded to restore functionality.
</div>
<div className={styles.automatic}>
Sonarr will try to connect automatically, or you can click reload below.
</div>
</ModalBody>
<ModalFooter>
<Button
kind={kinds.PRIMARY}
onPress={onModalClose}
>
Reload
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
ConnectionLostModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default ConnectionLostModal;

View file

@ -0,0 +1,12 @@
import { connect } from 'react-redux';
import ConnectionLostModal from './ConnectionLostModal';
function createMapDispatchToProps(dispatch, props) {
return {
onModalClose() {
location.reload();
}
};
}
export default connect(undefined, createMapDispatchToProps)(ConnectionLostModal);

View file

@ -0,0 +1,20 @@
import PropTypes from 'prop-types';
import React from 'react';
import Link from 'Components/Link/Link';
function ArtistNameLink({ nameSlug, artistName }) {
const link = `/series/${nameSlug}`;
return (
<Link to={link}>
{artistName}
</Link>
);
}
ArtistNameLink.propTypes = {
nameSlug: PropTypes.string.isRequired,
artistName: PropTypes.string.isRequired
};
export default ArtistNameLink;

View file

@ -0,0 +1,160 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import LazyLoad from 'react-lazyload';
const posterPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKgAAAD3AgMAAAD0/fcFAAAADFBMVEUyMjI7Ozs1NTU4ODjgOsZvAAAGWklEQVRo3u2Zv2/TQBTHj4eQnKsY2S1QJHDE2J2RkQHHUVWVjIipY5QBnTpV3bNblSq5RqFTFyQE/4T3iiliZ2GgvLt3d8/ncxNEV7+lbvvJ87vvu1/vRQw22GCDDTbYYIPFJutvf+ryX8hPubGfO0G4yq39Vv/i85/8Zk3OVm0j4Tpv2dG2EC7zwD5scepeb38Wd7t96dyt7M/FTqeFQwu1y+kRotvdeuDAP037yb3c2dKj+U0vOmaRGJ31oj5Rt3V9656LPnKUk72r6/O6rn/ZX+f97yeygnMlPDu7+/0/SyFPUE75iSPoH/+yFISKbM0a9Olf1BoCkyQbwuIO/ZcaqgiFq/4sAI2pFIyKjNyqXql+m+fSouKqV66xj1RAKRFVfvnM+kL9aN4vvVf42hMsNLxGpXKomPRM2ofmbyWNRjg0lcbD9wB9bKYpEc8ZFRc52nE8qg09Vy30UzyuFb9flC1UNtG4WjsEILrSWvAeEo1qafyAVIjmRYqPkIh1d1wjGyrg8xeUa4XvAJZr3h4VhzpC57Dy/5dNZ1z7FKr22mwsWnCwh4EAFCqgwJVGWc61l4Bn4Hv6UFES6oAXfh6yACUtm6ki1EUrQwlGZlJQ0EeMHvqJNA9mwNEpLdul8GiRunEdB1otE3x4kDPqnK2tWqzVIqHPbBgl4qUNhbW6SeihZJQ0mLgHF3lRPjWiFRUIcaJREikVZ01rIYBL68hOL3Do3EnAqEUeddE3JAGjI42amEkARt/q3zQ6Z5QG23TRgqQk1MmxsPndMEoZTey/OQPvrfcqROd2wsxa6A3ltygdyuPaMyhnoMQ37WsBUkYtIvWoGS0Uok80Gnp9a4WdMjpFlAWov/1qSQArj0JjUXcDwKPoqrGsRQvFyQKRkFaY9Brt2y3ZmUhNDhg9MJOV5lUdWikuCLXKL1kr6KDK5OC7Rxc8WTLnjEy20ZFN1ph2WOfNoZSueYDu6zgiNAtRyusToxVwjMYqyuzcL+0fAjRaKSEdWqHSaOeE0vJ+ZEVqjAAZo3ZjFpLmDaMJ5qyEAE2F2evPPDq2KGnFghpUKvI68yj94UBEaEbpZLQgdCkAGFUaneB/TpoO+tAcnAGKtsY0QQ+6Qd8hChd96APUCiL0+nUHnRKqAlRrLVevaG4Tuu9Q1CpCG4sehuhBjE7yGFWILjsBpEJN8tci7XpNH6BWSYAq1Cr2WgLOxQ0o2UIr9HeRv0rFSTfWEc6rMFZEr0OvY4tOe9CG0E4KRrRhB3NAajTO1t5BjE5wqXanS4lzYCm6qCA08gqbGMWTUHXQXNHahHBq6y3o1E9tXjCgBWijdA6J9oJ5xCjQiiWj647oWbFShWhl7jhFwihtGTcKZCoUqESy1yTDHT4RKW8ZIzquMmHsrCXApdnZYdLd3ujEEmlLgDWdG5cxSsYoXNDIaSfkrRgcKv0agGsazjrcteN9nS55c4/ysSE7KJBIryhneeswEtl5fUv2x22ZE+2N7w58xAnhTsDfiLoCWPERxwcnl3Hv/iBJMRYi4YMTzX3qCZH0QXrxNDqOaYcfRygf8uHVIXncRhVorY4xgEnPLWMUes16LiQv7SH7zKO4rq1WUqTta87IoAlJwF7XweWJ0SONPumifCULLnqIPmKUJ4tNFqOFRkddlK+PwaUUVwY49NRqpdBB5i6lfNUVSj+w18zmaC8oDPatBGrsULNh5zMFIgkv0GODoj1wKAnwKr6WPzYS2KgZLYTT6ri3hCDUCHDoS6N5XJjAnkfxALjhwqSv3GkcKpsiLne4iKKwCc3yBRdRcWlGbgmdoNOUS7O+gu+FRddzRF3BF5eRJA6hnwWiXEbGxSmFgCgoQqk4jUre0pX6iEoVlLxRIZ0mCJwigGiZqERwIR2V5+Avopniind2Z9FvvD6nB1f0x60ERmm3jVoJfkptwKMlNxgL1df2UA4FFbY94mZK5VAcWNxMiVs0jPa3aMA2syyamUFx46e3nUSo1grWdzT1XrJbRHXQNS20xZbWlwI4LRXIqPUVNdTQIZwobqhta9PVCs7KjNt0W5t/9VnQ/LtPS5EiIH0udzQq9xhd72h/ipVHL/zTrlbt9HpXqxYahzbcAN7hdrrLKbst2On9W+BxY/3+7fr4S4D/+Grh/l9Y8Ncggw022GCDDTbYYPexvyOoQXprv7w6AAAAAElFTkSuQmCC';
function findPoster(images) {
return _.find(images, { coverType: 'poster' });
}
function getPosterUrl(poster, size) {
if (poster) {
// Remove protocol
let url = poster.url.replace(/^https?:/, '');
url = url.replace('poster.jpg', `poster-${size}.jpg`);
return url;
}
}
class ArtistPoster extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const pixelRatio = Math.floor(window.devicePixelRatio);
const {
images,
size
} = props;
const poster = findPoster(images);
this.state = {
pixelRatio,
poster,
posterUrl: getPosterUrl(poster, pixelRatio * size),
hasError: false,
isLoaded: false
};
}
componentDidUpdate(prevProps) {
const {
images,
size
} = this.props;
const {
pixelRatio
} = this.state;
const poster = findPoster(images);
if (poster && poster.url !== this.state.poster.url) {
this.setState({
poster,
posterUrl: getPosterUrl(poster, pixelRatio * size),
hasError: false,
isLoaded: false
});
}
}
//
// Listeners
onError = () => {
this.setState({ hasError: true });
}
onLoad = () => {
this.setState({ isLoaded: true });
}
//
// Render
render() {
const {
className,
style,
size,
lazy,
overflow
} = this.props;
const {
posterUrl,
hasError,
isLoaded
} = this.state;
if (hasError || !posterUrl) {
return (
<img
className={className}
style={style}
src={posterPlaceholder}
/>
);
}
if (lazy) {
return (
<LazyLoad
height={size}
offset={100}
overflow={overflow}
placeholder={
<img
className={className}
style={style}
src={posterPlaceholder}
/>
}
>
<img
className={className}
style={style}
src={posterUrl}
onError={this.onError}
/>
</LazyLoad>
);
}
return (
<img
className={className}
style={style}
src={isLoaded ? posterUrl : posterPlaceholder}
onError={this.onError}
onLoad={this.onLoad}
/>
);
}
}
ArtistPoster.propTypes = {
className: PropTypes.string,
style: PropTypes.object,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
size: PropTypes.number.isRequired,
lazy: PropTypes.bool.isRequired,
overflow: PropTypes.bool.isRequired
};
ArtistPoster.defaultProps = {
size: 250,
lazy: true,
overflow: false
};
export default ArtistPoster;

View file

@ -0,0 +1,39 @@
@import "Content/icons";
.delete-series-modal {
i {
margin-right : 5px;
//.fa-icon-color(white);
}
.path {
white-space : nowrap;
font-size : 16px;
padding-bottom : 20px;
}
.delete-files-info,
.delete-label {
color : @brand-danger-dark;
}
.delete-files-info {
display : none;
}
.checkbox {
display : inline-block;
}
.c-checkbox:hover .check {
border-color : @brand-danger-dark;
}
input[type=checkbox]:checked + span {
background-color : @brand-danger-dark;
border-color : @brand-danger-dark;
}
}

View file

@ -0,0 +1,33 @@
import PropTypes from 'prop-types';
import React from 'react';
import { sizes } from 'Helpers/Props';
import Modal from 'Components/Modal/Modal';
import DeleteArtistModalContentConnector from './DeleteArtistModalContentConnector';
function DeleteArtistModal(props) {
const {
isOpen,
onModalClose,
...otherProps
} = props;
return (
<Modal
isOpen={isOpen}
size={sizes.MEDIUM}
onModalClose={onModalClose}
>
<DeleteArtistModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
DeleteArtistModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default DeleteArtistModal;

View file

@ -0,0 +1,12 @@
.pathContainer {
margin-bottom: 20px;
}
.pathIcon {
margin-right: 8px;
}
.deleteFilesMessage {
margin-top: 20px;
color: $dangerColor;
}

View file

@ -0,0 +1,139 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
import { icons, inputTypes, kinds } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import Icon from 'Components/Icon';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputGroup from 'Components/Form/FormInputGroup';
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 styles from './DeleteArtistModalContent.css';
class DeleteArtistModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
deleteFiles: false
};
}
//
// Listeners
onDeleteFilesChange = ({ value }) => {
this.setState({ deleteFiles: value });
}
onDeleteSeriesConfirmed = () => {
const deleteFiles = this.state.deleteFiles;
this.setState({ deleteFiles: false });
this.props.onDeletePress(deleteFiles);
}
//
// Render
render() {
const {
artistName,
path,
trackFileCount,
sizeOnDisk,
onModalClose
} = this.props;
const deleteFiles = this.state.deleteFiles;
let deleteFilesLabel = `Delete ${trackFileCount} Track Files`;
let deleteFilesHelpText = 'Delete the track files and artist folder';
if (trackFileCount === 0) {
deleteFilesLabel = 'Delete Artist Folder';
deleteFilesHelpText = 'Delete the artist folder and it\'s contents';
}
return (
<ModalContent
onModalClose={onModalClose}
>
<ModalHeader>
Delete - {artistName}
</ModalHeader>
<ModalBody>
<div className={styles.pathContainer}>
<Icon
className={styles.pathIcon}
name={icons.FOLDER}
/>
{path}
</div>
<FormGroup>
<FormLabel>{deleteFilesLabel}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="deleteFiles"
value={deleteFiles}
helpText={deleteFilesHelpText}
kind={kinds.DANGER}
onChange={this.onDeleteFilesChange}
/>
</FormGroup>
{
deleteFiles &&
<div className={styles.deleteFilesMessage}>
<div>The artist folder <strong>{path}</strong> and all it's content will be deleted.</div>
{
!!trackFileCount &&
<div>{trackFileCount} track files totaling {formatBytes(sizeOnDisk)}</div>
}
</div>
}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Close
</Button>
<Button
kind={kinds.DANGER}
onPress={this.onDeleteSeriesConfirmed}
>
Delete
</Button>
</ModalFooter>
</ModalContent>
);
}
}
DeleteArtistModalContent.propTypes = {
artistName: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
trackFileCount: PropTypes.number.isRequired,
sizeOnDisk: PropTypes.number.isRequired,
onDeletePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
DeleteArtistModalContent.defaultProps = {
trackFileCount: 0
};
export default DeleteArtistModalContent;

View file

@ -0,0 +1,55 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createArtistSelector from 'Store/Selectors/createArtistSelector';
import { deleteArtist } from 'Store/Actions/seriesActions';
import DeleteArtistModalContent from './DeleteArtistModalContent';
function createMapStateToProps() {
return createSelector(
createArtistSelector(),
(series) => {
return series;
}
);
}
const mapDispatchToProps = {
deleteArtist
};
class DeleteArtistModalContentConnector extends Component {
//
// Listeners
onDeletePress = (deleteFiles) => {
this.props.deleteArtist({
id: this.props.artistId,
deleteFiles
});
this.props.onModalClose(true);
}
//
// Render
render() {
return (
<DeleteArtistModalContent
{...this.props}
onDeletePress={this.onDeletePress}
/>
);
}
}
DeleteArtistModalContentConnector.propTypes = {
artistId: PropTypes.number.isRequired,
onModalClose: PropTypes.func.isRequired,
deleteArtist: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(DeleteArtistModalContentConnector);

View file

@ -0,0 +1,26 @@
.title {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
white-space: nowrap;
}
.monitored {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 42px;
}
.episodeNumber {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 50px;
}
.language,
.audio,
.video,
.status {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 100px;
}

View file

@ -0,0 +1,266 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import EpisodeSearchCellConnector from 'Episode/EpisodeSearchCellConnector';
import EpisodeNumber from 'Episode/EpisodeNumber';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import EpisodeStatusConnector from 'Episode/EpisodeStatusConnector';
import EpisodeFileLanguageConnector from 'EpisodeFile/EpisodeFileLanguageConnector';
import MediaInfoConnector from 'EpisodeFile/MediaInfoConnector';
import * as mediaInfoTypes from 'EpisodeFile/mediaInfoTypes';
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,
artistId,
episodeFileId,
monitored,
seasonNumber,
episodeNumber,
absoluteEpisodeNumber,
sceneSeasonNumber,
sceneEpisodeNumber,
sceneAbsoluteEpisodeNumber,
airDateUtc,
title,
unverifiedSceneNumbering,
isSaving,
seriesMonitored,
seriesType,
episodeFilePath,
episodeFileRelativePath,
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={styles.episodeNumber}
>
<EpisodeNumber
seasonNumber={seasonNumber}
episodeNumber={episodeNumber}
absoluteEpisodeNumber={absoluteEpisodeNumber}
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}
artistId={artistId}
episodeTitle={title}
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 (
<RelativeDateCellConnector
key={name}
date={airDateUtc}
/>
);
}
if (name === 'language') {
return (
<TableRowCell
key={name}
className={styles.language}
>
<EpisodeFileLanguageConnector
episodeFileId={episodeFileId}
/>
</TableRowCell>
);
}
if (name === 'audioInfo') {
return (
<TableRowCell
key={name}
className={styles.audio}
>
<MediaInfoConnector
type={mediaInfoTypes.AUDIO}
episodeFileId={episodeFileId}
/>
</TableRowCell>
);
}
if (name === 'videoCodec') {
return (
<TableRowCell
key={name}
className={styles.video}
>
<MediaInfoConnector
type={mediaInfoTypes.VIDEO}
episodeFileId={episodeFileId}
/>
</TableRowCell>
);
}
if (name === 'status') {
return (
<TableRowCell
key={name}
className={styles.status}
>
<EpisodeStatusConnector
episodeId={id}
episodeFileId={episodeFileId}
/>
</TableRowCell>
);
}
if (name === 'actions') {
return (
<EpisodeSearchCellConnector
key={name}
episodeId={id}
artistId={artistId}
episodeTitle={title}
/>
);
}
return null;
})
}
</TableRow>
);
}
}
EpisodeRow.propTypes = {
id: PropTypes.number.isRequired,
artistId: 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,
title: PropTypes.string.isRequired,
isSaving: PropTypes.bool,
unverifiedSceneNumbering: PropTypes.bool,
seriesMonitored: PropTypes.bool.isRequired,
seriesType: PropTypes.string.isRequired,
episodeFilePath: PropTypes.string,
episodeFileRelativePath: PropTypes.string,
mediaInfo: PropTypes.object,
alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onMonitorEpisodePress: PropTypes.func.isRequired
};
export default EpisodeRow;

View file

@ -0,0 +1,29 @@
import _ from 'lodash';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createArtistSelector from 'Store/Selectors/createArtistSelector';
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import EpisodeRow from './EpisodeRow';
function createMapStateToProps() {
return createSelector(
(state, { id }) => id,
(state, { sceneSeasonNumber }) => sceneSeasonNumber,
createArtistSelector(),
createEpisodeFileSelector(),
createCommandsSelector(),
(id, sceneSeasonNumber, series, episodeFile, commands) => {
const alternateTitles = sceneSeasonNumber ? _.filter(series.alternateTitles, { sceneSeasonNumber }) : [];
return {
seriesMonitored: series.monitored,
seriesType: series.seriesType,
episodeFilePath: episodeFile ? episodeFile.path : null,
episodeFileRelativePath: episodeFile ? episodeFile.relativePath : null,
alternateTitles
};
}
);
}
export default connect(createMapStateToProps)(EpisodeRow);

View file

@ -0,0 +1,3 @@
.alternateTitle {
white-space: nowrap;
}

View file

@ -0,0 +1,28 @@
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}
className={styles.alternateTitle}
>
{alternateTitle}
</li>
);
})
}
</ul>
);
}
SeriesAlternateTitles.propTypes = {
alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired
};
export default SeriesAlternateTitles;

View file

@ -0,0 +1,119 @@
.innerContentBody {
padding: 0;
}
.header {
position: relative;
width: 100%;
height: 425px;
}
.backdrop {
position: absolute;
z-index: -1;
width: 100%;
height: 100%;
background-size: cover;
}
.backdropOverlay {
position: absolute;
width: 100%;
height: 100%;
background: $black;
opacity: 0.7;
}
.headerContent {
display: flex;
padding: 30px;
width: 100%;
height: 100%;
color: $white;
}
.poster {
flex-shrink: 0;
margin-right: 35px;
width: 250px;
height: 368px;
}
.info {
flex-grow: 1;
overflow: hidden;
}
.titleContainer {
display: flex;
justify-content: space-between;
}
.title {
margin-bottom: 5px;
font-weight: 300;
font-size: 50px;
line-height: 50px;
}
.alternateTitlesIconContainer {
margin-left: 20px;
line-height: 50px;
}
.seriesNavigationButtons {
white-space: no-wrap;
}
.seriesNavigationButton {
composes: button from 'Components/Link/IconButton.css';
margin-left: 5px;
color: #e1e2e3;
white-space: nowrap;
}
.details {
font-weight: 300;
font-size: 20px;
}
.runtime {
margin-right: 15px;
}
.detailsLabel {
composes: label from 'Components/Label.css';
margin: 5px 10px 5px 0;
}
.sizeOnDisk,
.qualityProfileName,
.network,
.links,
.tags {
margin-left: 8px;
font-weight: 300;
font-size: 17px;
}
.contentContainer {
padding: 20px;
}
@media only screen and (max-width: $breakpointSmall) {
.contentContainer {
padding: 20px 0;
}
.headerContent {
padding: 15px;
}
}
@media only screen and (max-width: $breakpointLarge) {
.poster {
display: none;
}
}

View file

@ -0,0 +1,576 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import { align, icons, kinds, sizes, tooltipPositions } from 'Helpers/Props';
import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import Label from 'Components/Label';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip';
import EpisodeFileEditorModal from 'EpisodeFile/Editor/EpisodeFileEditorModal';
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
import ArtistPoster from 'Artist/ArtistPoster';
import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
import SeriesAlternateTitles from './SeriesAlternateTitles';
import SeriesDetailsSeasonConnector from './SeriesDetailsSeasonConnector';
import SeriesTagsConnector from './SeriesTagsConnector';
import SeriesDetailsLinks from './SeriesDetailsLinks';
import styles from './SeriesDetails.css';
function getFanartUrl(images) {
const fanartImage = _.find(images, { coverType: 'fanart' });
if (fanartImage) {
// Remove protocol
return fanartImage.url.replace(/^https?:/, '');
}
}
function getExpandedState(newState) {
return {
allExpanded: newState.allSelected,
allCollapsed: newState.allUnselected,
expandedState: newState.selectedState
};
}
class SeriesDetails extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isOrganizeModalOpen: false,
isManageEpisodesOpen: false,
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: false,
allExpanded: false,
allCollapsed: false,
expandedState: {}
};
}
//
// Listeners
onOrganizePress = () => {
this.setState({ isOrganizeModalOpen: true });
}
onOrganizeModalClose = () => {
this.setState({ isOrganizeModalOpen: false });
}
onManageEpisodesPress = () => {
this.setState({ isManageEpisodesOpen: true });
}
onManageEpisodesModalClose = () => {
this.setState({ isManageEpisodesOpen: false });
}
onEditSeriesPress = () => {
this.setState({ isEditArtistModalOpen: true });
}
onEditSeriesModalClose = () => {
this.setState({ isEditArtistModalOpen: false });
}
onDeleteSeriesPress = () => {
this.setState({
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: true
});
}
onDeleteSeriesModalClose = () => {
this.setState({ isDeleteArtistModalOpen: 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);
});
}
//
// Render
render() {
const {
id,
tvdbId,
tvMazeId,
imdbId,
title,
runtime,
ratings,
sizeOnDisk,
episodeFileCount,
qualityProfileId,
monitored,
status,
network,
overview,
images,
seasons,
alternateTitles,
tags,
isRefreshing,
isSearching,
isFetching,
isPopulated,
episodesError,
episodeFilesError,
previousSeries,
nextSeries,
onRefreshPress,
onSearchPress
} = this.props;
const {
isOrganizeModalOpen,
isManageEpisodesOpen,
isEditArtistModalOpen,
isDeleteArtistModalOpen,
allExpanded,
allCollapsed,
expandedState
} = this.state;
const continuing = status === 'continuing';
let episodeFilesCountMessage = 'No episode files';
if (episodeFileCount === 1) {
episodeFilesCountMessage = '1 episode file';
} else if (episodeFileCount > 1) {
episodeFilesCountMessage = `${episodeFileCount} episode files`;
}
let expandIcon = icons.EXPAND_INDETERMINATE;
if (allExpanded) {
expandIcon = icons.COLLAPSE;
} else if (allCollapsed) {
expandIcon = icons.EXPAND;
}
return (
<PageContent title={title}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label="Refresh & Scan"
iconName={icons.REFRESH}
spinningName={icons.REFRESH}
title="Refresh information and scan disk"
isSpinning={isRefreshing}
onPress={onRefreshPress}
/>
<PageToolbarButton
label="Search Monitored"
iconName={icons.SEARCH}
isSpinning={isSearching}
onPress={onSearchPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label="Preview Rename"
iconName={icons.ORGANIZE}
onPress={this.onOrganizePress}
/>
<PageToolbarButton
label="Manage Episodes"
iconName={icons.EPISODE_FILE}
onPress={this.onManageEpisodesPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label="Edit"
iconName={icons.EDIT}
onPress={this.onEditSeriesPress}
/>
<PageToolbarButton
label="Delete"
iconName={icons.DELETE}
onPress={this.onDeleteSeriesPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<PageToolbarButton
label={allExpanded ? 'Collapse All' : 'Expand All'}
iconName={expandIcon}
onPress={this.onExpandAllPress}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBodyConnector innerClassName={styles.innerContentBody}>
<div className={styles.header}>
<div
className={styles.backdrop}
style={{
backgroundImage: `url(${getFanartUrl(images)})`
}}
>
<div className={styles.backdropOverlay} />
</div>
<div className={styles.headerContent}>
<ArtistPoster
className={styles.poster}
images={images}
size={500}
lazy={false}
/>
<div className={styles.info}>
<div className={styles.titleContainer}>
<div className={styles.title}>
{title}
{
!!alternateTitles.length &&
<span className={styles.alternateTitlesIconContainer}>
<Popover
anchor={
<Icon
name={icons.ALTERNATE_TITLES}
size={20}
/>
}
title="Alternate Titles"
body={<SeriesAlternateTitles alternateTitles={alternateTitles} />}
position={tooltipPositions.BOTTOM}
/>
</span>
}
</div>
<div className={styles.seriesNavigationButtons}>
<IconButton
className={styles.seriesNavigationButton}
name={icons.ARROW_LEFT}
size={30}
title={`Go to ${previousSeries.title}`}
to={`/series/${previousSeries.titleSlug}`}
/>
<IconButton
className={styles.seriesNavigationButton}
name={icons.ARROW_RIGHT}
size={30}
title={`Go to ${nextSeries.title}`}
to={`/series/${nextSeries.titleSlug}`}
/>
</div>
</div>
<div className={styles.details}>
<div>
{
!!runtime &&
<span className={styles.runtime}>
{runtime} Minutes
</span>
}
<HeartRating
rating={ratings.value}
iconSize={20}
/>
</div>
</div>
<div className={styles.detailsLabels}>
<Label
className={styles.detailsLabel}
title={episodeFilesCountMessage}
size={sizes.LARGE}
>
<Icon
name={icons.DRIVE}
size={17}
/>
<span className={styles.sizeOnDisk}>
{
formatBytes(sizeOnDisk)
}
</span>
</Label>
<Label
className={styles.detailsLabel}
title="Quality Profile"
size={sizes.LARGE}
>
<Icon
name={icons.PROFILE}
size={17}
/>
<span className={styles.qualityProfileName}>
{
<QualityProfileNameConnector
qualityProfileId={qualityProfileId}
/>
}
</span>
</Label>
<Label
className={styles.detailsLabel}
size={sizes.LARGE}
>
<Icon
name={monitored ? icons.MONITORED : icons.UNMONITORED}
size={17}
/>
<span className={styles.qualityProfileName}>
{monitored ? 'Monitored' : 'Unmonitored'}
</span>
</Label>
<Label
className={styles.detailsLabel}
title={continuing ? 'More episodes/another season is expected' : 'No additional episodes or or another season is expected'}
size={sizes.LARGE}
>
<Icon
name={continuing ? icons.SERIES_CONTINUING : icons.SERIES_ENDED}
size={17}
/>
<span className={styles.qualityProfileName}>
{continuing ? 'Continuing' : 'Ended'}
</span>
</Label>
{
!!network &&
<Label
className={styles.detailsLabel}
title="Network"
size={sizes.LARGE}
>
<Icon
name={icons.NETWORK}
size={17}
/>
<span className={styles.qualityProfileName}>
{network}
</span>
</Label>
}
<Tooltip
anchor={
<Label
className={styles.detailsLabel}
size={sizes.LARGE}
>
<Icon
name={icons.EXTERNAL_LINK}
size={17}
/>
<span className={styles.links}>
Links
</span>
</Label>
}
tooltip={
<SeriesDetailsLinks
tvdbId={tvdbId}
tvMazeId={tvMazeId}
imdbId={imdbId}
/>
}
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}>
Tags
</span>
</Label>
}
tooltip={<SeriesTagsConnector artistId={id} />}
kind={kinds.INVERSE}
position={tooltipPositions.BOTTOM}
/>
}
</div>
<div>
{overview}
</div>
</div>
</div>
</div>
<div className={styles.contentContainer}>
{
!isPopulated && !episodesError && !episodeFilesError &&
<LoadingIndicator />
}
{
!isFetching && episodesError &&
<div>Loading episodes failed</div>
}
{
!isFetching && episodeFilesError &&
<div>Loading episode files failed</div>
}
{
isPopulated && !!seasons.length &&
<div>
{
seasons.slice(0).reverse().map((season) => {
return (
<SeriesDetailsSeasonConnector
key={season.seasonNumber}
artistId={id}
{...season}
isExpanded={expandedState[season.seasonNumber]}
onExpandPress={this.onExpandPress}
/>
);
})
}
</div>
}
{
isPopulated && !seasons.length &&
<div>
No episode information is available.
</div>
}
</div>
<OrganizePreviewModalConnector
isOpen={isOrganizeModalOpen}
artistId={id}
onModalClose={this.onOrganizeModalClose}
/>
<EpisodeFileEditorModal
isOpen={isManageEpisodesOpen}
artistId={id}
onModalClose={this.onManageEpisodesModalClose}
/>
<EditArtistModalConnector
isOpen={isEditArtistModalOpen}
artistId={id}
onModalClose={this.onEditSeriesModalClose}
onDeleteSeriesPress={this.onDeleteSeriesPress}
/>
<DeleteArtistModal
isOpen={isDeleteArtistModalOpen}
artistId={id}
onModalClose={this.onDeleteSeriesModalClose}
/>
</PageContentBodyConnector>
</PageContent>
);
}
}
SeriesDetails.propTypes = {
id: PropTypes.number.isRequired,
tvdbId: PropTypes.number.isRequired,
tvMazeId: PropTypes.number,
imdbId: PropTypes.string,
title: PropTypes.string.isRequired,
runtime: PropTypes.number.isRequired,
ratings: PropTypes.object.isRequired,
sizeOnDisk: PropTypes.number.isRequired,
episodeFileCount: PropTypes.number,
qualityProfileId: PropTypes.number.isRequired,
monitored: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
network: PropTypes.string,
overview: PropTypes.string.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
seasons: PropTypes.arrayOf(PropTypes.object).isRequired,
alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
isRefreshing: PropTypes.bool.isRequired,
isSearching: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
episodesError: PropTypes.object,
episodeFilesError: PropTypes.object,
previousSeries: PropTypes.object.isRequired,
nextSeries: PropTypes.object.isRequired,
onRefreshPress: PropTypes.func.isRequired,
onSearchPress: PropTypes.func.isRequired
};
export default SeriesDetails;

View file

@ -0,0 +1,184 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { findCommand } from 'Utilities/Command';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import { fetchEpisodes, clearEpisodes } from 'Store/Actions/episodeActions';
import { fetchEpisodeFiles, clearEpisodeFiles } from 'Store/Actions/episodeFileActions';
import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions';
import { executeCommand } from 'Store/Actions/commandActions';
import * as commandNames from 'Commands/commandNames';
import SeriesDetails from './SeriesDetails';
function createMapStateToProps() {
return createSelector(
(state, { titleSlug }) => titleSlug,
(state) => state.episodes,
(state) => state.episodeFiles,
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 previousSeries = sortedSeries[seriesIndex - 1] || _.last(sortedSeries);
const nextSeries = sortedSeries[seriesIndex + 1] || _.first(sortedSeries);
const isSeriesRefreshing = !!findCommand(commands, { name: commandNames.REFRESH_SERIES, artistId: series.id });
const allSeriesRefreshing = _.some(commands, (command) => command.name === commandNames.REFRESH_SERIES && !command.body.artistId);
const isRefreshing = isSeriesRefreshing || allSeriesRefreshing;
const isSearching = !!findCommand(commands, { name: commandNames.SERIES_SEARCH, artistId: series.id });
const isRenamingFiles = !!findCommand(commands, { name: commandNames.RENAME_FILES, artistId: series.id });
const isRenamingSeriesCommand = findCommand(commands, { name: commandNames.RENAME_SERIES });
const isRenamingSeries = !!(isRenamingSeriesCommand && isRenamingSeriesCommand.body.artistId.indexOf(series.id) > -1);
const isFetching = episodes.isFetching || episodeFiles.isFetching;
const isPopulated = episodes.isPopulated && episodeFiles.isPopulated;
const episodesError = episodes.error;
const episodeFilesError = episodeFiles.error;
const alternateTitles = _.reduce(series.alternateTitles, (acc, alternateTitle) => {
if ((alternateTitle.seasonNumber === -1 || alternateTitle.seasonNumber === undefined) &&
(alternateTitle.sceneSeasonNumber === -1 || alternateTitle.sceneSeasonNumber === undefined)) {
acc.push(alternateTitle.title);
}
return acc;
}, []);
return {
...series,
alternateTitles,
isRefreshing,
isSearching,
isRenamingFiles,
isRenamingSeries,
isFetching,
isPopulated,
episodesError,
episodeFilesError,
previousSeries,
nextSeries
};
}
);
}
const mapDispatchToProps = {
fetchEpisodes,
clearEpisodes,
fetchEpisodeFiles,
clearEpisodeFiles,
fetchQueueDetails,
clearQueueDetails,
executeCommand
};
class SeriesDetailsConnector extends Component {
//
// Lifecycle
componentDidMount() {
this._populate();
}
componentDidUpdate(prevProps) {
const {
id,
isRefreshing,
isRenamingFiles,
isRenamingSeries
} = this.props;
if (
(prevProps.isRefreshing && !isRefreshing) ||
(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() {
this._unpopulate();
}
//
// Control
_populate() {
const artistId = this.props.id;
this.props.fetchEpisodes({ artistId });
this.props.fetchEpisodeFiles({ artistId });
this.props.fetchQueueDetails({ artistId });
}
_unpopulate() {
this.props.clearEpisodes();
this.props.clearEpisodeFiles();
this.props.clearQueueDetails();
}
//
// Listeners
onRefreshPress = () => {
this.props.executeCommand({
name: commandNames.REFRESH_SERIES,
artistId: this.props.id
});
}
onSearchPress = () => {
this.props.executeCommand({
name: commandNames.SERIES_SEARCH,
artistId: this.props.id
});
}
//
// Render
render() {
return (
<SeriesDetails
{...this.props}
onRefreshPress={this.onRefreshPress}
onSearchPress={this.onSearchPress}
/>
);
}
}
SeriesDetailsConnector.propTypes = {
id: PropTypes.number.isRequired,
titleSlug: PropTypes.string.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,
fetchQueueDetails: PropTypes.func.isRequired,
clearQueueDetails: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(SeriesDetailsConnector);

Some files were not shown because too many files have changed in this diff Show more