Convert Manual Import to Typescript

pull/5540/head
Mark McDowall 1 year ago
parent 032d9a720c
commit defdc84b7e

@ -24,7 +24,8 @@ module.exports = {
globals: {
expect: false,
chai: false,
sinon: false
sinon: false,
JSX: true
},
parserOptions: {

@ -267,6 +267,7 @@ FormInputGroup.propTypes = {
helpTexts: PropTypes.arrayOf(PropTypes.string),
helpTextWarning: PropTypes.string,
helpLink: PropTypes.string,
autoFocus: PropTypes.bool,
includeNoChange: PropTypes.bool,
includeNoChangeDisabled: PropTypes.bool,
selectedValueOptions: PropTypes.object,

@ -68,6 +68,7 @@ class PathInputConnector extends Component {
}
PathInputConnector.propTypes = {
...PathInput.props,
includeFiles: PropTypes.bool.isRequired,
dispatchFetchPaths: PropTypes.func.isRequired,
dispatchClearPaths: PropTypes.func.isRequired

@ -1,98 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import styles from './Link.css';
class Link extends Component {
//
// Listeners
onClick = (event) => {
const {
isDisabled,
onPress
} = this.props;
if (!isDisabled && onPress) {
onPress(event);
}
};
//
// Render
render() {
const {
className,
component,
to,
target,
isDisabled,
noRouter,
onPress,
...otherProps
} = this.props;
const linkProps = { target };
let el = component;
if (to) {
if ((/\w+?:\/\//).test(to)) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_blank';
linkProps.rel = 'noreferrer';
} else if (noRouter) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_self';
} else {
el = RouterLink;
linkProps.to = `${window.Sonarr.urlBase}/${to.replace(/^\//, '')}`;
linkProps.target = target;
}
}
if (el === 'button' || el === 'input') {
linkProps.type = otherProps.type || 'button';
linkProps.disabled = isDisabled;
}
linkProps.className = classNames(
className,
styles.link,
to && styles.to,
isDisabled && 'isDisabled'
);
const props = {
...otherProps,
...linkProps
};
props.onClick = this.onClick;
return (
React.createElement(el, props)
);
}
}
Link.propTypes = {
className: PropTypes.string,
component: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
to: PropTypes.string,
target: PropTypes.string,
isDisabled: PropTypes.bool,
noRouter: PropTypes.bool,
onPress: PropTypes.func
};
Link.defaultProps = {
component: 'button',
noRouter: false
};
export default Link;

@ -0,0 +1,89 @@
import classNames from 'classnames';
import React, { ComponentClass, FunctionComponent, useCallback } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import styles from './Link.css';
interface ReactRouterLinkProps {
to?: string;
}
export interface LinkProps extends React.HTMLProps<HTMLAnchorElement> {
className?: string;
component?:
| string
| FunctionComponent<LinkProps>
| ComponentClass<LinkProps, unknown>;
to?: string;
target?: string;
isDisabled?: boolean;
noRouter?: boolean;
onPress?(event: Event): void;
}
function Link(props: LinkProps) {
const {
className,
component = 'button',
to,
target,
type,
isDisabled,
noRouter = false,
onPress,
...otherProps
} = props;
const onClick = useCallback(
(event) => {
if (!isDisabled && onPress) {
onPress(event);
}
},
[isDisabled, onPress]
);
const linkProps: React.HTMLProps<HTMLAnchorElement> & ReactRouterLinkProps = {
target,
};
let el = component;
if (to) {
if (/\w+?:\/\//.test(to)) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_blank';
linkProps.rel = 'noreferrer';
} else if (noRouter) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_self';
} else {
el = RouterLink;
linkProps.to = `${window.Sonarr.urlBase}/${to.replace(/^\//, '')}`;
linkProps.target = target;
}
}
if (el === 'button' || el === 'input') {
linkProps.type = type || 'button';
linkProps.disabled = isDisabled;
}
linkProps.className = classNames(
className,
styles.link,
to && styles.to,
isDisabled && 'isDisabled'
);
const elementProps = {
...otherProps,
type,
...linkProps,
};
elementProps.onClick = onClick;
return React.createElement(el, elementProps);
}
export default Link;

@ -1,25 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Link from 'Components/Link/Link';
import TableRowCell from './TableRowCell';
import styles from './TableRowCellButton.css';
function TableRowCellButton({ className, ...otherProps }) {
return (
<Link
className={className}
component={TableRowCell}
{...otherProps}
/>
);
}
TableRowCellButton.propTypes = {
className: PropTypes.string.isRequired
};
TableRowCellButton.defaultProps = {
className: styles.cell
};
export default TableRowCellButton;

@ -0,0 +1,19 @@
import React, { ReactNode } from 'react';
import Link, { LinkProps } from 'Components/Link/Link';
import TableRowCell from './TableRowCell';
import styles from './TableRowCellButton.css';
interface TableRowCellButtonProps extends LinkProps {
className?: string;
children: ReactNode;
}
function TableRowCellButton(props: TableRowCellButtonProps) {
const { className = styles.cell, ...otherProps } = props;
return (
<Link className={className} component={TableRowCell} {...otherProps} />
);
}
export default TableRowCellButton;

@ -121,6 +121,7 @@ function Table(props) {
}
Table.propTypes = {
...TableHeaderCell.props,
className: PropTypes.string,
horizontalScroll: PropTypes.bool.isRequired,
selectAll: PropTypes.bool.isRequired,

@ -0,0 +1,29 @@
import ModelBase from 'App/ModelBase';
import Series from 'Series/Series';
interface Episode extends ModelBase {
seriesId: number;
tvdbId: number;
episodeFileId: number;
seasonNumber: number;
episodeNumber: number;
airDate: string;
airDateUtc?: string;
runtime: number;
absoluteEpisodeNumber?: number;
sceneSeasonNumber?: number;
sceneEpisodeNumber?: number;
sceneAbsoluteEpisodeNumber?: number;
overview: string;
title: string;
episodeFile?: object;
hasFile: boolean;
monitored: boolean;
unverifiedSceneNumbering: boolean;
endTime?: string;
grabDate?: string;
seriesTitle?: string;
series?: Series;
}
export default Episode;

@ -0,0 +1,11 @@
import { useEffect, useRef } from 'react';
export default function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}

@ -1,37 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import SelectEpisodeModalContentConnector from './SelectEpisodeModalContentConnector';
class SelectEpisodeModal extends Component {
//
// Render
render() {
const {
isOpen,
onModalClose,
...otherProps
} = this.props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<SelectEpisodeModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
}
SelectEpisodeModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectEpisodeModal;

@ -0,0 +1,48 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import SelectEpisodeModalContent, {
SelectedEpisode,
} from './SelectEpisodeModalContent';
interface SelectEpisodeModalProps {
isOpen: boolean;
selectedIds: number[] | string[];
seriesId: number;
seasonNumber: number;
selectedDetails?: string;
isAnime: boolean;
modalTitle: string;
onEpisodesSelect(selectedEpisodes: SelectedEpisode[]): void;
onModalClose(): void;
}
function SelectEpisodeModal(props: SelectEpisodeModalProps) {
const {
isOpen,
selectedIds,
seriesId,
seasonNumber,
selectedDetails,
isAnime,
modalTitle,
onEpisodesSelect,
onModalClose,
} = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<SelectEpisodeModalContent
selectedIds={selectedIds}
seriesId={seriesId}
seasonNumber={seasonNumber}
selectedDetails={selectedDetails}
isAnime={isAnime}
modalTitle={modalTitle}
onEpisodesSelect={onEpisodesSelect}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default SelectEpisodeModal;

@ -25,7 +25,7 @@
overflow: hidden;
}
.path {
.details {
margin-right: 20px;
color: var(--dimColor);
word-break: break-word;
@ -40,7 +40,7 @@
display: block;
}
.path {
.details {
margin-right: 0;
margin-bottom: 10px;
}

@ -2,10 +2,10 @@
// Please do not change this file!
interface CssExports {
'buttons': string;
'details': string;
'filterInput': string;
'footer': string;
'modalBody': string;
'path': string;
'scroller': string;
}
export const cssExports: CssExports;

@ -1,245 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TextInput from 'Components/Form/TextInput';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Scroller from 'Components/Scroller/Scroller';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { kinds, scrollDirections } from 'Helpers/Props';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import SelectEpisodeRow from './SelectEpisodeRow';
import styles from './SelectEpisodeModalContent.css';
const columns = [
{
name: 'episodeNumber',
label: '#',
isSortable: true,
isVisible: true
},
{
name: 'title',
label: 'Title',
isVisible: true
},
{
name: 'airDate',
label: 'Air Date',
isVisible: true
}
];
class SelectEpisodeModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
allSelected: false,
allUnselected: false,
filter: '',
lastToggled: null,
selectedState: {}
};
}
//
// Control
getSelectedIds = () => {
return getSelectedIds(this.state.selectedState);
};
//
// Listeners
onFilterChange = ({ value }) => {
this.setState({ filter: value.toLowerCase() });
};
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);
});
};
onEpisodesSelect = () => {
this.props.onEpisodesSelect(this.getSelectedIds());
};
//
// Render
render() {
const {
ids,
isFetching,
isPopulated,
error,
items,
relativePath,
isAnime,
sortKey,
sortDirection,
modalTitle,
onSortPress,
onModalClose
} = this.props;
const {
allSelected,
allUnselected,
filter,
selectedState
} = this.state;
const filterEpisodeNumber = parseInt(filter);
const errorMessage = getErrorMessage(error, 'Unable to load episodes');
const selectedFilesCount = ids.length;
const selectedCount = this.getSelectedIds().length;
const selectionIsValid = (
selectedCount > 0 &&
selectedCount % selectedFilesCount === 0
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
<div className={styles.header}>
{modalTitle} - Select Episode(s)
</div>
</ModalHeader>
<ModalBody
className={styles.modalBody}
scrollDirection={scrollDirections.NONE}
>
<TextInput
className={styles.filterInput}
placeholder="Filter episodes by title or number"
name="filter"
value={filter}
autoFocus={true}
onChange={this.onFilterChange}
/>
<Scroller
className={styles.scroller}
autoFocus={false}
>
{
isFetching ? <LoadingIndicator /> : null
}
{
error ? <div>{errorMessage}</div> : null
}
{
isPopulated && !!items.length ?
<Table
columns={columns}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
onSelectAllChange={this.onSelectAllChange}
>
<TableBody>
{
items.map((item) => {
return item.title.toLowerCase().includes(filter) ||
item.episodeNumber === filterEpisodeNumber ?
(
<SelectEpisodeRow
key={item.id}
id={item.id}
episodeNumber={item.episodeNumber}
absoluteEpisodeNumber={item.absoluteEpisodeNumber}
title={item.title}
airDate={item.airDate}
isAnime={isAnime}
isSelected={selectedState[item.id]}
onSelectedChange={this.onSelectedChange}
/>
) :
null;
})
}
</TableBody>
</Table> :
null
}
{
isPopulated && !items.length ?
'No episodes were found for the selected season' :
null
}
</Scroller>
</ModalBody>
<ModalFooter className={styles.footer}>
<div className={styles.path}>
{
relativePath ?
relativePath :
`${selectedFilesCount} selected files`
}
</div>
<div className={styles.buttons}>
<Button onPress={onModalClose}>
Cancel
</Button>
<Button
kind={kinds.SUCCESS}
isDisabled={!selectionIsValid}
onPress={this.onEpisodesSelect}
>
Select Episodes
</Button>
</div>
</ModalFooter>
</ModalContent>
);
}
}
SelectEpisodeModalContent.propTypes = {
ids: PropTypes.arrayOf(PropTypes.number).isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
relativePath: PropTypes.string,
isAnime: PropTypes.bool.isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.string,
modalTitle: PropTypes.string,
onSortPress: PropTypes.func.isRequired,
onEpisodesSelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectEpisodeModalContent;

@ -0,0 +1,278 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import TextInput from 'Components/Form/TextInput';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Scroller from 'Components/Scroller/Scroller';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import Episode from 'Episode/Episode';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds, scrollDirections } from 'Helpers/Props';
import {
clearEpisodes,
fetchEpisodes,
setEpisodesSort,
} from 'Store/Actions/episodeSelectionActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import SelectEpisodeRow from './SelectEpisodeRow';
import styles from './SelectEpisodeModalContent.css';
const columns = [
{
name: 'episodeNumber',
label: '#',
isSortable: true,
isVisible: true,
},
{
name: 'title',
label: 'Title',
isVisible: true,
},
{
name: 'airDate',
label: 'Air Date',
isVisible: true,
},
];
function episodesSelector() {
return createSelector(
createClientSideCollectionSelector('episodeSelection'),
(episodes) => {
return episodes;
}
);
}
export interface SelectedEpisode {
fileId: number;
episodes: Episode[];
}
interface SelectEpisodeModalContentProps {
selectedIds: number[] | string[];
seriesId: number;
seasonNumber: number;
selectedDetails?: string;
isAnime: boolean;
sortKey?: string;
sortDirection?: string;
modalTitle?: string;
onEpisodesSelect(selectedEpisodes: SelectedEpisode[]): unknown;
onModalClose(): unknown;
}
//
// Render
function SelectEpisodeModalContent(props: SelectEpisodeModalContentProps) {
const {
selectedIds,
seriesId,
seasonNumber,
selectedDetails,
isAnime,
sortKey,
sortDirection,
modalTitle,
onEpisodesSelect,
onModalClose,
} = props;
const [filter, setFilter] = useState('');
const [selectState, setSelectState] = useSelectState();
const { allSelected, allUnselected, selectedState } = selectState;
const { isFetching, isPopulated, items, error } = useSelector(
episodesSelector()
);
const dispatch = useDispatch();
const filterEpisodeNumber = parseInt(filter);
const errorMessage = getErrorMessage(error, 'Unable to load episodes');
const selectedCount = selectedIds.length;
const selectedEpisodesCount = getSelectedIds(selectState).length;
const selectionIsValid =
selectedEpisodesCount > 0 && selectedEpisodesCount % selectedCount === 0;
const onFilterChange = useCallback(
({ value }) => {
setFilter(value.toLowerCase());
},
[setFilter]
);
const onSelectAllChange = useCallback(
({ value }) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
},
[items, setSelectState]
);
const onSelectedChange = useCallback(
({ id, value, shiftKey = false }) => {
setSelectState({
type: 'toggleSelected',
items,
id,
isSelected: value,
shiftKey,
});
},
[items, setSelectState]
);
const onSortPress = useCallback(
(newSortKey, newSortDirection) => {
dispatch(
setEpisodesSort({
sortKey: newSortKey,
sortDirection: newSortDirection,
})
);
},
[dispatch]
);
const onEpisodesSelectWrapper = useCallback(() => {
const episodeIds = getSelectedIds(selectedState);
const selectedEpisodes = items.reduce((acc, item) => {
if (episodeIds.indexOf(item.id) > -1) {
acc.push(item);
}
return acc;
}, []);
const episodesPerFile = selectedEpisodes.length / selectedIds.length;
const sortedEpisodes = selectedEpisodes.sort((a, b) => {
return a.seasonNumber - b.seasonNumber;
});
const mappedEpisodes = selectedIds.map((fileId, index): SelectedEpisode => {
const startingIndex = index * episodesPerFile;
const episodes = sortedEpisodes.slice(
startingIndex,
startingIndex + episodesPerFile
);
return {
fileId,
episodes,
};
});
onEpisodesSelect(mappedEpisodes);
}, [selectedIds, items, selectedState, onEpisodesSelect]);
useEffect(
() => {
dispatch(fetchEpisodes({ seriesId, seasonNumber }));
return () => {
dispatch(clearEpisodes());
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
let details = selectedDetails;
if (!details) {
details =
selectedCount > 1
? `${selectedCount} selected files`
: `${selectedCount} selected file`;
}
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{modalTitle} - Select Episode(s)</ModalHeader>
<ModalBody
className={styles.modalBody}
scrollDirection={scrollDirections.NONE}
>
<TextInput
className={styles.filterInput}
placeholder="Filter episodes by title or number"
name="filter"
value={filter}
autoFocus={true}
onChange={onFilterChange}
/>
<Scroller className={styles.scroller} autoFocus={false}>
{isFetching ? <LoadingIndicator /> : null}
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !!items.length ? (
<Table
columns={columns}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
onSelectAllChange={onSelectAllChange}
>
<TableBody>
{items.map((item) => {
return item.title.toLowerCase().includes(filter) ||
item.episodeNumber === filterEpisodeNumber ? (
<SelectEpisodeRow
key={item.id}
id={item.id}
episodeNumber={item.episodeNumber}
absoluteEpisodeNumber={item.absoluteEpisodeNumber}
title={item.title}
airDate={item.airDate}
isAnime={isAnime}
isSelected={selectedState[item.id]}
onSelectedChange={onSelectedChange}
/>
) : null;
})}
</TableBody>
</Table>
) : null}
{isPopulated && !items.length
? 'No episodes were found for the selected season'
: null}
</Scroller>
</ModalBody>
<ModalFooter className={styles.footer}>
<div className={styles.details}>{details}</div>
<div className={styles.buttons}>
<Button onPress={onModalClose}>Cancel</Button>
<Button
kind={kinds.SUCCESS}
isDisabled={!selectionIsValid}
onPress={onEpisodesSelectWrapper}
>
Select Episodes
</Button>
</div>
</ModalFooter>
</ModalContent>
);
}
export default SelectEpisodeModalContent;

