1
0
mirror of https://github.com/Radarr/Radarr.git synced 2024-11-19 17:32:38 +01:00

New: History custom filters

(cherry picked from commit 2fe8f3084c90688e6dd01d600796396e74f43ff9)

Closes #9298
This commit is contained in:
Mark McDowall 2023-05-22 20:06:32 -07:00 committed by Bogdan
parent 299d50d56c
commit 0591d05c3b
15 changed files with 275 additions and 25 deletions

View File

@ -14,6 +14,7 @@ import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptions
import TablePager from 'Components/Table/TablePager'; import TablePager from 'Components/Table/TablePager';
import { align, icons, kinds } from 'Helpers/Props'; import { align, icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import HistoryFilterModal from './HistoryFilterModal';
import HistoryRowConnector from './HistoryRowConnector'; import HistoryRowConnector from './HistoryRowConnector';
class History extends Component { class History extends Component {
@ -33,6 +34,7 @@ class History extends Component {
columns, columns,
selectedFilterKey, selectedFilterKey,
filters, filters,
customFilters,
totalRecords, totalRecords,
onFilterSelect, onFilterSelect,
onFirstPagePress, onFirstPagePress,
@ -70,7 +72,8 @@ class History extends Component {
alignMenu={align.RIGHT} alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey} selectedFilterKey={selectedFilterKey}
filters={filters} filters={filters}
customFilters={[]} customFilters={customFilters}
filterModalConnectorComponent={HistoryFilterModal}
onFilterSelect={onFilterSelect} onFilterSelect={onFilterSelect}
/> />
</PageToolbarSection> </PageToolbarSection>
@ -144,8 +147,9 @@ History.propTypes = {
moviesError: PropTypes.object, moviesError: 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.string.isRequired, selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired, filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
totalRecords: PropTypes.number, totalRecords: PropTypes.number,
onFilterSelect: PropTypes.func.isRequired, onFilterSelect: PropTypes.func.isRequired,
onFirstPagePress: PropTypes.func.isRequired onFirstPagePress: PropTypes.func.isRequired

View File

@ -4,6 +4,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import withCurrentPage from 'Components/withCurrentPage'; import withCurrentPage from 'Components/withCurrentPage';
import * as historyActions from 'Store/Actions/historyActions'; import * as historyActions from 'Store/Actions/historyActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import History from './History'; import History from './History';
@ -11,11 +12,13 @@ function createMapStateToProps() {
return createSelector( return createSelector(
(state) => state.history, (state) => state.history,
(state) => state.movies, (state) => state.movies,
(history, movies) => { createCustomFiltersSelector('history'),
(history, movies, customFilters) => {
return { return {
isMoviesFetching: movies.isFetching, isMoviesFetching: movies.isFetching,
isMoviesPopulated: movies.isPopulated, isMoviesPopulated: movies.isPopulated,
moviesError: movies.error, moviesError: movies.error,
customFilters,
...history ...history
}; };
} }

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 { setHistoryFilter } from 'Store/Actions/historyActions';
function createHistorySelector() {
return createSelector(
(state: AppState) => state.history.items,
(queueItems) => {
return queueItems;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.history.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
interface HistoryFilterModalProps {
isOpen: boolean;
}
export default function HistoryFilterModal(props: HistoryFilterModalProps) {
const sectionItems = useSelector(createHistorySelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'history';
const dispatch = useDispatch();
const dispatchSetFilter = useCallback(
(payload: unknown) => {
dispatch(setHistoryFilter(payload));
},
[dispatch]
);
return (
<FilterModal
// TODO: Don't spread all the props
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
customFilterType={customFilterType}
dispatchSetFilter={dispatchSetFilter}
/>
);
}

View File

@ -1,6 +1,7 @@
import InteractiveImportAppState from 'App/State/InteractiveImportAppState'; import InteractiveImportAppState from 'App/State/InteractiveImportAppState';
import CalendarAppState from './CalendarAppState'; import CalendarAppState from './CalendarAppState';
import CommandAppState from './CommandAppState'; import CommandAppState from './CommandAppState';
import HistoryAppState from './HistoryAppState';
import MovieCollectionAppState from './MovieCollectionAppState'; import MovieCollectionAppState from './MovieCollectionAppState';
import MovieFilesAppState from './MovieFilesAppState'; import MovieFilesAppState from './MovieFilesAppState';
import MoviesAppState, { MovieIndexAppState } from './MoviesAppState'; import MoviesAppState, { MovieIndexAppState } from './MoviesAppState';
@ -46,6 +47,7 @@ export interface CustomFilter {
interface AppState { interface AppState {
calendar: CalendarAppState; calendar: CalendarAppState;
commands: CommandAppState; commands: CommandAppState;
history: HistoryAppState;
interactiveImport: InteractiveImportAppState; interactiveImport: InteractiveImportAppState;
movieCollections: MovieCollectionAppState; movieCollections: MovieCollectionAppState;
movieFiles: MovieFilesAppState; movieFiles: MovieFilesAppState;

View File

@ -0,0 +1,10 @@
import AppSectionState, {
AppSectionFilterState,
} from 'App/State/AppSectionState';
import History from 'typings/History';
interface HistoryAppState
extends AppSectionState<History>,
AppSectionFilterState<History> {}
export default HistoryAppState;

View File

@ -6,6 +6,7 @@ import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Prop
import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue'; import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue';
import DateFilterBuilderRowValue from './DateFilterBuilderRowValue'; import DateFilterBuilderRowValue from './DateFilterBuilderRowValue';
import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector'; import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector';
import HistoryEventTypeFilterBuilderRowValue from './HistoryEventTypeFilterBuilderRowValue';
import ImportListFilterBuilderRowValueConnector from './ImportListFilterBuilderRowValueConnector'; import ImportListFilterBuilderRowValueConnector from './ImportListFilterBuilderRowValueConnector';
import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector'; import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector';
import LanguageFilterBuilderRowValue from './LanguageFilterBuilderRowValue'; import LanguageFilterBuilderRowValue from './LanguageFilterBuilderRowValue';
@ -60,6 +61,9 @@ function getRowValueConnector(selectedFilterBuilderProp) {
case filterBuilderValueTypes.DATE: case filterBuilderValueTypes.DATE:
return DateFilterBuilderRowValue; return DateFilterBuilderRowValue;
case filterBuilderValueTypes.HISTORY_EVENT_TYPE:
return HistoryEventTypeFilterBuilderRowValue;
case filterBuilderValueTypes.INDEXER: case filterBuilderValueTypes.INDEXER:
return IndexerFilterBuilderRowValueConnector; return IndexerFilterBuilderRowValueConnector;

View File

@ -0,0 +1,51 @@
import React from 'react';
import translate from 'Utilities/String/translate';
import FilterBuilderRowValue from './FilterBuilderRowValue';
import FilterBuilderRowValueProps from './FilterBuilderRowValueProps';
const EVENT_TYPE_OPTIONS = [
{
id: 1,
get name() {
return translate('Grabbed');
},
},
{
id: 3,
get name() {
return translate('Imported');
},
},
{
id: 4,
get name() {
return translate('Failed');
},
},
{
id: 5,
get name() {
return translate('Deleted');
},
},
{
id: 6,
get name() {
return translate('Renamed');
},
},
{
id: 7,
get name() {
return translate('Ignored');
},
},
];
function HistoryEventTypeFilterBuilderRowValue(
props: FilterBuilderRowValueProps
) {
return <FilterBuilderRowValue {...props} tagList={EVENT_TYPE_OPTIONS} />;
}
export default HistoryEventTypeFilterBuilderRowValue;

View File

@ -2,6 +2,7 @@ export const BOOL = 'bool';
export const BYTES = 'bytes'; export const BYTES = 'bytes';
export const DATE = 'date'; export const DATE = 'date';
export const DEFAULT = 'default'; export const DEFAULT = 'default';
export const HISTORY_EVENT_TYPE = 'historyEventType';
export const INDEXER = 'indexer'; export const INDEXER = 'indexer';
export const LANGUAGE = 'language'; export const LANGUAGE = 'language';
export const PROTOCOL = 'protocol'; export const PROTOCOL = 'protocol';

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { createAction } from 'redux-actions'; import { createAction } from 'redux-actions';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import { filterTypes, icons, sortDirections } from 'Helpers/Props'; import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, icons, 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';
@ -177,6 +177,33 @@ export const defaultState = {
} }
] ]
} }
],
filterBuilderProps: [
{
name: 'eventType',
label: () => translate('EventType'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.HISTORY_EVENT_TYPE
},
{
name: 'movieIds',
label: () => translate('Movie'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.MOVIE
},
{
name: 'quality',
label: () => translate('Quality'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.QUALITY
},
{
name: 'languages',
label: () => translate('Languages'),
type: filterBuilderTypes.CONTAINS,
valueType: filterBuilderValueTypes.LANGUAGE
}
] ]
}; };

View File

@ -0,0 +1,27 @@
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import CustomFormat from './CustomFormat';
export type HistoryEventType =
| 'grabbed'
| 'downloadFolderImported'
| 'downloadFailed'
| 'movieFileDeleted'
| 'movieFolderImported'
| 'movieFileRenamed'
| 'downloadIgnored';
export default interface History {
movieId: number;
sourceTitle: string;
languages: Language[];
quality: QualityModel;
customFormats: CustomFormat[];
customFormatScore: number;
qualityCutoffNotMet: boolean;
date: string;
downloadId: string;
eventType: HistoryEventType;
data: unknown;
id: number;
}

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Linq; using System.Linq;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
@ -30,7 +30,7 @@ public bool InstallIsActive
{ {
get get
{ {
var lastRecord = _historyService.Paged(new PagingSpec<MovieHistory>() { Page = 0, PageSize = 1, SortKey = "date", SortDirection = SortDirection.Descending }); var lastRecord = _historyService.Paged(new PagingSpec<MovieHistory>() { Page = 0, PageSize = 1, SortKey = "date", SortDirection = SortDirection.Descending }, null, null);
var monthAgo = DateTime.UtcNow.AddMonths(-1); var monthAgo = DateTime.UtcNow.AddMonths(-1);
return lastRecord.Records.Any(v => v.Date > monthAgo); return lastRecord.Records.Any(v => v.Date > monthAgo);

View File

@ -407,7 +407,7 @@ public virtual PagingSpec<TModel> GetPaged(PagingSpec<TModel> pagingSpec)
return pagingSpec; return pagingSpec;
} }
private void AddFilters(SqlBuilder builder, PagingSpec<TModel> pagingSpec) protected void AddFilters(SqlBuilder builder, PagingSpec<TModel> pagingSpec)
{ {
var filters = pagingSpec.FilterExpressions; var filters = pagingSpec.FilterExpressions;

View File

@ -19,6 +19,7 @@ public interface IHistoryRepository : IBasicRepository<MovieHistory>
void DeleteForMovies(List<int> movieIds); void DeleteForMovies(List<int> movieIds);
MovieHistory MostRecentForMovie(int movieId); MovieHistory MostRecentForMovie(int movieId);
List<MovieHistory> Since(DateTime date, MovieHistoryEventType? eventType); List<MovieHistory> Since(DateTime date, MovieHistoryEventType? eventType);
PagingSpec<MovieHistory> GetPaged(PagingSpec<MovieHistory> pagingSpec, int[] languages, int[] qualities);
} }
public class HistoryRepository : BasicRepository<MovieHistory>, IHistoryRepository public class HistoryRepository : BasicRepository<MovieHistory>, IHistoryRepository
@ -74,19 +75,6 @@ public void DeleteForMovies(List<int> movieIds)
Delete(c => movieIds.Contains(c.MovieId)); Delete(c => movieIds.Contains(c.MovieId));
} }
protected override SqlBuilder PagedBuilder() => new SqlBuilder(_database.DatabaseType)
.Join<MovieHistory, Movie>((h, m) => h.MovieId == m.Id)
.Join<Movie, QualityProfile>((m, p) => m.QualityProfileId == p.Id)
.LeftJoin<Movie, MovieMetadata>((m, mm) => m.MovieMetadataId == mm.Id);
protected override IEnumerable<MovieHistory> PagedQuery(SqlBuilder sql) =>
_database.QueryJoined<MovieHistory, Movie, QualityProfile>(sql, (hist, movie, profile) =>
{
hist.Movie = movie;
hist.Movie.QualityProfile = profile;
return hist;
});
public MovieHistory MostRecentForMovie(int movieId) public MovieHistory MostRecentForMovie(int movieId)
{ {
return Query(x => x.MovieId == movieId).MaxBy(h => h.Date); return Query(x => x.MovieId == movieId).MaxBy(h => h.Date);
@ -106,5 +94,77 @@ public List<MovieHistory> Since(DateTime date, MovieHistoryEventType? eventType)
return PagedQuery(builder).OrderBy(h => h.Date).ToList(); return PagedQuery(builder).OrderBy(h => h.Date).ToList();
} }
public PagingSpec<MovieHistory> GetPaged(PagingSpec<MovieHistory> pagingSpec, int[] languages, int[] qualities)
{
pagingSpec.Records = GetPagedRecords(PagedBuilder(pagingSpec, languages, qualities), pagingSpec, PagedQuery);
var countTemplate = $"SELECT COUNT(*) FROM (SELECT /**select**/ FROM \"{TableMapping.Mapper.TableNameMapping(typeof(MovieHistory))}\" /**join**/ /**innerjoin**/ /**leftjoin**/ /**where**/ /**groupby**/ /**having**/) AS \"Inner\"";
pagingSpec.TotalRecords = GetPagedRecordCount(PagedBuilder(pagingSpec, languages, qualities).Select(typeof(MovieHistory)), pagingSpec, countTemplate);
return pagingSpec;
}
private SqlBuilder PagedBuilder(PagingSpec<MovieHistory> pagingSpec, int[] languages, int[] qualities)
{
var builder = Builder()
.Join<MovieHistory, Movie>((h, m) => h.MovieId == m.Id)
.Join<Movie, QualityProfile>((m, p) => m.QualityProfileId == p.Id)
.LeftJoin<Movie, MovieMetadata>((m, mm) => m.MovieMetadataId == mm.Id);
AddFilters(builder, pagingSpec);
if (languages is { Length: > 0 })
{
builder.Where($"({BuildLanguageWhereClause(languages)})");
}
if (qualities is { Length: > 0 })
{
builder.Where($"({BuildQualityWhereClause(qualities)})");
}
return builder;
}
protected override IEnumerable<MovieHistory> PagedQuery(SqlBuilder builder) =>
_database.QueryJoined<MovieHistory, Movie, QualityProfile>(builder, (hist, movie, profile) =>
{
hist.Movie = movie;
hist.Movie.QualityProfile = profile;
return hist;
});
private string BuildLanguageWhereClause(int[] languages)
{
var clauses = new List<string>();
foreach (var language in languages)
{
// There are 4 different types of values we should see:
// - Not the last value in the array
// - When it's the last value in the array and on different OSes
// - When it was converted from a single language
clauses.Add($"\"{TableMapping.Mapper.TableNameMapping(typeof(MovieHistory))}\".\"Languages\" LIKE '[% {language},%]'");
clauses.Add($"\"{TableMapping.Mapper.TableNameMapping(typeof(MovieHistory))}\".\"Languages\" LIKE '[% {language}' || CHAR(13) || '%]'");
clauses.Add($"\"{TableMapping.Mapper.TableNameMapping(typeof(MovieHistory))}\".\"Languages\" LIKE '[% {language}' || CHAR(10) || '%]'");
clauses.Add($"\"{TableMapping.Mapper.TableNameMapping(typeof(MovieHistory))}\".\"Languages\" LIKE '[{language}]'");
}
return $"({string.Join(" OR ", clauses)})";
}
private string BuildQualityWhereClause(int[] qualities)
{
var clauses = new List<string>();
foreach (var quality in qualities)
{
clauses.Add($"\"{TableMapping.Mapper.TableNameMapping(typeof(MovieHistory))}\".\"Quality\" LIKE '%_quality_: {quality},%'");
}
return $"({string.Join(" OR ", clauses)})";
}
} }
} }

View File

@ -19,7 +19,7 @@ namespace NzbDrone.Core.History
public interface IHistoryService public interface IHistoryService
{ {
QualityModel GetBestQualityInHistory(QualityProfile profile, int movieId); QualityModel GetBestQualityInHistory(QualityProfile profile, int movieId);
PagingSpec<MovieHistory> Paged(PagingSpec<MovieHistory> pagingSpec); PagingSpec<MovieHistory> Paged(PagingSpec<MovieHistory> pagingSpec, int[] languages, int[] qualities);
MovieHistory MostRecentForMovie(int movieId); MovieHistory MostRecentForMovie(int movieId);
MovieHistory MostRecentForDownloadId(string downloadId); MovieHistory MostRecentForDownloadId(string downloadId);
MovieHistory Get(int historyId); MovieHistory Get(int historyId);
@ -49,9 +49,9 @@ public HistoryService(IHistoryRepository historyRepository, Logger logger)
_logger = logger; _logger = logger;
} }
public PagingSpec<MovieHistory> Paged(PagingSpec<MovieHistory> pagingSpec) public PagingSpec<MovieHistory> Paged(PagingSpec<MovieHistory> pagingSpec, int[] languages, int[] qualities)
{ {
return _historyRepository.GetPaged(pagingSpec); return _historyRepository.GetPaged(pagingSpec, languages, qualities);
} }
public MovieHistory MostRecentForMovie(int movieId) public MovieHistory MostRecentForMovie(int movieId)

View File

@ -61,7 +61,7 @@ protected HistoryResource MapToResource(MovieHistory model, bool includeMovie)
[HttpGet] [HttpGet]
[Produces("application/json")] [Produces("application/json")]
public PagingResource<HistoryResource> GetHistory([FromQuery] PagingRequestResource paging, bool includeMovie, int? eventType, string downloadId) public PagingResource<HistoryResource> GetHistory([FromQuery] PagingRequestResource paging, bool includeMovie, int? eventType, string downloadId, [FromQuery] int[] movieIds = null, [FromQuery] int[] languages = null, [FromQuery] int[] quality = null)
{ {
var pagingResource = new PagingResource<HistoryResource>(paging); var pagingResource = new PagingResource<HistoryResource>(paging);
var pagingSpec = pagingResource.MapToPagingSpec<HistoryResource, MovieHistory>("date", SortDirection.Descending); var pagingSpec = pagingResource.MapToPagingSpec<HistoryResource, MovieHistory>("date", SortDirection.Descending);
@ -77,16 +77,23 @@ public PagingResource<HistoryResource> GetHistory([FromQuery] PagingRequestResou
pagingSpec.FilterExpressions.Add(h => h.DownloadId == downloadId); pagingSpec.FilterExpressions.Add(h => h.DownloadId == downloadId);
} }
return pagingSpec.ApplyToPage(_historyService.Paged, h => MapToResource(h, includeMovie)); if (movieIds != null && movieIds.Any())
{
pagingSpec.FilterExpressions.Add(h => movieIds.Contains(h.MovieId));
}
return pagingSpec.ApplyToPage(h => _historyService.Paged(pagingSpec, languages, quality), h => MapToResource(h, includeMovie));
} }
[HttpGet("since")] [HttpGet("since")]
[Produces("application/json")]
public List<HistoryResource> GetHistorySince(DateTime date, MovieHistoryEventType? eventType = null, bool includeMovie = false) public List<HistoryResource> GetHistorySince(DateTime date, MovieHistoryEventType? eventType = null, bool includeMovie = false)
{ {
return _historyService.Since(date, eventType).Select(h => MapToResource(h, includeMovie)).ToList(); return _historyService.Since(date, eventType).Select(h => MapToResource(h, includeMovie)).ToList();
} }
[HttpGet("movie")] [HttpGet("movie")]
[Produces("application/json")]
public List<HistoryResource> GetMovieHistory(int movieId, MovieHistoryEventType? eventType = null, bool includeMovie = false) public List<HistoryResource> GetMovieHistory(int movieId, MovieHistoryEventType? eventType = null, bool includeMovie = false)
{ {
return _historyService.GetByMovieId(movieId, eventType).Select(h => MapToResource(h, includeMovie)).ToList(); return _historyService.GetByMovieId(movieId, eventType).Select(h => MapToResource(h, includeMovie)).ToList();