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")]