diff --git a/frontend/src/Activity/History/History.js b/frontend/src/Activity/History/History.js
index 522092725..e5cc31ecd 100644
--- a/frontend/src/Activity/History/History.js
+++ b/frontend/src/Activity/History/History.js
@@ -15,6 +15,7 @@ import TablePager from 'Components/Table/TablePager';
import { align, icons, kinds } from 'Helpers/Props';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import translate from 'Utilities/String/translate';
+import HistoryFilterModal from './HistoryFilterModal';
import HistoryRowConnector from './HistoryRowConnector';
class History extends Component {
@@ -52,6 +53,7 @@ class History extends Component {
columns,
selectedFilterKey,
filters,
+ customFilters,
totalRecords,
isEpisodesFetching,
isEpisodesPopulated,
@@ -92,7 +94,8 @@ class History extends Component {
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
- customFilters={[]}
+ customFilters={customFilters}
+ filterModalConnectorComponent={HistoryFilterModal}
onFilterSelect={onFilterSelect}
/>
@@ -163,8 +166,9 @@ History.propTypes = {
error: 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,
isEpisodesFetching: PropTypes.bool.isRequired,
isEpisodesPopulated: PropTypes.bool.isRequired,
diff --git a/frontend/src/Activity/History/HistoryConnector.js b/frontend/src/Activity/History/HistoryConnector.js
index 74b7fdfb4..b407960bd 100644
--- a/frontend/src/Activity/History/HistoryConnector.js
+++ b/frontend/src/Activity/History/HistoryConnector.js
@@ -6,6 +6,7 @@ import withCurrentPage from 'Components/withCurrentPage';
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
import { clearEpisodeFiles } from 'Store/Actions/episodeFileActions';
import * as historyActions from 'Store/Actions/historyActions';
+import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
@@ -15,11 +16,13 @@ function createMapStateToProps() {
return createSelector(
(state) => state.history,
(state) => state.episodes,
- (history, episodes) => {
+ createCustomFiltersSelector('history'),
+ (history, episodes, customFilters) => {
return {
isEpisodesFetching: episodes.isFetching,
isEpisodesPopulated: episodes.isPopulated,
episodesError: episodes.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 572a5a11e..fcf1833ee 100644
--- a/frontend/src/App/State/AppState.ts
+++ b/frontend/src/App/State/AppState.ts
@@ -3,6 +3,7 @@ import CalendarAppState from './CalendarAppState';
import CommandAppState from './CommandAppState';
import EpisodeFilesAppState from './EpisodeFilesAppState';
import EpisodesAppState from './EpisodesAppState';
+import HistoryAppState from './HistoryAppState';
import ParseAppState from './ParseAppState';
import QueueAppState from './QueueAppState';
import RootFolderAppState from './RootFolderAppState';
@@ -48,6 +49,7 @@ interface AppState {
commands: CommandAppState;
episodeFiles: EpisodeFilesAppState;
episodesSelection: EpisodesAppState;
+ history: HistoryAppState;
interactiveImport: InteractiveImportAppState;
parse: ParseAppState;
queue: QueueAppState;
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 6591bc4b7..01c24b460 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 IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector';
import LanguageFilterBuilderRowValue from './LanguageFilterBuilderRowValue';
import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
@@ -59,6 +60,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..c5f41ebc3
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+import FilterBuilderRowValueProps from './FilterBuilderRowValueProps';
+
+const EVENT_TYPE_OPTIONS = [
+ {
+ id: 1,
+ name: 'Grabbed',
+ },
+ {
+ id: 3,
+ name: 'Imported',
+ },
+ {
+ id: 4,
+ name: 'Failed',
+ },
+ {
+ id: 5,
+ name: 'Deleted',
+ },
+ {
+ id: 6,
+ name: 'Renamed',
+ },
+ {
+ id: 7,
+ name: '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 49b6fcbb6..1f4227779 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 26a2bb8a9..01d0fe66b 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';
@@ -185,6 +185,33 @@ export const defaultState = {
}
]
}
+ ],
+
+ filterBuilderProps: [
+ {
+ name: 'eventType',
+ label: 'Event Type',
+ type: filterBuilderTypes.EQUAL,
+ valueType: filterBuilderValueTypes.HISTORY_EVENT_TYPE
+ },
+ {
+ name: 'seriesIds',
+ label: 'Series',
+ type: filterBuilderTypes.EQUAL,
+ valueType: filterBuilderValueTypes.SERIES
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ type: filterBuilderTypes.EQUAL,
+ valueType: filterBuilderValueTypes.QUALITY
+ },
+ {
+ name: 'languages',
+ label: '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..99fabe275
--- /dev/null
+++ b/frontend/src/typings/History.ts
@@ -0,0 +1,28 @@
+import Language from 'Language/Language';
+import { QualityModel } from 'Quality/Quality';
+import CustomFormat from './CustomFormat';
+
+export type HistoryEventType =
+ | 'grabbed'
+ | 'seriesFolderImported'
+ | 'downloadFolderImported'
+ | 'downloadFailed'
+ | 'episodeFileDeleted'
+ | 'episodeFileRenamed'
+ | 'downloadIgnored';
+
+export default interface History {
+ episodeId: number;
+ seriesId: 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 6c71576a7..a4a043e01 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 @@ namespace NzbDrone.Core.Analytics
{
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 b5e57be87..a7da9eb38 100644
--- a/src/NzbDrone.Core/Datastore/BasicRepository.cs
+++ b/src/NzbDrone.Core/Datastore/BasicRepository.cs
@@ -407,7 +407,7 @@ namespace NzbDrone.Core.Datastore
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 c5546147a..7f3da4064 100644
--- a/src/NzbDrone.Core/History/HistoryRepository.cs
+++ b/src/NzbDrone.Core/History/HistoryRepository.cs
@@ -19,6 +19,7 @@ namespace NzbDrone.Core.History
List FindDownloadHistory(int idSeriesId, QualityModel quality);
void DeleteForSeries(List seriesIds);
List Since(DateTime date, EpisodeHistoryEventType? eventType);
+ PagingSpec GetPaged(PagingSpec pagingSpec, int[] languages, int[] qualities);
}
public class HistoryRepository : BasicRepository, IHistoryRepository
@@ -101,18 +102,6 @@ namespace NzbDrone.Core.History
Delete(c => seriesIds.Contains(c.SeriesId));
}
- protected override SqlBuilder PagedBuilder() => new SqlBuilder(_database.DatabaseType)
- .Join((h, a) => h.SeriesId == a.Id)
- .Join((h, a) => h.EpisodeId == a.Id);
-
- protected override IEnumerable PagedQuery(SqlBuilder builder) =>
- _database.QueryJoined(builder, (history, series, episode) =>
- {
- history.Series = series;
- history.Episode = episode;
- return history;
- });
-
public List Since(DateTime date, EpisodeHistoryEventType? eventType)
{
var builder = Builder()
@@ -132,5 +121,76 @@ namespace NzbDrone.Core.History
return history;
}).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(EpisodeHistory))}\" /**join**/ /**innerjoin**/ /**leftjoin**/ /**where**/ /**groupby**/ /**having**/) AS \"Inner\"";
+ pagingSpec.TotalRecords = GetPagedRecordCount(PagedBuilder(pagingSpec, languages, qualities).Select(typeof(EpisodeHistory)), pagingSpec, countTemplate);
+
+ return pagingSpec;
+ }
+
+ private SqlBuilder PagedBuilder(PagingSpec pagingSpec, int[] languages, int[] qualities)
+ {
+ var builder = Builder()
+ .Join((h, a) => h.SeriesId == a.Id)
+ .Join((h, a) => h.EpisodeId == a.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, (history, series, episode) =>
+ {
+ history.Series = series;
+ history.Episode = episode;
+ return history;
+ });
+
+ 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(EpisodeHistory))}\".\"Languages\" LIKE '[% {language},%]'");
+ clauses.Add($"\"{TableMapping.Mapper.TableNameMapping(typeof(EpisodeHistory))}\".\"Languages\" LIKE '[% {language}' || CHAR(13) || '%]'");
+ clauses.Add($"\"{TableMapping.Mapper.TableNameMapping(typeof(EpisodeHistory))}\".\"Languages\" LIKE '[% {language}' || CHAR(10) || '%]'");
+ clauses.Add($"\"{TableMapping.Mapper.TableNameMapping(typeof(EpisodeHistory))}\".\"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(EpisodeHistory))}\".\"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 1f1e9a668..86fe74846 100644
--- a/src/NzbDrone.Core/History/HistoryService.cs
+++ b/src/NzbDrone.Core/History/HistoryService.cs
@@ -16,7 +16,7 @@ namespace NzbDrone.Core.History
{
public interface IHistoryService
{
- PagingSpec Paged(PagingSpec pagingSpec);
+ PagingSpec Paged(PagingSpec pagingSpec, int[] languages, int[] qualities);
EpisodeHistory MostRecentForEpisode(int episodeId);
List FindByEpisodeId(int episodeId);
EpisodeHistory MostRecentForDownloadId(string downloadId);
@@ -47,9 +47,9 @@ namespace NzbDrone.Core.History
_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 EpisodeHistory MostRecentForEpisode(int episodeId)
diff --git a/src/Sonarr.Api.V3/History/HistoryController.cs b/src/Sonarr.Api.V3/History/HistoryController.cs
index 088ed9163..3fb8b1cf4 100644
--- a/src/Sonarr.Api.V3/History/HistoryController.cs
+++ b/src/Sonarr.Api.V3/History/HistoryController.cs
@@ -62,7 +62,7 @@ namespace Sonarr.Api.V3.History
[HttpGet]
[Produces("application/json")]
- public PagingResource GetHistory([FromQuery] PagingRequestResource paging, bool includeSeries, bool includeEpisode, int? eventType, int? episodeId, string downloadId)
+ public PagingResource GetHistory([FromQuery] PagingRequestResource paging, bool includeSeries, bool includeEpisode, int? eventType, int? episodeId, string downloadId, [FromQuery] int[] seriesIds = null, [FromQuery] int[] languages = null, [FromQuery] int[] quality = null)
{
var pagingResource = new PagingResource(paging);
var pagingSpec = pagingResource.MapToPagingSpec("date", SortDirection.Descending);
@@ -83,7 +83,17 @@ namespace Sonarr.Api.V3.History
pagingSpec.FilterExpressions.Add(h => h.DownloadId == downloadId);
}
- return pagingSpec.ApplyToPage(_historyService.Paged, h => MapToResource(h, includeSeries, includeEpisode));
+ if (seriesIds != null && seriesIds.Any())
+ {
+ pagingSpec.FilterExpressions.Add(h => seriesIds.Contains(h.SeriesId));
+ }
+
+ if (seriesIds != null && seriesIds.Any())
+ {
+ pagingSpec.FilterExpressions.Add(h => seriesIds.Contains(h.SeriesId));
+ }
+
+ return pagingSpec.ApplyToPage(h => _historyService.Paged(pagingSpec, languages, quality), h => MapToResource(h, includeSeries, includeEpisode));
}
[HttpGet("since")]