From 6c4e259a8d55f00528be3eb6bce27d95d476484d Mon Sep 17 00:00:00 2001 From: Stevie Robinson Date: Mon, 29 Apr 2024 23:30:39 +0200 Subject: [PATCH] New: Blocklist Custom Filters --- frontend/src/Activity/Blocklist/Blocklist.js | 32 +++++++- .../Activity/Blocklist/BlocklistConnector.js | 11 ++- .../Blocklist/BlocklistFilterModal.tsx | 54 +++++++++++++ frontend/src/App/State/AppState.ts | 2 + frontend/src/App/State/BlocklistAppState.ts | 8 ++ .../src/Store/Actions/blocklistActions.js | 33 +++++++- frontend/src/typings/Blocklist.ts | 16 ++++ .../Blocklisting/BlocklistService.cs | 6 ++ .../CustomFormatCalculationService.cs | 2 +- src/NzbDrone.Core/Localization/Core/en.json | 23 +++--- .../Blocklist/BlocklistController.cs | 78 ++++++++++++++++++- 11 files changed, 246 insertions(+), 19 deletions(-) create mode 100644 frontend/src/Activity/Blocklist/BlocklistFilterModal.tsx create mode 100644 frontend/src/App/State/BlocklistAppState.ts create mode 100644 frontend/src/typings/Blocklist.ts diff --git a/frontend/src/Activity/Blocklist/Blocklist.js b/frontend/src/Activity/Blocklist/Blocklist.js index 797aa5175..40af88d6e 100644 --- a/frontend/src/Activity/Blocklist/Blocklist.js +++ b/frontend/src/Activity/Blocklist/Blocklist.js @@ -1,7 +1,9 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import BlocklistFilterModal from 'Activity/Blocklist/BlocklistFilterModal'; import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FilterMenu from 'Components/Menu/FilterMenu'; import ConfirmModal from 'Components/Modal/ConfirmModal'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; @@ -114,9 +116,14 @@ class Blocklist extends Component { error, items, columns, + count, + selectedFilterKey, + filters, + customFilters, totalRecords, isRemoving, isClearingBlocklistExecuting, + onFilterSelect, ...otherProps } = this.props; @@ -161,6 +168,15 @@ class Blocklist extends Component { iconName={icons.TABLE} /> + + @@ -180,7 +196,11 @@ class Blocklist extends Component { { isPopulated && !error && !items.length && - {translate('NoHistoryBlocklist')} + { + selectedFilterKey === 'all' ? + translate('NoHistoryBlocklist') : + translate('BlocklistFilterHasNoItems') + } } @@ -251,11 +271,19 @@ Blocklist.propTypes = { error: PropTypes.object, items: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + count: PropTypes.number.isRequired, totalRecords: PropTypes.number, isRemoving: PropTypes.bool.isRequired, isClearingBlocklistExecuting: PropTypes.bool.isRequired, onRemoveSelected: PropTypes.func.isRequired, - onClearBlocklistPress: PropTypes.func.isRequired + onClearBlocklistPress: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired }; +Blocklist.defaultProps = { + count: 0 +}; export default Blocklist; diff --git a/frontend/src/Activity/Blocklist/BlocklistConnector.js b/frontend/src/Activity/Blocklist/BlocklistConnector.js index 454fa13a9..5eb055a06 100644 --- a/frontend/src/Activity/Blocklist/BlocklistConnector.js +++ b/frontend/src/Activity/Blocklist/BlocklistConnector.js @@ -6,6 +6,7 @@ import * as commandNames from 'Commands/commandNames'; import withCurrentPage from 'Components/withCurrentPage'; import * as blocklistActions from 'Store/Actions/blocklistActions'; import { executeCommand } from 'Store/Actions/commandActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; import Blocklist from './Blocklist'; @@ -13,10 +14,12 @@ import Blocklist from './Blocklist'; function createMapStateToProps() { return createSelector( (state) => state.blocklist, + createCustomFiltersSelector('blocklist'), createCommandExecutingSelector(commandNames.CLEAR_BLOCKLIST), - (blocklist, isClearingBlocklistExecuting) => { + (blocklist, customFilters, isClearingBlocklistExecuting) => { return { isClearingBlocklistExecuting, + customFilters, ...blocklist }; } @@ -97,6 +100,10 @@ class BlocklistConnector extends Component { this.props.setBlocklistSort({ sortKey }); }; + onFilterSelect = (selectedFilterKey) => { + this.props.setBlocklistFilter({ selectedFilterKey }); + }; + onClearBlocklistPress = () => { this.props.executeCommand({ name: commandNames.CLEAR_BLOCKLIST }); }; @@ -122,6 +129,7 @@ class BlocklistConnector extends Component { onPageSelect={this.onPageSelect} onRemoveSelected={this.onRemoveSelected} onSortPress={this.onSortPress} + onFilterSelect={this.onFilterSelect} onTableOptionChange={this.onTableOptionChange} onClearBlocklistPress={this.onClearBlocklistPress} {...this.props} @@ -142,6 +150,7 @@ BlocklistConnector.propTypes = { gotoBlocklistPage: PropTypes.func.isRequired, removeBlocklistItems: PropTypes.func.isRequired, setBlocklistSort: PropTypes.func.isRequired, + setBlocklistFilter: PropTypes.func.isRequired, setBlocklistTableOption: PropTypes.func.isRequired, clearBlocklist: PropTypes.func.isRequired, executeCommand: PropTypes.func.isRequired diff --git a/frontend/src/Activity/Blocklist/BlocklistFilterModal.tsx b/frontend/src/Activity/Blocklist/BlocklistFilterModal.tsx new file mode 100644 index 000000000..ea80458f1 --- /dev/null +++ b/frontend/src/Activity/Blocklist/BlocklistFilterModal.tsx @@ -0,0 +1,54 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FilterModal from 'Components/Filter/FilterModal'; +import { setBlocklistFilter } from 'Store/Actions/blocklistActions'; + +function createBlocklistSelector() { + return createSelector( + (state: AppState) => state.blocklist.items, + (blocklistItems) => { + return blocklistItems; + } + ); +} + +function createFilterBuilderPropsSelector() { + return createSelector( + (state: AppState) => state.blocklist.filterBuilderProps, + (filterBuilderProps) => { + return filterBuilderProps; + } + ); +} + +interface BlocklistFilterModalProps { + isOpen: boolean; +} + +export default function BlocklistFilterModal(props: BlocklistFilterModalProps) { + const sectionItems = useSelector(createBlocklistSelector()); + const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); + const customFilterType = 'blocklist'; + + const dispatch = useDispatch(); + + const dispatchSetFilter = useCallback( + (payload: unknown) => { + dispatch(setBlocklistFilter(payload)); + }, + [dispatch] + ); + + return ( + + ); +} diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index 72aa0d7f0..222a8e26f 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -1,4 +1,5 @@ import InteractiveImportAppState from 'App/State/InteractiveImportAppState'; +import BlocklistAppState from './BlocklistAppState'; import CalendarAppState from './CalendarAppState'; import CommandAppState from './CommandAppState'; import EpisodeFilesAppState from './EpisodeFilesAppState'; @@ -54,6 +55,7 @@ export interface AppSectionState { interface AppState { app: AppSectionState; + blocklist: BlocklistAppState; calendar: CalendarAppState; commands: CommandAppState; episodeFiles: EpisodeFilesAppState; diff --git a/frontend/src/App/State/BlocklistAppState.ts b/frontend/src/App/State/BlocklistAppState.ts new file mode 100644 index 000000000..e838ad625 --- /dev/null +++ b/frontend/src/App/State/BlocklistAppState.ts @@ -0,0 +1,8 @@ +import Blocklist from 'typings/Blocklist'; +import AppSectionState, { AppSectionFilterState } from './AppSectionState'; + +interface BlocklistAppState + extends AppSectionState, + AppSectionFilterState {} + +export default BlocklistAppState; diff --git a/frontend/src/Store/Actions/blocklistActions.js b/frontend/src/Store/Actions/blocklistActions.js index f341b72aa..f422bc095 100644 --- a/frontend/src/Store/Actions/blocklistActions.js +++ b/frontend/src/Store/Actions/blocklistActions.js @@ -1,6 +1,6 @@ import { createAction } from 'redux-actions'; import { batchActions } from 'redux-batched-actions'; -import { sortDirections } from 'Helpers/Props'; +import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createAjaxRequest from 'Utilities/createAjaxRequest'; import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; @@ -77,6 +77,31 @@ export const defaultState = { isVisible: true, isModifiable: false } + ], + + selectedFilterKey: 'all', + + filters: [ + { + key: 'all', + label: () => translate('All'), + filters: [] + } + ], + + filterBuilderProps: [ + { + name: 'seriesIds', + label: () => translate('Series'), + type: filterBuilderTypes.EQUAL, + valueType: filterBuilderValueTypes.SERIES + }, + { + name: 'protocol', + label: () => translate('Protocol'), + type: filterBuilderTypes.EQUAL, + valueType: filterBuilderValueTypes.PROTOCOL + } ] }; @@ -84,6 +109,7 @@ export const persistState = [ 'blocklist.pageSize', 'blocklist.sortKey', 'blocklist.sortDirection', + 'blocklist.selectedFilterKey', 'blocklist.columns' ]; @@ -97,6 +123,7 @@ export const GOTO_NEXT_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistNextPage'; export const GOTO_LAST_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistLastPage'; export const GOTO_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistPage'; export const SET_BLOCKLIST_SORT = 'blocklist/setBlocklistSort'; +export const SET_BLOCKLIST_FILTER = 'blocklist/setBlocklistFilter'; export const SET_BLOCKLIST_TABLE_OPTION = 'blocklist/setBlocklistTableOption'; export const REMOVE_BLOCKLIST_ITEM = 'blocklist/removeBlocklistItem'; export const REMOVE_BLOCKLIST_ITEMS = 'blocklist/removeBlocklistItems'; @@ -112,6 +139,7 @@ export const gotoBlocklistNextPage = createThunk(GOTO_NEXT_BLOCKLIST_PAGE); export const gotoBlocklistLastPage = createThunk(GOTO_LAST_BLOCKLIST_PAGE); export const gotoBlocklistPage = createThunk(GOTO_BLOCKLIST_PAGE); export const setBlocklistSort = createThunk(SET_BLOCKLIST_SORT); +export const setBlocklistFilter = createThunk(SET_BLOCKLIST_FILTER); export const setBlocklistTableOption = createAction(SET_BLOCKLIST_TABLE_OPTION); export const removeBlocklistItem = createThunk(REMOVE_BLOCKLIST_ITEM); export const removeBlocklistItems = createThunk(REMOVE_BLOCKLIST_ITEMS); @@ -132,7 +160,8 @@ export const actionHandlers = handleThunks({ [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_BLOCKLIST_PAGE, [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_BLOCKLIST_PAGE, [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_BLOCKLIST_PAGE, - [serverSideCollectionHandlers.SORT]: SET_BLOCKLIST_SORT + [serverSideCollectionHandlers.SORT]: SET_BLOCKLIST_SORT, + [serverSideCollectionHandlers.FILTER]: SET_BLOCKLIST_FILTER }), [REMOVE_BLOCKLIST_ITEM]: createRemoveItemHandler(section, '/blocklist'), diff --git a/frontend/src/typings/Blocklist.ts b/frontend/src/typings/Blocklist.ts new file mode 100644 index 000000000..4cc675cc5 --- /dev/null +++ b/frontend/src/typings/Blocklist.ts @@ -0,0 +1,16 @@ +import ModelBase from 'App/ModelBase'; +import Language from 'Language/Language'; +import { QualityModel } from 'Quality/Quality'; +import CustomFormat from 'typings/CustomFormat'; + +interface Blocklist extends ModelBase { + languages: Language[]; + quality: QualityModel; + customFormats: CustomFormat[]; + title: string; + date?: string; + protocol: string; + seriesId?: number; +} + +export default Blocklist; diff --git a/src/NzbDrone.Core/Blocklisting/BlocklistService.cs b/src/NzbDrone.Core/Blocklisting/BlocklistService.cs index 0ec53522c..16c02be35 100644 --- a/src/NzbDrone.Core/Blocklisting/BlocklistService.cs +++ b/src/NzbDrone.Core/Blocklisting/BlocklistService.cs @@ -14,6 +14,7 @@ namespace NzbDrone.Core.Blocklisting { public interface IBlocklistService { + List GetBlocklist(); bool Blocklisted(int seriesId, ReleaseInfo release); bool BlocklistedTorrentHash(int seriesId, string hash); PagingSpec Paged(PagingSpec pagingSpec); @@ -34,6 +35,11 @@ namespace NzbDrone.Core.Blocklisting _blocklistRepository = blocklistRepository; } + public List GetBlocklist() + { + return _blocklistRepository.All().ToList(); + } + public bool Blocklisted(int seriesId, ReleaseInfo release) { if (release.DownloadProtocol == DownloadProtocol.Torrent) diff --git a/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs b/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs index 1f0cb296b..b01579c65 100644 --- a/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs +++ b/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs @@ -64,7 +64,7 @@ namespace NzbDrone.Core.CustomFormats var episodeInfo = new ParsedEpisodeInfo { - SeriesTitle = series.Title, + SeriesTitle = series?.Title ?? parsed.SeriesTitle, ReleaseTitle = parsed?.ReleaseTitle ?? blocklist.SourceTitle, Quality = blocklist.Quality, Languages = blocklist.Languages, diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 78b8557e7..333906070 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -158,6 +158,7 @@ "BlocklistAndSearch": "Blocklist and Search", "BlocklistAndSearchHint": "Start a search for a replacement after blocklisting", "BlocklistAndSearchMultipleHint": "Start searches for replacements after blocklisting", + "BlocklistFilterHasNoItems": "Selected blocklist filter contains no items", "BlocklistLoadError": "Unable to load blocklist", "BlocklistMultipleOnlyHint": "Blocklist without searching for replacements", "BlocklistOnly": "Blocklist Only", @@ -248,8 +249,8 @@ "ConnectionLost": "Connection Lost", "ConnectionLostReconnect": "{appName} will try to connect automatically, or you can click reload below.", "ConnectionLostToBackend": "{appName} has lost its connection to the backend and will need to be reloaded to restore functionality.", - "Connections": "Connections", "ConnectionSettingsUrlBaseHelpText": "Adds a prefix to the {connectionName} url, such as {url}", + "Connections": "Connections", "Continuing": "Continuing", "ContinuingOnly": "Continuing Only", "ContinuingSeriesDescription": "More episodes/another season is expected", @@ -280,8 +281,8 @@ "CustomFormats": "Custom Formats", "CustomFormatsLoadError": "Unable to load Custom Formats", "CustomFormatsSettings": "Custom Formats Settings", - "CustomFormatsSettingsTriggerInfo": "A Custom Format will be applied to a release or file when it matches at least one of each of the different condition types chosen.", "CustomFormatsSettingsSummary": "Custom Formats and Settings", + "CustomFormatsSettingsTriggerInfo": "A Custom Format will be applied to a release or file when it matches at least one of each of the different condition types chosen.", "CustomFormatsSpecificationFlag": "Flag", "CustomFormatsSpecificationLanguage": "Language", "CustomFormatsSpecificationMaximumSize": "Maximum Size", @@ -410,16 +411,16 @@ "DownloadClientAriaSettingsDirectoryHelpText": "Optional location to put downloads in, leave blank to use the default Aria2 location", "DownloadClientCheckNoneAvailableHealthCheckMessage": "No download client is available", "DownloadClientCheckUnableToCommunicateWithHealthCheckMessage": "Unable to communicate with {downloadClientName}. {errorMessage}", + "DownloadClientDelugeSettingsDirectory": "Download Directory", + "DownloadClientDelugeSettingsDirectoryCompleted": "Move When Completed Directory", + "DownloadClientDelugeSettingsDirectoryCompletedHelpText": "Optional location to move completed downloads to, leave blank to use the default Deluge location", + "DownloadClientDelugeSettingsDirectoryHelpText": "Optional location to put downloads in, leave blank to use the default Deluge location", "DownloadClientDelugeSettingsUrlBaseHelpText": "Adds a prefix to the deluge json url, see {url}", "DownloadClientDelugeTorrentStateError": "Deluge is reporting an error", "DownloadClientDelugeValidationLabelPluginFailure": "Configuration of label failed", "DownloadClientDelugeValidationLabelPluginFailureDetail": "{appName} was unable to add the label to {clientName}.", "DownloadClientDelugeValidationLabelPluginInactive": "Label plugin not activated", "DownloadClientDelugeValidationLabelPluginInactiveDetail": "You must have the Label plugin enabled in {clientName} to use categories.", - "DownloadClientDelugeSettingsDirectory": "Download Directory", - "DownloadClientDelugeSettingsDirectoryHelpText": "Optional location to put downloads in, leave blank to use the default Deluge location", - "DownloadClientDelugeSettingsDirectoryCompleted": "Move When Completed Directory", - "DownloadClientDelugeSettingsDirectoryCompletedHelpText": "Optional location to move completed downloads to, leave blank to use the default Deluge location", "DownloadClientDownloadStationProviderMessage": "{appName} is unable to connect to Download Station if 2-Factor Authentication is enabled on your DSM account", "DownloadClientDownloadStationSettingsDirectoryHelpText": "Optional shared folder to put downloads into, leave blank to use the default Download Station location", "DownloadClientDownloadStationValidationApiVersion": "Download Station API version not supported, should be at least {requiredVersion}. It supports from {minVersion} to {maxVersion}", @@ -866,12 +867,12 @@ "ImportListsSimklSettingsUserListTypePlanToWatch": "Plan To Watch", "ImportListsSimklSettingsUserListTypeWatching": "Watching", "ImportListsSonarrSettingsApiKeyHelpText": "API Key of the {appName} instance to import from", - "ImportListsSonarrSettingsSyncSeasonMonitoring": "Sync Season Monitoring", - "ImportListsSonarrSettingsSyncSeasonMonitoringHelpText": "Sync season monitoring from {appName} instance, if enabled 'Monitor' will be ignored", "ImportListsSonarrSettingsFullUrl": "Full URL", "ImportListsSonarrSettingsFullUrlHelpText": "URL, including port, of the {appName} instance to import from", "ImportListsSonarrSettingsQualityProfilesHelpText": "Quality Profiles from the source instance to import from", "ImportListsSonarrSettingsRootFoldersHelpText": "Root Folders from the source instance to import from", + "ImportListsSonarrSettingsSyncSeasonMonitoring": "Sync Season Monitoring", + "ImportListsSonarrSettingsSyncSeasonMonitoringHelpText": "Sync season monitoring from {appName} instance, if enabled 'Monitor' will be ignored", "ImportListsSonarrSettingsTagsHelpText": "Tags from the source instance to import from", "ImportListsSonarrValidationInvalidUrl": "{appName} URL is invalid, are you missing a URL base?", "ImportListsTraktSettingsAdditionalParameters": "Additional Parameters", @@ -975,11 +976,11 @@ "IndexerSettingsCookieHelpText": "If your site requires a login cookie to access the rss, you'll have to retrieve it via a browser.", "IndexerSettingsMinimumSeeders": "Minimum Seeders", "IndexerSettingsMinimumSeedersHelpText": "Minimum number of seeders required.", + "IndexerSettingsMultiLanguageRelease": "Multi Languages", + "IndexerSettingsMultiLanguageReleaseHelpText": "What languages are normally in a multi release on this indexer?", "IndexerSettingsPasskey": "Passkey", "IndexerSettingsRejectBlocklistedTorrentHashes": "Reject Blocklisted Torrent Hashes While Grabbing", "IndexerSettingsRejectBlocklistedTorrentHashesHelpText": "If a torrent is blocked by hash it may not properly be rejected during RSS/Search for some indexers, enabling this will allow it to be rejected after the torrent is grabbed, but before it is sent to the client.", - "IndexerSettingsMultiLanguageRelease": "Multi Languages", - "IndexerSettingsMultiLanguageReleaseHelpText": "What languages are normally in a multi release on this indexer?", "IndexerSettingsRssUrl": "RSS URL", "IndexerSettingsRssUrlHelpText": "Enter to URL to an {indexer} compatible RSS feed", "IndexerSettingsSeasonPackSeedTime": "Season-Pack Seed Time", @@ -1789,7 +1790,6 @@ "SelectSeries": "Select Series", "SendAnonymousUsageData": "Send Anonymous Usage Data", "Series": "Series", - "SeriesFootNote": "Optionally control truncation to a maximum number of bytes including ellipsis (`...`). Truncating from the end (e.g. `{Series Title:30}`) or the beginning (e.g. `{Series Title:-30}`) are both supported.", "SeriesAndEpisodeInformationIsProvidedByTheTVDB": "Series and episode information is provided by TheTVDB.com. [Please consider supporting them]({url}) .", "SeriesCannotBeFound": "Sorry, that series cannot be found.", "SeriesDetailsCountEpisodeFiles": "{episodeFileCount} episode files", @@ -1803,6 +1803,7 @@ "SeriesFolderFormat": "Series Folder Format", "SeriesFolderFormatHelpText": "Used when adding a new series or moving series via the series editor", "SeriesFolderImportedTooltip": "Episode imported from series folder", + "SeriesFootNote": "Optionally control truncation to a maximum number of bytes including ellipsis (`...`). Truncating from the end (e.g. `{Series Title:30}`) or the beginning (e.g. `{Series Title:-30}`) are both supported.", "SeriesID": "Series ID", "SeriesIndexFooterContinuing": "Continuing (All episodes downloaded)", "SeriesIndexFooterDownloading": "Downloading (One or more episodes)", diff --git a/src/Sonarr.Api.V3/Blocklist/BlocklistController.cs b/src/Sonarr.Api.V3/Blocklist/BlocklistController.cs index 2091d3cfe..898069911 100644 --- a/src/Sonarr.Api.V3/Blocklist/BlocklistController.cs +++ b/src/Sonarr.Api.V3/Blocklist/BlocklistController.cs @@ -1,7 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; using Microsoft.AspNetCore.Mvc; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Blocklisting; using NzbDrone.Core.CustomFormats; using NzbDrone.Core.Datastore; +using NzbDrone.Core.Indexers; using Sonarr.Http; using Sonarr.Http.Extensions; using Sonarr.Http.REST.Attributes; @@ -23,12 +28,81 @@ namespace Sonarr.Api.V3.Blocklist [HttpGet] [Produces("application/json")] - public PagingResource GetBlocklist([FromQuery] PagingRequestResource paging) + public PagingResource GetBlocklist([FromQuery] PagingRequestResource paging, [FromQuery] int[] seriesIds = null, DownloadProtocol? protocol = null) { var pagingResource = new PagingResource(paging); var pagingSpec = pagingResource.MapToPagingSpec("date", SortDirection.Descending); - return pagingSpec.ApplyToPage(_blocklistService.Paged, model => BlocklistResourceMapper.MapToResource(model, _formatCalculator)); + return pagingSpec.ApplyToPage(spec => GetBlocklist(spec, seriesIds?.ToHashSet(), protocol), model => BlocklistResourceMapper.MapToResource(model, _formatCalculator)); + } + + private PagingSpec GetBlocklist(PagingSpec pagingSpec, HashSet seriesIds, DownloadProtocol? protocol) + { + var ascending = pagingSpec.SortDirection == SortDirection.Ascending; + var orderByFunc = GetOrderByFunc(pagingSpec); + + var blocklist = _blocklistService.GetBlocklist(); + + var hasSeriesIdFilter = seriesIds.Any(); + var fullBlocklist = blocklist.Where(b => + { + var include = true; + + if (hasSeriesIdFilter) + { + include &= seriesIds.Contains(b.SeriesId); + } + + if (include && protocol.HasValue) + { + include &= b.Protocol == protocol.Value; + } + + return include; + }).ToList(); + + IOrderedEnumerable ordered; + + if (pagingSpec.SortKey == "date") + { + ordered = ascending + ? fullBlocklist.OrderBy(b => b.Date) + : fullBlocklist.OrderByDescending(b => b.Date); + } + else if (pagingSpec.SortKey == "indexer") + { + ordered = ascending + ? fullBlocklist.OrderBy(b => b.Indexer, StringComparer.InvariantCultureIgnoreCase) + : fullBlocklist.OrderByDescending(b => b.Indexer, StringComparer.InvariantCultureIgnoreCase); + } + else + { + ordered = ascending ? fullBlocklist.OrderBy(orderByFunc) : fullBlocklist.OrderByDescending(orderByFunc); + } + + pagingSpec.Records = ordered.Skip((pagingSpec.Page - 1) * pagingSpec.PageSize).Take(pagingSpec.PageSize).ToList(); + pagingSpec.TotalRecords = fullBlocklist.Count; + + if (pagingSpec.Records.Empty() && pagingSpec.Page > 1) + { + pagingSpec.Page = (int)Math.Max(Math.Ceiling((decimal)(pagingSpec.TotalRecords / pagingSpec.PageSize)), 1); + pagingSpec.Records = ordered.Skip((pagingSpec.Page - 1) * pagingSpec.PageSize).Take(pagingSpec.PageSize).ToList(); + } + + return pagingSpec; + } + + private Func GetOrderByFunc(PagingSpec pagingSpec) + { + switch (pagingSpec.SortKey) + { + case "series.sortTitle": + return q => q.Series?.SortTitle ?? q.Series?.Title; + case "sourceTitle": + return q => q.SourceTitle; + default: + return q => q.Date; + } } [RestDeleteById]