diff --git a/frontend/src/Activity/History/History.js b/frontend/src/Activity/History/History.js
index 3afa2a740..21a06fb57 100644
--- a/frontend/src/Activity/History/History.js
+++ b/frontend/src/Activity/History/History.js
@@ -14,6 +14,7 @@ import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptions
import TablePager from 'Components/Table/TablePager';
import { align, icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
+import HistoryFilterModal from './HistoryFilterModal';
import HistoryRowConnector from './HistoryRowConnector';
class History extends Component {
@@ -33,6 +34,7 @@ class History extends Component {
columns,
selectedFilterKey,
filters,
+ customFilters,
totalRecords,
onFilterSelect,
onFirstPagePress,
@@ -70,7 +72,8 @@ class History extends Component {
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
- customFilters={[]}
+ customFilters={customFilters}
+ filterModalConnectorComponent={HistoryFilterModal}
onFilterSelect={onFilterSelect}
/>
@@ -144,8 +147,9 @@ History.propTypes = {
moviesError: PropTypes.object,
items: 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,
+ customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
totalRecords: PropTypes.number,
onFilterSelect: PropTypes.func.isRequired,
onFirstPagePress: PropTypes.func.isRequired
diff --git a/frontend/src/Activity/History/HistoryConnector.js b/frontend/src/Activity/History/HistoryConnector.js
index 6041cf7b5..6cb5d5f7c 100644
--- a/frontend/src/Activity/History/HistoryConnector.js
+++ b/frontend/src/Activity/History/HistoryConnector.js
@@ -4,6 +4,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import withCurrentPage from 'Components/withCurrentPage';
import * as historyActions from 'Store/Actions/historyActions';
+import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import History from './History';
@@ -11,11 +12,13 @@ function createMapStateToProps() {
return createSelector(
(state) => state.history,
(state) => state.movies,
- (history, movies) => {
+ createCustomFiltersSelector('history'),
+ (history, movies, customFilters) => {
return {
isMoviesFetching: movies.isFetching,
isMoviesPopulated: movies.isPopulated,
moviesError: movies.error,
+ customFilters,
...history
};
}
diff --git a/frontend/src/Activity/History/HistoryFilterModal.tsx b/frontend/src/Activity/History/HistoryFilterModal.tsx
new file mode 100644
index 000000000..f4ad2e57c
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryFilterModal.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 { 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 (
+
+ );
+}
diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts
index f297e9fb9..45536b0a5 100644
--- a/frontend/src/App/State/AppState.ts
+++ b/frontend/src/App/State/AppState.ts
@@ -1,6 +1,7 @@
import InteractiveImportAppState from 'App/State/InteractiveImportAppState';
import CalendarAppState from './CalendarAppState';
import CommandAppState from './CommandAppState';
+import HistoryAppState from './HistoryAppState';
import MovieCollectionAppState from './MovieCollectionAppState';
import MovieFilesAppState from './MovieFilesAppState';
import MoviesAppState, { MovieIndexAppState } from './MoviesAppState';
@@ -46,6 +47,7 @@ export interface CustomFilter {
interface AppState {
calendar: CalendarAppState;
commands: CommandAppState;
+ history: HistoryAppState;
interactiveImport: InteractiveImportAppState;
movieCollections: MovieCollectionAppState;
movieFiles: MovieFilesAppState;
diff --git a/frontend/src/App/State/HistoryAppState.ts b/frontend/src/App/State/HistoryAppState.ts
new file mode 100644
index 000000000..e368ff86e
--- /dev/null
+++ b/frontend/src/App/State/HistoryAppState.ts
@@ -0,0 +1,10 @@
+import AppSectionState, {
+ AppSectionFilterState,
+} from 'App/State/AppSectionState';
+import History from 'typings/History';
+
+interface HistoryAppState
+ extends AppSectionState,
+ AppSectionFilterState {}
+
+export default HistoryAppState;
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js
index 7641d53ea..3eb7519f3 100644
--- a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js
@@ -6,6 +6,7 @@ import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Prop
import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue';
import DateFilterBuilderRowValue from './DateFilterBuilderRowValue';
import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector';
+import HistoryEventTypeFilterBuilderRowValue from './HistoryEventTypeFilterBuilderRowValue';
import ImportListFilterBuilderRowValueConnector from './ImportListFilterBuilderRowValueConnector';
import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector';
import LanguageFilterBuilderRowValue from './LanguageFilterBuilderRowValue';
@@ -60,6 +61,9 @@ function getRowValueConnector(selectedFilterBuilderProp) {
case filterBuilderValueTypes.DATE:
return DateFilterBuilderRowValue;
+ case filterBuilderValueTypes.HISTORY_EVENT_TYPE:
+ return HistoryEventTypeFilterBuilderRowValue;
+
case filterBuilderValueTypes.INDEXER:
return IndexerFilterBuilderRowValueConnector;
diff --git a/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx
new file mode 100644
index 000000000..4ecddf646
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx
@@ -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 ;
+}
+
+export default HistoryEventTypeFilterBuilderRowValue;
diff --git a/frontend/src/Helpers/Props/filterBuilderValueTypes.js b/frontend/src/Helpers/Props/filterBuilderValueTypes.js
index 11977f727..3e2d599ba 100644
--- a/frontend/src/Helpers/Props/filterBuilderValueTypes.js
+++ b/frontend/src/Helpers/Props/filterBuilderValueTypes.js
@@ -2,6 +2,7 @@ export const BOOL = 'bool';
export const BYTES = 'bytes';
export const DATE = 'date';
export const DEFAULT = 'default';
+export const HISTORY_EVENT_TYPE = 'historyEventType';
export const INDEXER = 'indexer';
export const LANGUAGE = 'language';
export const PROTOCOL = 'protocol';
diff --git a/frontend/src/Store/Actions/historyActions.js b/frontend/src/Store/Actions/historyActions.js
index 7a66da335..a3babe53b 100644
--- a/frontend/src/Store/Actions/historyActions.js
+++ b/frontend/src/Store/Actions/historyActions.js
@@ -1,7 +1,7 @@
import React from 'react';
import { createAction } from 'redux-actions';
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 createAjaxRequest from 'Utilities/createAjaxRequest';
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
+ }
]
};
diff --git a/frontend/src/typings/History.ts b/frontend/src/typings/History.ts
new file mode 100644
index 000000000..ee4c11842
--- /dev/null
+++ b/frontend/src/typings/History.ts
@@ -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;
+}
diff --git a/src/NzbDrone.Core/Analytics/AnalyticsService.cs b/src/NzbDrone.Core/Analytics/AnalyticsService.cs
index 2727431be..e09a0e37f 100644
--- a/src/NzbDrone.Core/Analytics/AnalyticsService.cs
+++ b/src/NzbDrone.Core/Analytics/AnalyticsService.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Linq;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Configuration;
@@ -30,7 +30,7 @@ public bool InstallIsActive
{
get
{
- var lastRecord = _historyService.Paged(new PagingSpec() { Page = 0, PageSize = 1, SortKey = "date", SortDirection = SortDirection.Descending });
+ var lastRecord = _historyService.Paged(new PagingSpec() { Page = 0, PageSize = 1, SortKey = "date", SortDirection = SortDirection.Descending }, null, null);
var monthAgo = DateTime.UtcNow.AddMonths(-1);
return lastRecord.Records.Any(v => v.Date > monthAgo);
diff --git a/src/NzbDrone.Core/Datastore/BasicRepository.cs b/src/NzbDrone.Core/Datastore/BasicRepository.cs
index d4b3380e5..8287c29a8 100644
--- a/src/NzbDrone.Core/Datastore/BasicRepository.cs
+++ b/src/NzbDrone.Core/Datastore/BasicRepository.cs
@@ -407,7 +407,7 @@ public virtual PagingSpec GetPaged(PagingSpec pagingSpec)
return pagingSpec;
}
- private void AddFilters(SqlBuilder builder, PagingSpec pagingSpec)
+ protected void AddFilters(SqlBuilder builder, PagingSpec pagingSpec)
{
var filters = pagingSpec.FilterExpressions;
diff --git a/src/NzbDrone.Core/History/HistoryRepository.cs b/src/NzbDrone.Core/History/HistoryRepository.cs
index 82a4fcb5c..6f5fd24ec 100644
--- a/src/NzbDrone.Core/History/HistoryRepository.cs
+++ b/src/NzbDrone.Core/History/HistoryRepository.cs
@@ -19,6 +19,7 @@ public interface IHistoryRepository : IBasicRepository
void DeleteForMovies(List movieIds);
MovieHistory MostRecentForMovie(int movieId);
List Since(DateTime date, MovieHistoryEventType? eventType);
+ PagingSpec GetPaged(PagingSpec pagingSpec, int[] languages, int[] qualities);
}
public class HistoryRepository : BasicRepository, IHistoryRepository
@@ -74,19 +75,6 @@ public void DeleteForMovies(List movieIds)
Delete(c => movieIds.Contains(c.MovieId));
}
- protected override SqlBuilder PagedBuilder() => new SqlBuilder(_database.DatabaseType)
- .Join((h, m) => h.MovieId == m.Id)
- .Join((m, p) => m.QualityProfileId == p.Id)
- .LeftJoin((m, mm) => m.MovieMetadataId == mm.Id);
-
- protected override IEnumerable PagedQuery(SqlBuilder sql) =>
- _database.QueryJoined(sql, (hist, movie, profile) =>
- {
- hist.Movie = movie;
- hist.Movie.QualityProfile = profile;
- return hist;
- });
-
public MovieHistory MostRecentForMovie(int movieId)
{
return Query(x => x.MovieId == movieId).MaxBy(h => h.Date);
@@ -106,5 +94,77 @@ public List Since(DateTime date, MovieHistoryEventType? eventType)
return PagedQuery(builder).OrderBy(h => h.Date).ToList();
}
+
+ public PagingSpec GetPaged(PagingSpec 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 pagingSpec, int[] languages, int[] qualities)
+ {
+ var builder = Builder()
+ .Join((h, m) => h.MovieId == m.Id)
+ .Join((m, p) => m.QualityProfileId == p.Id)
+ .LeftJoin((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 PagedQuery(SqlBuilder builder) =>
+ _database.QueryJoined(builder, (hist, movie, profile) =>
+ {
+ hist.Movie = movie;
+ hist.Movie.QualityProfile = profile;
+ return hist;
+ });
+
+ private string BuildLanguageWhereClause(int[] languages)
+ {
+ var clauses = new List();
+
+ 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();
+
+ foreach (var quality in qualities)
+ {
+ clauses.Add($"\"{TableMapping.Mapper.TableNameMapping(typeof(MovieHistory))}\".\"Quality\" LIKE '%_quality_: {quality},%'");
+ }
+
+ return $"({string.Join(" OR ", clauses)})";
+ }
}
}
diff --git a/src/NzbDrone.Core/History/HistoryService.cs b/src/NzbDrone.Core/History/HistoryService.cs
index 1c1bb2c56..c7807624b 100644
--- a/src/NzbDrone.Core/History/HistoryService.cs
+++ b/src/NzbDrone.Core/History/HistoryService.cs
@@ -19,7 +19,7 @@ namespace NzbDrone.Core.History
public interface IHistoryService
{
QualityModel GetBestQualityInHistory(QualityProfile profile, int movieId);
- PagingSpec Paged(PagingSpec pagingSpec);
+ PagingSpec Paged(PagingSpec pagingSpec, int[] languages, int[] qualities);
MovieHistory MostRecentForMovie(int movieId);
MovieHistory MostRecentForDownloadId(string downloadId);
MovieHistory Get(int historyId);
@@ -49,9 +49,9 @@ public HistoryService(IHistoryRepository historyRepository, Logger logger)
_logger = logger;
}
- public PagingSpec Paged(PagingSpec pagingSpec)
+ public PagingSpec Paged(PagingSpec pagingSpec, int[] languages, int[] qualities)
{
- return _historyRepository.GetPaged(pagingSpec);
+ return _historyRepository.GetPaged(pagingSpec, languages, qualities);
}
public MovieHistory MostRecentForMovie(int movieId)
diff --git a/src/Radarr.Api.V3/History/HistoryController.cs b/src/Radarr.Api.V3/History/HistoryController.cs
index 70ff758c1..c82e7a85c 100644
--- a/src/Radarr.Api.V3/History/HistoryController.cs
+++ b/src/Radarr.Api.V3/History/HistoryController.cs
@@ -61,7 +61,7 @@ protected HistoryResource MapToResource(MovieHistory model, bool includeMovie)
[HttpGet]
[Produces("application/json")]
- public PagingResource GetHistory([FromQuery] PagingRequestResource paging, bool includeMovie, int? eventType, string downloadId)
+ public PagingResource 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(paging);
var pagingSpec = pagingResource.MapToPagingSpec("date", SortDirection.Descending);
@@ -77,16 +77,23 @@ public PagingResource GetHistory([FromQuery] PagingRequestResou
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")]
+ [Produces("application/json")]
public List GetHistorySince(DateTime date, MovieHistoryEventType? eventType = null, bool includeMovie = false)
{
return _historyService.Since(date, eventType).Select(h => MapToResource(h, includeMovie)).ToList();
}
[HttpGet("movie")]
+ [Produces("application/json")]
public List GetMovieHistory(int movieId, MovieHistoryEventType? eventType = null, bool includeMovie = false)
{
return _historyService.GetByMovieId(movieId, eventType).Select(h => MapToResource(h, includeMovie)).ToList();