New: Calendar filtering by tags

Closes #5476
This commit is contained in:
Mark McDowall 2023-05-11 22:36:26 -07:00
parent 7c0d344437
commit 62b948b24c
11 changed files with 208 additions and 44 deletions

View File

@ -1,4 +1,5 @@
import InteractiveImportAppState from 'App/State/InteractiveImportAppState'; import InteractiveImportAppState from 'App/State/InteractiveImportAppState';
import CalendarAppState from './CalendarAppState';
import EpisodeFilesAppState from './EpisodeFilesAppState'; import EpisodeFilesAppState from './EpisodeFilesAppState';
import EpisodesAppState from './EpisodesAppState'; import EpisodesAppState from './EpisodesAppState';
import QueueAppState from './QueueAppState'; import QueueAppState from './QueueAppState';
@ -39,6 +40,7 @@ export interface CustomFilter {
} }
interface AppState { interface AppState {
calendar: CalendarAppState;
episodesSelection: EpisodesAppState; episodesSelection: EpisodesAppState;
episodeFiles: EpisodeFilesAppState; episodeFiles: EpisodeFilesAppState;
interactiveImport: InteractiveImportAppState; interactiveImport: InteractiveImportAppState;

View File

@ -0,0 +1,9 @@
import AppSectionState from 'App/State/AppSectionState';
import Episode from 'Episode/Episode';
import { FilterBuilderProp } from './AppState';
interface CalendarAppState extends AppSectionState<Episode> {
filterBuilderProps: FilterBuilderProp<Episode>[];
}
export default CalendarAppState;

View File

@ -0,0 +1,56 @@
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 { setCalendarFilter } from 'Store/Actions/calendarActions';
function createCalendarSelector() {
return createSelector(
(state: AppState) => state.calendar.items,
(calendar) => {
return calendar;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.calendar.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
interface SeriesIndexFilterModalProps {
isOpen: boolean;
}
export default function CalendarFilterModal(
props: SeriesIndexFilterModalProps
) {
const sectionItems = useSelector(createCalendarSelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'calendar';
const dispatch = useDispatch();
const dispatchSetFilter = useCallback(
(payload: unknown) => {
dispatch(setCalendarFilter(payload));
},
[dispatch]
);
return (
<FilterModal
// TODO: Don't spread all the props
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
customFilterType={customFilterType}
dispatchSetFilter={dispatchSetFilter}
/>
);
}

View File

@ -11,6 +11,7 @@ import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import { align, icons } from 'Helpers/Props'; import { align, icons } from 'Helpers/Props';
import NoSeries from 'Series/NoSeries'; import NoSeries from 'Series/NoSeries';
import CalendarConnector from './CalendarConnector'; import CalendarConnector from './CalendarConnector';
import CalendarFilterModal from './CalendarFilterModal';
import CalendarLinkModal from './iCal/CalendarLinkModal'; import CalendarLinkModal from './iCal/CalendarLinkModal';
import LegendConnector from './Legend/LegendConnector'; import LegendConnector from './Legend/LegendConnector';
import CalendarOptionsModal from './Options/CalendarOptionsModal'; import CalendarOptionsModal from './Options/CalendarOptionsModal';
@ -75,6 +76,7 @@ class CalendarPage extends Component {
const { const {
selectedFilterKey, selectedFilterKey,
filters, filters,
customFilters,
hasSeries, hasSeries,
missingEpisodeIds, missingEpisodeIds,
isRssSyncExecuting, isRssSyncExecuting,
@ -132,7 +134,8 @@ class CalendarPage extends Component {
isDisabled={!hasSeries} isDisabled={!hasSeries}
selectedFilterKey={selectedFilterKey} selectedFilterKey={selectedFilterKey}
filters={filters} filters={filters}
customFilters={[]} customFilters={customFilters}
filterModalConnectorComponent={CalendarFilterModal}
onFilterSelect={onFilterSelect} onFilterSelect={onFilterSelect}
/> />
</PageToolbarSection> </PageToolbarSection>
@ -178,6 +181,7 @@ class CalendarPage extends Component {
CalendarPage.propTypes = { CalendarPage.propTypes = {
selectedFilterKey: PropTypes.string.isRequired, selectedFilterKey: PropTypes.string.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired, filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
hasSeries: PropTypes.bool.isRequired, hasSeries: PropTypes.bool.isRequired,
missingEpisodeIds: PropTypes.arrayOf(PropTypes.number).isRequired, missingEpisodeIds: PropTypes.arrayOf(PropTypes.number).isRequired,
isRssSyncExecuting: PropTypes.bool.isRequired, isRssSyncExecuting: PropTypes.bool.isRequired,

View File

@ -5,6 +5,7 @@ import * as commandNames from 'Commands/commandNames';
import withCurrentPage from 'Components/withCurrentPage'; import withCurrentPage from 'Components/withCurrentPage';
import { searchMissing, setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions'; import { searchMissing, setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions';
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 createCommandsSelector from 'Store/Selectors/createCommandsSelector'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector'; import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector';
@ -59,6 +60,7 @@ function createMapStateToProps() {
return createSelector( return createSelector(
(state) => state.calendar.selectedFilterKey, (state) => state.calendar.selectedFilterKey,
(state) => state.calendar.filters, (state) => state.calendar.filters,
createCustomFiltersSelector('calendar'),
createSeriesCountSelector(), createSeriesCountSelector(),
createUISettingsSelector(), createUISettingsSelector(),
createMissingEpisodeIdsSelector(), createMissingEpisodeIdsSelector(),
@ -67,6 +69,7 @@ function createMapStateToProps() {
( (
selectedFilterKey, selectedFilterKey,
filters, filters,
customFilters,
seriesCount, seriesCount,
uiSettings, uiSettings,
missingEpisodeIds, missingEpisodeIds,
@ -76,6 +79,7 @@ function createMapStateToProps() {
return { return {
selectedFilterKey, selectedFilterKey,
filters, filters,
customFilters,
colorImpairedMode: uiSettings.enableColorImpairedMode, colorImpairedMode: uiSettings.enableColorImpairedMode,
hasSeries: !!seriesCount, hasSeries: !!seriesCount,
missingEpisodeIds, missingEpisodeIds,

View File

@ -1,14 +1,18 @@
import * as filterTypes from './filterTypes'; import * as filterTypes from './filterTypes';
export const ARRAY = 'array'; export const ARRAY = 'array';
export const CONTAINS = 'contains';
export const DATE = 'date'; export const DATE = 'date';
export const EQUAL = 'equal';
export const EXACT = 'exact'; export const EXACT = 'exact';
export const NUMBER = 'number'; export const NUMBER = 'number';
export const STRING = 'string'; export const STRING = 'string';
export const all = [ export const all = [
ARRAY, ARRAY,
CONTAINS,
DATE, DATE,
EQUAL,
EXACT, EXACT,
NUMBER, NUMBER,
STRING STRING
@ -20,6 +24,10 @@ export const possibleFilterTypes = {
{ key: filterTypes.NOT_CONTAINS, value: 'does not contain' } { key: filterTypes.NOT_CONTAINS, value: 'does not contain' }
], ],
[CONTAINS]: [
{ key: filterTypes.CONTAINS, value: 'contains' }
],
[DATE]: [ [DATE]: [
{ key: filterTypes.LESS_THAN, value: 'is before' }, { key: filterTypes.LESS_THAN, value: 'is before' },
{ key: filterTypes.GREATER_THAN, value: 'is after' }, { key: filterTypes.GREATER_THAN, value: 'is after' },
@ -29,6 +37,10 @@ export const possibleFilterTypes = {
{ key: filterTypes.NOT_IN_NEXT, value: 'not in the next' } { key: filterTypes.NOT_IN_NEXT, value: 'not in the next' }
], ],
[EQUAL]: [
{ key: filterTypes.EQUAL, value: 'is' }
],
[EXACT]: [ [EXACT]: [
{ key: filterTypes.EQUAL, value: 'is' }, { key: filterTypes.EQUAL, value: 'is' },
{ key: filterTypes.NOT_EQUAL, value: 'is not' } { key: filterTypes.NOT_EQUAL, value: 'is not' }

View File

@ -4,9 +4,10 @@ import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions'; import { batchActions } from 'redux-batched-actions';
import * as calendarViews from 'Calendar/calendarViews'; import * as calendarViews from 'Calendar/calendarViews';
import * as commandNames from 'Commands/commandNames'; import * as commandNames from 'Commands/commandNames';
import { filterTypes } from 'Helpers/Props'; import { filterBuilderTypes, filterBuilderValueTypes, filterTypes } 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 findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
import { set, update } from './baseActions'; import { set, update } from './baseActions';
import { executeCommandHelper } from './commandActions'; import { executeCommandHelper } from './commandActions';
import createHandleActions from './Creators/createHandleActions'; import createHandleActions from './Creators/createHandleActions';
@ -50,14 +51,16 @@ export const defaultState = {
selectedFilterKey: 'monitored', selectedFilterKey: 'monitored',
customFilters: [],
filters: [ filters: [
{ {
key: 'all', key: 'all',
label: 'All', label: 'All',
filters: [ filters: [
{ {
key: 'monitored', key: 'unmonitored',
value: false, value: [true],
type: filterTypes.EQUAL type: filterTypes.EQUAL
} }
] ]
@ -67,20 +70,35 @@ export const defaultState = {
label: 'Monitored Only', label: 'Monitored Only',
filters: [ filters: [
{ {
key: 'monitored', key: 'unmonitored',
value: true, value: [false],
type: filterTypes.EQUAL type: filterTypes.EQUAL
} }
] ]
} }
],
filterBuilderProps: [
{
name: 'unmonitored',
label: 'Include Unmonitored',
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.BOOL
},
{
name: 'tags',
label: 'Tags',
type: filterBuilderTypes.CONTAINS,
valueType: filterBuilderValueTypes.TAG
}
] ]
}; };
export const persistState = [ export const persistState = [
'calendar.view', 'calendar.view',
'calendar.selectedFilterKey', 'calendar.selectedFilterKey',
'calendar.options' 'calendar.options',
'seriesIndex.customFilters'
]; ];
// //
@ -192,6 +210,10 @@ function isRangePopulated(start, end, state) {
return false; return false;
} }
function getCustomFilters(state, type) {
return state.customFilters.items.filter((customFilter) => customFilter.type === type);
}
// //
// Action Creators // Action Creators
@ -213,7 +235,8 @@ export const actionHandlers = handleThunks({
[FETCH_CALENDAR]: function(getState, payload, dispatch) { [FETCH_CALENDAR]: function(getState, payload, dispatch) {
const state = getState(); const state = getState();
const calendar = state.calendar; const calendar = state.calendar;
const unmonitored = calendar.selectedFilterKey === 'all'; const customFilters = getCustomFilters(state, section);
const selectedFilters = findSelectedFilters(calendar.selectedFilterKey, calendar.filters, customFilters);
const { const {
time = calendar.time, time = calendar.time,
@ -240,13 +263,26 @@ export const actionHandlers = handleThunks({
dispatch(set(attrs)); dispatch(set(attrs));
const promise = createAjaxRequest({ const requestParams = {
url: '/calendar',
data: {
unmonitored,
start, start,
end end
};
selectedFilters.forEach((selectedFilter) => {
if (selectedFilter.key === 'unmonitored') {
requestParams.unmonitored = selectedFilter.value.includes(true);
} }
if (selectedFilter.key === 'tags') {
requestParams.tags = selectedFilter.value.join(',');
}
});
requestParams.unmonitored = requestParams.unmonitored ?? false;
const promise = createAjaxRequest({
url: '/calendar',
data: requestParams
}).request; }).request;
promise.done((data) => { promise.done((data) => {

View File

@ -108,7 +108,7 @@ function sort(items, state) {
return _.orderBy(items, clauses, orders); return _.orderBy(items, clauses, orders);
} }
function createCustomFiltersSelector(type, alternateType) { export function createCustomFiltersSelector(type, alternateType) {
return createSelector( return createSelector(
(state) => state.customFilters.items, (state) => state.customFilters.items,
(customFilters) => { (customFilters) => {

View File

@ -1,28 +0,0 @@
const thunks = {};
function identity(payload) {
return payload;
}
export function createThunk(type, identityFunction = identity) {
return function(payload = {}) {
return function(dispatch, getState) {
const thunk = thunks[type];
if (thunk) {
return thunk(getState, identityFunction(payload), dispatch);
}
throw Error(`Thunk handler has not been registered for ${type}`);
};
};
}
export function handleThunks(handlers) {
const types = Object.keys(handlers);
types.forEach((type) => {
thunks[type] = handlers[type];
});
}

View File

@ -0,0 +1,37 @@
import { Dispatch } from 'redux';
import AppState from 'App/State/AppState';
type GetState = () => AppState;
type Thunk = (
getState: GetState,
identity: unknown,
dispatch: Dispatch
) => unknown;
const thunks: Record<string, Thunk> = {};
function identity(payload: unknown) {
return payload;
}
export function createThunk(type: string, identityFunction = identity) {
return function (payload: unknown = {}) {
return function (dispatch: Dispatch, getState: GetState) {
const thunk = thunks[type];
if (thunk) {
return thunk(getState, identityFunction(payload), dispatch);
}
throw Error(`Thunk handler has not been registered for ${type}`);
};
};
}
export function handleThunks(handlers: Record<string, Thunk>) {
const types = Object.keys(handlers);
types.forEach((type) => {
thunks[type] = handlers[type];
});
}

View File

@ -2,8 +2,10 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.CustomFormats; using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Tags;
using NzbDrone.Core.Tv; using NzbDrone.Core.Tv;
using NzbDrone.SignalR; using NzbDrone.SignalR;
using Sonarr.Api.V3.Episodes; using Sonarr.Api.V3.Episodes;
@ -14,23 +16,53 @@ namespace Sonarr.Api.V3.Calendar
[V3ApiController] [V3ApiController]
public class CalendarController : EpisodeControllerWithSignalR public class CalendarController : EpisodeControllerWithSignalR
{ {
private readonly ITagService _tagService;
public CalendarController(IBroadcastSignalRMessage signalR, public CalendarController(IBroadcastSignalRMessage signalR,
IEpisodeService episodeService, IEpisodeService episodeService,
ISeriesService seriesService, ISeriesService seriesService,
IUpgradableSpecification qualityUpgradableSpecification, IUpgradableSpecification qualityUpgradableSpecification,
ITagService tagService,
ICustomFormatCalculationService formatCalculator) ICustomFormatCalculationService formatCalculator)
: base(episodeService, seriesService, qualityUpgradableSpecification, formatCalculator, signalR) : base(episodeService, seriesService, qualityUpgradableSpecification, formatCalculator, signalR)
{ {
_tagService = tagService;
} }
[HttpGet] [HttpGet]
[Produces("application/json")] [Produces("application/json")]
public List<EpisodeResource> GetCalendar(DateTime? start, DateTime? end, bool unmonitored = false, bool includeSeries = false, bool includeEpisodeFile = false, bool includeEpisodeImages = false) public List<EpisodeResource> GetCalendar(DateTime? start, DateTime? end, bool unmonitored = false, bool includeSeries = false, bool includeEpisodeFile = false, bool includeEpisodeImages = false, string tags = "")
{ {
var startUse = start ?? DateTime.Today; var startUse = start ?? DateTime.Today;
var endUse = end ?? DateTime.Today.AddDays(2); var endUse = end ?? DateTime.Today.AddDays(2);
var episodes = _episodeService.EpisodesBetweenDates(startUse, endUse, unmonitored);
var allSeries = _seriesService.GetAllSeries();
var parsedTags = new List<int>();
var result = new List<Episode>();
var resources = MapToResource(_episodeService.EpisodesBetweenDates(startUse, endUse, unmonitored), includeSeries, includeEpisodeFile, includeEpisodeImages); if (tags.IsNotNullOrWhiteSpace())
{
parsedTags.AddRange(tags.Split(',').Select(_tagService.GetTag).Select(t => t.Id));
}
foreach (var episode in episodes)
{
var series = allSeries.SingleOrDefault(s => s.Id == episode.SeriesId);
if (series == null)
{
continue;
}
if (parsedTags.Any() && parsedTags.None(series.Tags.Contains))
{
continue;
}
result.Add(episode);
}
var resources = MapToResource(result, includeSeries, includeEpisodeFile, includeEpisodeImages);
return resources.OrderBy(e => e.AirDateUtc).ToList(); return resources.OrderBy(e => e.AirDateUtc).ToList();
} }