New: Blocklist Custom Filters

This commit is contained in:
Stevie Robinson 2024-04-29 23:30:39 +02:00
parent b81c3ee4a8
commit 6c4e259a8d
11 changed files with 246 additions and 19 deletions

View File

@ -1,7 +1,9 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import BlocklistFilterModal from 'Activity/Blocklist/BlocklistFilterModal';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import ConfirmModal from 'Components/Modal/ConfirmModal'; import ConfirmModal from 'Components/Modal/ConfirmModal';
import PageContent from 'Components/Page/PageContent'; import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody'; import PageContentBody from 'Components/Page/PageContentBody';
@ -114,9 +116,14 @@ class Blocklist extends Component {
error, error,
items, items,
columns, columns,
count,
selectedFilterKey,
filters,
customFilters,
totalRecords, totalRecords,
isRemoving, isRemoving,
isClearingBlocklistExecuting, isClearingBlocklistExecuting,
onFilterSelect,
...otherProps ...otherProps
} = this.props; } = this.props;
@ -161,6 +168,15 @@ class Blocklist extends Component {
iconName={icons.TABLE} iconName={icons.TABLE}
/> />
</TableOptionsModalWrapper> </TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={BlocklistFilterModal}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection> </PageToolbarSection>
</PageToolbar> </PageToolbar>
@ -180,7 +196,11 @@ class Blocklist extends Component {
{ {
isPopulated && !error && !items.length && isPopulated && !error && !items.length &&
<Alert kind={kinds.INFO}> <Alert kind={kinds.INFO}>
{translate('NoHistoryBlocklist')} {
selectedFilterKey === 'all' ?
translate('NoHistoryBlocklist') :
translate('BlocklistFilterHasNoItems')
}
</Alert> </Alert>
} }
@ -251,11 +271,19 @@ Blocklist.propTypes = {
error: PropTypes.object, error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: 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, totalRecords: PropTypes.number,
isRemoving: PropTypes.bool.isRequired, isRemoving: PropTypes.bool.isRequired,
isClearingBlocklistExecuting: PropTypes.bool.isRequired, isClearingBlocklistExecuting: PropTypes.bool.isRequired,
onRemoveSelected: PropTypes.func.isRequired, onRemoveSelected: PropTypes.func.isRequired,
onClearBlocklistPress: PropTypes.func.isRequired onClearBlocklistPress: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired
}; };
Blocklist.defaultProps = {
count: 0
};
export default Blocklist; export default Blocklist;

View File

@ -6,6 +6,7 @@ import * as commandNames from 'Commands/commandNames';
import withCurrentPage from 'Components/withCurrentPage'; import withCurrentPage from 'Components/withCurrentPage';
import * as blocklistActions from 'Store/Actions/blocklistActions'; import * as blocklistActions from 'Store/Actions/blocklistActions';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import Blocklist from './Blocklist'; import Blocklist from './Blocklist';
@ -13,10 +14,12 @@ import Blocklist from './Blocklist';
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
(state) => state.blocklist, (state) => state.blocklist,
createCustomFiltersSelector('blocklist'),
createCommandExecutingSelector(commandNames.CLEAR_BLOCKLIST), createCommandExecutingSelector(commandNames.CLEAR_BLOCKLIST),
(blocklist, isClearingBlocklistExecuting) => { (blocklist, customFilters, isClearingBlocklistExecuting) => {
return { return {
isClearingBlocklistExecuting, isClearingBlocklistExecuting,
customFilters,
...blocklist ...blocklist
}; };
} }
@ -97,6 +100,10 @@ class BlocklistConnector extends Component {
this.props.setBlocklistSort({ sortKey }); this.props.setBlocklistSort({ sortKey });
}; };
onFilterSelect = (selectedFilterKey) => {
this.props.setBlocklistFilter({ selectedFilterKey });
};
onClearBlocklistPress = () => { onClearBlocklistPress = () => {
this.props.executeCommand({ name: commandNames.CLEAR_BLOCKLIST }); this.props.executeCommand({ name: commandNames.CLEAR_BLOCKLIST });
}; };
@ -122,6 +129,7 @@ class BlocklistConnector extends Component {
onPageSelect={this.onPageSelect} onPageSelect={this.onPageSelect}
onRemoveSelected={this.onRemoveSelected} onRemoveSelected={this.onRemoveSelected}
onSortPress={this.onSortPress} onSortPress={this.onSortPress}
onFilterSelect={this.onFilterSelect}
onTableOptionChange={this.onTableOptionChange} onTableOptionChange={this.onTableOptionChange}
onClearBlocklistPress={this.onClearBlocklistPress} onClearBlocklistPress={this.onClearBlocklistPress}
{...this.props} {...this.props}
@ -142,6 +150,7 @@ BlocklistConnector.propTypes = {
gotoBlocklistPage: PropTypes.func.isRequired, gotoBlocklistPage: PropTypes.func.isRequired,
removeBlocklistItems: PropTypes.func.isRequired, removeBlocklistItems: PropTypes.func.isRequired,
setBlocklistSort: PropTypes.func.isRequired, setBlocklistSort: PropTypes.func.isRequired,
setBlocklistFilter: PropTypes.func.isRequired,
setBlocklistTableOption: PropTypes.func.isRequired, setBlocklistTableOption: PropTypes.func.isRequired,
clearBlocklist: PropTypes.func.isRequired, clearBlocklist: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired executeCommand: PropTypes.func.isRequired

View File

@ -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 (
<FilterModal
// TODO: Don't spread all the props
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
customFilterType={customFilterType}
dispatchSetFilter={dispatchSetFilter}
/>
);
}

View File

@ -1,4 +1,5 @@
import InteractiveImportAppState from 'App/State/InteractiveImportAppState'; import InteractiveImportAppState from 'App/State/InteractiveImportAppState';
import BlocklistAppState from './BlocklistAppState';
import CalendarAppState from './CalendarAppState'; import CalendarAppState from './CalendarAppState';
import CommandAppState from './CommandAppState'; import CommandAppState from './CommandAppState';
import EpisodeFilesAppState from './EpisodeFilesAppState'; import EpisodeFilesAppState from './EpisodeFilesAppState';
@ -54,6 +55,7 @@ export interface AppSectionState {
interface AppState { interface AppState {
app: AppSectionState; app: AppSectionState;
blocklist: BlocklistAppState;
calendar: CalendarAppState; calendar: CalendarAppState;
commands: CommandAppState; commands: CommandAppState;
episodeFiles: EpisodeFilesAppState; episodeFiles: EpisodeFilesAppState;

View File

@ -0,0 +1,8 @@
import Blocklist from 'typings/Blocklist';
import AppSectionState, { AppSectionFilterState } from './AppSectionState';
interface BlocklistAppState
extends AppSectionState<Blocklist>,
AppSectionFilterState<Blocklist> {}
export default BlocklistAppState;

View File

@ -1,6 +1,6 @@
import { createAction } from 'redux-actions'; import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-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 { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest'; import createAjaxRequest from 'Utilities/createAjaxRequest';
import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
@ -77,6 +77,31 @@ export const defaultState = {
isVisible: true, isVisible: true,
isModifiable: false 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.pageSize',
'blocklist.sortKey', 'blocklist.sortKey',
'blocklist.sortDirection', 'blocklist.sortDirection',
'blocklist.selectedFilterKey',
'blocklist.columns' '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_LAST_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistLastPage';
export const GOTO_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistPage'; export const GOTO_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistPage';
export const SET_BLOCKLIST_SORT = 'blocklist/setBlocklistSort'; export const SET_BLOCKLIST_SORT = 'blocklist/setBlocklistSort';
export const SET_BLOCKLIST_FILTER = 'blocklist/setBlocklistFilter';
export const SET_BLOCKLIST_TABLE_OPTION = 'blocklist/setBlocklistTableOption'; export const SET_BLOCKLIST_TABLE_OPTION = 'blocklist/setBlocklistTableOption';
export const REMOVE_BLOCKLIST_ITEM = 'blocklist/removeBlocklistItem'; export const REMOVE_BLOCKLIST_ITEM = 'blocklist/removeBlocklistItem';
export const REMOVE_BLOCKLIST_ITEMS = 'blocklist/removeBlocklistItems'; 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 gotoBlocklistLastPage = createThunk(GOTO_LAST_BLOCKLIST_PAGE);
export const gotoBlocklistPage = createThunk(GOTO_BLOCKLIST_PAGE); export const gotoBlocklistPage = createThunk(GOTO_BLOCKLIST_PAGE);
export const setBlocklistSort = createThunk(SET_BLOCKLIST_SORT); export const setBlocklistSort = createThunk(SET_BLOCKLIST_SORT);
export const setBlocklistFilter = createThunk(SET_BLOCKLIST_FILTER);
export const setBlocklistTableOption = createAction(SET_BLOCKLIST_TABLE_OPTION); export const setBlocklistTableOption = createAction(SET_BLOCKLIST_TABLE_OPTION);
export const removeBlocklistItem = createThunk(REMOVE_BLOCKLIST_ITEM); export const removeBlocklistItem = createThunk(REMOVE_BLOCKLIST_ITEM);
export const removeBlocklistItems = createThunk(REMOVE_BLOCKLIST_ITEMS); export const removeBlocklistItems = createThunk(REMOVE_BLOCKLIST_ITEMS);
@ -132,7 +160,8 @@ export const actionHandlers = handleThunks({
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_BLOCKLIST_PAGE, [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_BLOCKLIST_PAGE,
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_BLOCKLIST_PAGE, [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_BLOCKLIST_PAGE,
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_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'), [REMOVE_BLOCKLIST_ITEM]: createRemoveItemHandler(section, '/blocklist'),

View File

@ -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;

View File

@ -14,6 +14,7 @@ namespace NzbDrone.Core.Blocklisting
{ {
public interface IBlocklistService public interface IBlocklistService
{ {
List<Blocklist> GetBlocklist();
bool Blocklisted(int seriesId, ReleaseInfo release); bool Blocklisted(int seriesId, ReleaseInfo release);
bool BlocklistedTorrentHash(int seriesId, string hash); bool BlocklistedTorrentHash(int seriesId, string hash);
PagingSpec<Blocklist> Paged(PagingSpec<Blocklist> pagingSpec); PagingSpec<Blocklist> Paged(PagingSpec<Blocklist> pagingSpec);
@ -34,6 +35,11 @@ namespace NzbDrone.Core.Blocklisting
_blocklistRepository = blocklistRepository; _blocklistRepository = blocklistRepository;
} }
public List<Blocklist> GetBlocklist()
{
return _blocklistRepository.All().ToList();
}
public bool Blocklisted(int seriesId, ReleaseInfo release) public bool Blocklisted(int seriesId, ReleaseInfo release)
{ {
if (release.DownloadProtocol == DownloadProtocol.Torrent) if (release.DownloadProtocol == DownloadProtocol.Torrent)

View File

@ -64,7 +64,7 @@ namespace NzbDrone.Core.CustomFormats
var episodeInfo = new ParsedEpisodeInfo var episodeInfo = new ParsedEpisodeInfo
{ {
SeriesTitle = series.Title, SeriesTitle = series?.Title ?? parsed.SeriesTitle,
ReleaseTitle = parsed?.ReleaseTitle ?? blocklist.SourceTitle, ReleaseTitle = parsed?.ReleaseTitle ?? blocklist.SourceTitle,
Quality = blocklist.Quality, Quality = blocklist.Quality,
Languages = blocklist.Languages, Languages = blocklist.Languages,

View File

@ -158,6 +158,7 @@
"BlocklistAndSearch": "Blocklist and Search", "BlocklistAndSearch": "Blocklist and Search",
"BlocklistAndSearchHint": "Start a search for a replacement after blocklisting", "BlocklistAndSearchHint": "Start a search for a replacement after blocklisting",
"BlocklistAndSearchMultipleHint": "Start searches for replacements after blocklisting", "BlocklistAndSearchMultipleHint": "Start searches for replacements after blocklisting",
"BlocklistFilterHasNoItems": "Selected blocklist filter contains no items",
"BlocklistLoadError": "Unable to load blocklist", "BlocklistLoadError": "Unable to load blocklist",
"BlocklistMultipleOnlyHint": "Blocklist without searching for replacements", "BlocklistMultipleOnlyHint": "Blocklist without searching for replacements",
"BlocklistOnly": "Blocklist Only", "BlocklistOnly": "Blocklist Only",
@ -248,8 +249,8 @@
"ConnectionLost": "Connection Lost", "ConnectionLost": "Connection Lost",
"ConnectionLostReconnect": "{appName} will try to connect automatically, or you can click reload below.", "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.", "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}", "ConnectionSettingsUrlBaseHelpText": "Adds a prefix to the {connectionName} url, such as {url}",
"Connections": "Connections",
"Continuing": "Continuing", "Continuing": "Continuing",
"ContinuingOnly": "Continuing Only", "ContinuingOnly": "Continuing Only",
"ContinuingSeriesDescription": "More episodes/another season is expected", "ContinuingSeriesDescription": "More episodes/another season is expected",
@ -280,8 +281,8 @@
"CustomFormats": "Custom Formats", "CustomFormats": "Custom Formats",
"CustomFormatsLoadError": "Unable to load Custom Formats", "CustomFormatsLoadError": "Unable to load Custom Formats",
"CustomFormatsSettings": "Custom Formats Settings", "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", "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", "CustomFormatsSpecificationFlag": "Flag",
"CustomFormatsSpecificationLanguage": "Language", "CustomFormatsSpecificationLanguage": "Language",
"CustomFormatsSpecificationMaximumSize": "Maximum Size", "CustomFormatsSpecificationMaximumSize": "Maximum Size",
@ -410,16 +411,16 @@
"DownloadClientAriaSettingsDirectoryHelpText": "Optional location to put downloads in, leave blank to use the default Aria2 location", "DownloadClientAriaSettingsDirectoryHelpText": "Optional location to put downloads in, leave blank to use the default Aria2 location",
"DownloadClientCheckNoneAvailableHealthCheckMessage": "No download client is available", "DownloadClientCheckNoneAvailableHealthCheckMessage": "No download client is available",
"DownloadClientCheckUnableToCommunicateWithHealthCheckMessage": "Unable to communicate with {downloadClientName}. {errorMessage}", "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}", "DownloadClientDelugeSettingsUrlBaseHelpText": "Adds a prefix to the deluge json url, see {url}",
"DownloadClientDelugeTorrentStateError": "Deluge is reporting an error", "DownloadClientDelugeTorrentStateError": "Deluge is reporting an error",
"DownloadClientDelugeValidationLabelPluginFailure": "Configuration of label failed", "DownloadClientDelugeValidationLabelPluginFailure": "Configuration of label failed",
"DownloadClientDelugeValidationLabelPluginFailureDetail": "{appName} was unable to add the label to {clientName}.", "DownloadClientDelugeValidationLabelPluginFailureDetail": "{appName} was unable to add the label to {clientName}.",
"DownloadClientDelugeValidationLabelPluginInactive": "Label plugin not activated", "DownloadClientDelugeValidationLabelPluginInactive": "Label plugin not activated",
"DownloadClientDelugeValidationLabelPluginInactiveDetail": "You must have the Label plugin enabled in {clientName} to use categories.", "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", "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", "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}", "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", "ImportListsSimklSettingsUserListTypePlanToWatch": "Plan To Watch",
"ImportListsSimklSettingsUserListTypeWatching": "Watching", "ImportListsSimklSettingsUserListTypeWatching": "Watching",
"ImportListsSonarrSettingsApiKeyHelpText": "API Key of the {appName} instance to import from", "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", "ImportListsSonarrSettingsFullUrl": "Full URL",
"ImportListsSonarrSettingsFullUrlHelpText": "URL, including port, of the {appName} instance to import from", "ImportListsSonarrSettingsFullUrlHelpText": "URL, including port, of the {appName} instance to import from",
"ImportListsSonarrSettingsQualityProfilesHelpText": "Quality Profiles from the source instance to import from", "ImportListsSonarrSettingsQualityProfilesHelpText": "Quality Profiles from the source instance to import from",
"ImportListsSonarrSettingsRootFoldersHelpText": "Root Folders 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", "ImportListsSonarrSettingsTagsHelpText": "Tags from the source instance to import from",
"ImportListsSonarrValidationInvalidUrl": "{appName} URL is invalid, are you missing a URL base?", "ImportListsSonarrValidationInvalidUrl": "{appName} URL is invalid, are you missing a URL base?",
"ImportListsTraktSettingsAdditionalParameters": "Additional Parameters", "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.", "IndexerSettingsCookieHelpText": "If your site requires a login cookie to access the rss, you'll have to retrieve it via a browser.",
"IndexerSettingsMinimumSeeders": "Minimum Seeders", "IndexerSettingsMinimumSeeders": "Minimum Seeders",
"IndexerSettingsMinimumSeedersHelpText": "Minimum number of seeders required.", "IndexerSettingsMinimumSeedersHelpText": "Minimum number of seeders required.",
"IndexerSettingsMultiLanguageRelease": "Multi Languages",
"IndexerSettingsMultiLanguageReleaseHelpText": "What languages are normally in a multi release on this indexer?",
"IndexerSettingsPasskey": "Passkey", "IndexerSettingsPasskey": "Passkey",
"IndexerSettingsRejectBlocklistedTorrentHashes": "Reject Blocklisted Torrent Hashes While Grabbing", "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.", "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", "IndexerSettingsRssUrl": "RSS URL",
"IndexerSettingsRssUrlHelpText": "Enter to URL to an {indexer} compatible RSS feed", "IndexerSettingsRssUrlHelpText": "Enter to URL to an {indexer} compatible RSS feed",
"IndexerSettingsSeasonPackSeedTime": "Season-Pack Seed Time", "IndexerSettingsSeasonPackSeedTime": "Season-Pack Seed Time",
@ -1789,7 +1790,6 @@
"SelectSeries": "Select Series", "SelectSeries": "Select Series",
"SendAnonymousUsageData": "Send Anonymous Usage Data", "SendAnonymousUsageData": "Send Anonymous Usage Data",
"Series": "Series", "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}) .", "SeriesAndEpisodeInformationIsProvidedByTheTVDB": "Series and episode information is provided by TheTVDB.com. [Please consider supporting them]({url}) .",
"SeriesCannotBeFound": "Sorry, that series cannot be found.", "SeriesCannotBeFound": "Sorry, that series cannot be found.",
"SeriesDetailsCountEpisodeFiles": "{episodeFileCount} episode files", "SeriesDetailsCountEpisodeFiles": "{episodeFileCount} episode files",
@ -1803,6 +1803,7 @@
"SeriesFolderFormat": "Series Folder Format", "SeriesFolderFormat": "Series Folder Format",
"SeriesFolderFormatHelpText": "Used when adding a new series or moving series via the series editor", "SeriesFolderFormatHelpText": "Used when adding a new series or moving series via the series editor",
"SeriesFolderImportedTooltip": "Episode imported from series folder", "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", "SeriesID": "Series ID",
"SeriesIndexFooterContinuing": "Continuing (All episodes downloaded)", "SeriesIndexFooterContinuing": "Continuing (All episodes downloaded)",
"SeriesIndexFooterDownloading": "Downloading (One or more episodes)", "SeriesIndexFooterDownloading": "Downloading (One or more episodes)",

View File

@ -1,7 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Blocklisting; using NzbDrone.Core.Blocklisting;
using NzbDrone.Core.CustomFormats; using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Datastore; using NzbDrone.Core.Datastore;
using NzbDrone.Core.Indexers;
using Sonarr.Http; using Sonarr.Http;
using Sonarr.Http.Extensions; using Sonarr.Http.Extensions;
using Sonarr.Http.REST.Attributes; using Sonarr.Http.REST.Attributes;
@ -23,12 +28,81 @@ namespace Sonarr.Api.V3.Blocklist
[HttpGet] [HttpGet]
[Produces("application/json")] [Produces("application/json")]
public PagingResource<BlocklistResource> GetBlocklist([FromQuery] PagingRequestResource paging) public PagingResource<BlocklistResource> GetBlocklist([FromQuery] PagingRequestResource paging, [FromQuery] int[] seriesIds = null, DownloadProtocol? protocol = null)
{ {
var pagingResource = new PagingResource<BlocklistResource>(paging); var pagingResource = new PagingResource<BlocklistResource>(paging);
var pagingSpec = pagingResource.MapToPagingSpec<BlocklistResource, NzbDrone.Core.Blocklisting.Blocklist>("date", SortDirection.Descending); var pagingSpec = pagingResource.MapToPagingSpec<BlocklistResource, NzbDrone.Core.Blocklisting.Blocklist>("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<NzbDrone.Core.Blocklisting.Blocklist> GetBlocklist(PagingSpec<NzbDrone.Core.Blocklisting.Blocklist> pagingSpec, HashSet<int> 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<NzbDrone.Core.Blocklisting.Blocklist> 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<NzbDrone.Core.Blocklisting.Blocklist, object> GetOrderByFunc(PagingSpec<NzbDrone.Core.Blocklisting.Blocklist> 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] [RestDeleteById]