@ -1,122 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import {
clearInteractiveImportEpisodes,
fetchInteractiveImportEpisodes,
reprocessInteractiveImportItems,
setInteractiveImportEpisodesSort,
updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import SelectEpisodeModalContent from './SelectEpisodeModalContent';
function createMapStateToProps() {
return createSelector(
createClientSideCollectionSelector('interactiveImport.episodes'),
(episodes) => {
return episodes;
}
);
}
const mapDispatchToProps = {
dispatchFetchInteractiveImportEpisodes: fetchInteractiveImportEpisodes,
dispatchSetInteractiveImportEpisodesSort: setInteractiveImportEpisodesSort,
dispatchClearInteractiveImportEpisodes: clearInteractiveImportEpisodes,
dispatchUpdateInteractiveImportItem: updateInteractiveImportItem,
dispatchReprocessInteractiveImportItems: reprocessInteractiveImportItems
};
class SelectEpisodeModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
seriesId,
seasonNumber
} = this.props;
this.props.dispatchFetchInteractiveImportEpisodes({ seriesId, seasonNumber });
}
componentWillUnmount() {
// This clears the episodes for the queue and hides the queue
// We'll need another place to store episodes for manual import
this.props.dispatchClearInteractiveImportEpisodes();
}
//
// Listeners
onSortPress = (sortKey, sortDirection) => {
this.props.dispatchSetInteractiveImportEpisodesSort({ sortKey, sortDirection });
};
onEpisodesSelect = (episodeIds) => {
const {
ids,
items,
dispatchUpdateInteractiveImportItem,
dispatchReprocessInteractiveImportItems,
onModalClose
} = this.props;
const selectedEpisodes = items.reduce((acc, item) => {
if (episodeIds.indexOf(item.id) > -1) {
acc.push(item);
}
return acc;
}, []);
const episodesPerFile = selectedEpisodes.length / ids.length;
const sortedEpisodes = selectedEpisodes.sort((a, b) => {
return a.seasonNumber - b.seasonNumber;
});
ids.forEach((id, index) => {
const startingIndex = index * episodesPerFile;
const episodes = sortedEpisodes.slice(startingIndex, startingIndex + episodesPerFile);
dispatchUpdateInteractiveImportItem({
id,
episodes
});
});
dispatchReprocessInteractiveImportItems({ ids });
onModalClose(true);
};
//
// Render
render() {
return (
<SelectEpisodeModalContent
{...this.props}
onSortPress={this.onSortPress}
onEpisodesSelect={this.onEpisodesSelect}
/>
);
}
}
SelectEpisodeModalContentConnector.propTypes = {
ids: PropTypes.arrayOf(PropTypes.number).isRequired,
seriesId: PropTypes.number.isRequired,
seasonNumber: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
dispatchFetchInteractiveImportEpisodes: PropTypes.func.isRequired,
dispatchSetInteractiveImportEpisodesSort: PropTypes.func.isRequired,
dispatchClearInteractiveImportEpisodes: PropTypes.func.isRequired,
dispatchUpdateInteractiveImportItem: PropTypes.func.isRequired,
dispatchReprocessInteractiveImportItems: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(SelectEpisodeModalContentConnector);

@ -1,170 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import PathInputConnector from 'Components/Form/PathInputConnector';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { icons, kinds, sizes } from 'Helpers/Props';
import RecentFolderRow from './RecentFolderRow';
import styles from './InteractiveImportSelectFolderModalContent.css';
const recentFoldersColumns = [
{
name: 'folder',
label: 'Folder'
},
{
name: 'lastUsed',
label: 'Last Used'
},
{
name: 'actions',
label: ''
}
];
class InteractiveImportSelectFolderModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
folder: ''
};
}
//
// Listeners
onPathChange = ({ value }) => {
this.setState({ folder: value });
};
onRecentPathPress = (folder) => {
this.setState({ folder });
};
onQuickImportPress = () => {
this.props.onQuickImportPress(this.state.folder);
};
onInteractiveImportPress = () => {
this.props.onInteractiveImportPress(this.state.folder);
};
//
// Render
render() {
const {
recentFolders,
onRemoveRecentFolderPress,
modalTitle,
onModalClose
} = this.props;
const folder = this.state.folder;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{modalTitle} - Select Folder
</ModalHeader>
<ModalBody>
<PathInputConnector
name="folder"
value={folder}
onChange={this.onPathChange}
/>
{
!!recentFolders.length &&
<div className={styles.recentFoldersContainer}>
<Table
columns={recentFoldersColumns}
>
<TableBody>
{
recentFolders.slice(0).reverse().map((recentFolder) => {
return (
<RecentFolderRow
key={recentFolder.folder}
folder={recentFolder.folder}
lastUsed={recentFolder.lastUsed}
onPress={this.onRecentPathPress}
onRemoveRecentFolderPress={onRemoveRecentFolderPress}
/>
);
})
}
</TableBody>
</Table>
</div>
}
<div className={styles.buttonsContainer}>
<div className={styles.buttonContainer}>
<Button
className={styles.button}
kind={kinds.PRIMARY}
size={sizes.LARGE}
isDisabled={!folder}
onPress={this.onQuickImportPress}
>
<Icon
className={styles.buttonIcon}
name={icons.QUICK}
/>
Move Automatically
</Button>
</div>
<div className={styles.buttonContainer}>
<Button
className={styles.button}
kind={kinds.PRIMARY}
size={sizes.LARGE}
isDisabled={!folder}
onPress={this.onInteractiveImportPress}
>
<Icon
className={styles.buttonIcon}
name={icons.INTERACTIVE}
/>
Interactive Import
</Button>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Cancel
</Button>
</ModalFooter>
</ModalContent>
);
}
}
InteractiveImportSelectFolderModalContent.propTypes = {
recentFolders: PropTypes.arrayOf(PropTypes.object).isRequired,
modalTitle: PropTypes.string.isRequired,
onQuickImportPress: PropTypes.func.isRequired,
onInteractiveImportPress: PropTypes.func.isRequired,
onRemoveRecentFolderPress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default InteractiveImportSelectFolderModalContent;

@ -0,0 +1,172 @@
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import PathInputConnector from 'Components/Form/PathInputConnector';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { icons, kinds, sizes } from 'Helpers/Props';
import { executeCommand } from 'Store/Actions/commandActions';
import {
addRecentFolder,
removeRecentFolder,
} from 'Store/Actions/interactiveImportActions';
import translate from 'Utilities/String/translate';
import RecentFolder from './RecentFolder';
import RecentFolderRow from './RecentFolderRow';
import styles from './InteractiveImportSelectFolderModalContent.css';
const recentFoldersColumns = [
{
name: 'folder',
label: 'Folder',
},
{
name: 'lastUsed',
label: 'Last Used',
},
{
name: 'actions',
label: '',
},
];
interface InteractiveImportSelectFolderModalContentProps {
modalTitle: string;
onFolderSelect(folder: string): void;
onModalClose(): void;
}
function InteractiveImportSelectFolderModalContent(
props: InteractiveImportSelectFolderModalContentProps
) {
const { modalTitle, onFolderSelect, onModalClose } = props;
const [folder, setFolder] = useState('');
const dispatch = useDispatch();
const recentFolders: RecentFolder[] = useSelector(
createSelector(
(state) => state.interactiveImport.recentFolders,
(recentFolders) => {
return recentFolders;
}
)
);
const onPathChange = useCallback(
({ value }) => {
setFolder(value);
},
[setFolder]
);
const onRecentPathPress = useCallback(
(value) => {
setFolder(value);
},
[setFolder]
);
const onQuickImportPress = useCallback(() => {
dispatch(addRecentFolder({ folder }));
dispatch(
executeCommand({
name: commandNames.DOWNLOADED_EPSIODES_SCAN,
path: folder,
})
);
onModalClose();
}, [folder, onModalClose, dispatch]);
const onInteractiveImportPress = useCallback(() => {
dispatch(addRecentFolder({ folder }));
onFolderSelect(folder);
}, [folder, onFolderSelect, dispatch]);
const onRemoveRecentFolderPress = useCallback(
(f) => {
dispatch(removeRecentFolder({ folder: f }));
},
[dispatch]
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{modalTitle} - {translate('Select Folder')}
</ModalHeader>
<ModalBody>
<PathInputConnector
name="folder"
value={folder}
onChange={onPathChange}
/>
{recentFolders.length ? (
<div className={styles.recentFoldersContainer}>
<Table columns={recentFoldersColumns}>
<TableBody>
{recentFolders
.slice(0)
.reverse()
.map((recentFolder) => {
return (
<RecentFolderRow
key={recentFolder.folder}
folder={recentFolder.folder}
lastUsed={recentFolder.lastUsed}
onPress={onRecentPathPress}
onRemoveRecentFolderPress={onRemoveRecentFolderPress}
/>
);
})}
</TableBody>
</Table>
</div>
) : null}
<div className={styles.buttonsContainer}>
<div className={styles.buttonContainer}>
<Button
className={styles.button}
kind={kinds.PRIMARY}
size={sizes.LARGE}
isDisabled={!folder}
onPress={onQuickImportPress}
>
<Icon className={styles.buttonIcon} name={icons.QUICK} />
{translate('Move Automatically')}
</Button>
</div>
<div className={styles.buttonContainer}>
<Button
className={styles.button}
kind={kinds.PRIMARY}
size={sizes.LARGE}
isDisabled={!folder}
onPress={onInteractiveImportPress}
>
<Icon className={styles.buttonIcon} name={icons.INTERACTIVE} />
{translate('Interactive Import')}
</Button>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default InteractiveImportSelectFolderModalContent;

@ -1,80 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import { executeCommand } from 'Store/Actions/commandActions';
import { addRecentFolder, removeRecentFolder } from 'Store/Actions/interactiveImportActions';
import InteractiveImportSelectFolderModalContent from './InteractiveImportSelectFolderModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.interactiveImport.recentFolders,
(recentFolders) => {
return {
recentFolders
};
}
);
}
const mapDispatchToProps = {
addRecentFolder,
removeRecentFolder,
executeCommand
};
class InteractiveImportSelectFolderModalContentConnector extends Component {
//
// Listeners
onQuickImportPress = (folder) => {
this.props.addRecentFolder({ folder });
this.props.executeCommand({
name: commandNames.DOWNLOADED_EPSIODES_SCAN,
path: folder
});
this.props.onModalClose();
};
onInteractiveImportPress = (folder) => {
this.props.addRecentFolder({ folder });
this.props.onFolderSelect(folder);
};
onRemoveRecentFolderPress = (folder) => {
this.props.removeRecentFolder({ folder });
};
//
// Render
render() {
if (this.path) {
return null;
}
return (
<InteractiveImportSelectFolderModalContent
{...this.props}
onQuickImportPress={this.onQuickImportPress}
onInteractiveImportPress={this.onInteractiveImportPress}
onRemoveRecentFolderPress={this.onRemoveRecentFolderPress}
/>
);
}
}
InteractiveImportSelectFolderModalContentConnector.propTypes = {
path: PropTypes.string,
onFolderSelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
addRecentFolder: PropTypes.func.isRequired,
removeRecentFolder: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(InteractiveImportSelectFolderModalContentConnector);

@ -0,0 +1,6 @@
interface RecentFolder {
folder: string;
lastUsed: string;
}
export default RecentFolder;

@ -0,0 +1,7 @@
enum ImportMode {
Auto = 'auto',
Move = 'move',
Copy = 'copy',
}
export default ImportMode;

@ -1,568 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import SelectInput from 'Components/Form/SelectInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import SelectedMenuItem from 'Components/Menu/SelectedMenuItem';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { align, icons, kinds, scrollDirections } from 'Helpers/Props';
import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal';
import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal';
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal';
import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal';
import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import InteractiveImportRow from './InteractiveImportRow';
import styles from './InteractiveImportModalContent.css';
const columns = [
{
name: 'relativePath',
label: 'Relative Path',
isSortable: true,
isVisible: true
},
{
name: 'series',
label: 'Series',
isSortable: true,
isVisible: true
},
{
name: 'season',
label: 'Season',
isVisible: true
},
{
name: 'episodes',
label: 'Episode(s)',
isVisible: true
},
{
name: 'releaseGroup',
label: 'Release Group',
isVisible: true
},
{
name: 'quality',
label: 'Quality',
isSortable: true,
isVisible: true
},
{
name: 'languages',
label: 'Languages',
isSortable: true,
isVisible: true
},
{
name: 'size',
label: 'Size',
isSortable: true,
isVisible: true
},
{
name: 'customFormats',
label: React.createElement(Icon, {
name: icons.INTERACTIVE,
title: 'Custom Format'
}),
isSortable: true,
isVisible: true
},
{
name: 'rejections',
label: React.createElement(Icon, {
name: icons.DANGER,
kind: kinds.DANGER
}),
isSortable: true,
isVisible: true
}
];
const filterExistingFilesOptions = {
ALL: 'all',
NEW: 'new'
};
const importModeOptions = [
{ key: 'chooseImportMode', value: 'Choose Import Mode', disabled: true },
{ key: 'move', value: 'Move Files' },
{ key: 'copy', value: 'Hardlink/Copy Files' }
];
const SELECT = 'select';
const SERIES = 'series';
const SEASON = 'season';
const EPISODE = 'episode';
const RELEASE_GROUP = 'releaseGroup';
const QUALITY = 'quality';
const LANGUAGE = 'language';
class InteractiveImportModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const instanceColumns = _.cloneDeep(columns);
if (!props.showSeries) {
instanceColumns.find((c) => c.name === 'series').isVisible = false;
}
this.state = {
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {},
invalidRowsSelected: [],
withoutEpisodeFileIdRowsSelected: [],
selectModalOpen: null,
columns: instanceColumns,
isConfirmDeleteModalOpen: false
};
}
componentDidUpdate(prevProps) {
const {
isDeleting,
deleteError,
onModalClose
} = this.props;
if (!isDeleting && prevProps.isDeleting && !deleteError) {
onModalClose();
}
}
//
// Control
getSelectedIds = () => {
return getSelectedIds(this.state.selectedState);
};
//
// Listeners
onSelectAllChange = ({ value }) => {
this.setState(selectAll(this.state.selectedState, value));
};
onSelectedChange = ({ id, value, hasEpisodeFileId, shiftKey = false }) => {
this.setState((state) => {
return {
...toggleSelected(state, this.props.items, id, value, shiftKey),
withoutEpisodeFileIdRowsSelected: hasEpisodeFileId || !value ?
_.without(state.withoutEpisodeFileIdRowsSelected, id) :
[...state.withoutEpisodeFileIdRowsSelected, id]
};
});
};
onValidRowChange = (id, isValid) => {
this.setState((state) => {
if (isValid) {
return {
invalidRowsSelected: _.without(state.invalidRowsSelected, id)
};
}
return {
invalidRowsSelected: [...state.invalidRowsSelected, id]
};
});
};
onDeleteSelectedPress = () => {
this.setState({ isConfirmDeleteModalOpen: true });
};
onConfirmDelete = () => {
this.setState({ isConfirmDeleteModalOpen: false });
this.props.onDeleteSelectedPress(this.getSelectedIds());
};
onConfirmDeleteModalClose = () => {
this.setState({ isConfirmDeleteModalOpen: false });
};
onImportSelectedPress = () => {
const {
downloadId,
showImportMode,
importMode,
onImportSelectedPress
} = this.props;
const selected = this.getSelectedIds();
const finalImportMode = downloadId || !showImportMode ? 'auto' : importMode;
onImportSelectedPress(selected, finalImportMode);
};
onFilterExistingFilesChange = (value) => {
this.props.onFilterExistingFilesChange(value !== filterExistingFilesOptions.ALL);
};
onImportModeChange = ({ value }) => {
this.props.onImportModeChange(value);
};
onSelectModalSelect = ({ value }) => {
this.setState({ selectModalOpen: value });
};
onSelectModalClose = () => {
this.setState({ selectModalOpen: null });
};
//
// Render
render() {
const {
downloadId,
allowSeriesChange,
autoSelectRow,
showFilterExistingFiles,
showDelete,
showImportMode,
filterExistingFiles,
title,
folder,
isFetching,
isPopulated,
error,
items,
sortKey,
sortDirection,
importMode,
interactiveImportErrorMessage,
isDeleting,
modalTitle,
onSortPress,
onModalClose
} = this.props;
const {
allSelected,
allUnselected,
selectedState,
invalidRowsSelected,
withoutEpisodeFileIdRowsSelected,
selectModalOpen,
isConfirmDeleteModalOpen
} = this.state;
const selectedIds = this.getSelectedIds();
const orderedSelectedIds = items.reduce((acc, file) => {
if (selectedIds.includes(file.id)) {
acc.push(file.id);
}
return acc;
}, []);
const selectedItem = selectedIds.length ?
items.find((file) => file.id === selectedIds[0]) :
null;
const errorMessage = getErrorMessage(error, 'Unable to load manual import items');
const bulkSelectOptions = [
{ key: SELECT, value: 'Select...', disabled: true },
{ key: SEASON, value: 'Select Season' },
{ key: EPISODE, value: 'Select Episode(s)' },
{ key: QUALITY, value: 'Select Quality' },
{ key: RELEASE_GROUP, value: 'Select Release Group' },
{ key: LANGUAGE, value: 'Select Language' }
];
if (allowSeriesChange) {
bulkSelectOptions.splice(1, 0, {
key: SERIES,
value: 'Select Series'
});
}
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{modalTitle} - {title || folder}
</ModalHeader>
<ModalBody scrollDirection={scrollDirections.BOTH}>
{
showFilterExistingFiles &&
<div className={styles.filterContainer}>
<Menu alignMenu={align.RIGHT}>
<MenuButton>
<Icon
name={icons.FILTER}
size={22}
/>
<div className={styles.filterText}>
{
filterExistingFiles ? 'Unmapped Files Only' : 'All Files'
}
</div>
</MenuButton>
<MenuContent>
<SelectedMenuItem
name={filterExistingFilesOptions.ALL}
isSelected={!filterExistingFiles}
onPress={this.onFilterExistingFilesChange}
>
All Files
</SelectedMenuItem>
<SelectedMenuItem
name={filterExistingFilesOptions.NEW}
isSelected={filterExistingFiles}
onPress={this.onFilterExistingFilesChange}
>
Unmapped Files Only
</SelectedMenuItem>
</MenuContent>
</Menu>
</div>
}
{
isFetching &&
<LoadingIndicator />
}
{
error &&
<div>{errorMessage}</div>
}
{
isPopulated && !!items.length && !isFetching && !isFetching &&
<Table
columns={this.state.columns}
horizontalScroll={true}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
onSelectAllChange={this.onSelectAllChange}
>
<TableBody>
{
items.map((item) => {
return (
<InteractiveImportRow
key={item.id}
isSelected={selectedState[item.id]}
{...item}
allowSeriesChange={allowSeriesChange}
autoSelectRow={autoSelectRow}
columns={this.state.columns}
modalTitle={modalTitle}
onSelectedChange={this.onSelectedChange}
onValidRowChange={this.onValidRowChange}
/>
);
})
}
</TableBody>
</Table>
}
{
isPopulated && !items.length && !isFetching &&
'No video files were found in the selected folder'
}
</ModalBody>
<ModalFooter className={styles.footer}>
<div className={styles.leftButtons}>
{
showDelete ?
<SpinnerButton
className={styles.deleteButton}
kind={kinds.DANGER}
isSpinning={isDeleting}
isDisabled={!selectedIds.length || !!withoutEpisodeFileIdRowsSelected.length}
onPress={this.onDeleteSelectedPress}
>
Delete
</SpinnerButton> :
null
}
{
!downloadId && showImportMode ?
<SelectInput
className={styles.importMode}
name="importMode"
value={importMode}
values={importModeOptions}
onChange={this.onImportModeChange}
/> :
null
}
<SelectInput
className={styles.bulkSelect}
name="select"
value={SELECT}
values={bulkSelectOptions}
isDisabled={!selectedIds.length}
onChange={this.onSelectModalSelect}
/>
</div>
<div className={styles.rightButtons}>
<Button onPress={onModalClose}>
Cancel
</Button>
{
interactiveImportErrorMessage &&
<span className={styles.errorMessage}>{interactiveImportErrorMessage}</span>
}
<Button
kind={kinds.SUCCESS}
isDisabled={!selectedIds.length || !!invalidRowsSelected.length}
onPress={this.onImportSelectedPress}
>
Import
</Button>
</div>
</ModalFooter>
<SelectSeriesModal
isOpen={selectModalOpen === SERIES}
ids={selectedIds}
modalTitle={modalTitle}
onModalClose={this.onSelectModalClose}
/>
<SelectSeasonModal
isOpen={selectModalOpen === SEASON}
ids={selectedIds}
seriesId={selectedItem && selectedItem.series && selectedItem.series.id}
modalTitle={modalTitle}
onModalClose={this.onSelectModalClose}
/>
<SelectEpisodeModal
isOpen={selectModalOpen === EPISODE}
ids={orderedSelectedIds}
seriesId={selectedItem && selectedItem.series && selectedItem.series.id}
seasonNumber={selectedItem && selectedItem.seasonNumber}
modalTitle={modalTitle}
onModalClose={this.onSelectModalClose}
/>
<SelectReleaseGroupModal
isOpen={selectModalOpen === RELEASE_GROUP}
ids={selectedIds}
releaseGroup=""
modalTitle={modalTitle}
onModalClose={this.onSelectModalClose}
/>
<SelectLanguageModal
isOpen={selectModalOpen === LANGUAGE}
ids={selectedIds}
languageIds={[0]}
modalTitle={modalTitle}
onModalClose={this.onSelectModalClose}
/>
<SelectQualityModal
isOpen={selectModalOpen === QUALITY}
ids={selectedIds}
qualityId={0}
proper={false}
real={false}
modalTitle={modalTitle}
onModalClose={this.onSelectModalClose}
/>
<ConfirmModal
isOpen={isConfirmDeleteModalOpen}
kind={kinds.DANGER}
title="Delete Selected Episode Files"
message={'Are you sure you want to delete the selected episode files?'}
confirmLabel="Delete"
onConfirm={this.onConfirmDelete}
onCancel={this.onConfirmDeleteModalClose}
/>
</ModalContent>
);
}
}
InteractiveImportModalContent.propTypes = {
downloadId: PropTypes.string,
showSeries: PropTypes.bool.isRequired,
allowSeriesChange: PropTypes.bool.isRequired,
autoSelectRow: PropTypes.bool.isRequired,
showDelete: PropTypes.bool.isRequired,
showImportMode: PropTypes.bool.isRequired,
showFilterExistingFiles: PropTypes.bool.isRequired,
filterExistingFiles: PropTypes.bool.isRequired,
importMode: PropTypes.string.isRequired,
title: PropTypes.string,
folder: PropTypes.string,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.string,
interactiveImportErrorMessage: PropTypes.string,
isDeleting: PropTypes.bool.isRequired,
deleteError: PropTypes.object,
modalTitle: PropTypes.string.isRequired,
onSortPress: PropTypes.func.isRequired,
onFilterExistingFilesChange: PropTypes.func.isRequired,
onImportModeChange: PropTypes.func.isRequired,
onDeleteSelectedPress: PropTypes.func.isRequired,
onImportSelectedPress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
InteractiveImportModalContent.defaultProps = {
showSeries: true,
allowSeriesChange: true,
autoSelectRow: true,
showFilterExistingFiles: false,
showDelete: false,
showImportMode: true,
importMode: 'move'
};
export default InteractiveImportModalContent;

@ -0,0 +1,873 @@
import { cloneDeep, without } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import SelectInput from 'Components/Form/SelectInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import SelectedMenuItem from 'Components/Menu/SelectedMenuItem';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import usePrevious from 'Helpers/Hooks/usePrevious';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { align, icons, kinds, scrollDirections } from 'Helpers/Props';
import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal';
import ImportMode from 'InteractiveImport/ImportMode';
import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal';
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal';
import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal';
import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal';
import { executeCommand } from 'Store/Actions/commandActions';
import {
deleteEpisodeFiles,
updateEpisodeFiles,
} from 'Store/Actions/episodeFileActions';
import {
clearInteractiveImport,
fetchInteractiveImportItems,
reprocessInteractiveImportItems,
setInteractiveImportMode,
setInteractiveImportSort,
updateInteractiveImportItems,
} from 'Store/Actions/interactiveImportActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import InteractiveImportRow from './InteractiveImportRow';
import styles from './InteractiveImportModalContent.css';
type SelectType =
| 'select'
| 'series'
| 'season'
| 'episode'
| 'releaseGroup'
| 'quality'
| 'language';
const COLUMNS = [
{
name: 'relativePath',
label: 'Relative Path',
isSortable: true,
isVisible: true,
},
{
name: 'series',
label: 'Series',
isSortable: true,
isVisible: true,
},
{
name: 'season',
label: 'Season',
isVisible: true,
},
{
name: 'episodes',
label: 'Episode(s)',
isVisible: true,
},
{
name: 'releaseGroup',
label: 'Release Group',
isVisible: true,
},
{
name: 'quality',
label: 'Quality',
isSortable: true,
isVisible: true,
},
{
name: 'languages',
label: 'Languages',
isSortable: true,
isVisible: true,
},
{
name: 'size',
label: 'Size',
isSortable: true,
isVisible: true,
},
{
name: 'customFormats',
label: React.createElement(Icon, {
name: icons.INTERACTIVE,
title: 'Custom Format',
}),
isSortable: true,
isVisible: true,
},
{
name: 'rejections',
label: React.createElement(Icon, {
name: icons.DANGER,
kind: kinds.DANGER,
}),
isSortable: true,
isVisible: true,
},
];
const filterExistingFilesOptions = {
ALL: 'all',
NEW: 'new',
};
const importModeOptions = [
{ key: 'chooseImportMode', value: 'Choose Import Mode', disabled: true },
{ key: 'move', value: 'Move Files' },
{ key: 'copy', value: 'Hardlink/Copy Files' },
];
function isSameEpisodeFile(file, originalFile) {
const { series, seasonNumber, episodes } = file;
if (!originalFile) {
return false;
}
if (!originalFile.series || series.id !== originalFile.series.id) {
return false;
}
if (seasonNumber !== originalFile.seasonNumber) {
return false;
}
return !hasDifferentItems(originalFile.episodes, episodes);
}
const episodeFilesInfoSelector = createSelector(
(state) => state.episodeFiles.isDeleting,
(state) => state.episodeFiles.deleteError,
(isDeleting, deleteError) => {
return {
isDeleting,
deleteError,
};
}
);
const importModeSelector = createSelector(
(state) => state.interactiveImport.importMode,
(importMode) => {
return importMode;
}
);
interface InteractiveImportModalContentProps {
downloadId?: string;
seriesId?: number;
seasonNumber?: number;
showSeries?: boolean;
allowSeriesChange?: boolean;
autoSelectRow?: boolean;
showDelete?: boolean;
showImportMode?: boolean;
showFilterExistingFiles?: boolean;
title?: string;
folder?: string;
sortKey?: string;
sortDirection?: string;
initialSortKey?: string;
initialSortDirection?: string;
modalTitle: string;
onModalClose(): void;
}
function InteractiveImportModalContent(
props: InteractiveImportModalContentProps
) {
const {
downloadId,
seriesId,
seasonNumber,
allowSeriesChange = true,
autoSelectRow = true,
showSeries = true,
showFilterExistingFiles = false,
showDelete = false,
showImportMode = true,
title,
folder,
initialSortKey,
initialSortDirection,
modalTitle,
onModalClose,
} = props;
const {
isFetching,
isPopulated,
error,
items,
originalItems,
sortKey,
sortDirection,
} = useSelector(createClientSideCollectionSelector('interactiveImport'));
const { isDeleting, deleteError } = useSelector(episodeFilesInfoSelector);
const importMode = useSelector(importModeSelector);
const [invalidRowsSelected, setInvalidRowsSelected] = useState([]);
const [
withoutEpisodeFileIdRowsSelected,
setWithoutEpisodeFileIdRowsSelected,
] = useState([]);
const [selectModalOpen, setSelectModalOpen] = useState<SelectType | null>(
null
);
const [isConfirmDeleteModalOpen, setIsConfirmDeleteModalOpen] =
useState(false);
const [filterExistingFiles, setFilterExistingFiles] = useState(false);
const [interactiveImportErrorMessage, setInteractiveImportErrorMessage] =
useState<string | null>(null);
const [selectState, setSelectState] = useSelectState();
const [bulkSelectOptions, setBulkSelectOptions] = useState([
{ key: 'select', value: 'Select...', disabled: true },
{ key: 'season', value: 'Select Season' },
{ key: 'episode', value: 'Select Episode(s)' },
{ key: 'quality', value: 'Select Quality' },
{ key: 'releaseGroup', value: 'Select Release Group' },
{ key: 'language', value: 'Select Language' },
]);
const { allSelected, allUnselected, selectedState } = selectState;
const previousIsDeleting = usePrevious(isDeleting);
const dispatch = useDispatch();
const columns: Column[] = useMemo(() => {
const result = cloneDeep(COLUMNS);
if (!showSeries) {
result.find((c) => c.name === 'series').isVisible = false;
}
return result;
}, [showSeries]);
const selectedIds = useMemo(() => {
return getSelectedIds(selectedState);
}, [selectedState]);
useEffect(
() => {
if (allowSeriesChange) {
const newBulkSelectOptions = [...bulkSelectOptions];
newBulkSelectOptions.splice(1, 0, {
key: 'series',
value: 'Select Series',
});
setBulkSelectOptions(newBulkSelectOptions);
}
if (initialSortKey) {
const sortProps: { sortKey: string; sortDirection?: string } = {
sortKey: initialSortKey,
};
if (initialSortDirection) {
sortProps.sortDirection = initialSortDirection;
}
dispatch(setInteractiveImportSort(sortProps));
}
dispatch(
fetchInteractiveImportItems({
downloadId,
seriesId,
seasonNumber,
folder,
filterExistingFiles,
})
);
// returned function will be called on component unmount
return () => {
dispatch(clearInteractiveImport());
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
useEffect(() => {
if (!isDeleting && previousIsDeleting && !deleteError) {
onModalClose();
}
}, [previousIsDeleting, isDeleting, deleteError, onModalClose]);
const onSelectAllChange = useCallback(
({ value }) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
},
[items, setSelectState]
);
const onSelectedChange = useCallback(
({ id, value, hasEpisodeFileId, shiftKey = false }) => {
setSelectState({
type: 'toggleSelected',
items,
id,
isSelected: value,
shiftKey,
});
setWithoutEpisodeFileIdRowsSelected(
hasEpisodeFileId || !value
? without(withoutEpisodeFileIdRowsSelected, id)
: [...withoutEpisodeFileIdRowsSelected, id]
);
},
[
items,
withoutEpisodeFileIdRowsSelected,
setSelectState,
setWithoutEpisodeFileIdRowsSelected,
]
);
const onValidRowChange = useCallback(
(id: number, isValid: boolean) => {
if (isValid && invalidRowsSelected.includes(id)) {
setInvalidRowsSelected(without(invalidRowsSelected, id));
} else if (!isValid && !invalidRowsSelected.includes(id)) {
setInvalidRowsSelected([...invalidRowsSelected, id]);
}
},
[invalidRowsSelected, setInvalidRowsSelected]
);
const onDeleteSelectedPress = useCallback(() => {
setIsConfirmDeleteModalOpen(true);
}, [setIsConfirmDeleteModalOpen]);
const onConfirmDelete = useCallback(() => {
setIsConfirmDeleteModalOpen(false);
const episodeFileIds = items.reduce((acc, item) => {
if (selectedIds.indexOf(item.id) > -1 && item.episodeFileId) {
acc.push(item.episodeFileId);
}
return acc;
}, []);
dispatch(deleteEpisodeFiles({ episodeFileIds }));
}, [items, selectedIds, setIsConfirmDeleteModalOpen, dispatch]);
const onConfirmDeleteModalClose = useCallback(() => {
setIsConfirmDeleteModalOpen(false);
}, [setIsConfirmDeleteModalOpen]);
const onImportSelectedPress = useCallback(() => {
const finalImportMode =
downloadId || !showImportMode ? ImportMode.Auto : importMode;
const existingFiles = [];
const files = [];
if (finalImportMode === 'chooseImportMode') {
setInteractiveImportErrorMessage('An import mode must be selected');
return;
}
items.forEach((item) => {
const isSelected = selectedIds.indexOf(item.id) > -1;
if (isSelected) {
const {
series,
seasonNumber,
episodes,
releaseGroup,
quality,
languages,
episodeFileId,
} = item;
if (!series) {
setInteractiveImportErrorMessage(
'Series must be chosen for each selected file'
);
return;
}
if (isNaN(seasonNumber)) {
setInteractiveImportErrorMessage(
'Season must be chosen for each selected file'
);
return;
}
if (!episodes || !episodes.length) {
setInteractiveImportErrorMessage(
'One or more episodes must be chosen for each selected file'
);
return;
}
if (!quality) {
setInteractiveImportErrorMessage(
'Quality must be chosen for each selected file'
);
return;
}
if (!languages) {
setInteractiveImportErrorMessage(
'Language(s) must be chosen for each selected file'
);
return;
}
setInteractiveImportErrorMessage(null);
if (episodeFileId) {
const originalItem = originalItems.find((i) => i.id === item.id);
if (isSameEpisodeFile(item, originalItem)) {
existingFiles.push({
id: episodeFileId,
releaseGroup,
quality,
languages,
});
return;
}
}
files.push({
path: item.path,
folderName: item.folderName,
seriesId: series.id,
episodeIds: episodes.map((e) => e.id),
releaseGroup,
quality,
languages,
downloadId,
episodeFileId,
});
}
});
let shouldClose = false;
if (existingFiles.length) {
dispatch(
updateEpisodeFiles({
files: existingFiles,
})
);
shouldClose = true;
}
if (files.length) {
dispatch(
executeCommand({
name: commandNames.INTERACTIVE_IMPORT,
files,
importMode,
})
);
shouldClose = true;
}
if (shouldClose) {
onModalClose();
}
}, [
downloadId,
showImportMode,
importMode,
items,
originalItems,
selectedIds,
onModalClose,
dispatch,
]);
const onSortPress = useCallback(
(sortKey, sortDirection) => {
dispatch(setInteractiveImportSort({ sortKey, sortDirection }));
},
[dispatch]
);
const onFilterExistingFilesChange = useCallback(
(value) => {
const filter = value !== filterExistingFilesOptions.ALL;
setFilterExistingFiles(filter);
dispatch(
fetchInteractiveImportItems({
downloadId,
seriesId,
folder,
filterExistingFiles: filter,
})
);
},
[downloadId, seriesId, folder, setFilterExistingFiles, dispatch]
);
const onImportModeChange = useCallback(
({ value }) => {
dispatch(setInteractiveImportMode({ importMode: value }));
},
[dispatch]
);
const onSelectModalSelect = useCallback(
({ value }) => {
setSelectModalOpen(value);
},
[setSelectModalOpen]
);
const onSelectModalClose = useCallback(() => {
setSelectModalOpen(null);
}, [setSelectModalOpen]);
const onSeriesSelect = useCallback(
(series) => {
dispatch(
updateInteractiveImportItems({
ids: selectedIds,
series,
seasonNumber: undefined,
episodes: [],
})
);
dispatch(reprocessInteractiveImportItems({ ids: selectedIds }));
setSelectModalOpen(null);
},
[selectedIds, setSelectModalOpen, dispatch]
);
const onSeasonSelect = useCallback(
(seasonNumber) => {
dispatch(
updateInteractiveImportItems({
ids: selectedIds,
seasonNumber,
episodes: [],
})
);
dispatch(reprocessInteractiveImportItems({ ids: selectedIds }));
setSelectModalOpen(null);
},
[selectedIds, setSelectModalOpen, dispatch]
);
const onEpisodesSelect = useCallback(
(episodes) => {
dispatch(
updateInteractiveImportItems({
ids: selectedIds,
episodes,
})
);
dispatch(reprocessInteractiveImportItems({ ids: selectedIds }));
setSelectModalOpen(null);
},
[selectedIds, setSelectModalOpen, dispatch]
);
const onReleaseGroupSelect = useCallback(
(releaseGroup) => {
dispatch(
updateInteractiveImportItems({
ids: selectedIds,
releaseGroup,
})
);
dispatch(reprocessInteractiveImportItems({ ids: selectedIds }));
setSelectModalOpen(null);
},
[selectedIds, dispatch]
);
const onLanguagesSelect = useCallback(
(newLanguages) => {
dispatch(
updateInteractiveImportItems({
ids: selectedIds,
languages: newLanguages,
})
);
dispatch(reprocessInteractiveImportItems({ ids: selectedIds }));
setSelectModalOpen(null);
},
[selectedIds, dispatch]
);
const onQualitySelect = useCallback(
(quality) => {
dispatch(
updateInteractiveImportItems({
ids: selectedIds,
quality,
})
);
dispatch(reprocessInteractiveImportItems({ ids: selectedIds }));
setSelectModalOpen(null);
},
[selectedIds, dispatch]
);
const orderedSelectedIds = items.reduce((acc, file) => {
if (selectedIds.includes(file.id)) {
acc.push(file.id);
}
return acc;
}, []);
const selectedItem = selectedIds.length
? items.find((file) => file.id === selectedIds[0])
: null;
const errorMessage = getErrorMessage(
error,
'Unable to load manual import items'
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{modalTitle} - {title || folder}
</ModalHeader>
<ModalBody scrollDirection={scrollDirections.BOTH}>
{showFilterExistingFiles && (
<div className={styles.filterContainer}>
<Menu alignMenu={align.RIGHT}>
<MenuButton>
<Icon name={icons.FILTER} size={22} />
<div className={styles.filterText}>
{filterExistingFiles ? 'Unmapped Files Only' : 'All Files'}
</div>
</MenuButton>
<MenuContent>
<SelectedMenuItem
name={filterExistingFilesOptions.ALL}
isSelected={!filterExistingFiles}
onPress={onFilterExistingFilesChange}
>
All Files
</SelectedMenuItem>
<SelectedMenuItem
name={filterExistingFilesOptions.NEW}
isSelected={filterExistingFiles}
onPress={onFilterExistingFilesChange}
>
Unmapped Files Only
</SelectedMenuItem>
</MenuContent>
</Menu>
</div>
)}
{isFetching ? <LoadingIndicator /> : null}
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table
columns={columns}
horizontalScroll={true}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
onSelectAllChange={onSelectAllChange}
>
<TableBody>
{items.map((item) => {
return (
<InteractiveImportRow
key={item.id}
isSelected={selectedState[item.id]}
{...item}
allowSeriesChange={allowSeriesChange}
autoSelectRow={autoSelectRow}
columns={columns}
modalTitle={modalTitle}
onSelectedChange={onSelectedChange}
onValidRowChange={onValidRowChange}
/>
);
})}
</TableBody>
</Table>
) : null}
{isPopulated && !items.length && !isFetching
? 'No video files were found in the selected folder'
: null}
</ModalBody>
<ModalFooter className={styles.footer}>
<div className={styles.leftButtons}>
{showDelete ? (
<SpinnerButton
className={styles.deleteButton}
kind={kinds.DANGER}
isSpinning={isDeleting}
isDisabled={
!selectedIds.length || !!withoutEpisodeFileIdRowsSelected.length
}
onPress={onDeleteSelectedPress}
>
Delete
</SpinnerButton>
) : null}
{!downloadId && showImportMode ? (
<SelectInput
className={styles.importMode}
name="importMode"
value={importMode}
values={importModeOptions}
onChange={onImportModeChange}
/>
) : null}
<SelectInput
className={styles.bulkSelect}
name="select"
value={'select'}
values={bulkSelectOptions}
isDisabled={!selectedIds.length}
onChange={onSelectModalSelect}
/>
</div>
<div className={styles.rightButtons}>
<Button onPress={onModalClose}>Cancel</Button>
{interactiveImportErrorMessage && (
<span className={styles.errorMessage}>
{interactiveImportErrorMessage}
</span>
)}
<Button
kind={kinds.SUCCESS}
isDisabled={!selectedIds.length || !!invalidRowsSelected.length}
onPress={onImportSelectedPress}
>
Import
</Button>
</div>
</ModalFooter>
<SelectSeriesModal
isOpen={selectModalOpen === 'series'}
modalTitle={modalTitle}
onSeriesSelect={onSeriesSelect}
onModalClose={onSelectModalClose}
/>
<SelectSeasonModal
isOpen={selectModalOpen === 'season'}
seriesId={selectedItem?.series?.id}
modalTitle={modalTitle}
onSeasonSelect={onSeasonSelect}
onModalClose={onSelectModalClose}
/>
<SelectEpisodeModal
isOpen={selectModalOpen === 'episode'}
selectedIds={orderedSelectedIds}
seriesId={selectedItem?.series?.id}
seasonNumber={selectedItem?.seasonNumber}
isAnime={selectedItem?.series.type === 'anime'}
modalTitle={modalTitle}
onEpisodesSelect={onEpisodesSelect}
onModalClose={onSelectModalClose}
/>
<SelectReleaseGroupModal
isOpen={selectModalOpen === 'releaseGroup'}
releaseGroup=""
modalTitle={modalTitle}
onReleaseGroupSelect={onReleaseGroupSelect}
onModalClose={onSelectModalClose}
/>
<SelectLanguageModal
isOpen={selectModalOpen === 'language'}
languageIds={[0]}
modalTitle={modalTitle}
onLanguagesSelect={onLanguagesSelect}
onModalClose={onSelectModalClose}
/>
<SelectQualityModal
isOpen={selectModalOpen === 'quality'}
qualityId={0}
proper={false}
real={false}
modalTitle={modalTitle}
onQualitySelect={onQualitySelect}
onModalClose={onSelectModalClose}
/>
<ConfirmModal
isOpen={isConfirmDeleteModalOpen}
kind={kinds.DANGER}
title="Delete Selected Episode Files"
message={'Are you sure you want to delete the selected episode files?'}
confirmLabel="Delete"
onConfirm={onConfirmDelete}
onCancel={onConfirmDeleteModalClose}
/>
</ModalContent>
);
}
export default InteractiveImportModalContent;

@ -1,327 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import { sortDirections } from 'Helpers/Props';
import { executeCommand } from 'Store/Actions/commandActions';
import { deleteEpisodeFiles, updateEpisodeFiles } from 'Store/Actions/episodeFileActions';
import { clearInteractiveImport, fetchInteractiveImportItems, setInteractiveImportMode, setInteractiveImportSort } from 'Store/Actions/interactiveImportActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import InteractiveImportModalContent from './InteractiveImportModalContent';
function isSameEpisodeFile(file, originalFile) {
const {
series,
seasonNumber,
episodes
} = file;
if (!originalFile) {
return false;
}
if (!originalFile.series || series.id !== originalFile.series.id) {
return false;
}
if (seasonNumber !== originalFile.seasonNumber) {
return false;
}
return !hasDifferentItems(originalFile.episodes, episodes);
}
function createMapStateToProps() {
return createSelector(
createClientSideCollectionSelector('interactiveImport'),
(state) => state.episodeFiles.isDeleting,
(state) => state.episodeFiles.deleteError,
(interactiveImport, isDeleting, deleteError) => {
return {
...interactiveImport,
isDeleting,
deleteError
};
}
);
}
const mapDispatchToProps = {
dispatchFetchInteractiveImportItems: fetchInteractiveImportItems,
dispatchSetInteractiveImportSort: setInteractiveImportSort,
dispatchSetInteractiveImportMode: setInteractiveImportMode,
dispatchClearInteractiveImport: clearInteractiveImport,
dispatchUpdateEpisodeFiles: updateEpisodeFiles,
dispatchDeleteEpisodeFiles: deleteEpisodeFiles,
dispatchExecuteCommand: executeCommand
};
class InteractiveImportModalContentConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
interactiveImportErrorMessage: null,
filterExistingFiles: true
};
}
componentDidMount() {
const {
downloadId,
seriesId,
seasonNumber,
folder,
initialSortKey,
initialSortDirection,
dispatchSetInteractiveImportSort,
dispatchFetchInteractiveImportItems
} = this.props;
const {
filterExistingFiles
} = this.state;
if (initialSortKey) {
const sortProps = {
sortKey: initialSortKey
};
if (initialSortDirection) {
sortProps.sortDirection = initialSortDirection;
}
dispatchSetInteractiveImportSort(sortProps);
}
dispatchFetchInteractiveImportItems({
downloadId,
seriesId,
seasonNumber,
folder,
filterExistingFiles
});
}
componentDidUpdate(prevProps, prevState) {
const {
filterExistingFiles
} = this.state;
if (prevState.filterExistingFiles !== filterExistingFiles) {
const {
downloadId,
seriesId,
folder
} = this.props;
this.props.dispatchFetchInteractiveImportItems({
downloadId,
seriesId,
folder,
filterExistingFiles
});
}
}
componentWillUnmount() {
this.props.dispatchClearInteractiveImport();
}
//
// Listeners
onSortPress = (sortKey, sortDirection) => {
this.props.dispatchSetInteractiveImportSort({ sortKey, sortDirection });
};
onFilterExistingFilesChange = (filterExistingFiles) => {
this.setState({ filterExistingFiles });
};
onImportModeChange = (importMode) => {
this.props.dispatchSetInteractiveImportMode({ importMode });
};
onDeleteSelectedPress = (selected) => {
const {
items,
dispatchDeleteEpisodeFiles
} = this.props;
const episodeFileIds = items.reduce((acc, item) => {
if (selected.indexOf(item.id) > -1 && item.episodeFileId) {
acc.push(item.episodeFileId);
}
return acc;
}, []);
dispatchDeleteEpisodeFiles({ episodeFileIds });
};
onImportSelectedPress = (selected, importMode) => {
const {
items,
originalItems,
dispatchUpdateEpisodeFiles,
dispatchExecuteCommand,
onModalClose
} = this.props;
const existingFiles = [];
const files = [];
if (importMode === 'chooseImportMode') {
this.setState({ interactiveImportErrorMessage: 'An import mode must be selected' });
return;
}
items.forEach((item) => {
const isSelected = selected.indexOf(item.id) > -1;
if (isSelected) {
const {
series,
seasonNumber,
episodes,
releaseGroup,
quality,
languages,
episodeFileId
} = item;
if (!series) {
this.setState({ interactiveImportErrorMessage: 'Series must be chosen for each selected file' });
return;
}
if (isNaN(seasonNumber)) {
this.setState({ interactiveImportErrorMessage: 'Season must be chosen for each selected file' });
return;
}
if (!episodes || !episodes.length) {
this.setState({ interactiveImportErrorMessage: 'One or more episodes must be chosen for each selected file' });
return;
}
if (!quality) {
this.setState({ interactiveImportErrorMessage: 'Quality must be chosen for each selected file' });
return;
}
if (!languages) {
this.setState({ interactiveImportErrorMessage: 'Language(s) must be chosen for each selected file' });
return;
}
if (episodeFileId) {
const originalItem = originalItems.find((i) => i.id === item.id);
if (isSameEpisodeFile(item, originalItem)) {
existingFiles.push({
id: episodeFileId,
releaseGroup,
quality,
languages
});
return;
}
}
files.push({
path: item.path,
folderName: item.folderName,
seriesId: series.id,
episodeIds: episodes.map((e) => e.id),
releaseGroup,
quality,
languages,
downloadId: this.props.downloadId,
episodeFileId
});
}
});
let shouldClose = false;
if (existingFiles.length) {
dispatchUpdateEpisodeFiles({
files: existingFiles
});
shouldClose = true;
}
if (files.length) {
dispatchExecuteCommand({
name: commandNames.INTERACTIVE_IMPORT,
files,
importMode
});
shouldClose = true;
}
if (shouldClose) {
onModalClose();
}
};
//
// Render
render() {
const {
interactiveImportErrorMessage,
filterExistingFiles
} = this.state;
return (
<InteractiveImportModalContent
{...this.props}
interactiveImportErrorMessage={interactiveImportErrorMessage}
filterExistingFiles={filterExistingFiles}
onSortPress={this.onSortPress}
onFilterExistingFilesChange={this.onFilterExistingFilesChange}
onImportModeChange={this.onImportModeChange}
onDeleteSelectedPress={this.onDeleteSelectedPress}
onImportSelectedPress={this.onImportSelectedPress}
/>
);
}
}
InteractiveImportModalContentConnector.propTypes = {
downloadId: PropTypes.string,
seriesId: PropTypes.number,
seasonNumber: PropTypes.number,
folder: PropTypes.string,
filterExistingFiles: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
initialSortKey: PropTypes.string,
initialSortDirection: PropTypes.oneOf(sortDirections.all),
originalItems: PropTypes.arrayOf(PropTypes.object).isRequired,
dispatchFetchInteractiveImportItems: PropTypes.func.isRequired,
dispatchSetInteractiveImportSort: PropTypes.func.isRequired,
dispatchSetInteractiveImportMode: PropTypes.func.isRequired,
dispatchClearInteractiveImport: PropTypes.func.isRequired,
dispatchUpdateEpisodeFiles: PropTypes.func.isRequired,
dispatchDeleteEpisodeFiles: PropTypes.func.isRequired,
dispatchExecuteCommand: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
InteractiveImportModalContentConnector.defaultProps = {
filterExistingFiles: true
};
export default connect(createMapStateToProps, mapDispatchToProps)(InteractiveImportModalContentConnector);

@ -1,504 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRowCellButton from 'Components/Table/Cells/TableRowCellButton';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal';
import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal';
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal';
import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal';
import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal';
import formatBytes from 'Utilities/Number/formatBytes';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import InteractiveImportRowCellPlaceholder from './InteractiveImportRowCellPlaceholder';
import styles from './InteractiveImportRow.css';
class InteractiveImportRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isSelectSeriesModalOpen: false,
isSelectSeasonModalOpen: false,
isSelectEpisodeModalOpen: false,
isSelectReleaseGroupModalOpen: false,
isSelectQualityModalOpen: false,
isSelectLanguageModalOpen: false
};
}
componentDidMount() {
const {
allowSeriesChange,
id,
series,
seasonNumber,
episodes,
quality,
languages,
episodeFileId,
columns
} = this.props;
if (
allowSeriesChange &&
series &&
seasonNumber != null &&
episodes.length &&
quality &&
languages
) {
this.props.onSelectedChange({
id,
hasEpisodeFileId: !!episodeFileId,
value: true
});
}
this.setState({
isSeriesColumnVisible: columns.find((c) => c.name === 'series').isVisible
});
}
componentDidUpdate(prevProps) {
const {
id,
series,
seasonNumber,
episodes,
quality,
languages,
isSelected,
onValidRowChange
} = this.props;
if (
prevProps.series === series &&
prevProps.seasonNumber === seasonNumber &&
!hasDifferentItems(prevProps.episodes, episodes) &&
prevProps.quality === quality &&
prevProps.languages === languages &&
prevProps.isSelected === isSelected
) {
return;
}
const isValid = !!(
series &&
seasonNumber != null &&
episodes.length &&
quality &&
languages
);
if (isSelected && !isValid) {
onValidRowChange(id, false);
} else {
onValidRowChange(id, true);
}
}
//
// Control
selectRowAfterChange = (value) => {
const {
id,
episodeFileId,
isSelected
} = this.props;
if (!isSelected && value === true) {
this.props.onSelectedChange({
id,
hasEpisodeFileId: !!episodeFileId,
value
});
}
};
//
// Listeners
onSelectedChange = (result) => {
const {
episodeFileId,
onSelectedChange
} = this.props;
onSelectedChange({
...result,
hasEpisodeFileId: !!episodeFileId
});
};
onSelectSeriesPress = () => {
this.setState({ isSelectSeriesModalOpen: true });
};
onSelectSeasonPress = () => {
this.setState({ isSelectSeasonModalOpen: true });
};
onSelectEpisodePress = () => {
this.setState({ isSelectEpisodeModalOpen: true });
};
onSelectReleaseGroupPress = () => {
this.setState({ isSelectReleaseGroupModalOpen: true });
};
onSelectQualityPress = () => {
this.setState({ isSelectQualityModalOpen: true });
};
onSelectLanguagePress = () => {
this.setState({ isSelectLanguageModalOpen: true });
};
onSelectSeriesModalClose = (changed) => {
this.setState({ isSelectSeriesModalOpen: false });
this.selectRowAfterChange(changed);
};
onSelectSeasonModalClose = (changed) => {
this.setState({ isSelectSeasonModalOpen: false });
this.selectRowAfterChange(changed);
};
onSelectEpisodeModalClose = (changed) => {
this.setState({ isSelectEpisodeModalOpen: false });
this.selectRowAfterChange(changed);
};
onSelectReleaseGroupModalClose = (changed) => {
this.setState({ isSelectReleaseGroupModalOpen: false });
this.selectRowAfterChange(changed);
};
onSelectQualityModalClose = (changed) => {
this.setState({ isSelectQualityModalOpen: false });
this.selectRowAfterChange(changed);
};
onSelectLanguageModalClose = (changed) => {
this.setState({ isSelectLanguageModalOpen: false });
this.selectRowAfterChange(changed);
};
//
// Render
render() {
const {
id,
allowSeriesChange,
relativePath,
series,
seasonNumber,
episodes,
quality,
languages,
releaseGroup,
size,
customFormats,
rejections,
isReprocessing,
isSelected,
modalTitle
} = this.props;
const {
isSelectSeriesModalOpen,
isSelectSeasonModalOpen,
isSelectEpisodeModalOpen,
isSelectReleaseGroupModalOpen,
isSelectQualityModalOpen,
isSelectLanguageModalOpen
} = this.state;
const seriesTitle = series ? series.title : '';
const isAnime = series ? series.seriesType === 'anime' : false;
const episodeInfo = episodes.map((episode) => {
return (
<div key={episode.id}>
{episode.episodeNumber}
{
isAnime && episode.absoluteEpisodeNumber != null ?
` (${episode.absoluteEpisodeNumber})` :
''
}
{` - ${episode.title}`}
</div>
);
});
const showSeriesPlaceholder = isSelected && !series;
const showSeasonNumberPlaceholder = isSelected && !!series && isNaN(seasonNumber) && !isReprocessing;
const showEpisodeNumbersPlaceholder = isSelected && Number.isInteger(seasonNumber) && !episodes.length;
const showReleaseGroupPlaceholder = isSelected && !releaseGroup;
const showQualityPlaceholder = isSelected && !quality;
const showLanguagePlaceholder = isSelected && !languages;
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={this.onSelectedChange}
/>
<TableRowCell
className={styles.relativePath}
title={relativePath}
>
{relativePath}
</TableRowCell>
{
this.state.isSeriesColumnVisible ?
<TableRowCellButton
isDisabled={!allowSeriesChange}
title={allowSeriesChange ? 'Click to change series' : undefined}
onPress={this.onSelectSeriesPress}
>
{
showSeriesPlaceholder ? <InteractiveImportRowCellPlaceholder /> : seriesTitle
}
</TableRowCellButton> :
null
}
<TableRowCellButton
isDisabled={!series}
title={series ? 'Click to change season' : undefined}
onPress={this.onSelectSeasonPress}
>
{
showSeasonNumberPlaceholder ? <InteractiveImportRowCellPlaceholder /> : seasonNumber
}
{
isReprocessing && seasonNumber == null ?
<LoadingIndicator className={styles.reprocessing}
size={20}
/> : null
}
</TableRowCellButton>
<TableRowCellButton
isDisabled={!series || isNaN(seasonNumber)}
title={series && !isNaN(seasonNumber) ? 'Click to change episode' : undefined}
onPress={this.onSelectEpisodePress}
>
{
showEpisodeNumbersPlaceholder ? <InteractiveImportRowCellPlaceholder /> : episodeInfo
}
</TableRowCellButton>
<TableRowCellButton
title="Click to change release group"
onPress={this.onSelectReleaseGroupPress}
>
{
showReleaseGroupPlaceholder ?
<InteractiveImportRowCellPlaceholder
isOptional={true}
/> :
releaseGroup
}
</TableRowCellButton>
<TableRowCellButton
className={styles.quality}
title="Click to change quality"
onPress={this.onSelectQualityPress}
>
{
showQualityPlaceholder &&
<InteractiveImportRowCellPlaceholder />
}
{
!showQualityPlaceholder && !!quality &&
<EpisodeQuality
className={styles.label}
quality={quality}
/>
}
</TableRowCellButton>
<TableRowCellButton
className={styles.language}
title="Click to change language"
onPress={this.onSelectLanguagePress}
>
{
showLanguagePlaceholder &&
<InteractiveImportRowCellPlaceholder />
}
{
!showLanguagePlaceholder && !!languages &&
<EpisodeLanguages
className={styles.label}
languages={languages}
/>
}
</TableRowCellButton>
<TableRowCell>
{formatBytes(size)}
</TableRowCell>
<TableRowCell>
{
customFormats?.length ?
<Popover
anchor={
<Icon name={icons.INTERACTIVE} />
}
title="Formats"
body={
<div className={styles.customFormatTooltip}>
<EpisodeFormats formats={customFormats} />
</div>
}
position={tooltipPositions.LEFT}
/> :
null
}
</TableRowCell>
<TableRowCell>
{
rejections.length ?
<Popover
anchor={
<Icon
name={icons.DANGER}
kind={kinds.DANGER}
/>
}
title="Release Rejected"
body={
<ul>
{
rejections.map((rejection, index) => {
return (
<li key={index}>
{rejection.reason}
</li>
);
})
}
</ul>
}
position={tooltipPositions.LEFT}
canFlip={false}
/> :
null
}
</TableRowCell>
<SelectSeriesModal
isOpen={isSelectSeriesModalOpen}
ids={[id]}
modalTitle={modalTitle}
onModalClose={this.onSelectSeriesModalClose}
/>
<SelectSeasonModal
isOpen={isSelectSeasonModalOpen}
ids={[id]}
seriesId={series && series.id}
modalTitle={modalTitle}
onModalClose={this.onSelectSeasonModalClose}
/>
<SelectEpisodeModal
isOpen={isSelectEpisodeModalOpen}
ids={[id]}
seriesId={series && series.id}
isAnime={isAnime}
seasonNumber={seasonNumber}
relativePath={relativePath}
modalTitle={modalTitle}
onModalClose={this.onSelectEpisodeModalClose}
/>
<SelectReleaseGroupModal
isOpen={isSelectReleaseGroupModalOpen}
ids={[id]}
releaseGroup={releaseGroup ?? ''}
modalTitle={modalTitle}
onModalClose={this.onSelectReleaseGroupModalClose}
/>
<SelectQualityModal
isOpen={isSelectQualityModalOpen}
ids={[id]}
qualityId={quality ? quality.quality.id : 0}
proper={quality ? quality.revision.version > 1 : false}
real={quality ? quality.revision.real > 0 : false}
modalTitle={modalTitle}
onModalClose={this.onSelectQualityModalClose}
/>
<SelectLanguageModal
isOpen={isSelectLanguageModalOpen}
ids={[id]}
languageIds={languages ? languages.map((l) => l.id) : []}
modalTitle={modalTitle}
onModalClose={this.onSelectLanguageModalClose}
/>
</TableRow>
);
}
}
InteractiveImportRow.propTypes = {
id: PropTypes.number.isRequired,
allowSeriesChange: PropTypes.bool.isRequired,
relativePath: PropTypes.string.isRequired,
series: PropTypes.object,
seasonNumber: PropTypes.number,
episodes: PropTypes.arrayOf(PropTypes.object).isRequired,
releaseGroup: PropTypes.string,
quality: PropTypes.object,
languages: PropTypes.arrayOf(PropTypes.object),
size: PropTypes.number.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object),
rejections: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
episodeFileId: PropTypes.number,
isReprocessing: PropTypes.bool,
isSelected: PropTypes.bool,
modalTitle: PropTypes.string.isRequired,
onSelectedChange: PropTypes.func.isRequired,
onValidRowChange: PropTypes.func.isRequired
};
InteractiveImportRow.defaultProps = {
episodes: []
};
export default InteractiveImportRow;

