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 @@ 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 d4b3380e5..8287c29a8 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 82a4fcb5c..6f5fd24ec 100644 --- a/src/NzbDrone.Core/History/HistoryRepository.cs +++ b/src/NzbDrone.Core/History/HistoryRepository.cs @@ -19,6 +19,7 @@ namespace NzbDrone.Core.History 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 @@ namespace NzbDrone.Core.History 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 @@ namespace NzbDrone.Core.History 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 @@ 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 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 @@ namespace Radarr.Api.V3.History [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 @@ namespace Radarr.Api.V3.History 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();