From e408c6f0559a16c33f82c1e2c10b3815d9216f26 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Mon, 22 May 2023 20:06:32 -0700 Subject: [PATCH] New: History custom filters (cherry picked from commit 2fe8f3084c90688e6dd01d600796396e74f43ff9) Closes #4213 Closes #4235 Closes #4236 --- frontend/src/Activity/History/History.js | 8 ++- .../src/Activity/History/HistoryConnector.js | 5 +- .../Activity/History/HistoryFilterModal.tsx | 54 +++++++++++++++ frontend/src/App/State/AppState.ts | 2 + frontend/src/App/State/HistoryAppState.ts | 10 +++ .../Filter/Builder/FilterBuilderRow.js | 4 ++ .../HistoryEventTypeFilterBuilderRowValue.tsx | 69 +++++++++++++++++++ .../Helpers/Props/filterBuilderValueTypes.js | 1 + frontend/src/Store/Actions/historyActions.js | 23 ++++++- frontend/src/typings/History.ts | 29 ++++++++ .../History/HistoryController.cs | 9 ++- .../Analytics/AnalyticsService.cs | 2 +- .../Datastore/BasicRepository.cs | 2 +- .../History/EntityHistoryRepository.cs | 62 +++++++++++++---- .../History/EntityHistoryService.cs | 6 +- 15 files changed, 262 insertions(+), 24 deletions(-) create mode 100644 frontend/src/Activity/History/HistoryFilterModal.tsx create mode 100644 frontend/src/App/State/HistoryAppState.ts create mode 100644 frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx create mode 100644 frontend/src/typings/History.ts diff --git a/frontend/src/Activity/History/History.js b/frontend/src/Activity/History/History.js index 5c830ee1a..d144a5402 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, isArtistFetching, isArtistPopulated, @@ -94,7 +96,8 @@ class History extends Component { alignMenu={align.RIGHT} selectedFilterKey={selectedFilterKey} filters={filters} - customFilters={[]} + customFilters={customFilters} + filterModalConnectorComponent={HistoryFilterModal} onFilterSelect={onFilterSelect} /> @@ -165,8 +168,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, isArtistFetching: PropTypes.bool.isRequired, isArtistPopulated: PropTypes.bool.isRequired, diff --git a/frontend/src/Activity/History/HistoryConnector.js b/frontend/src/Activity/History/HistoryConnector.js index 801aaf0e0..2b3354bc5 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 { clearAlbums, fetchAlbums } from 'Store/Actions/albumActions'; import * as historyActions from 'Store/Actions/historyActions'; import { clearTracks, fetchTracks } from 'Store/Actions/trackActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; @@ -17,7 +18,8 @@ function createMapStateToProps() { (state) => state.artist, (state) => state.albums, (state) => state.tracks, - (history, artist, albums, tracks) => { + createCustomFiltersSelector('history'), + (history, artist, albums, tracks, customFilters) => { return { isArtistFetching: artist.isFetching, isArtistPopulated: artist.isPopulated, @@ -27,6 +29,7 @@ function createMapStateToProps() { isTracksFetching: tracks.isFetching, isTracksPopulated: tracks.isPopulated, tracksError: tracks.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 5b7b01252..5add86a4e 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -1,5 +1,6 @@ import AlbumAppState from './AlbumAppState'; import ArtistAppState, { ArtistIndexAppState } from './ArtistAppState'; +import HistoryAppState from './HistoryAppState'; import QueueAppState from './QueueAppState'; import SettingsAppState from './SettingsAppState'; import TagsAppState from './TagsAppState'; @@ -42,6 +43,7 @@ interface AppState { albums: AlbumAppState; artist: ArtistAppState; artistIndex: ArtistIndexAppState; + history: HistoryAppState; queue: QueueAppState; settings: SettingsAppState; tags: TagsAppState; 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 91b9ccc64..f613d20bc 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js @@ -8,6 +8,7 @@ import ArtistStatusFilterBuilderRowValue from './ArtistStatusFilterBuilderRowVal import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue'; import DateFilterBuilderRowValue from './DateFilterBuilderRowValue'; import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector'; +import HistoryEventTypeFilterBuilderRowValue from './HistoryEventTypeFilterBuilderRowValue'; import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector'; import MetadataProfileFilterBuilderRowValueConnector from './MetadataProfileFilterBuilderRowValueConnector'; import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue'; @@ -58,6 +59,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..45e6fc756 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx @@ -0,0 +1,69 @@ +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('TrackImported'); + }, + }, + { + id: 4, + get name() { + return translate('DownloadFailed'); + }, + }, + { + id: 7, + get name() { + return translate('ImportFailed'); + }, + }, + { + id: 8, + get name() { + return translate('DownloadImported'); + }, + }, + { + id: 5, + get name() { + return translate('Deleted'); + }, + }, + { + id: 6, + get name() { + return translate('Renamed'); + }, + }, + { + id: 9, + get name() { + return translate('Retagged'); + }, + }, + { + 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 a2f982404..b19d16c8a 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 METADATA_PROFILE = 'metadataProfile'; export const PROTOCOL = 'protocol'; diff --git a/frontend/src/Store/Actions/historyActions.js b/frontend/src/Store/Actions/historyActions.js index 9dc212645..2fddabbda 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'; @@ -219,6 +219,27 @@ export const defaultState = { } ] } + ], + + filterBuilderProps: [ + { + name: 'eventType', + label: () => translate('EventType'), + type: filterBuilderTypes.EQUAL, + valueType: filterBuilderValueTypes.HISTORY_EVENT_TYPE + }, + { + name: 'artistIds', + label: () => translate('Artist'), + type: filterBuilderTypes.EQUAL, + valueType: filterBuilderValueTypes.ARTIST + }, + { + name: 'quality', + label: () => translate('Quality'), + type: filterBuilderTypes.EQUAL, + valueType: filterBuilderValueTypes.QUALITY + } ] }; diff --git a/frontend/src/typings/History.ts b/frontend/src/typings/History.ts new file mode 100644 index 000000000..55e004742 --- /dev/null +++ b/frontend/src/typings/History.ts @@ -0,0 +1,29 @@ +import { QualityModel } from 'Quality/Quality'; +import CustomFormat from './CustomFormat'; + +export type HistoryEventType = + | 'grabbed' + | 'artistFolderImported' + | 'trackFileImported' + | 'downloadFailed' + | 'trackFileDeleted' + | 'trackFileRenamed' + | 'albumImportIncomplete' + | 'downloadImported' + | 'trackFileRetagged' + | 'downloadIgnored'; + +export default interface History { + episodeId: number; + seriesId: number; + sourceTitle: string; + quality: QualityModel; + customFormats: CustomFormat[]; + customFormatScore: number; + qualityCutoffNotMet: boolean; + date: string; + downloadId: string; + eventType: HistoryEventType; + data: unknown; + id: number; +} diff --git a/src/Lidarr.Api.V1/History/HistoryController.cs b/src/Lidarr.Api.V1/History/HistoryController.cs index 7c8197209..150ef3b08 100644 --- a/src/Lidarr.Api.V1/History/HistoryController.cs +++ b/src/Lidarr.Api.V1/History/HistoryController.cs @@ -68,7 +68,7 @@ namespace Lidarr.Api.V1.History [HttpGet] [Produces("application/json")] - public PagingResource GetHistory([FromQuery] PagingRequestResource paging, bool includeArtist, bool includeAlbum, bool includeTrack, int? eventType, int? albumId, string downloadId) + public PagingResource GetHistory([FromQuery] PagingRequestResource paging, bool includeArtist, bool includeAlbum, bool includeTrack, int? eventType, int? albumId, string downloadId, [FromQuery] int[] artistIds = null, [FromQuery] int[] quality = null) { var pagingResource = new PagingResource(paging); var pagingSpec = pagingResource.MapToPagingSpec("date", SortDirection.Descending); @@ -89,7 +89,12 @@ namespace Lidarr.Api.V1.History pagingSpec.FilterExpressions.Add(h => h.DownloadId == downloadId); } - return pagingSpec.ApplyToPage(_historyService.Paged, h => MapToResource(h, includeArtist, includeAlbum, includeTrack)); + if (artistIds != null && artistIds.Any()) + { + pagingSpec.FilterExpressions.Add(h => artistIds.Contains(h.ArtistId)); + } + + return pagingSpec.ApplyToPage(h => _historyService.Paged(pagingSpec, quality), h => MapToResource(h, includeArtist, includeAlbum, includeTrack)); } [HttpGet("since")] diff --git a/src/NzbDrone.Core/Analytics/AnalyticsService.cs b/src/NzbDrone.Core/Analytics/AnalyticsService.cs index f22d3d908..bca32969c 100644 --- a/src/NzbDrone.Core/Analytics/AnalyticsService.cs +++ b/src/NzbDrone.Core/Analytics/AnalyticsService.cs @@ -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); 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 009b6614e..49013dd8a 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/EntityHistoryRepository.cs b/src/NzbDrone.Core/History/EntityHistoryRepository.cs index 4491bed11..e5f427bc0 100644 --- a/src/NzbDrone.Core/History/EntityHistoryRepository.cs +++ b/src/NzbDrone.Core/History/EntityHistoryRepository.cs @@ -18,6 +18,7 @@ namespace NzbDrone.Core.History List FindDownloadHistory(int idArtistId, QualityModel quality); void DeleteForArtists(List artistIds); List Since(DateTime date, EntityHistoryEventType? eventType); + PagingSpec GetPaged(PagingSpec pagingSpec, int[] qualities); } public class EntityHistoryRepository : BasicRepository, IHistoryRepository @@ -98,19 +99,6 @@ namespace NzbDrone.Core.History Delete(c => artistIds.Contains(c.ArtistId)); } - protected override SqlBuilder PagedBuilder() => new SqlBuilder(_database.DatabaseType) - .Join((h, a) => h.ArtistId == a.Id) - .Join((h, a) => h.AlbumId == a.Id) - .LeftJoin((h, t) => h.TrackId == t.Id); - protected override IEnumerable PagedQuery(SqlBuilder builder) => - _database.QueryJoined(builder, (history, artist, album, track) => - { - history.Artist = artist; - history.Album = album; - history.Track = track; - return history; - }); - public List Since(DateTime date, EntityHistoryEventType? eventType) { var builder = Builder() @@ -130,5 +118,53 @@ namespace NzbDrone.Core.History return history; }).OrderBy(h => h.Date).ToList(); } + + public PagingSpec GetPaged(PagingSpec pagingSpec, int[] qualities) + { + pagingSpec.Records = GetPagedRecords(PagedBuilder(pagingSpec, qualities), pagingSpec, PagedQuery); + + var countTemplate = $"SELECT COUNT(*) FROM (SELECT /**select**/ FROM \"{TableMapping.Mapper.TableNameMapping(typeof(EntityHistory))}\" /**join**/ /**innerjoin**/ /**leftjoin**/ /**where**/ /**groupby**/ /**having**/) AS \"Inner\""; + pagingSpec.TotalRecords = GetPagedRecordCount(PagedBuilder(pagingSpec, qualities).Select(typeof(EntityHistory)), pagingSpec, countTemplate); + + return pagingSpec; + } + + private SqlBuilder PagedBuilder(PagingSpec pagingSpec, int[] qualities) + { + var builder = Builder() + .Join((h, a) => h.ArtistId == a.Id) + .Join((h, a) => h.AlbumId == a.Id) + .LeftJoin((h, t) => h.TrackId == t.Id); + + AddFilters(builder, pagingSpec); + + if (qualities is { Length: > 0 }) + { + builder.Where($"({BuildQualityWhereClause(qualities)})"); + } + + return builder; + } + + protected override IEnumerable PagedQuery(SqlBuilder builder) => + _database.QueryJoined(builder, (history, artist, album, track) => + { + history.Artist = artist; + history.Album = album; + history.Track = track; + return history; + }); + + private string BuildQualityWhereClause(int[] qualities) + { + var clauses = new List(); + + foreach (var quality in qualities) + { + clauses.Add($"\"{TableMapping.Mapper.TableNameMapping(typeof(EntityHistory))}\".\"Quality\" LIKE '%_quality_: {quality},%'"); + } + + return $"({string.Join(" OR ", clauses)})"; + } } } diff --git a/src/NzbDrone.Core/History/EntityHistoryService.cs b/src/NzbDrone.Core/History/EntityHistoryService.cs index b1a028af4..061b4b267 100644 --- a/src/NzbDrone.Core/History/EntityHistoryService.cs +++ b/src/NzbDrone.Core/History/EntityHistoryService.cs @@ -18,7 +18,7 @@ namespace NzbDrone.Core.History { public interface IHistoryService { - PagingSpec Paged(PagingSpec pagingSpec); + PagingSpec Paged(PagingSpec pagingSpec, int[] qualities); EntityHistory MostRecentForAlbum(int albumId); EntityHistory MostRecentForDownloadId(string downloadId); EntityHistory Get(int historyId); @@ -52,9 +52,9 @@ namespace NzbDrone.Core.History _logger = logger; } - public PagingSpec Paged(PagingSpec pagingSpec) + public PagingSpec Paged(PagingSpec pagingSpec, int[] qualities) { - return _historyRepository.GetPaged(pagingSpec); + return _historyRepository.GetPaged(pagingSpec, qualities); } public EntityHistory MostRecentForAlbum(int albumId)