@ -0,0 +1,506 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRowCellButton from 'Components/Table/Cells/TableRowCellButton';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import Column from 'Components/Table/Column';
import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover';
import Episode from 'Episode/Episode';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal';
import { SelectedEpisode } from 'InteractiveImport/Episode/SelectEpisodeModalContent';
import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal';
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal';
import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal';
import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import Series from 'Series/Series';
import {
reprocessInteractiveImportItems,
updateInteractiveImportItem,
} from 'Store/Actions/interactiveImportActions';
import Rejection from 'typings/Rejection';
import formatBytes from 'Utilities/Number/formatBytes';
import InteractiveImportRowCellPlaceholder from './InteractiveImportRowCellPlaceholder';
import styles from './InteractiveImportRow.css';
type SelectType =
| 'series'
| 'season'
| 'episode'
| 'releaseGroup'
| 'quality'
| 'language';
interface InteractiveImportRowProps {
id: number;
allowSeriesChange: boolean;
relativePath: string;
series?: Series;
seasonNumber?: number;
episodes?: Episode[];
releaseGroup?: string;
quality?: QualityModel;
languages?: Language[];
size: number;
customFormats?: object[];
rejections: Rejection[];
columns: Column[];
episodeFileId?: number;
isReprocessing?: boolean;
isSelected?: boolean;
modalTitle: string;
onSelectedChange(...args: unknown[]): void;
onValidRowChange(id: number, isValid: boolean): void;
}
function InteractiveImportRow(props: InteractiveImportRowProps) {
const {
id,
allowSeriesChange,
relativePath,
series,
seasonNumber,
episodes = [],
quality,
languages,
releaseGroup,
size,
customFormats,
rejections,
isReprocessing,
isSelected,
modalTitle,
episodeFileId,
columns,
onSelectedChange,
onValidRowChange,
} = props;
const dispatch = useDispatch();
const isSeriesColumnVisible = useMemo(
() => columns.find((c) => c.name === 'series').isVisible,
[columns]
);
const [selectModalOpen, setSelectModalOpen] = useState<SelectType | null>(
null
);
useEffect(
() => {
if (
allowSeriesChange &&
series &&
seasonNumber != null &&
episodes.length &&
quality &&
languages
) {
onSelectedChange({
id,
hasEpisodeFileId: !!episodeFileId,
value: true,
});
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
useEffect(() => {
const isValid = !!(
series &&
seasonNumber != null &&
episodes.length &&
quality &&
languages
);
if (isSelected && !isValid) {
onValidRowChange(id, false);
} else {
onValidRowChange(id, true);
}
}, [
id,
series,
seasonNumber,
episodes,
quality,
languages,
isSelected,
onValidRowChange,
]);
const onSelectedChangeWrapper = useCallback(
(result) => {
onSelectedChange({
...result,
hasEpisodeFileId: !!episodeFileId,
});
},
[episodeFileId, onSelectedChange]
);
const selectRowAfterChange = useCallback(() => {
if (!isSelected) {
onSelectedChange({
id,
hasEpisodeFileId: !!episodeFileId,
value: true,
});
}
}, [id, episodeFileId, isSelected, onSelectedChange]);
const onSelectModalClose = useCallback(() => {
setSelectModalOpen(null);
}, [setSelectModalOpen]);
const onSelectSeriesPress = useCallback(() => {
setSelectModalOpen('series');
}, [setSelectModalOpen]);
const onSeriesSelect = useCallback(
(series: Series) => {
dispatch(
updateInteractiveImportItem({
id,
series,
seasonNumber: undefined,
episodes: [],
})
);
dispatch(reprocessInteractiveImportItems({ ids: [id] }));
setSelectModalOpen(null);
selectRowAfterChange();
},
[id, dispatch, setSelectModalOpen, selectRowAfterChange]
);
const onSelectSeasonPress = useCallback(() => {
setSelectModalOpen('season');
}, [setSelectModalOpen]);
const onSeasonSelect = useCallback(
(seasonNumber: number) => {
dispatch(
updateInteractiveImportItem({
id,
seasonNumber,
episodes: [],
})
);
dispatch(reprocessInteractiveImportItems({ ids: [id] }));
setSelectModalOpen(null);
selectRowAfterChange();
},
[id, dispatch, setSelectModalOpen, selectRowAfterChange]
);
const onSelectEpisodePress = useCallback(() => {
setSelectModalOpen('episode');
}, [setSelectModalOpen]);
const onEpisodesSelect = useCallback(
(selectedEpisodes: SelectedEpisode[]) => {
dispatch(
updateInteractiveImportItem({
id,
episodes: selectedEpisodes[0].episodes,
})
);
dispatch(reprocessInteractiveImportItems({ ids: [id] }));
setSelectModalOpen(null);
selectRowAfterChange();
},
[id, dispatch, setSelectModalOpen, selectRowAfterChange]
);
const onSelectReleaseGroupPress = useCallback(() => {
setSelectModalOpen('releaseGroup');
}, [setSelectModalOpen]);
const onReleaseGroupSelect = useCallback(
(releaseGroup: string) => {
dispatch(
updateInteractiveImportItem({
id,
releaseGroup,
})
);
dispatch(reprocessInteractiveImportItems({ ids: [id] }));
setSelectModalOpen(null);
selectRowAfterChange();
},
[id, dispatch, setSelectModalOpen, selectRowAfterChange]
);
const onSelectQualityPress = useCallback(() => {
setSelectModalOpen('quality');
}, [setSelectModalOpen]);
const onQualitySelect = useCallback(
(quality: QualityModel) => {
dispatch(
updateInteractiveImportItem({
id,
quality,
})
);
dispatch(reprocessInteractiveImportItems({ ids: [id] }));
setSelectModalOpen(null);
selectRowAfterChange();
},
[id, dispatch, setSelectModalOpen, selectRowAfterChange]
);
const onSelectLanguagePress = useCallback(() => {
setSelectModalOpen('language');
}, [setSelectModalOpen]);
const onLanguagesSelect = useCallback(
(languages: Language[]) => {
dispatch(
updateInteractiveImportItem({
id,
languages,
})
);
dispatch(reprocessInteractiveImportItems({ ids: [id] }));
setSelectModalOpen(null);
selectRowAfterChange();
},
[id, dispatch, setSelectModalOpen, selectRowAfterChange]
);
const seriesTitle = series ? series.title : '';
const isAnime = series?.seriesType === 'anime';
const episodeInfo = episodes.map((episode) => {
return (
<div key={episode.id}>
{episode.episodeNumber}
{isAnime && episode.absoluteEpisodeNumber != null
? ` (${episode.absoluteEpisodeNumber})`
: ''}
{` - ${episode.title}`}
</div>
);
});
const showSeriesPlaceholder = isSelected && !series;
const showSeasonNumberPlaceholder =
isSelected && !!series && isNaN(seasonNumber) && !isReprocessing;
const showEpisodeNumbersPlaceholder =
isSelected && Number.isInteger(seasonNumber) && !episodes.length;
const showReleaseGroupPlaceholder = isSelected && !releaseGroup;
const showQualityPlaceholder = isSelected && !quality;
const showLanguagePlaceholder = isSelected && !languages;
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChangeWrapper}
/>
<TableRowCell className={styles.relativePath} title={relativePath}>
{relativePath}
</TableRowCell>
{isSeriesColumnVisible ? (
<TableRowCellButton
isDisabled={!allowSeriesChange}
title={allowSeriesChange ? 'Click to change series' : undefined}
onPress={onSelectSeriesPress}
>
{showSeriesPlaceholder ? (
<InteractiveImportRowCellPlaceholder />
) : (
seriesTitle
)}
</TableRowCellButton>
) : null}
<TableRowCellButton
isDisabled={!series}
title={series ? 'Click to change season' : undefined}
onPress={onSelectSeasonPress}
>
{showSeasonNumberPlaceholder ? (
<InteractiveImportRowCellPlaceholder />
) : (
seasonNumber
)}
{isReprocessing && seasonNumber == null ? (
<LoadingIndicator className={styles.reprocessing} size={20} />
) : null}
</TableRowCellButton>
<TableRowCellButton
isDisabled={!series || isNaN(seasonNumber)}
title={
series && !isNaN(seasonNumber) ? 'Click to change episode' : undefined
}
onPress={onSelectEpisodePress}
>
{showEpisodeNumbersPlaceholder ? (
<InteractiveImportRowCellPlaceholder />
) : (
episodeInfo
)}
</TableRowCellButton>
<TableRowCellButton
title="Click to change release group"
onPress={onSelectReleaseGroupPress}
>
{showReleaseGroupPlaceholder ? (
<InteractiveImportRowCellPlaceholder isOptional={true} />
) : (
releaseGroup
)}
</TableRowCellButton>
<TableRowCellButton
className={styles.quality}
title="Click to change quality"
onPress={onSelectQualityPress}
>
{showQualityPlaceholder && <InteractiveImportRowCellPlaceholder />}
{!showQualityPlaceholder && !!quality && (
<EpisodeQuality className={styles.label} quality={quality} />
)}
</TableRowCellButton>
<TableRowCellButton
className={styles.languages}
title="Click to change language"
onPress={onSelectLanguagePress}
>
{showLanguagePlaceholder && <InteractiveImportRowCellPlaceholder />}
{!showLanguagePlaceholder && !!languages && (
<EpisodeLanguages className={styles.label} languages={languages} />
)}
</TableRowCellButton>
<TableRowCell>{formatBytes(size)}</TableRowCell>
<TableRowCell>
{customFormats?.length ? (
<Popover
anchor={<Icon name={icons.INTERACTIVE} />}
title="Formats"
body={
<div className={styles.customFormatTooltip}>
<EpisodeFormats formats={customFormats} />
</div>
}
position={tooltipPositions.LEFT}
/>
) : null}
</TableRowCell>
<TableRowCell>
{rejections.length ? (
<Popover
anchor={<Icon name={icons.DANGER} kind={kinds.DANGER} />}
title="Release Rejected"
body={
<ul>
{rejections.map((rejection, index) => {
return <li key={index}>{rejection.reason}</li>;
})}
</ul>
}
position={tooltipPositions.LEFT}
canFlip={false}
/>
) : null}
</TableRowCell>
<SelectSeriesModal
isOpen={selectModalOpen === 'series'}
modalTitle={modalTitle}
onSeriesSelect={onSeriesSelect}
onModalClose={onSelectModalClose}
/>
<SelectSeasonModal
isOpen={selectModalOpen === 'season'}
seriesId={series && series.id}
modalTitle={modalTitle}
onSeasonSelect={onSeasonSelect}
onModalClose={onSelectModalClose}
/>
<SelectEpisodeModal
isOpen={selectModalOpen === 'episode'}
selectedIds={[id]}
seriesId={series && series.id}
isAnime={isAnime}
seasonNumber={seasonNumber}
selectedDetails={relativePath}
modalTitle={modalTitle}
onEpisodesSelect={onEpisodesSelect}
onModalClose={onSelectModalClose}
/>
<SelectReleaseGroupModal
isOpen={selectModalOpen === 'releaseGroup'}
releaseGroup={releaseGroup ?? ''}
modalTitle={modalTitle}
onReleaseGroupSelect={onReleaseGroupSelect}
onModalClose={onSelectModalClose}
/>
<SelectQualityModal
isOpen={selectModalOpen === 'quality'}
qualityId={quality ? quality.quality.id : 0}
proper={quality ? quality.revision.version > 1 : false}
real={quality ? quality.revision.real > 0 : false}
modalTitle={modalTitle}
onQualitySelect={onQualitySelect}
onModalClose={onSelectModalClose}
/>
<SelectLanguageModal
isOpen={selectModalOpen === 'language'}
languageIds={languages ? languages.map((l) => l.id) : []}
modalTitle={modalTitle}
onLanguagesSelect={onLanguagesSelect}
onModalClose={onSelectModalClose}
/>
</TableRow>
);
}
export default InteractiveImportRow;

@ -0,0 +1,25 @@
import ModelBase from 'App/ModelBase';
import Episode from 'Episode/Episode';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import Series from 'Series/Series';
interface InteractiveImport extends ModelBase {
path: string;
relativePath: string;
folderName: string;
name: string;
size: number;
releaseGroup: string;
quality: QualityModel;
languages: Language[];
series?: Series;
seasonNumber: number;
episodes: Episode[];
qualityWeight: number;
customFormats: object[];
rejections: string[];
episodeFileId?: number;
}
export default InteractiveImport;

@ -1,86 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import InteractiveImportSelectFolderModalContentConnector from './Folder/InteractiveImportSelectFolderModalContentConnector';
import InteractiveImportModalContentConnector from './Interactive/InteractiveImportModalContentConnector';
class InteractiveImportModal extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
folder: null
};
}
componentDidUpdate(prevProps) {
if (prevProps.isOpen && !this.props.isOpen) {
this.setState({ folder: null });
}
}
//
// Listeners
onFolderSelect = (folder) => {
this.setState({ folder });
};
//
// Render
render() {
const {
isOpen,
folder,
downloadId,
onModalClose,
...otherProps
} = this.props;
const folderPath = folder || this.state.folder;
return (
<Modal
isOpen={isOpen}
size={sizes.EXTRA_LARGE}
closeOnBackgroundClick={false}
onModalClose={onModalClose}
>
{
folderPath || downloadId ?
<InteractiveImportModalContentConnector
folder={folderPath}
downloadId={downloadId}
{...otherProps}
onModalClose={onModalClose}
/> :
<InteractiveImportSelectFolderModalContentConnector
{...otherProps}
onFolderSelect={this.onFolderSelect}
onModalClose={onModalClose}
/>
}
</Modal>
);
}
}
InteractiveImportModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
folder: PropTypes.string,
downloadId: PropTypes.string,
modalTitle: PropTypes.string.isRequired,
onModalClose: PropTypes.func.isRequired
};
InteractiveImportModal.defaultProps = {
modalTitle: 'Manual Import'
};
export default InteractiveImportModal;

