From 2bf1ce1763d130f8138fda45dd319514518205f7 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Wed, 3 Nov 2021 15:44:16 -0700 Subject: [PATCH] New: Manage episodes through Manual Import modal --- .../Editor/EpisodeFileEditorModal.js | 34 -- .../Editor/EpisodeFileEditorModalContent.css | 8 - .../Editor/EpisodeFileEditorModalContent.js | 310 ------------------ .../EpisodeFileEditorModalContentConnector.js | 174 ---------- .../Editor/EpisodeFileEditorRow.css | 3 - .../Editor/EpisodeFileEditorRow.js | 89 ----- .../InteractiveImportModalContent.css | 6 + .../InteractiveImportModalContent.js | 56 +++- .../InteractiveImportModalContentConnector.js | 135 ++++++-- .../Interactive/InteractiveImportRow.js | 64 +++- .../Organize/OrganizePreviewModalContent.js | 2 +- frontend/src/Series/Details/SeriesDetails.js | 40 +-- .../src/Series/Details/SeriesDetailsSeason.js | 31 +- .../Details/SeriesDetailsSeasonConnector.js | 1 + .../src/Store/Actions/episodeFileActions.js | 43 +-- .../Store/Actions/interactiveImportActions.js | 4 +- .../EpisodeImport/Manual/ManualImportFile.cs | 1 + .../EpisodeImport/Manual/ManualImportItem.cs | 1 + .../Manual/ManualImportService.cs | 66 +++- .../MediaFiles/MediaFileService.cs | 16 +- .../EpisodeFiles/EpisodeFileModule.cs | 47 ++- .../ManualImport/ManualImportModule.cs | 7 +- .../ManualImport/ManualImportResource.cs | 2 + 23 files changed, 401 insertions(+), 739 deletions(-) delete mode 100644 frontend/src/EpisodeFile/Editor/EpisodeFileEditorModal.js delete mode 100644 frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.css delete mode 100644 frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.js delete mode 100644 frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContentConnector.js delete mode 100644 frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.css delete mode 100644 frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.js diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModal.js b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModal.js deleted file mode 100644 index 35d00caf2..000000000 --- a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModal.js +++ /dev/null @@ -1,34 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import EpisodeFileEditorModalContentConnector from './EpisodeFileEditorModalContentConnector'; - -function EpisodeFileEditorModal(props) { - const { - isOpen, - onModalClose, - ...otherProps - } = props; - - return ( - - { - isOpen && - - } - - ); -} - -EpisodeFileEditorModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default EpisodeFileEditorModal; diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.css b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.css deleted file mode 100644 index 49e946826..000000000 --- a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.css +++ /dev/null @@ -1,8 +0,0 @@ -.actions { - display: flex; - margin-right: auto; -} - -.selectInput { - margin-left: 10px; -} diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.js b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.js deleted file mode 100644 index 59602f682..000000000 --- a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.js +++ /dev/null @@ -1,310 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; -import getSelectedIds from 'Utilities/Table/getSelectedIds'; -import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState'; -import selectAll from 'Utilities/Table/selectAll'; -import toggleSelected from 'Utilities/Table/toggleSelected'; -import { kinds } from 'Helpers/Props'; -import SelectInput from 'Components/Form/SelectInput'; -import Button from 'Components/Link/Button'; -import SpinnerButton from 'Components/Link/SpinnerButton'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import ConfirmModal from 'Components/Modal/ConfirmModal'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import SeasonNumber from 'Season/SeasonNumber'; -import EpisodeFileEditorRow from './EpisodeFileEditorRow'; -import styles from './EpisodeFileEditorModalContent.css'; - -const columns = [ - { - name: 'episodeNumber', - label: 'Episode', - isVisible: true - }, - { - name: 'relativePath', - label: 'Relative Path', - isVisible: true - }, - { - name: 'airDateUtc', - label: 'Air Date', - isVisible: true - }, - { - name: 'language', - label: 'Language', - isVisible: true - }, - { - name: 'quality', - label: 'Quality', - isVisible: true - } -]; - -class EpisodeFileEditorModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - allSelected: false, - allUnselected: false, - lastToggled: null, - selectedState: {}, - isConfirmDeleteModalOpen: false - }; - } - - componentDidUpdate(prevProps) { - if (hasDifferentItems(prevProps.items, this.props.items)) { - this.setState((state) => { - return removeOldSelectedState(state, prevProps.items); - }); - } - } - - // - // Control - - getSelectedIds = () => { - const selectedIds = getSelectedIds(this.state.selectedState); - - return selectedIds.reduce((acc, id) => { - const matchingItem = this.props.items.find((item) => item.id === id); - - if (matchingItem && !acc.includes(matchingItem.episodeFileId)) { - acc.push(matchingItem.episodeFileId); - } - - return acc; - }, []); - } - - // - // Listeners - - onSelectAllChange = ({ value }) => { - this.setState(selectAll(this.state.selectedState, value)); - } - - onSelectedChange = ({ id, value, shiftKey = false }) => { - this.setState((state) => { - return toggleSelected(state, this.props.items, id, value, shiftKey); - }); - } - - onDeletePress = () => { - this.setState({ isConfirmDeleteModalOpen: true }); - } - - onConfirmDelete = () => { - this.setState({ isConfirmDeleteModalOpen: false }); - this.props.onDeletePress(this.getSelectedIds()); - } - - onConfirmDeleteModalClose = () => { - this.setState({ isConfirmDeleteModalOpen: false }); - } - - onLanguageChange = ({ value }) => { - const selectedIds = this.getSelectedIds(); - - if (!selectedIds.length) { - return; - } - - this.props.onLanguageChange(selectedIds, parseInt(value)); - } - - onQualityChange = ({ value }) => { - const selectedIds = this.getSelectedIds(); - - if (!selectedIds.length) { - return; - } - - this.props.onQualityChange(selectedIds, parseInt(value)); - } - - // - // Render - - render() { - const { - seasonNumber, - isDeleting, - isFetching, - isPopulated, - error, - items, - languages, - qualities, - seriesType, - onModalClose - } = this.props; - - const { - allSelected, - allUnselected, - selectedState, - isConfirmDeleteModalOpen - } = this.state; - - const languageOptions = _.reduceRight(languages, (acc, language) => { - acc.push({ - key: language.id, - value: language.name - }); - - return acc; - }, [{ key: 'selectLanguage', value: 'Select Language', disabled: true }]); - - const qualityOptions = _.reduceRight(qualities, (acc, quality) => { - acc.push({ - key: quality.id, - value: quality.name - }); - - return acc; - }, [{ key: 'selectQuality', value: 'Select Quality', disabled: true }]); - - const hasSelectedFiles = this.getSelectedIds().length > 0; - - return ( - - - Manage Episodes {seasonNumber != null && } - - - - { - isFetching && !isPopulated ? - : - null - } - - { - !isFetching && error ? -
{error}
: - null - } - - { - isPopulated && !items.length ? -
- No episode files to manage. -
: - null - } - - { - isPopulated && items.length ? - - - { - items.map((item) => { - return ( - - ); - }) - } - -
: - null - } -
- - -
- - Delete - - -
- -
- -
- -
-
- - -
- - -
- ); - } -} - -EpisodeFileEditorModalContent.propTypes = { - seasonNumber: PropTypes.number, - isDeleting: PropTypes.bool.isRequired, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - languages: PropTypes.arrayOf(PropTypes.object).isRequired, - qualities: PropTypes.arrayOf(PropTypes.object).isRequired, - seriesType: PropTypes.string.isRequired, - onDeletePress: PropTypes.func.isRequired, - onLanguageChange: PropTypes.func.isRequired, - onQualityChange: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default EpisodeFileEditorModalContent; diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContentConnector.js b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContentConnector.js deleted file mode 100644 index 79c96d1c0..000000000 --- a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContentConnector.js +++ /dev/null @@ -1,174 +0,0 @@ -/* eslint max-params: 0 */ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import getQualities from 'Utilities/Quality/getQualities'; -import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; -import { deleteEpisodeFiles, updateEpisodeFiles } from 'Store/Actions/episodeFileActions'; -import { fetchLanguageProfileSchema, fetchQualityProfileSchema } from 'Store/Actions/settingsActions'; -import EpisodeFileEditorModalContent from './EpisodeFileEditorModalContent'; - -function createSchemaSelector() { - return createSelector( - (state) => state.settings.languageProfiles, - (state) => state.settings.qualityProfiles, - (languageProfiles, qualityProfiles) => { - const languages = _.map(languageProfiles.schema.languages, 'language'); - const qualities = getQualities(qualityProfiles.schema.items); - - let error = null; - - if (languageProfiles.schemaError) { - error = 'Unable to load languages'; - } else if (qualityProfiles.schemaError) { - error = 'Unable to load qualities'; - } - - return { - isFetching: languageProfiles.isSchemaFetching || qualityProfiles.isSchemaFetching, - isPopulated: languageProfiles.isSchemaPopulated && qualityProfiles.isSchemaPopulated, - error, - languages, - qualities - }; - } - ); -} - -function createMapStateToProps() { - return createSelector( - (state, { seasonNumber }) => seasonNumber, - (state) => state.episodes, - (state) => state.episodeFiles, - createSchemaSelector(), - createSeriesSelector(), - ( - seasonNumber, - episodes, - episodeFiles, - schema, - series - ) => { - const filtered = _.filter(episodes.items, (episode) => { - if (seasonNumber >= 0 && episode.seasonNumber !== seasonNumber) { - return false; - } - - if (!episode.episodeFileId) { - return false; - } - - return _.some(episodeFiles.items, { id: episode.episodeFileId }); - }); - - const sorted = _.orderBy(filtered, ['seasonNumber', 'episodeNumber'], ['desc', 'desc']); - - const items = _.map(sorted, (episode) => { - const episodeFile = _.find(episodeFiles.items, { id: episode.episodeFileId }); - - return { - relativePath: episodeFile.relativePath, - language: episodeFile.language, - quality: episodeFile.quality, - languageCutoffNotMet: episodeFile.languageCutoffNotMet, - qualityCutoffNotMet: episodeFile.qualityCutoffNotMet, - ...episode - }; - }); - - return { - ...schema, - items, - seriesType: series.seriesType, - isDeleting: episodeFiles.isDeleting, - isSaving: episodeFiles.isSaving - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - dispatchFetchLanguageProfileSchema(name, path) { - dispatch(fetchLanguageProfileSchema()); - }, - - dispatchFetchQualityProfileSchema(name, path) { - dispatch(fetchQualityProfileSchema()); - }, - - dispatchUpdateEpisodeFiles(updateProps) { - dispatch(updateEpisodeFiles(updateProps)); - }, - - onDeletePress(episodeFileIds) { - dispatch(deleteEpisodeFiles({ episodeFileIds })); - } - }; -} - -class EpisodeFileEditorModalContentConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.dispatchFetchLanguageProfileSchema(); - this.props.dispatchFetchQualityProfileSchema(); - } - - // - // Listeners - - onLanguageChange = (episodeFileIds, languageId) => { - const language = _.find(this.props.languages, { id: languageId }); - - this.props.dispatchUpdateEpisodeFiles({ episodeFileIds, language }); - } - - onQualityChange = (episodeFileIds, qualityId) => { - const quality = { - quality: _.find(this.props.qualities, { id: qualityId }), - revision: { - version: 1, - real: 0 - } - }; - - this.props.dispatchUpdateEpisodeFiles({ episodeFileIds, quality }); - } - - // - // Render - - render() { - const { - dispatchFetchLanguageProfileSchema, - dispatchFetchQualityProfileSchema, - dispatchUpdateEpisodeFiles, - ...otherProps - } = this.props; - - return ( - - ); - } -} - -EpisodeFileEditorModalContentConnector.propTypes = { - seriesId: PropTypes.number.isRequired, - seasonNumber: PropTypes.number, - languages: PropTypes.arrayOf(PropTypes.object).isRequired, - qualities: PropTypes.arrayOf(PropTypes.object).isRequired, - dispatchFetchLanguageProfileSchema: PropTypes.func.isRequired, - dispatchFetchQualityProfileSchema: PropTypes.func.isRequired, - dispatchUpdateEpisodeFiles: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeFileEditorModalContentConnector); diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.css b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.css deleted file mode 100644 index f86e1de6b..000000000 --- a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.css +++ /dev/null @@ -1,3 +0,0 @@ -.absoluteEpisodeNumber { - margin-left: 5px; -} diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.js b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.js deleted file mode 100644 index 7e7acdc45..000000000 --- a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.js +++ /dev/null @@ -1,89 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import padNumber from 'Utilities/Number/padNumber'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; -import TableRow from 'Components/Table/TableRow'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; -import EpisodeLanguage from 'Episode/EpisodeLanguage'; -import EpisodeQuality from 'Episode/EpisodeQuality'; -import styles from './EpisodeFileEditorRow'; - -function EpisodeFileEditorRow(props) { - const { - id, - seriesType, - seasonNumber, - episodeNumber, - absoluteEpisodeNumber, - relativePath, - airDateUtc, - language, - quality, - qualityCutoffNotMet, - languageCutoffNotMet, - isSelected, - onSelectedChange - } = props; - - return ( - - - - - {seasonNumber}x{padNumber(episodeNumber, 2)} - - { - seriesType === 'anime' && !!absoluteEpisodeNumber && - - ({absoluteEpisodeNumber}) - - } - - - - {relativePath} - - - - - - - - - - - - - ); -} - -EpisodeFileEditorRow.propTypes = { - id: PropTypes.number.isRequired, - seriesType: PropTypes.string.isRequired, - seasonNumber: PropTypes.number.isRequired, - episodeNumber: PropTypes.number.isRequired, - absoluteEpisodeNumber: PropTypes.number, - relativePath: PropTypes.string.isRequired, - airDateUtc: PropTypes.string.isRequired, - language: PropTypes.object.isRequired, - quality: PropTypes.object.isRequired, - qualityCutoffNotMet: PropTypes.bool.isRequired, - languageCutoffNotMet: PropTypes.bool.isRequired, - isSelected: PropTypes.bool, - onSelectedChange: PropTypes.func.isRequired -}; - -export default EpisodeFileEditorRow; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css index d50f3a261..5eb1aba72 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css @@ -26,6 +26,12 @@ justify-content: flex-end; } +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: 10px; +} + .importMode, .bulkSelect { composes: select from '~Components/Form/SelectInput.css'; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js index 47077c163..ac98c70f5 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js @@ -112,13 +112,21 @@ class InteractiveImportModalContent extends Component { 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: [], - selectModalOpen: null + withoutEpisodeFileIdRowsSelected: [], + selectModalOpen: null, + columns: instanceColumns }; } @@ -136,9 +144,14 @@ class InteractiveImportModalContent extends Component { this.setState(selectAll(this.state.selectedState, value)); } - onSelectedChange = ({ id, value, shiftKey = false }) => { + onSelectedChange = ({ id, value, hasEpisodeFileId, shiftKey = false }) => { this.setState((state) => { - return toggleSelected(state, this.props.items, id, value, shiftKey); + return { + ...toggleSelected(state, this.props.items, id, value, shiftKey), + withoutEpisodeFileIdRowsSelected: hasEpisodeFileId || !value ? + _.without(state.withoutEpisodeFileIdRowsSelected, id) : + [...state.withoutEpisodeFileIdRowsSelected, id] + }; }); } @@ -156,6 +169,16 @@ class InteractiveImportModalContent extends Component { }); } + onDeleteSelectedPress = () => { + const { + onDeleteSelectedPress + } = this.props; + + const selected = this.getSelectedIds(); + + onDeleteSelectedPress(selected); + } + onImportSelectedPress = () => { const { downloadId, @@ -193,7 +216,9 @@ class InteractiveImportModalContent extends Component { const { downloadId, allowSeriesChange, + autoSelectRow, showFilterExistingFiles, + showDelete, showImportMode, filterExistingFiles, title, @@ -215,6 +240,7 @@ class InteractiveImportModalContent extends Component { allUnselected, selectedState, invalidRowsSelected, + withoutEpisodeFileIdRowsSelected, selectModalOpen } = this.state; @@ -308,7 +334,7 @@ class InteractiveImportModalContent extends Component { { isPopulated && !!items.length && !isFetching && !isFetching && @@ -345,6 +373,19 @@ class InteractiveImportModalContent extends Component {
+ { + showDelete ? + : + null + } + { !downloadId && showImportMode ? e.id); + const originalEpisodeIds = originalFile.episodes ? originalFile.episodes.map((e) => e.id) : []; + + return episodeIds.every((episodeId) => { + return originalEpisodeIds.indexOf(episodeId) >= 0; + }); +} + function createMapStateToProps() { return createSelector( createClientSideCollectionSelector('interactiveImport'), @@ -23,6 +51,8 @@ const mapDispatchToProps = { dispatchSetInteractiveImportSort: setInteractiveImportSort, dispatchSetInteractiveImportMode: setInteractiveImportMode, dispatchClearInteractiveImport: clearInteractiveImport, + dispatchUpdateEpisodeFiles: updateEpisodeFiles, + dispatchDeleteEpisodeFiles: deleteEpisodeFiles, dispatchExecuteCommand: executeCommand }; @@ -44,16 +74,34 @@ class InteractiveImportModalContentConnector extends Component { const { downloadId, seriesId, - folder + seasonNumber, + folder, + initialSortKey, + initialSortDirection, + dispatchSetInteractiveImportSort, + dispatchFetchInteractiveImportItems } = this.props; const { filterExistingFiles } = this.state; - this.props.dispatchFetchInteractiveImportItems({ + if (initialSortKey) { + const sortProps = { + sortKey: initialSortKey + }; + + if (initialSortDirection) { + sortProps.sortDirection = initialSortDirection; + } + + dispatchSetInteractiveImportSort(sortProps); + } + + dispatchFetchInteractiveImportItems({ downloadId, seriesId, + seasonNumber, folder, filterExistingFiles }); @@ -99,10 +147,23 @@ class InteractiveImportModalContentConnector extends Component { this.props.dispatchSetInteractiveImportMode({ importMode }); } + onDeleteSelectedPress = (selected) => { + // TODO: Delete selected (if they have episode IDs) + } + onImportSelectedPress = (selected, importMode) => { + const { + items, + originalItems, + dispatchUpdateEpisodeFiles, + dispatchExecuteCommand, + onModalClose + } = this.props; + + const existingFiles = []; const files = []; - _.forEach(this.props.items, (item) => { + items.forEach((item) => { const isSelected = selected.indexOf(item.id) > -1; if (isSelected) { @@ -112,32 +173,48 @@ class InteractiveImportModalContentConnector extends Component { episodes, releaseGroup, quality, - language + language, + episodeFileId } = item; if (!series) { this.setState({ interactiveImportErrorMessage: 'Series must be chosen for each selected file' }); - return false; + return; } if (isNaN(seasonNumber)) { this.setState({ interactiveImportErrorMessage: 'Season must be chosen for each selected file' }); - return false; + return; } if (!episodes || !episodes.length) { this.setState({ interactiveImportErrorMessage: 'One or more episodes must be chosen for each selected file' }); - return false; + return; } if (!quality) { this.setState({ interactiveImportErrorMessage: 'Quality must be chosen for each selected file' }); - return false; + return; } if (!language) { this.setState({ interactiveImportErrorMessage: 'Language must be chosen for each selected file' }); - return false; + return; + } + + if (episodeFileId) { + const originalItem = originalItems.find((i) => i.id === item.id); + + if (isSameEpisodeFile(item, originalItem)) { + existingFiles.push({ + id: episodeFileId, + releaseGroup, + quality, + language + }); + + return; + } } files.push({ @@ -148,22 +225,35 @@ class InteractiveImportModalContentConnector extends Component { releaseGroup, quality, language, - downloadId: this.props.downloadId + downloadId: this.props.downloadId, + episodeFileId }); } }); - if (!files.length) { - return; + let shouldClose = false; + + if (existingFiles.length) { + dispatchUpdateEpisodeFiles({ + files: existingFiles + }); + + shouldClose = true; } - this.props.dispatchExecuteCommand({ - name: commandNames.INTERACTIVE_IMPORT, - files, - importMode - }); + if (files.length) { + dispatchExecuteCommand({ + name: commandNames.INTERACTIVE_IMPORT, + files, + importMode + }); + + shouldClose = true; + } - this.props.onModalClose(); + if (shouldClose) { + onModalClose(); + } } // @@ -183,6 +273,7 @@ class InteractiveImportModalContentConnector extends Component { onSortPress={this.onSortPress} onFilterExistingFilesChange={this.onFilterExistingFilesChange} onImportModeChange={this.onImportModeChange} + onDeleteSelectedPress={this.onDeleteSelectedPress} onImportSelectedPress={this.onImportSelectedPress} /> ); @@ -192,13 +283,19 @@ class InteractiveImportModalContentConnector extends Component { 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 }; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js index 7b3b1c0b4..3512fa827 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js @@ -41,23 +41,35 @@ class InteractiveImportRow extends Component { componentDidMount() { const { + allowSeriesChange, id, series, seasonNumber, episodes, quality, - language + language, + episodeFileId, + columns } = this.props; if ( + allowSeriesChange && series && seasonNumber != null && episodes.length && quality && language ) { - this.props.onSelectedChange({ id, value: true }); + this.props.onSelectedChange({ + id, + hasEpisodeFileId: !!episodeFileId, + value: true + }); } + + this.setState({ + isSeriesColumnVisible: columns.find((c) => c.name === 'series').isVisible + }); } componentDidUpdate(prevProps) { @@ -104,17 +116,34 @@ class InteractiveImportRow extends Component { selectRowAfterChange = (value) => { const { id, + episodeFileId, isSelected } = this.props; if (!isSelected && value === true) { - this.props.onSelectedChange({ id, value }); + this.props.onSelectedChange({ + id, + hasEpisodeFileId: !!episodeFileId, + value + }); } } // // Listeners + onSelectedChange = (result) => { + const { + episodeFileId, + onSelectedChange + } = this.props; + + onSelectedChange({ + ...result, + hasEpisodeFileId: !!episodeFileId + }); + } + onSelectSeriesPress = () => { this.setState({ isSelectSeriesModalOpen: true }); } @@ -186,8 +215,7 @@ class InteractiveImportRow extends Component { size, rejections, isReprocessing, - isSelected, - onSelectedChange + isSelected } = this.props; const { @@ -224,7 +252,7 @@ class InteractiveImportRow extends Component { - - { - showSeriesPlaceholder ? : seriesTitle - } - + { + this.state.isSeriesColumnVisible ? + + { + showSeriesPlaceholder ? : seriesTitle + } + : + null + } { - this.setState({ isInteractiveImportModalOpen: true }); - } - - onInteractiveImportModalClose = () => { - this.setState({ isInteractiveImportModalOpen: false }); - } - onEditSeriesPress = () => { this.setState({ isEditSeriesModalOpen: true }); } @@ -227,7 +217,6 @@ class SeriesDetails extends Component { isEditSeriesModalOpen, isDeleteSeriesModalOpen, isSeriesHistoryModalOpen, - isInteractiveImportModalOpen, isMonitorOptionsModalOpen, allExpanded, allCollapsed, @@ -299,12 +288,6 @@ class SeriesDetails extends Component { onPress={this.onSeriesHistoryPress} /> - - - @@ -677,16 +669,6 @@ class SeriesDetails extends Component { onModalClose={this.onDeleteSeriesModalClose} /> - - - { - seasonNumber === 0 ? - - Specials - : - - Season {seasonNumber} - - } + + {title} + - @@ -513,6 +519,7 @@ class SeriesDetailsSeason extends Component { SeriesDetailsSeason.propTypes = { seriesId: PropTypes.number.isRequired, + path: PropTypes.string.isRequired, monitored: PropTypes.bool.isRequired, seasonNumber: PropTypes.number.isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired, diff --git a/frontend/src/Series/Details/SeriesDetailsSeasonConnector.js b/frontend/src/Series/Details/SeriesDetailsSeasonConnector.js index c69c9810c..2c34d0b49 100644 --- a/frontend/src/Series/Details/SeriesDetailsSeasonConnector.js +++ b/frontend/src/Series/Details/SeriesDetailsSeasonConnector.js @@ -34,6 +34,7 @@ function createMapStateToProps() { columns: episodes.columns, isSearching, seriesMonitored: series.monitored, + path: series.path, isSmallScreen: dimensions.isSmallScreen }; } diff --git a/frontend/src/Store/Actions/episodeFileActions.js b/frontend/src/Store/Actions/episodeFileActions.js index e4a0cc951..46da28c87 100644 --- a/frontend/src/Store/Actions/episodeFileActions.js +++ b/frontend/src/Store/Actions/episodeFileActions.js @@ -140,28 +140,14 @@ export const actionHandlers = handleThunks({ }, [UPDATE_EPISODE_FILES]: function(getState, payload, dispatch) { - const { - episodeFileIds, - language, - quality - } = payload; + const { files } = payload; dispatch(set({ section, isSaving: true })); - const requestData = { - episodeFileIds - }; - - if (language) { - requestData.language = language; - } - - if (quality) { - requestData.quality = quality; - } + const requestData = files; const promise = createAjaxRequest({ - url: '/episodeFile/editor', + url: '/episodeFile/bulk', method: 'PUT', dataType: 'json', data: JSON.stringify(requestData) @@ -169,23 +155,22 @@ export const actionHandlers = handleThunks({ promise.done((data) => { dispatch(batchActions([ - ...episodeFileIds.map((id) => { + ...files.map((file) => { + const id = file.id; const props = {}; - - const episodeFile = data.find((file) => file.id === id); + const episodeFile = data.find((f) => f.id === id); props.qualityCutoffNotMet = episodeFile.qualityCutoffNotMet; props.languageCutoffNotMet = episodeFile.languageCutoffNotMet; + props.language = file.language; + props.quality = file.quality; + props.releaseGroup = file.releaseGroup; - if (language) { - props.language = language; - } - - if (quality) { - props.quality = quality; - } - - return updateItem({ section, id, ...props }); + return updateItem({ + section, + id, + ...props + }); }), set({ diff --git a/frontend/src/Store/Actions/interactiveImportActions.js b/frontend/src/Store/Actions/interactiveImportActions.js index ee6b3f058..cc55d4bcb 100644 --- a/frontend/src/Store/Actions/interactiveImportActions.js +++ b/frontend/src/Store/Actions/interactiveImportActions.js @@ -29,6 +29,7 @@ export const defaultState = { isPopulated: false, error: null, items: [], + originalItems: [], sortKey: 'quality', sortDirection: sortDirections.DESCENDING, recentFolders: [], @@ -127,7 +128,8 @@ export const actionHandlers = handleThunks({ section, isFetching: false, isPopulated: true, - error: null + error: null, + originalItems: data }) ])); }); diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportFile.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportFile.cs index 557276771..8f155568b 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportFile.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportFile.cs @@ -12,6 +12,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual public string FolderName { get; set; } public int SeriesId { get; set; } public List EpisodeIds { get; set; } + public int? EpisodeFileId { get; set; } public QualityModel Quality { get; set; } public Language Language { get; set; } public string ReleaseGroup { get; set; } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportItem.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportItem.cs index 70926e8d2..76f84724f 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportItem.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportItem.cs @@ -16,6 +16,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual public Series Series { get; set; } public int? SeasonNumber { get; set; } public List Episodes { get; set; } + public int? EpisodeFileId { get; set; } public QualityModel Quality { get; set; } public Language Language { get; set; } public string ReleaseGroup { get; set; } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs index 000ebdc38..3a4c0bfde 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs @@ -22,6 +22,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual { public interface IManualImportService { + List GetMediaFiles(int seriesId, int? seasonNumber); List GetMediaFiles(string path, string downloadId, int? seriesId, bool filterExistingFiles); ManualImportItem ReprocessItem(string path, string downloadId, int seriesId, int? seasonNumber, List episodeIds, string releaseGroup, QualityModel quality, Language language); } @@ -38,6 +39,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual private readonly IAggregationService _aggregationService; private readonly ITrackedDownloadService _trackedDownloadService; private readonly IDownloadedEpisodesImportService _downloadedEpisodesImportService; + private readonly IMediaFileService _mediaFileService; private readonly IEventAggregator _eventAggregator; private readonly Logger _logger; @@ -51,6 +53,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual IImportApprovedEpisodes importApprovedEpisodes, ITrackedDownloadService trackedDownloadService, IDownloadedEpisodesImportService downloadedEpisodesImportService, + IMediaFileService mediaFileService, IEventAggregator eventAggregator, Logger logger) { @@ -64,10 +67,46 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual _importApprovedEpisodes = importApprovedEpisodes; _trackedDownloadService = trackedDownloadService; _downloadedEpisodesImportService = downloadedEpisodesImportService; + _mediaFileService = mediaFileService; _eventAggregator = eventAggregator; _logger = logger; } + public List GetMediaFiles(int seriesId, int? seasonNumber) + { + var series = _seriesService.GetSeries(seriesId); + var directoryInfo = new DirectoryInfo(series.Path); + var seriesFiles = seasonNumber.HasValue ? _mediaFileService.GetFilesBySeason(seriesId, seasonNumber.Value) : _mediaFileService.GetFilesBySeries(seriesId); + + var items = seriesFiles.Select(episodeFile => MapItem(episodeFile, series, directoryInfo.Name)).ToList(); + + if (!seasonNumber.HasValue) + { + var mediaFiles = _diskScanService.FilterPaths(series.Path, _diskScanService.GetVideoFiles(series.Path)).ToList(); + var unmappedFiles = MediaFileService.FilterExistingFiles(mediaFiles, seriesFiles, series); + + items.AddRange(unmappedFiles.Select(file => + new ManualImportItem + { + Path = Path.Combine(series.Path, file), + FolderName = directoryInfo.Name, + RelativePath = series.Path.GetRelativePath(file), + Name = Path.GetFileNameWithoutExtension(file), + Series = series, + SeasonNumber = null, + Episodes = new List(), + ReleaseGroup = string.Empty, + Quality = new QualityModel(Quality.Unknown), + Language = Language.Unknown, + Size = _diskProvider.GetFileSize(file), + Rejections = Enumerable.Empty() + } + )); + } + + return items; + } + public List GetMediaFiles(string path, string downloadId, int? seriesId, bool filterExistingFiles) { if (downloadId.IsNotNullOrWhiteSpace()) @@ -363,6 +402,27 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual return item; } + private ManualImportItem MapItem(EpisodeFile episodeFile, Series series, string folderName) + { + var item = new ManualImportItem(); + + item.Path = Path.Combine(series.Path, episodeFile.RelativePath); + item.FolderName = folderName; + item.RelativePath = episodeFile.RelativePath; + item.Name = Path.GetFileNameWithoutExtension(episodeFile.Path); + item.Series = series; + item.SeasonNumber = episodeFile.SeasonNumber; + item.Episodes = episodeFile.Episodes.Value; + item.ReleaseGroup = episodeFile.ReleaseGroup; + item.Quality = episodeFile.Quality; + item.Language = episodeFile.Language; + item.Size = _diskProvider.GetFileSize(item.Path); + item.Rejections = Enumerable.Empty(); + item.EpisodeFileId = episodeFile.Id; + + return item; + } + public void Execute(ManualImportCommand message) { _logger.ProgressTrace("Manually importing {0} files using mode {1}", message.Files.Count, message.ImportMode); @@ -379,6 +439,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual var episodes = _episodeService.GetEpisodes(file.EpisodeIds); var fileEpisodeInfo = Parser.Parser.ParsePath(file.Path) ?? new ParsedEpisodeInfo(); var existingFile = series.Path.IsParentPath(file.Path); + TrackedDownload trackedDownload = null; var localEpisode = new LocalEpisode @@ -437,7 +498,10 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual } } - _logger.ProgressTrace("Manually imported {0} files", imported.Count); + if (imported.Any()) + { + _logger.ProgressTrace("Manually imported {0} files", imported.Count); + } foreach (var groupedTrackedDownload in importedTrackedDownload.GroupBy(i => i.TrackedDownload.DownloadItem.DownloadId).ToList()) { diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileService.cs index 93fa6aa19..179085138 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileService.cs @@ -88,11 +88,9 @@ namespace NzbDrone.Core.MediaFiles public List FilterExistingFiles(List files, Series series) { - var seriesFiles = GetFilesBySeries(series.Id).Select(f => Path.Combine(series.Path, f.RelativePath)).ToList(); + var seriesFiles = GetFilesBySeries(series.Id); - if (!seriesFiles.Any()) return files; - - return files.Except(seriesFiles, PathEqualityComparer.Instance).ToList(); + return FilterExistingFiles(files, seriesFiles, series); } public EpisodeFile Get(int id) @@ -115,5 +113,15 @@ namespace NzbDrone.Core.MediaFiles var files = GetFilesBySeries(message.Series.Id); _mediaFileRepository.DeleteMany(files); } + + public static List FilterExistingFiles(List files, List seriesFiles, Series series) + { + var seriesFilePaths = seriesFiles.Select(f => Path.Combine(series.Path, f.RelativePath)).ToList(); + + if (!seriesFilePaths.Any()) return files; + + return files.Except(seriesFilePaths, PathEqualityComparer.Instance).ToList(); + } + } } diff --git a/src/Sonarr.Api.V3/EpisodeFiles/EpisodeFileModule.cs b/src/Sonarr.Api.V3/EpisodeFiles/EpisodeFileModule.cs index 6fb6c4837..6feb5102b 100644 --- a/src/Sonarr.Api.V3/EpisodeFiles/EpisodeFileModule.cs +++ b/src/Sonarr.Api.V3/EpisodeFiles/EpisodeFileModule.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; using Nancy; using NzbDrone.Core.Datastore.Events; -using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Exceptions; using NzbDrone.Core.MediaFiles; @@ -44,7 +43,8 @@ namespace Sonarr.Api.V3.EpisodeFiles UpdateResource = SetQuality; DeleteResource = DeleteEpisodeFile; - Put("/editor", episodeFiles => SetQuality()); + Put("/editor", episodeFiles => SetPropertiesEditor()); + Put("/bulk", episodeFiles => SetPropertiesBulk()); Delete("/bulk", episodeFiles => DeleteEpisodeFiles()); } @@ -109,7 +109,8 @@ namespace Sonarr.Api.V3.EpisodeFiles _mediaFileService.Update(episodeFile); } - private object SetQuality() + // Deprecated: Use SetPropertiesBulk instead + private object SetPropertiesEditor() { var resource = Request.Body.FromJson(); var episodeFiles = _mediaFileService.GetFiles(resource.EpisodeFileIds); @@ -141,8 +142,44 @@ namespace Sonarr.Api.V3.EpisodeFiles var series = _seriesService.GetSeries(episodeFiles.First().SeriesId); - return ResponseWithCode(episodeFiles.ConvertAll(f => f.ToResource(series, _upgradableSpecification)) - , HttpStatusCode.Accepted); + return ResponseWithCode(episodeFiles.ConvertAll(f => f.ToResource(series, _upgradableSpecification)), HttpStatusCode.Accepted); + } + + private object SetPropertiesBulk() + { + var resource = Request.Body.FromJson>(); + var episodeFiles = _mediaFileService.GetFiles(resource.Select(r => r.Id)); + + foreach (var episodeFile in episodeFiles) + { + var resourceEpisodeFile = resource.Single(r => r.Id == episodeFile.Id); + + if (resourceEpisodeFile.Language != null) + { + episodeFile.Language = resourceEpisodeFile.Language; + } + + if (resourceEpisodeFile.Quality != null) + { + episodeFile.Quality = resourceEpisodeFile.Quality; + } + + if (resourceEpisodeFile.SceneName != null && SceneChecker.IsSceneTitle(resourceEpisodeFile.SceneName)) + { + episodeFile.SceneName = resourceEpisodeFile.SceneName; + } + + if (resourceEpisodeFile.ReleaseGroup != null) + { + episodeFile.ReleaseGroup = resourceEpisodeFile.ReleaseGroup; + } + } + + _mediaFileService.Update(episodeFiles); + + var series = _seriesService.GetSeries(episodeFiles.First().SeriesId); + + return ResponseWithCode(episodeFiles.ConvertAll(f => f.ToResource(series, _upgradableSpecification)), HttpStatusCode.Accepted); } private void DeleteEpisodeFile(int id) diff --git a/src/Sonarr.Api.V3/ManualImport/ManualImportModule.cs b/src/Sonarr.Api.V3/ManualImport/ManualImportModule.cs index ebcb1a30c..0c754be1c 100644 --- a/src/Sonarr.Api.V3/ManualImport/ManualImportModule.cs +++ b/src/Sonarr.Api.V3/ManualImport/ManualImportModule.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.Linq; -using Nancy; using NzbDrone.Common.Extensions; using NzbDrone.Core.Languages; using NzbDrone.Core.MediaFiles.EpisodeImport.Manual; @@ -30,6 +29,12 @@ namespace Sonarr.Api.V3.ManualImport var downloadId = (string)Request.Query.downloadId; var filterExistingFiles = Request.GetBooleanQueryParameter("filterExistingFiles", true); var seriesId = Request.GetNullableIntegerQueryParameter("seriesId", null); + var seasonNumber = Request.GetNullableIntegerQueryParameter("seasonNumber", null); + + if (seriesId.HasValue) + { + return _manualImportService.GetMediaFiles(seriesId.Value, seasonNumber).ToResource().Select(AddQualityWeight).ToList(); + } return _manualImportService.GetMediaFiles(folder, downloadId, seriesId, filterExistingFiles).ToResource().Select(AddQualityWeight).ToList(); } diff --git a/src/Sonarr.Api.V3/ManualImport/ManualImportResource.cs b/src/Sonarr.Api.V3/ManualImport/ManualImportResource.cs index ea6318545..d11c0e0a4 100644 --- a/src/Sonarr.Api.V3/ManualImport/ManualImportResource.cs +++ b/src/Sonarr.Api.V3/ManualImport/ManualImportResource.cs @@ -21,6 +21,7 @@ namespace Sonarr.Api.V3.ManualImport public SeriesResource Series { get; set; } public int? SeasonNumber { get; set; } public List Episodes { get; set; } + public int? EpisodeFileId { get; set; } public string ReleaseGroup { get; set; } public QualityModel Quality { get; set; } public Language Language { get; set; } @@ -46,6 +47,7 @@ namespace Sonarr.Api.V3.ManualImport Series = model.Series.ToResource(), SeasonNumber = model.SeasonNumber, Episodes = model.Episodes.ToResource(), + EpisodeFileId = model.EpisodeFileId, ReleaseGroup = model.ReleaseGroup, Quality = model.Quality, Language = model.Language,