From f30ae69c10051a09717f3816c4275794f10c15b7 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Mon, 12 Oct 2020 10:49:35 -0700 Subject: [PATCH] New: Reprocess items after selection in Manual Import Closes #3818 --- .../SelectEpisodeModalContentConnector.js | 33 +++++++----- .../SelectLanguageModalContentConnector.js | 18 +++++-- .../SelectQualityModalContentConnector.js | 18 +++++-- .../Store/Actions/interactiveImportActions.js | 4 ++ .../EpisodeImport/ImportDecisionMaker.cs | 17 +++--- .../Manual/ManualImportService.cs | 53 ++++++++++++++----- .../ManualImport/ManualImportModule.cs | 5 +- .../ManualImportReprocessResource.cs | 5 ++ 8 files changed, 111 insertions(+), 42 deletions(-) diff --git a/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContentConnector.js b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContentConnector.js index 80ceb0fc1..7a25effb9 100644 --- a/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContentConnector.js +++ b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContentConnector.js @@ -6,7 +6,8 @@ import { updateInteractiveImportItem, fetchInteractiveImportEpisodes, setInteractiveImportEpisodesSort, - clearInteractiveImportEpisodes + clearInteractiveImportEpisodes, + reprocessInteractiveImportItems } from 'Store/Actions/interactiveImportActions'; import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; import SelectEpisodeModalContent from './SelectEpisodeModalContent'; @@ -21,10 +22,11 @@ function createMapStateToProps() { } const mapDispatchToProps = { - fetchInteractiveImportEpisodes, - setInteractiveImportEpisodesSort, - clearInteractiveImportEpisodes, - updateInteractiveImportItem + dispatchFetchInteractiveImportEpisodes: fetchInteractiveImportEpisodes, + dispatchSetInteractiveImportEpisodesSort: setInteractiveImportEpisodesSort, + dispatchClearInteractiveImportEpisodes: clearInteractiveImportEpisodes, + dispatchUpdateInteractiveImportItem: updateInteractiveImportItem, + dispatchReprocessInteractiveImportItems: reprocessInteractiveImportItems }; class SelectEpisodeModalContentConnector extends Component { @@ -38,26 +40,28 @@ class SelectEpisodeModalContentConnector extends Component { seasonNumber } = this.props; - this.props.fetchInteractiveImportEpisodes({ seriesId, seasonNumber }); + 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.clearInteractiveImportEpisodes(); + this.props.dispatchClearInteractiveImportEpisodes(); } // // Listeners onSortPress = (sortKey, sortDirection) => { - this.props.setInteractiveImportEpisodesSort({ sortKey, sortDirection }); + this.props.dispatchSetInteractiveImportEpisodesSort({ sortKey, sortDirection }); } onEpisodesSelect = (episodeIds) => { const { ids, items, + dispatchUpdateInteractiveImportItem, + dispatchReprocessInteractiveImportItems, onModalClose } = this.props; @@ -78,12 +82,14 @@ class SelectEpisodeModalContentConnector extends Component { const startingIndex = index * episodesPerFile; const episodes = sortedEpisodes.slice(startingIndex, startingIndex + episodesPerFile); - this.props.updateInteractiveImportItem({ + dispatchUpdateInteractiveImportItem({ id, episodes }); }); + dispatchReprocessInteractiveImportItems({ ids }); + onModalClose(true); } @@ -106,10 +112,11 @@ SelectEpisodeModalContentConnector.propTypes = { seriesId: PropTypes.number.isRequired, seasonNumber: PropTypes.number.isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired, - fetchInteractiveImportEpisodes: PropTypes.func.isRequired, - setInteractiveImportEpisodesSort: PropTypes.func.isRequired, - clearInteractiveImportEpisodes: PropTypes.func.isRequired, - updateInteractiveImportItem: PropTypes.func.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 }; diff --git a/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js b/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js index c084d4915..5f9c79bf2 100644 --- a/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js +++ b/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js @@ -4,7 +4,7 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { fetchLanguageProfileSchema } from 'Store/Actions/settingsActions'; -import { updateInteractiveImportItems } from 'Store/Actions/interactiveImportActions'; +import { updateInteractiveImportItems, reprocessInteractiveImportItems } from 'Store/Actions/interactiveImportActions'; import SelectLanguageModalContent from './SelectLanguageModalContent'; function createMapStateToProps() { @@ -30,7 +30,8 @@ function createMapStateToProps() { const mapDispatchToProps = { dispatchFetchLanguageProfileSchema: fetchLanguageProfileSchema, - dispatchUpdateInteractiveImportItems: updateInteractiveImportItems + dispatchUpdateInteractiveImportItems: updateInteractiveImportItems, + dispatchReprocessInteractiveImportItems: reprocessInteractiveImportItems }; class SelectLanguageModalContentConnector extends Component { @@ -48,15 +49,23 @@ class SelectLanguageModalContentConnector extends Component { // Listeners onLanguageSelect = ({ value }) => { + const { + ids, + dispatchUpdateInteractiveImportItems, + dispatchReprocessInteractiveImportItems + } = this.props; + const languageId = parseInt(value); const language = _.find(this.props.items, (item) => item.language.id === languageId).language; - this.props.dispatchUpdateInteractiveImportItems({ - ids: this.props.ids, + dispatchUpdateInteractiveImportItems({ + ids, language }); + dispatchReprocessInteractiveImportItems({ ids }); + this.props.onModalClose(true); } @@ -81,6 +90,7 @@ SelectLanguageModalContentConnector.propTypes = { items: PropTypes.arrayOf(PropTypes.object).isRequired, dispatchFetchLanguageProfileSchema: PropTypes.func.isRequired, dispatchUpdateInteractiveImportItems: PropTypes.func.isRequired, + dispatchReprocessInteractiveImportItems: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js b/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js index 1cf55cde6..dfef33e04 100644 --- a/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js +++ b/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js @@ -5,7 +5,7 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import getQualities from 'Utilities/Quality/getQualities'; import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions'; -import { updateInteractiveImportItems } from 'Store/Actions/interactiveImportActions'; +import { updateInteractiveImportItems, reprocessInteractiveImportItems } from 'Store/Actions/interactiveImportActions'; import SelectQualityModalContent from './SelectQualityModalContent'; function createMapStateToProps() { @@ -31,7 +31,8 @@ function createMapStateToProps() { const mapDispatchToProps = { dispatchFetchQualityProfileSchema: fetchQualityProfileSchema, - dispatchUpdateInteractiveImportItems: updateInteractiveImportItems + dispatchUpdateInteractiveImportItems: updateInteractiveImportItems, + dispatchReprocessInteractiveImportItems: reprocessInteractiveImportItems }; class SelectQualityModalContentConnector extends Component { @@ -49,6 +50,12 @@ class SelectQualityModalContentConnector extends Component { // Listeners onQualitySelect = ({ qualityId, proper, real }) => { + const { + ids, + dispatchUpdateInteractiveImportItems, + dispatchReprocessInteractiveImportItems + } = this.props; + const quality = _.find(this.props.items, (item) => item.id === qualityId); @@ -57,14 +64,16 @@ class SelectQualityModalContentConnector extends Component { real: real ? 1 : 0 }; - this.props.dispatchUpdateInteractiveImportItems({ - ids: this.props.ids, + dispatchUpdateInteractiveImportItems({ + ids, quality: { quality, revision } }); + dispatchReprocessInteractiveImportItems({ ids }); + this.props.onModalClose(true); } @@ -89,6 +98,7 @@ SelectQualityModalContentConnector.propTypes = { items: PropTypes.arrayOf(PropTypes.object).isRequired, dispatchFetchQualityProfileSchema: PropTypes.func.isRequired, dispatchUpdateInteractiveImportItems: PropTypes.func.isRequired, + dispatchReprocessInteractiveImportItems: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Store/Actions/interactiveImportActions.js b/frontend/src/Store/Actions/interactiveImportActions.js index 308c2c893..9d0f9672d 100644 --- a/frontend/src/Store/Actions/interactiveImportActions.js +++ b/frontend/src/Store/Actions/interactiveImportActions.js @@ -169,6 +169,10 @@ export const actionHandlers = handleThunks({ id, path: item.path, seriesId: item.series.id, + season: item.season, + episodeIds: item.episodes.map((e) => e.id), + quality: item.quality, + language: item.language, downloadId: item.downloadId }; }); diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs index 58f3375e9..282fb5bd1 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs @@ -17,6 +17,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport List GetImportDecisions(List videoFiles, Series series); List GetImportDecisions(List videoFiles, Series series, DownloadClientItem downloadClientItem, ParsedEpisodeInfo folderInfo, bool sceneSource); List GetImportDecisions(List videoFiles, Series series, DownloadClientItem downloadClientItem, ParsedEpisodeInfo folderInfo, bool sceneSource, bool filterExistingFiles); + ImportDecision GetDecision(LocalEpisode localEpisode, DownloadClientItem downloadClientItem); } public class ImportDecisionMaker : IMakeImportDecision @@ -90,6 +91,14 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport return decisions; } + public ImportDecision GetDecision(LocalEpisode localEpisode, DownloadClientItem downloadClientItem) + { + var reasons = _specifications.Select(c => EvaluateSpec(c, localEpisode, downloadClientItem)) + .Where(c => c != null); + + return new ImportDecision(localEpisode, reasons.ToArray()); + } + private ImportDecision GetDecision(LocalEpisode localEpisode, DownloadClientItem downloadClientItem, bool otherFiles) { ImportDecision decision = null; @@ -150,14 +159,6 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport return decision; } - private ImportDecision GetDecision(LocalEpisode localEpisode, DownloadClientItem downloadClientItem) - { - var reasons = _specifications.Select(c => EvaluateSpec(c, localEpisode, downloadClientItem)) - .Where(c => c != null); - - return new ImportDecision(localEpisode, reasons.ToArray()); - } - private Rejection EvaluateSpec(IImportDecisionEngineSpecification spec, LocalEpisode localEpisode, DownloadClientItem downloadClientItem) { try diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs index b852e0087..253828af0 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs @@ -9,11 +9,13 @@ using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Download; using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.Languages; using NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; using NzbDrone.Core.Tv; namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual @@ -21,7 +23,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual public interface IManualImportService { List GetMediaFiles(string path, string downloadId, int? seriesId, bool filterExistingFiles); - ManualImportItem ReprocessItem(string path, string downloadId, int seriesId); + ManualImportItem ReprocessItem(string path, string downloadId, int seriesId, List episodeIds, QualityModel quality, Language language); } public class ManualImportService : IExecute, IManualImportService @@ -94,11 +96,32 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual return ProcessFolder(path, path, downloadId, seriesId, filterExistingFiles); } - public ManualImportItem ReprocessItem(string path, string downloadId, int seriesId) + public ManualImportItem ReprocessItem(string path, string downloadId, int seriesId, List episodeIds, QualityModel quality, Language language) { var rootFolder = Path.GetDirectoryName(path); var series = _seriesService.GetSeries(seriesId); + if (episodeIds.Any()) + { + var downloadClientItem = GetTrackedDownload(downloadId)?.DownloadItem; + + var localEpisode = new LocalEpisode + { + Series = series, + Episodes = _episodeService.GetEpisodes(episodeIds), + FileEpisodeInfo = Parser.Parser.ParsePath(path), + DownloadClientEpisodeInfo = downloadClientItem == null ? null : Parser.Parser.ParseTitle(downloadClientItem.Title), + Path = path, + SceneSource = SceneSource(series, rootFolder), + ExistingFile = series.Path.IsParentPath(path), + Size = _diskProvider.GetFileSize(path), + Language = language, + Quality = quality + }; + + return MapItem(_importDecisionMaker.GetDecision(localEpisode, downloadClientItem), rootFolder, downloadId, null); + } + return ProcessFile(rootFolder, rootFolder, path, downloadId, series); } @@ -162,7 +185,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual { try { - DownloadClientItem downloadClientItem = null; + var trackedDownload = GetTrackedDownload(downloadId); var relativeFile = baseFolder.GetRelativePath(file); if (series == null) @@ -175,15 +198,9 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual series = _parsingService.GetSeries(relativeFile); } - if (downloadId.IsNotNullOrWhiteSpace()) + if (trackedDownload != null && series == null) { - var trackedDownload = _trackedDownloadService.Find(downloadId); - downloadClientItem = trackedDownload?.DownloadItem; - - if (series == null) - { - series = trackedDownload?.RemoteEpisode?.Series; - } + series = trackedDownload?.RemoteEpisode?.Series; } if (series == null) @@ -209,7 +226,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual } var importDecisions = _importDecisionMaker.GetImportDecisions(new List {file}, series, - downloadClientItem, null, SceneSource(series, baseFolder)); + trackedDownload?.DownloadItem, null, SceneSource(series, baseFolder)); if (importDecisions.Any()) { @@ -236,6 +253,18 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual return !(series.Path.PathEquals(folder) || series.Path.IsParentPath(folder)); } + private TrackedDownload GetTrackedDownload(string downloadId) + { + if (downloadId.IsNotNullOrWhiteSpace()) + { + var trackedDownload = _trackedDownloadService.Find(downloadId); + + return trackedDownload; + } + + return null; + } + private ManualImportItem MapItem(ImportDecision decision, string rootFolder, string downloadId, string folderName) { var item = new ManualImportItem(); diff --git a/src/Sonarr.Api.V3/ManualImport/ManualImportModule.cs b/src/Sonarr.Api.V3/ManualImport/ManualImportModule.cs index 3150ec3a9..1568b37a0 100644 --- a/src/Sonarr.Api.V3/ManualImport/ManualImportModule.cs +++ b/src/Sonarr.Api.V3/ManualImport/ManualImportModule.cs @@ -38,11 +38,14 @@ namespace Sonarr.Api.V3.ManualImport foreach (var item in items) { - var processedItem = _manualImportService.ReprocessItem(item.Path, item.DownloadId, item.SeriesId); + var processedItem = _manualImportService.ReprocessItem(item.Path, item.DownloadId, item.SeriesId, item.EpisodeIds ?? new List(), item.Quality, item.Language); item.SeasonNumber = processedItem.SeasonNumber; item.Episodes = processedItem.Episodes.ToResource(); item.Rejections = processedItem.Rejections; + + // Clear episode IDs in favour of the full episode + item.EpisodeIds = null; } return items; diff --git a/src/Sonarr.Api.V3/ManualImport/ManualImportReprocessResource.cs b/src/Sonarr.Api.V3/ManualImport/ManualImportReprocessResource.cs index e1810d9ae..0ebe3a464 100644 --- a/src/Sonarr.Api.V3/ManualImport/ManualImportReprocessResource.cs +++ b/src/Sonarr.Api.V3/ManualImport/ManualImportReprocessResource.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Languages; +using NzbDrone.Core.Qualities; using Sonarr.Api.V3.Episodes; using Sonarr.Http.REST; @@ -11,6 +13,9 @@ namespace Sonarr.Api.V3.ManualImport public int SeriesId { get; set; } public int? SeasonNumber { get; set; } public List Episodes { get; set; } + public List EpisodeIds { get; set; } + public QualityModel Quality { get; set; } + public Language Language { get; set; } public string DownloadId { get; set; } public IEnumerable Rejections { get; set; }