@ -0,0 +1,73 @@
import React, { useCallback, useEffect, useState } from 'react';
import Modal from 'Components/Modal/Modal';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { sizes } from 'Helpers/Props';
import InteractiveImportSelectFolderModalContent from './Folder/InteractiveImportSelectFolderModalContent';
import InteractiveImportModalContent from './Interactive/InteractiveImportModalContent';
interface InteractiveImportModalProps {
isOpen: boolean;
folder?: string;
downloadId?: string;
modalTitle?: string;
onModalClose(): void;
}
function InteractiveImportModal(props: InteractiveImportModalProps) {
const {
isOpen,
folder,
downloadId,
modalTitle = 'Manual Import',
onModalClose,
...otherProps
} = props;
const [folderPath, setFolderPath] = useState<string | undefined>(folder);
const previousIsOpen = usePrevious(isOpen);
const onFolderSelect = useCallback(
(f) => {
setFolderPath(f);
},
[setFolderPath]
);
useEffect(() => {
setFolderPath(folder);
}, [folder, setFolderPath]);
useEffect(() => {
if (previousIsOpen && !isOpen) {
setFolderPath(folder);
}
}, [folder, previousIsOpen, isOpen, setFolderPath]);
return (
<Modal
isOpen={isOpen}
size={sizes.EXTRA_LARGE}
closeOnBackgroundClick={false}
onModalClose={onModalClose}
>
{folderPath || downloadId ? (
<InteractiveImportModalContent
{...otherProps}
folder={folderPath}
downloadId={downloadId}
modalTitle={modalTitle}
onModalClose={onModalClose}
/>
) : (
<InteractiveImportSelectFolderModalContent
{...otherProps}
modalTitle={modalTitle}
onFolderSelect={onFolderSelect}
onModalClose={onModalClose}
/>
)}
</Modal>
);
}
export default InteractiveImportModal;

