From ce8a5d8a6bed6bfbbab0047e799b60b4326cc9d8 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Mon, 22 Dec 2025 11:13:23 -0800 Subject: [PATCH] New: Manually import multiple items at the same time from Activity: Queue --- frontend/src/Activity/Queue/Queue.tsx | 42 ++++++++++++++++++- frontend/src/Activity/Queue/QueueRow.tsx | 4 +- .../InteractiveImportModalContent.tsx | 18 ++++---- .../InteractiveImportModal.tsx | 8 ++-- .../InteractiveImport/useInteractiveImport.ts | 2 +- .../src/Utilities/Fetch/getQueryString.ts | 1 + src/NzbDrone.Core/Localization/Core/en.json | 2 + .../ManualImport/ManualImportController.cs | 28 +++++++++++-- .../ManualImport/ManualImportResource.cs | 2 +- 9 files changed, 85 insertions(+), 22 deletions(-) diff --git a/frontend/src/Activity/Queue/Queue.tsx b/frontend/src/Activity/Queue/Queue.tsx index f5d3896d3..2f2d72217 100644 --- a/frontend/src/Activity/Queue/Queue.tsx +++ b/frontend/src/Activity/Queue/Queue.tsx @@ -26,6 +26,7 @@ import useEpisodes from 'Episode/useEpisodes'; import { useCustomFiltersList } from 'Filters/useCustomFilters'; import { align, icons, kinds } from 'Helpers/Props'; import { SortDirection } from 'Helpers/Props/sortDirections'; +import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; import { CheckInputChanged } from 'typings/inputs'; import QueueModel from 'typings/Queue'; import { TableOptionsChangePayload } from 'typings/Table'; @@ -109,6 +110,9 @@ function QueueContent() { const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] = useState(false); + const [isInteractiveImportDownloadIds, setIsInteractiveImportDownloadIds] = + useState(() => []); + const isRefreshing = isLoading || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting; @@ -156,12 +160,30 @@ function QueueContent() { shouldBlockRefresh.current = false; removeQueueItems({ ids: selectedIds }); setIsConfirmRemoveModalOpen(false); - }, [selectedIds, setIsConfirmRemoveModalOpen, removeQueueItems]); + }, [selectedIds, removeQueueItems]); const handleConfirmRemoveModalClose = useCallback(() => { shouldBlockRefresh.current = false; setIsConfirmRemoveModalOpen(false); - }, [setIsConfirmRemoveModalOpen]); + }, []); + + const handleImportSelectedPress = useCallback(() => { + shouldBlockRefresh.current = true; + setIsInteractiveImportDownloadIds( + selectedIds + .map((id) => { + const item = records.find((i) => i.id === id); + + return item?.downloadId; + }) + .filter((id): id is string => !!id) + ); + }, [records, selectedIds]); + + const handleImportSelectedModalClose = useCallback(() => { + shouldBlockRefresh.current = false; + setIsInteractiveImportDownloadIds([]); + }, []); const handleFilterSelect = useCallback( (selectedFilterKey: string | number) => { @@ -292,6 +314,15 @@ function QueueContent() { isSpinning={isRemoving} onPress={handleRemoveSelectedPress} /> + + + + @@ -358,6 +389,13 @@ function QueueContent() { onRemovePress={handleRemoveSelectedConfirmed} onModalClose={handleConfirmRemoveModalClose} /> + + 0} + downloadIds={isInteractiveImportDownloadIds} + title={translate('InteractiveImportMultipleQueueItems')} + onModalClose={handleImportSelectedModalClose} + /> ); } diff --git a/frontend/src/Activity/Queue/QueueRow.tsx b/frontend/src/Activity/Queue/QueueRow.tsx index 9d03513b4..6ac67f65c 100644 --- a/frontend/src/Activity/Queue/QueueRow.tsx +++ b/frontend/src/Activity/Queue/QueueRow.tsx @@ -45,7 +45,7 @@ interface QueueRowProps { id: number; seriesId?: number; episodeIds: number[]; - downloadId?: string; + downloadId: string; title: string; status: string; trackedDownloadStatus?: QueueTrackedDownloadStatus; @@ -399,7 +399,7 @@ function QueueRow(props: QueueRowProps) { diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx index ba0d1e60a..df83629bb 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx @@ -202,7 +202,7 @@ function isSameEpisodeFile( const filterExistingFilesStore = create(() => false); export interface InteractiveImportModalContentProps { - downloadId?: string; + downloadIds?: string[]; seriesId?: number; seasonNumber?: number; showSeries?: boolean; @@ -224,7 +224,7 @@ function InteractiveImportModalContentInner( props: InteractiveImportModalContentProps ) { const { - downloadId, + downloadIds, seriesId, seasonNumber, allowSeriesChange = true, @@ -252,7 +252,7 @@ function InteractiveImportModalContentInner( data, originalItems, } = useInteractiveImport({ - downloadId, + downloadIds, seriesId, seasonNumber, folder, @@ -484,7 +484,8 @@ function InteractiveImportModalContentInner( }, [setIsConfirmDeleteModalOpen]); const handleImportSelectedPress = useCallback(() => { - const finalImportMode = downloadId || !showImportMode ? 'auto' : importMode; + const finalImportMode = + downloadIds || !showImportMode ? 'auto' : importMode; const existingFiles: Partial[] = []; const files: InteractiveImportCommandOptions[] = []; @@ -502,6 +503,7 @@ function InteractiveImportModalContentInner( if (isSelected) { const { + downloadId, series, seasonNumber, episodes, @@ -605,7 +607,7 @@ function InteractiveImportModalContentInner( onModalClose(); } }, [ - downloadId, + downloadIds, showImportMode, importMode, items, @@ -921,7 +923,7 @@ function InteractiveImportModalContentInner( ) : null} - {!downloadId && showImportMode ? ( + {!downloadIds && showImportMode ? ( state); - const { downloadId, seriesId, seasonNumber, folder } = props; + const { downloadIds, seriesId, seasonNumber, folder } = props; const { data } = useInteractiveImport({ - downloadId, + downloadIds, seriesId, seasonNumber, folder, diff --git a/frontend/src/InteractiveImport/InteractiveImportModal.tsx b/frontend/src/InteractiveImport/InteractiveImportModal.tsx index da2966160..6f887b4e5 100644 --- a/frontend/src/InteractiveImport/InteractiveImportModal.tsx +++ b/frontend/src/InteractiveImport/InteractiveImportModal.tsx @@ -12,7 +12,7 @@ interface InteractiveImportModalProps extends Omit { isOpen: boolean; folder?: string; - downloadId?: string; + downloadIds?: string[]; modalTitle?: string; onModalClose(): void; } @@ -21,7 +21,7 @@ function InteractiveImportModal(props: InteractiveImportModalProps) { const { isOpen, folder, - downloadId, + downloadIds, modalTitle = translate('ManualImport'), onModalClose, ...otherProps @@ -54,11 +54,11 @@ function InteractiveImportModal(props: InteractiveImportModalProps) { closeOnBackgroundClick={false} onModalClose={onModalClose} > - {folderPath || downloadId ? ( + {folderPath || downloadIds ? ( diff --git a/frontend/src/InteractiveImport/useInteractiveImport.ts b/frontend/src/InteractiveImport/useInteractiveImport.ts index 6697e4da2..a35f9fca4 100644 --- a/frontend/src/InteractiveImport/useInteractiveImport.ts +++ b/frontend/src/InteractiveImport/useInteractiveImport.ts @@ -13,7 +13,7 @@ import ReleaseType from './ReleaseType'; const DEFAULT_ITEMS: InteractiveImport[] = []; interface InteractiveImportParams { - downloadId?: string; + downloadIds?: string[]; seriesId?: number; seasonNumber?: number; folder?: string; diff --git a/frontend/src/Utilities/Fetch/getQueryString.ts b/frontend/src/Utilities/Fetch/getQueryString.ts index 1afc6419a..279352771 100644 --- a/frontend/src/Utilities/Fetch/getQueryString.ts +++ b/frontend/src/Utilities/Fetch/getQueryString.ts @@ -7,6 +7,7 @@ export interface QueryParams { | boolean | PropertyFilter[] | number[] + | string[] | undefined; } diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index c1494b88a..19a4243df 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -976,6 +976,7 @@ "ImportMechanismHandlingDisabledHealthCheckMessage": "Enable Completed Download Handling", "ImportScriptPath": "Import Script Path", "ImportScriptPathHelpText": "The path to the script to use for importing", + "ImportSelected": "Import Selected", "ImportSeries": "Import Series", "ImportUsingScript": "Import Using Script", "ImportUsingScriptHelpText": "Copy files for importing using a script (ex. for transcoding)", @@ -1086,6 +1087,7 @@ "InstanceNameHelpText": "Instance name in tab and for Syslog app name", "InteractiveImport": "Interactive Import", "InteractiveImportLoadError": "Unable to load manual import items", + "InteractiveImportMultipleQueueItems": "Multiple Queue Items", "InteractiveImportNoEpisode": "One or more episodes must be chosen for each selected file", "InteractiveImportNoFilesFound": "No video files were found in the selected folder", "InteractiveImportNoImportMode": "An import mode must be selected", diff --git a/src/Sonarr.Api.V5/ManualImport/ManualImportController.cs b/src/Sonarr.Api.V5/ManualImport/ManualImportController.cs index e3c54b429..06556d28a 100644 --- a/src/Sonarr.Api.V5/ManualImport/ManualImportController.cs +++ b/src/Sonarr.Api.V5/ManualImport/ManualImportController.cs @@ -20,14 +20,34 @@ public ManualImportController(IManualImportService manualImportService) [HttpGet] [Produces("application/json")] - public List GetMediaFiles(string? folder, string? downloadId, int? seriesId, int? seasonNumber, bool filterExistingFiles = true) + public List GetMediaFiles(string? folder, [FromQuery] string[]? downloadIds, int? seriesId, int? seasonNumber, bool filterExistingFiles = true) { - if (seriesId.HasValue && downloadId.IsNullOrWhiteSpace()) + if (seriesId.HasValue && downloadIds == null) { - return _manualImportService.GetMediaFiles(seriesId.Value, seasonNumber).ToResource().Select(AddQualityWeight).ToList(); + return _manualImportService.GetMediaFiles(seriesId.Value, seasonNumber) + .ToResource() + .Select(AddQualityWeight) + .ToList(); } - return _manualImportService.GetMediaFiles(folder, downloadId, seriesId, filterExistingFiles).ToResource().Select(AddQualityWeight).ToList(); + if (downloadIds != null && downloadIds.Any()) + { + var files = new List(); + + foreach (var downloadId in downloadIds.Distinct()) + { + files.AddRange(_manualImportService.GetMediaFiles(null, downloadId, seriesId, filterExistingFiles)); + } + + return files.ToResource() + .Select(AddQualityWeight) + .ToList(); + } + + return _manualImportService.GetMediaFiles(folder, null, seriesId, filterExistingFiles) + .ToResource() + .Select(AddQualityWeight) + .ToList(); } [HttpPost] diff --git a/src/Sonarr.Api.V5/ManualImport/ManualImportResource.cs b/src/Sonarr.Api.V5/ManualImport/ManualImportResource.cs index ec1483755..222084245 100644 --- a/src/Sonarr.Api.V5/ManualImport/ManualImportResource.cs +++ b/src/Sonarr.Api.V5/ManualImport/ManualImportResource.cs @@ -52,7 +52,7 @@ public static ManualImportResource ToResource(this ManualImportItem model) Size = model.Size, Series = model.Series?.ToResource(), SeasonNumber = model.SeasonNumber, - Episodes = model.Episodes.ToResource(), + Episodes = model.Episodes?.ToResource() ?? [], EpisodeFileId = model.EpisodeFileId, ReleaseGroup = model.ReleaseGroup, Quality = model.Quality,