@ -1,39 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import SelectLanguageModalContentConnector from './SelectLanguageModalContentConnector';
class SelectLanguageModal extends Component {
//
// Render
render() {
const {
isOpen,
onModalClose,
...otherProps
} = this.props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
size={sizes.MEDIUM}
>
<SelectLanguageModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
}
SelectLanguageModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectLanguageModal;

@ -0,0 +1,31 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import Language from 'Language/Language';
import SelectLanguageModalContent from './SelectLanguageModalContent';
interface SelectLanguageModalProps {
isOpen: boolean;
languageIds: number[];
modalTitle: string;
onLanguagesSelect(languages: Language[]): void;
onModalClose(): void;
}
function SelectLanguageModal(props: SelectLanguageModalProps) {
const { isOpen, languageIds, modalTitle, onLanguagesSelect, onModalClose } =
props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose} size={sizes.MEDIUM}>
<SelectLanguageModalContent
languageIds={languageIds}
modalTitle={modalTitle}
onLanguagesSelect={onLanguagesSelect}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default SelectLanguageModal;

@ -1,154 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import styles from './SelectLanguageModalContent.css';
class SelectLanguageModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const {
languageIds
} = props;
this.state = {
languageIds
};
}
//
// Listeners
onLanguageChange = ({ value, name }) => {
const {
languageIds
} = this.state;
const changedId = parseInt(name);
let newLanguages = languageIds;
if (value) {
newLanguages.push(changedId);
}
if (!value) {
newLanguages = languageIds.filter((i) => i !== changedId);
}
this.setState({ languageIds: newLanguages });
};
onLanguageSelect = () => {
this.props.onLanguageSelect(this.state);
};
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items,
modalTitle,
onModalClose
} = this.props;
const {
languageIds
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{modalTitle}
</ModalHeader>
<ModalBody>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>
Unable To Load Languages
</div>
}
{
isPopulated && !error &&
<Form>
{
items.map(( language ) => {
return (
<FormGroup
key={language.id}
size={sizes.EXTRA_SMALL}
className={styles.languageInput}
>
<FormLabel>{language.name}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name={language.id.toString()}
value={languageIds.includes(language.id)}
onChange={this.onLanguageChange}
/>
</FormGroup>
);
})
}
</Form>
}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Cancel
</Button>
<Button
kind={kinds.SUCCESS}
onPress={this.onLanguageSelect}
>
Select Languages
</Button>
</ModalFooter>
</ModalContent>
);
}
}
SelectLanguageModalContent.propTypes = {
languageIds: PropTypes.arrayOf(PropTypes.number).isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
modalTitle: PropTypes.string.isRequired,
onLanguageSelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
SelectLanguageModalContent.defaultProps = {
languages: []
};
export default SelectLanguageModalContent;

@ -0,0 +1,119 @@
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import Language from 'Language/Language';
import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector';
import styles from './SelectLanguageModalContent.css';
interface SelectLanguageModalContentProps {
languageIds: number[];
modalTitle: string;
onLanguagesSelect(languages: Language[]): void;
onModalClose(): void;
}
function createFilteredLanguagesSelector() {
return createSelector(createLanguagesSelector(), (languages) => {
const { isFetching, isPopulated, error, items } = languages;
const filterItems = ['Any', 'Original'];
const filteredLanguages = items.filter(
(lang) => !filterItems.includes(lang.name)
);
return {
isFetching,
isPopulated,
error,
items: filteredLanguages,
};
});
}
function SelectLanguageModalContent(props: SelectLanguageModalContentProps) {
const { modalTitle, onLanguagesSelect, onModalClose } = props;
const { isFetching, isPopulated, error, items } = useSelector(
createFilteredLanguagesSelector()
);
const [languageIds, setLanguageIds] = useState(props.languageIds);
const onLanguageChange = useCallback(
({ value, name }) => {
const changedId = parseInt(name);
let newLanguages = [...languageIds];
if (value) {
newLanguages.push(changedId);
} else {
newLanguages = languageIds.filter((i) => i !== changedId);
}
setLanguageIds(newLanguages);
},
[languageIds, setLanguageIds]
);
const onLanguagesSelectWrapper = useCallback(() => {
const languages = items.filter((lang) => languageIds.includes(lang.id));
onLanguagesSelect(languages);
}, [items, languageIds, onLanguagesSelect]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{modalTitle} - Select Language</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && error ? <div>Unable To Load Languages</div> : null}
{isPopulated && !error ? (
<Form>
{items.map((language) => {
return (
<FormGroup
key={language.id}
size={sizes.EXTRA_SMALL}
className={styles.languageInput}
>
<FormLabel>{language.name}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name={language.id.toString()}
value={languageIds.includes(language.id)}
onChange={onLanguageChange}
/>
</FormGroup>
);
})}
</Form>
) : null}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>Cancel</Button>
<Button kind={kinds.SUCCESS} onPress={onLanguagesSelectWrapper}>
Select Languages
</Button>
</ModalFooter>
</ModalContent>
);
}
export default SelectLanguageModalContent;

@ -1,96 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { reprocessInteractiveImportItems, updateInteractiveImportItems } from 'Store/Actions/interactiveImportActions';
import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector';
import SelectLanguageModalContent from './SelectLanguageModalContent';
function createMapStateToProps() {
return createSelector(
createLanguagesSelector(),
(languages) => {
const {
isFetching,
isPopulated,
error,
items
} = languages;
const filterItems = ['Any', 'Original'];
const filteredLanguages = items.filter((lang) => !filterItems.includes(lang.name));
return {
isFetching,
isPopulated,
error,
items: filteredLanguages
};
}
);
}
const mapDispatchToProps = {
dispatchUpdateInteractiveImportItems: updateInteractiveImportItems,
dispatchReprocessInteractiveImportItems: reprocessInteractiveImportItems
};
class SelectLanguageModalContentConnector extends Component {
//
// Listeners
onLanguageSelect = ({ languageIds }) => {
const {
ids,
dispatchUpdateInteractiveImportItems,
dispatchReprocessInteractiveImportItems
} = this.props;
const languages = [];
languageIds.forEach((languageId) => {
const language = _.find(this.props.items,
(item) => item.id === parseInt(languageId));
if (language !== undefined) {
languages.push(language);
}
});
dispatchUpdateInteractiveImportItems({
ids,
languages
});
dispatchReprocessInteractiveImportItems({ ids });
this.props.onModalClose(true);
};
//
// Render
render() {
return (
<SelectLanguageModalContent
{...this.props}
onLanguageSelect={this.onLanguageSelect}
/>
);
}
}
SelectLanguageModalContentConnector.propTypes = {
ids: PropTypes.arrayOf(PropTypes.number).isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
dispatchUpdateInteractiveImportItems: PropTypes.func.isRequired,
dispatchReprocessInteractiveImportItems: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(SelectLanguageModalContentConnector);

@ -1,37 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import SelectQualityModalContentConnector from './SelectQualityModalContentConnector';
class SelectQualityModal extends Component {
//
// Render
render() {
const {
isOpen,
onModalClose,
...otherProps
} = this.props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<SelectQualityModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
}
SelectQualityModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectQualityModal;

@ -0,0 +1,41 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { QualityModel } from 'Quality/Quality';
import SelectQualityModalContent from './SelectQualityModalContent';
interface SelectQualityModalProps {
isOpen: boolean;
qualityId: number;
proper: boolean;
real: boolean;
modalTitle: string;
onQualitySelect(quality: QualityModel): void;
onModalClose(): void;
}
function SelectQualityModal(props: SelectQualityModalProps) {
const {
isOpen,
qualityId,
proper,
real,
modalTitle,
onQualitySelect,
onModalClose,
} = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<SelectQualityModalContent
qualityId={qualityId}
proper={proper}
real={real}
modalTitle={modalTitle}
onQualitySelect={onQualitySelect}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default SelectQualityModal;

@ -1,168 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
class SelectQualityModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const {
qualityId,
proper,
real
} = props;
this.state = {
qualityId,
proper,
real
};
}
//
// Listeners
onQualityChange = ({ value }) => {
this.setState({ qualityId: parseInt(value) });
};
onProperChange = ({ value }) => {
this.setState({ proper: value });
};
onRealChange = ({ value }) => {
this.setState({ real: value });
};
onQualitySelect = () => {
this.props.onQualitySelect(this.state);
};
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items,
modalTitle,
onModalClose
} = this.props;
const {
qualityId,
proper,
real
} = this.state;
const qualityOptions = items.map(({ id, name }) => {
return {
key: id,
value: name
};
});
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{modalTitle} - Select Quality
</ModalHeader>
<ModalBody>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>Unable to load qualities</div>
}
{
isPopulated && !error &&
<Form>
<FormGroup>
<FormLabel>Quality</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="quality"
value={qualityId}
values={qualityOptions}
onChange={this.onQualityChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Proper</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="proper"
value={proper}
onChange={this.onProperChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Real</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="real"
value={real}
onChange={this.onRealChange}
/>
</FormGroup>
</Form>
}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Cancel
</Button>
<Button
kind={kinds.SUCCESS}
onPress={this.onQualitySelect}
>
Select Quality
</Button>
</ModalFooter>
</ModalContent>
);
}
}
SelectQualityModalContent.propTypes = {
qualityId: PropTypes.number.isRequired,
proper: PropTypes.bool.isRequired,
real: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
modalTitle: PropTypes.string.isRequired,
onQualitySelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectQualityModalContent;

@ -0,0 +1,169 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
import { QualityModel } from 'Quality/Quality';
import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
import getQualities from 'Utilities/Quality/getQualities';
function createQualitySchemeSelctor() {
return createSelector(
(state) => state.settings.qualityProfiles,
(qualityProfiles) => {
const { isSchemaFetching, isSchemaPopulated, schemaError, schema } =
qualityProfiles;
return {
isFetching: isSchemaFetching,
isPopulated: isSchemaPopulated,
error: schemaError,
items: getQualities(schema.items),
};
}
);
}
interface SelectQualityModalContentProps {
qualityId: number;
proper: boolean;
real: boolean;
modalTitle: string;
onQualitySelect(quality: QualityModel): void;
onModalClose(): void;
}
function SelectQualityModalContent(props: SelectQualityModalContentProps) {
const { modalTitle, onQualitySelect, onModalClose } = props;
const [qualityId, setQualityId] = useState(props.qualityId);
const [proper, setProper] = useState(props.proper);
const [real, setReal] = useState(props.real);
const { isFetching, isPopulated, error, items } = useSelector(
createQualitySchemeSelctor()
);
const dispatch = useDispatch();
useEffect(
() => {
dispatch(fetchQualityProfileSchema());
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const qualityOptions = useMemo(() => {
return items.map(({ id, name }) => {
return {
key: id,
value: name,
};
});
}, [items]);
const onQualityChange = useCallback(
({ value }) => {
setQualityId(parseInt(value));
},
[setQualityId]
);
const onProperChange = useCallback(
({ value }) => {
setProper(value);
},
[setProper]
);
const onRealChange = useCallback(
({ value }) => {
setReal(value);
},
[setReal]
);
const onQualitySelectWrapper = useCallback(() => {
const quality = items.find((item) => item.id === qualityId);
const revision = {
version: proper ? 2 : 1,
real: real ? 1 : 0,
isRepack: false,
};
onQualitySelect({
quality,
revision,
});
}, [items, qualityId, proper, real, onQualitySelect]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{modalTitle} - Select Quality</ModalHeader>
<ModalBody>
{isFetching && <LoadingIndicator />}
{!isFetching && error ? <div>Unable to load qualities</div> : null}
{isPopulated && !error ? (
<Form>
<FormGroup>
<FormLabel>Quality</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="quality"
value={qualityId}
values={qualityOptions}
onChange={onQualityChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Proper</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="proper"
value={proper}
onChange={onProperChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Real</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="real"
value={real}
onChange={onRealChange}
/>
</FormGroup>
</Form>
) : null}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>Cancel</Button>
<Button kind={kinds.SUCCESS} onPress={onQualitySelectWrapper}>
Select Quality
</Button>
</ModalFooter>
</ModalContent>
);
}
export default SelectQualityModalContent;

@ -1,105 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { reprocessInteractiveImportItems, updateInteractiveImportItems } from 'Store/Actions/interactiveImportActions';
import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
import getQualities from 'Utilities/Quality/getQualities';
import SelectQualityModalContent from './SelectQualityModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.qualityProfiles,
(qualityProfiles) => {
const {
isSchemaFetching: isFetching,
isSchemaPopulated: isPopulated,
schemaError: error,
schema
} = qualityProfiles;
return {
isFetching,
isPopulated,
error,
items: getQualities(schema.items)
};
}
);
}
const mapDispatchToProps = {
dispatchFetchQualityProfileSchema: fetchQualityProfileSchema,
dispatchUpdateInteractiveImportItems: updateInteractiveImportItems,
dispatchReprocessInteractiveImportItems: reprocessInteractiveImportItems
};
class SelectQualityModalContentConnector extends Component {
//
// Lifecycle
componentDidMount = () => {
if (!this.props.isPopulated) {
this.props.dispatchFetchQualityProfileSchema();
}
};
//
// Listeners
onQualitySelect = ({ qualityId, proper, real }) => {
const {
ids,
dispatchUpdateInteractiveImportItems,
dispatchReprocessInteractiveImportItems
} = this.props;
const quality = _.find(this.props.items,
(item) => item.id === qualityId);
const revision = {
version: proper ? 2 : 1,
real: real ? 1 : 0
};
dispatchUpdateInteractiveImportItems({
ids,
quality: {
quality,
revision
}
});
dispatchReprocessInteractiveImportItems({ ids });
this.props.onModalClose(true);
};
//
// Render
render() {
return (
<SelectQualityModalContent
{...this.props}
onQualitySelect={this.onQualitySelect}
/>
);
}
}
SelectQualityModalContentConnector.propTypes = {
ids: PropTypes.arrayOf(PropTypes.number).isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
dispatchFetchQualityProfileSchema: PropTypes.func.isRequired,
dispatchUpdateInteractiveImportItems: PropTypes.func.isRequired,
dispatchReprocessInteractiveImportItems: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(SelectQualityModalContentConnector);

@ -1,37 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import SelectReleaseGroupModalContentConnector from './SelectReleaseGroupModalContentConnector';
class SelectReleaseGroupModal extends Component {
//
// Render
render() {
const {
isOpen,
onModalClose,
...otherProps
} = this.props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<SelectReleaseGroupModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
}
SelectReleaseGroupModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectReleaseGroupModal;

@ -0,0 +1,34 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import SelectReleaseGroupModalContent from './SelectReleaseGroupModalContent';
interface SelectReleaseGroupModalProps {
isOpen: boolean;
releaseGroup: string;
modalTitle: string;
onReleaseGroupSelect(releaseGroup: string): void;
onModalClose(): void;
}
function SelectReleaseGroupModal(props: SelectReleaseGroupModalProps) {
const {
isOpen,
releaseGroup,
modalTitle,
onReleaseGroupSelect,
onModalClose,
} = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<SelectReleaseGroupModalContent
releaseGroup={releaseGroup}
modalTitle={modalTitle}
onReleaseGroupSelect={onReleaseGroupSelect}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default SelectReleaseGroupModal;

@ -1,105 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, scrollDirections } from 'Helpers/Props';
import styles from './SelectReleaseGroupModalContent.css';
class SelectReleaseGroupModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const {
releaseGroup
} = props;
this.state = {
releaseGroup
};
}
//
// Listeners
onReleaseGroupChange = ({ value }) => {
this.setState({ releaseGroup: value });
};
onReleaseGroupSelect = () => {
this.props.onReleaseGroupSelect(this.state);
};
//
// Render
render() {
const {
modalTitle,
onModalClose
} = this.props;
const {
releaseGroup
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{modalTitle} - Set Release Group
</ModalHeader>
<ModalBody
className={styles.modalBody}
scrollDirection={scrollDirections.NONE}
>
<Form>
<FormGroup>
<FormLabel>Release Group</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="releaseGroup"
value={releaseGroup}
autoFocus={true}
onChange={this.onReleaseGroupChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Cancel
</Button>
<Button
kind={kinds.SUCCESS}
onPress={this.onReleaseGroupSelect}
>
Set Release Group
</Button>
</ModalFooter>
</ModalContent>
);
}
}
SelectReleaseGroupModalContent.propTypes = {
releaseGroup: PropTypes.string.isRequired,
modalTitle: PropTypes.string.isRequired,
onReleaseGroupSelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectReleaseGroupModalContent;

@ -0,0 +1,72 @@
import React, { useCallback, useState } from 'react';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, scrollDirections } from 'Helpers/Props';
import styles from './SelectReleaseGroupModalContent.css';
interface SelectReleaseGroupModalContentProps {
releaseGroup: string;
modalTitle: string;
onReleaseGroupSelect(releaseGroup: string): void;
onModalClose(): void;
}
function SelectReleaseGroupModalContent(
props: SelectReleaseGroupModalContentProps
) {
const { modalTitle, onReleaseGroupSelect, onModalClose } = props;
const [releaseGroup, setReleaseGroup] = useState(props.releaseGroup);
const onReleaseGroupChange = useCallback(
({ value }) => {
setReleaseGroup(value);
},
[setReleaseGroup]
);
const onReleaseGroupSelectWrapper = useCallback(() => {
onReleaseGroupSelect(releaseGroup);
}, [releaseGroup, onReleaseGroupSelect]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{modalTitle} - Set Release Group</ModalHeader>
<ModalBody
className={styles.modalBody}
scrollDirection={scrollDirections.NONE}
>
<Form>
<FormGroup>
<FormLabel>Release Group</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="releaseGroup"
value={releaseGroup}
autoFocus={true}
onChange={onReleaseGroupChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>Cancel</Button>
<Button kind={kinds.SUCCESS} onPress={onReleaseGroupSelectWrapper}>
Set Release Group
</Button>
</ModalFooter>
</ModalContent>
);
}
export default SelectReleaseGroupModalContent;

@ -1,54 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { reprocessInteractiveImportItems, updateInteractiveImportItems } from 'Store/Actions/interactiveImportActions';
import SelectReleaseGroupModalContent from './SelectReleaseGroupModalContent';
const mapDispatchToProps = {
dispatchUpdateInteractiveImportItems: updateInteractiveImportItems,
dispatchReprocessInteractiveImportItems: reprocessInteractiveImportItems
};
class SelectReleaseGroupModalContentConnector extends Component {
//
// Listeners
onReleaseGroupSelect = ({ releaseGroup }) => {
const {
ids,
dispatchUpdateInteractiveImportItems,
dispatchReprocessInteractiveImportItems
} = this.props;
dispatchUpdateInteractiveImportItems({
ids,
releaseGroup
});
dispatchReprocessInteractiveImportItems({ ids });
this.props.onModalClose(true);
};
//
// Render
render() {
return (
<SelectReleaseGroupModalContent
{...this.props}
onReleaseGroupSelect={this.onReleaseGroupSelect}
/>
);
}
}
SelectReleaseGroupModalContentConnector.propTypes = {
ids: PropTypes.arrayOf(PropTypes.number).isRequired,
dispatchUpdateInteractiveImportItems: PropTypes.func.isRequired,
dispatchReprocessInteractiveImportItems: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(null, mapDispatchToProps)(SelectReleaseGroupModalContentConnector);

@ -1,37 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import SelectSeasonModalContentConnector from './SelectSeasonModalContentConnector';
class SelectSeasonModal extends Component {
//
// Render
render() {
const {
isOpen,
onModalClose,
...otherProps
} = this.props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<SelectSeasonModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
}
SelectSeasonModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectSeasonModal;

@ -0,0 +1,28 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import SelectSeasonModalContent from './SelectSeasonModalContent';
interface SelectSeasonModalProps {
isOpen: boolean;
modalTitle: string;
seriesId: number;
onSeasonSelect(seasonNumber): void;
onModalClose(): void;
}
function SelectSeasonModal(props: SelectSeasonModalProps) {
const { isOpen, modalTitle, seriesId, onSeasonSelect, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<SelectSeasonModalContent
modalTitle={modalTitle}
seriesId={seriesId}
onSeasonSelect={onSeasonSelect}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default SelectSeasonModal;

@ -1,60 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import SelectSeasonRow from './SelectSeasonRow';
class SelectSeasonModalContent extends Component {
//
// Render
render() {
const {
items,
modalTitle,
onSeasonSelect,
onModalClose
} = this.props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{modalTitle} - Select Season
</ModalHeader>
<ModalBody>
{
items.map((item) => {
return (
<SelectSeasonRow
key={item.seasonNumber}
seasonNumber={item.seasonNumber}
onSeasonSelect={onSeasonSelect}
/>
);
})
}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Cancel
</Button>
</ModalFooter>
</ModalContent>
);
}
}
SelectSeasonModalContent.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
modalTitle: PropTypes.string.isRequired,
onSeasonSelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectSeasonModalContent;

@ -0,0 +1,48 @@
import React, { useMemo } from 'react';
import { useSelector } from 'react-redux';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import SelectSeasonRow from './SelectSeasonRow';
interface SelectSeasonModalContentProps {
seriesId: number;
modalTitle: string;
onSeasonSelect(seasonNumber): void;
onModalClose(): void;
}
function SelectSeasonModalContent(props: SelectSeasonModalContentProps) {
const { seriesId, modalTitle, onSeasonSelect, onModalClose } = props;
const series = useSelector(createSeriesSelector(seriesId));
const seasons = useMemo(() => {
return series.seasons.slice(0).reverse();
}, [series]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{modalTitle} - Select Season</ModalHeader>
<ModalBody>
{seasons.map((item) => {
return (
<SelectSeasonRow
key={item.seasonNumber}
seasonNumber={item.seasonNumber}
onSeasonSelect={onSeasonSelect}
/>
);
})}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>Cancel</Button>
</ModalFooter>
</ModalContent>
);
}
export default SelectSeasonModalContent;

@ -1,68 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import SelectSeasonModalContent from './SelectSeasonModalContent';
function createMapStateToProps() {
return createSelector(
createSeriesSelector(),
(series) => {
if (!series) {
return {
items: []
};
}
return {
items: series.seasons.slice(0).reverse()
};
}
);
}
const mapDispatchToProps = {
updateInteractiveImportItem
};
class SelectSeasonModalContentConnector extends Component {
//
// Listeners
onSeasonSelect = (seasonNumber) => {
this.props.ids.forEach((id) => {
this.props.updateInteractiveImportItem({
id,
seasonNumber,
episodes: []
});
});
this.props.onModalClose(true);
};
//
// Render
render() {
return (
<SelectSeasonModalContent
{...this.props}
onSeasonSelect={this.onSeasonSelect}
/>
);
}
}
SelectSeasonModalContentConnector.propTypes = {
ids: PropTypes.arrayOf(PropTypes.number).isRequired,
seriesId: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
updateInteractiveImportItem: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(SelectSeasonModalContentConnector);

@ -1,40 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Link from 'Components/Link/Link';
import styles from './SelectSeasonRow.css';
class SelectSeasonRow extends Component {
//
// Listeners
onPress = () => {
this.props.onSeasonSelect(this.props.seasonNumber);
};
//
// Render
render() {
const seasonNumber = this.props.seasonNumber;
return (
<Link
className={styles.season}
component="div"
onPress={this.onPress}
>
{
seasonNumber === 0 ? 'Specials' : `Season ${seasonNumber}`
}
</Link>
);
}
}
SelectSeasonRow.propTypes = {
seasonNumber: PropTypes.number.isRequired,
onSeasonSelect: PropTypes.func.isRequired
};
export default SelectSeasonRow;

@ -0,0 +1,28 @@
import React, { useCallback } from 'react';
import Link from 'Components/Link/Link';
import styles from './SelectSeasonRow.css';
interface SelectSeasonRowProps {
seasonNumber: number;
onSeasonSelect(season: number): unknown;
}
function SelectSeasonRow(props: SelectSeasonRowProps) {
const { seasonNumber, onSeasonSelect } = props;
const onSeasonSelectWrapper = useCallback(() => {
onSeasonSelect(seasonNumber);
}, [seasonNumber, onSeasonSelect]);
return (
<Link
className={styles.season}
component="div"
onPress={onSeasonSelectWrapper}
>
{seasonNumber === 0 ? 'Specials' : `Season ${seasonNumber}`}
</Link>
);
}
export default SelectSeasonRow;

@ -1,37 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import SelectSeriesModalContentConnector from './SelectSeriesModalContentConnector';
class SelectSeriesModal extends Component {
//
// Render
render() {
const {
isOpen,
onModalClose,
...otherProps
} = this.props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<SelectSeriesModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
}
SelectSeriesModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectSeriesModal;

@ -0,0 +1,27 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import Series from 'Series/Series';
import SelectSeriesModalContent from './SelectSeriesModalContent';
interface SelectSeriesModalProps {
isOpen: boolean;
modalTitle: string;
onSeriesSelect(series: Series): void;
onModalClose(): void;
}
function SelectSeriesModal(props: SelectSeriesModalProps) {
const { isOpen, modalTitle, onSeriesSelect, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<SelectSeriesModalContent
modalTitle={modalTitle}
onSeriesSelect={onSeriesSelect}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default SelectSeriesModal;

@ -1,105 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TextInput from 'Components/Form/TextInput';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Scroller from 'Components/Scroller/Scroller';
import { scrollDirections } from 'Helpers/Props';
import SelectSeriesRow from './SelectSeriesRow';
import styles from './SelectSeriesModalContent.css';
class SelectSeriesModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
filter: ''
};
}
//
// Listeners
onFilterChange = ({ value }) => {
this.setState({ filter: value });
};
//
// Render
render() {
const {
items,
modalTitle,
onSeriesSelect,
onModalClose
} = this.props;
const filter = this.state.filter;
const filterLower = filter.toLowerCase();
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{modalTitle} - Select Series
</ModalHeader>
<ModalBody
className={styles.modalBody}
scrollDirection={scrollDirections.NONE}
>
<TextInput
className={styles.filterInput}
placeholder="Filter series"
name="filter"
value={filter}
autoFocus={true}
onChange={this.onFilterChange}
/>
<Scroller
className={styles.scroller}
autoFocus={false}
>
{
items.map((item) => {
return item.title.toLowerCase().includes(filterLower) ?
(
<SelectSeriesRow
key={item.id}
id={item.id}
title={item.title}
onSeriesSelect={onSeriesSelect}
/>
) :
null;
})
}
</Scroller>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Cancel
</Button>
</ModalFooter>
</ModalContent>
);
}
}
SelectSeriesModalContent.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
modalTitle: PropTypes.string.isRequired,
onSeriesSelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectSeriesModalContent;

@ -0,0 +1,92 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import TextInput from 'Components/Form/TextInput';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Scroller from 'Components/Scroller/Scroller';
import { scrollDirections } from 'Helpers/Props';
import Series from 'Series/Series';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import SelectSeriesRow from './SelectSeriesRow';
import styles from './SelectSeriesModalContent.css';
interface SelectSeriesModalContentProps {
modalTitle: string;
onSeriesSelect(series: Series): void;
onModalClose(): void;
}
function SelectSeriesModalContent(props: SelectSeriesModalContentProps) {
const { modalTitle, onSeriesSelect, onModalClose } = props;
const allSeries = useSelector(createAllSeriesSelector());
const [filter, setFilter] = useState('');
const onFilterChange = useCallback(
({ value }) => {
setFilter(value);
},
[setFilter]
);
const onSeriesSelectWrapper = useCallback(
(seriesId: number) => {
const series = allSeries.find((s) => s.id === seriesId);
onSeriesSelect(series);
},
[allSeries, onSeriesSelect]
);
const items = useMemo(() => {
const sorted = [...allSeries].sort((a, b) =>
a.sortTitle.localeCompare(b.sortTitle)
);
return sorted.filter((item) =>
item.title.toLowerCase().includes(filter.toLowerCase())
);
}, [allSeries, filter]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{modalTitle} - Select Series</ModalHeader>
<ModalBody
className={styles.modalBody}
scrollDirection={scrollDirections.NONE}
>
<TextInput
className={styles.filterInput}
placeholder="Filter series"
name="filter"
value={filter}
autoFocus={true}
onChange={onFilterChange}
/>
<Scroller className={styles.scroller} autoFocus={false}>
{items.map((item) => {
return (
<SelectSeriesRow
key={item.id}
id={item.id}
title={item.title}
onSeriesSelect={onSeriesSelectWrapper}
/>
);
})}
</Scroller>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>Cancel</Button>
</ModalFooter>
</ModalContent>
);
}
export default SelectSeriesModalContent;

@ -1,86 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { reprocessInteractiveImportItems, updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import SelectSeriesModalContent from './SelectSeriesModalContent';
function createMapStateToProps() {
return createSelector(
createAllSeriesSelector(),
(items) => {
return {
items: [...items].sort((a, b) => {
if (a.sortTitle < b.sortTitle) {
return -1;
}
if (a.sortTitle > b.sortTitle) {
return 1;
}
return 0;
})
};
}
);
}
const mapDispatchToProps = {
dispatchReprocessInteractiveImportItems: reprocessInteractiveImportItems,
dispatchUpdateInteractiveImportItem: updateInteractiveImportItem
};
class SelectSeriesModalContentConnector extends Component {
//
// Listeners
onSeriesSelect = (seriesId) => {
const {
ids,
items,
dispatchUpdateInteractiveImportItem,
dispatchReprocessInteractiveImportItems,
onModalClose
} = this.props;
const series = items.find((s) => s.id === seriesId);
ids.forEach((id) => {
dispatchUpdateInteractiveImportItem({
id,
series,
seasonNumber: undefined,
episodes: []
});
});
dispatchReprocessInteractiveImportItems({ ids });
onModalClose(true);
};
//
// Render
render() {
return (
<SelectSeriesModalContent
{...this.props}
onSeriesSelect={this.onSeriesSelect}
/>
);
}
}
SelectSeriesModalContentConnector.propTypes = {
ids: PropTypes.arrayOf(PropTypes.number).isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
dispatchReprocessInteractiveImportItems: PropTypes.func.isRequired,
dispatchUpdateInteractiveImportItem: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(SelectSeriesModalContentConnector);

@ -0,0 +1,6 @@
interface Language {
id: number;
name: string;
}
export default Language;

@ -0,0 +1,30 @@
export enum QualitySource {
Unknown = 'unkonwn',
Television = 'television',
TelevisionRaw = 'televisionRaw',
Web = 'web',
WebRip = 'webRip',
DVD = 'dvd',
Bluray = 'bluray',
BlurayRaw = 'blurayRaw',
}
export interface Revision {
version: number;
real: number;
isRepack: boolean;
}
interface Quality {
id: number;
name: string;
resolution: number;
source: QualitySource;
}
export interface QualityModel {
quality: Quality;
revision: Revision;
}
export default Quality;

@ -0,0 +1,61 @@
import { createAction } from 'redux-actions';
import { sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import updateSectionState from 'Utilities/State/updateSectionState';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
//
// Variables
export const section = 'episodeSelection';
//
// State
export const defaultState = {
isFetching: false,
isReprocessing: false,
isPopulated: false,
error: null,
sortKey: 'episodeNumber',
sortDirection: sortDirections.ASCENDING,
items: []
};
//
// Actions Types
export const FETCH_EPISODES = 'episodeSelection/fetchEpisodes';
export const SET_EPISODES_SORT = 'episodeSelection/setEpisodesSort';
export const CLEAR_EPISODES = 'episodeSelection/clearEpisodes';
//
// Action Creators
export const fetchEpisodes = createThunk(FETCH_EPISODES);
export const setEpisodesSort = createAction(SET_EPISODES_SORT);
export const clearEpisodes = createAction(CLEAR_EPISODES);
//
// Action Handlers
export const actionHandlers = handleThunks({
[FETCH_EPISODES]: createFetchHandler(section, '/episode')
});
//
// Reducers
export const reducers = createHandleActions({
[SET_EPISODES_SORT]: createSetClientSideCollectionSortReducer(section),
[CLEAR_EPISODES]: (state) => {
return updateSectionState(state, section, {
...defaultState
});
}
}, defaultState, section);

@ -8,6 +8,7 @@ import * as customFilters from './customFilterActions';
import * as episodes from './episodeActions';
import * as episodeFiles from './episodeFileActions';
import * as episodeHistory from './episodeHistoryActions';
import * as episodeSelection from './episodeSelectionActions';
import * as history from './historyActions';
import * as importSeries from './importSeriesActions';
import * as interactiveImportActions from './interactiveImportActions';
@ -37,6 +38,7 @@ export default [
episodes,
episodeFiles,
episodeHistory,
episodeSelection,
history,
importSeries,
interactiveImportActions,

@ -4,10 +4,8 @@ import { batchActions } from 'redux-batched-actions';
import { sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import updateSectionState from 'Utilities/State/updateSectionState';
import naturalExpansion from 'Utilities/String/naturalExpansion';
import { set, update, updateItem } from './baseActions';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
@ -16,7 +14,6 @@ import createSetClientSideCollectionSortReducer from './Creators/Reducers/create
export const section = 'interactiveImport';
const episodesSection = `${section}.episodes`;
let abortCurrentRequest = null;
let currentIds = [];
@ -51,16 +48,6 @@ export const defaultState = {
quality: function(item, direction) {
return item.qualityWeight || 0;
}
},
episodes: {
isFetching: false,
isReprocessing: false,
isPopulated: false,
error: null,
sortKey: 'episodeNumber',
sortDirection: sortDirections.ASCENDING,
items: []
}
};
@ -84,10 +71,6 @@ export const ADD_RECENT_FOLDER = 'interactiveImport/addRecentFolder';
export const REMOVE_RECENT_FOLDER = 'interactiveImport/removeRecentFolder';
export const SET_INTERACTIVE_IMPORT_MODE = 'interactiveImport/setInteractiveImportMode';
export const FETCH_INTERACTIVE_IMPORT_EPISODES = 'interactiveImport/fetchInteractiveImportEpisodes';
export const SET_INTERACTIVE_IMPORT_EPISODES_SORT = 'interactiveImport/setInteractiveImportEpisodesSort';
export const CLEAR_INTERACTIVE_IMPORT_EPISODES = 'interactiveImport/clearInteractiveImportEpisodes';
//
// Action Creators
@ -101,10 +84,6 @@ export const addRecentFolder = createAction(ADD_RECENT_FOLDER);
export const removeRecentFolder = createAction(REMOVE_RECENT_FOLDER);
export const setInteractiveImportMode = createAction(SET_INTERACTIVE_IMPORT_MODE);
export const fetchInteractiveImportEpisodes = createThunk(FETCH_INTERACTIVE_IMPORT_EPISODES);
export const setInteractiveImportEpisodesSort = createAction(SET_INTERACTIVE_IMPORT_EPISODES_SORT);
export const clearInteractiveImportEpisodes = createAction(CLEAR_INTERACTIVE_IMPORT_EPISODES);
//
// Action Handlers
export const actionHandlers = handleThunks({
@ -218,9 +197,7 @@ export const actionHandlers = handleThunks({
}))
));
});
},
[FETCH_INTERACTIVE_IMPORT_EPISODES]: createFetchHandler('interactiveImport.episodes', '/episode')
}
});
//
@ -242,13 +219,13 @@ export const reducers = createHandleActions({
},
[UPDATE_INTERACTIVE_IMPORT_ITEMS]: (state, { payload }) => {
const ids = payload.ids;
const { ids, ...otherPayload } = payload;
const newState = Object.assign({}, state);
const items = [...newState.items];
ids.forEach((id) => {
const index = items.findIndex((item) => item.id === id);
const item = Object.assign({}, items[index], payload);
const item = Object.assign({}, items[index], otherPayload);
items.splice(index, 1, item);
});
@ -299,14 +276,6 @@ export const reducers = createHandleActions({
[SET_INTERACTIVE_IMPORT_MODE]: function(state, { payload }) {
return Object.assign({}, state, { importMode: payload.importMode });
},
[SET_INTERACTIVE_IMPORT_EPISODES_SORT]: createSetClientSideCollectionSortReducer(episodesSection),
[CLEAR_INTERACTIVE_IMPORT_EPISODES]: (state) => {
return updateSectionState(state, episodesSection, {
...defaultState.episodes
});
}
}, defaultState, section);

@ -0,0 +1,11 @@
export enum RejectionType {
Permanent = 'permanent',
Temporary = 'temporary',
}
interface Rejection {
reason: string;
type: RejectionType;
}
export default Rejection;
Loading…
Cancel
Save