From 7bba42288092f4349ab095a71399cc635ed50168 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Wed, 25 Sep 2024 10:27:34 -0500 Subject: [PATCH] fix emptying trash (#1905) --- CHANGELOG.md | 2 + .../Maintenance/Commands/EmptyTrashHandler.cs | 30 +--- .../Interfaces/Search/ISearchIndex.cs | 2 +- .../Search/ElasticSearchIndex.cs | 17 +- .../Search/LuceneSearchIndex.cs | 4 +- ErsatzTV/Pages/Trash.razor | 162 ++++++++++++++---- 6 files changed, 146 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 973e3023b..9b39cd0b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Fixed - Fix startup error with MySql backend caused by database cleaner +- Fix emptying trash with ElasticSearch backend +- Fix double loading of trash UI elements, and fix reloading of all UI elements after emptying trash ## [0.8.8-beta] - 2024-09-19 ### Added diff --git a/ErsatzTV.Application/Maintenance/Commands/EmptyTrashHandler.cs b/ErsatzTV.Application/Maintenance/Commands/EmptyTrashHandler.cs index cf3085b79..e15549c16 100644 --- a/ErsatzTV.Application/Maintenance/Commands/EmptyTrashHandler.cs +++ b/ErsatzTV.Application/Maintenance/Commands/EmptyTrashHandler.cs @@ -3,7 +3,6 @@ using ErsatzTV.Core; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Search; -using ErsatzTV.Infrastructure.Search; namespace ErsatzTV.Application.Maintenance; @@ -27,33 +26,16 @@ public class EmptyTrashHandler : IRequestHandler(); - - foreach (string type in types) - { - SearchResult result = await _searchIndex.Search(_client, $"type:{type} AND (state:FileNotFound)", 0, 0); - ids.AddRange(result.Items.Map(i => i.Id)); - } + SearchResult result = await _searchIndex.Search(_client, "state:FileNotFound", 0, 10_000); + var ids = result.Items.Map(i => i.Id).ToList(); - Either deleteResult = await _mediaItemRepository.DeleteItems(ids); - if (deleteResult.IsRight) + // ElasticSearch remove items may fail, so do that first + if (await _searchIndex.RemoveItems(ids)) { - await _searchIndex.RemoveItems(ids); _searchIndex.Commit(); + return await _mediaItemRepository.DeleteItems(ids); } - return deleteResult; + return BaseError.New("Failed to empty trash"); } } diff --git a/ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs b/ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs index 70f711bb0..a55196a15 100644 --- a/ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs +++ b/ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs @@ -24,7 +24,7 @@ public interface ISearchIndex : IDisposable IFallbackMetadataProvider fallbackMetadataProvider, List items); - Task RemoveItems(IEnumerable ids); + Task RemoveItems(IEnumerable ids); Task Search(IClient client, string query, int skip, int limit); void Commit(); } diff --git a/ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs b/ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs index 20f9c980c..e9680c4db 100644 --- a/ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs +++ b/ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs @@ -2,6 +2,7 @@ using System.Globalization; using Bugsnag; using Elastic.Clients.Elasticsearch; using Elastic.Clients.Elasticsearch.Aggregations; +using Elastic.Clients.Elasticsearch.Core.Bulk; using Elastic.Clients.Elasticsearch.IndexManagement; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Metadata; @@ -146,15 +147,17 @@ public class ElasticSearchIndex : ISearchIndex return Unit.Default; } - public async Task RemoveItems(IEnumerable ids) + public async Task RemoveItems(IEnumerable ids) { - await _client.BulkAsync( - descriptor => descriptor - .Index(IndexName) - .DeleteMany(ids.Map(id => new Id(id))) - ); + var deleteBulkRequest = new BulkRequest { Operations = [] }; + foreach (int id in ids) + { + var deleteOperation = new BulkDeleteOperation(new Id(id)) { Index = IndexName }; + deleteBulkRequest.Operations.Add(deleteOperation); + } - return Unit.Default; + BulkResponse deleteResponse = await _client.BulkAsync(deleteBulkRequest).ConfigureAwait(false); + return deleteResponse.IsValidResponse; } public async Task Search(IClient client, string query, int skip, int limit) diff --git a/ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs b/ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs index 26862638a..b4969655e 100644 --- a/ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs +++ b/ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs @@ -186,14 +186,14 @@ public sealed class LuceneSearchIndex : ISearchIndex return Unit.Default; } - public Task RemoveItems(IEnumerable ids) + public Task RemoveItems(IEnumerable ids) { foreach (int id in ids) { _writer.DeleteDocuments(new Term(IdField, id.ToString(CultureInfo.InvariantCulture))); } - return Task.FromResult(Unit.Default); + return Task.FromResult(true); } public Task Search(IClient client, string query, int skip, int limit) diff --git a/ErsatzTV/Pages/Trash.razor b/ErsatzTV/Pages/Trash.razor index 61fd7f520..985aed5fc 100644 --- a/ErsatzTV/Pages/Trash.razor +++ b/ErsatzTV/Pages/Trash.razor @@ -5,6 +5,7 @@ @using ErsatzTV.Extensions @inherits MultiSelectBase @inject NavigationManager NavigationManager +@inject PersistentComponentState ApplicationState
@@ -29,47 +30,47 @@ } else { - if (_movies?.Count > 0) + if (_movies?.Cards.Count > 0) { @_movies.Count Movies } - if (_shows?.Count > 0) + if (_shows?.Cards.Count > 0) { @_shows.Count Shows } - if (_seasons?.Count > 0) + if (_seasons?.Cards.Count > 0) { @_seasons.Count Seasons } - if (_episodes?.Count > 0) + if (_episodes?.Cards.Count > 0) { @_episodes.Count Episodes } - if (_artists?.Count > 0) + if (_artists?.Cards.Count > 0) { @_artists.Count Artists } - if (_musicVideos?.Count > 0) + if (_musicVideos?.Cards.Count > 0) { @_musicVideos.Count Music Videos } - if (_otherVideos?.Count > 0) + if (_otherVideos?.Cards.Count > 0) { @_otherVideos.Count Other Videos } - if (_songs?.Count > 0) + if (_songs?.Cards.Count > 0) { @_songs.Count Songs } - if (_images?.Count > 0) + if (_images?.Cards.Count > 0) { @_images.Count Images } @@ -93,7 +94,7 @@
- @if (_movies?.Count > 0) + @if (_movies?.Cards.Count > 0) {
} - @if (_shows?.Count > 0) + @if (_shows?.Cards.Count > 0) {
} - @if (_seasons?.Count > 0) + @if (_seasons?.Cards.Count > 0) {
} - @if (_episodes?.Count > 0) + @if (_episodes?.Cards.Count > 0) {
} - @if (_artists?.Count > 0) + @if (_artists?.Cards.Count > 0) {
} - @if (_musicVideos?.Count > 0) + @if (_musicVideos?.Cards.Count > 0) {
} - @if (_otherVideos?.Count > 0) + @if (_otherVideos?.Cards.Count > 0) {
} - @if (_songs?.Count > 0) + @if (_songs?.Cards.Count > 0) {
} - @if (_images?.Count > 0) + @if (_images?.Cards.Count > 0) {
@code { + private readonly CancellationTokenSource _cts = new(); + private string _query; private MovieCardResultsViewModel _movies; private TelevisionShowCardResultsViewModel _shows; @@ -364,27 +367,102 @@ private ImageCardResultsViewModel _images; private ArtistCardResultsViewModel _artists; - protected override Task OnInitializedAsync() => RefreshData(); + private PersistingComponentStateSubscription _persistingSubscription; + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _persistingSubscription.Dispose(); + + _cts.Cancel(); + _cts.Dispose(); + } + + base.Dispose(disposing); + } + + protected override Task OnInitializedAsync() + { + _persistingSubscription = ApplicationState.RegisterOnPersisting(PersistData); + return base.OnInitializedAsync(); + } + + private Task PersistData() + { + ApplicationState.PersistAsJson("_movies", _movies); + ApplicationState.PersistAsJson("_shows", _shows); + ApplicationState.PersistAsJson("_seasons", _seasons); + ApplicationState.PersistAsJson("_episodes", _episodes); + ApplicationState.PersistAsJson("_musicVideos", _musicVideos); + ApplicationState.PersistAsJson("_otherVideos", _otherVideos); + ApplicationState.PersistAsJson("_songs", _songs); + ApplicationState.PersistAsJson("_images", _images); + ApplicationState.PersistAsJson("_artists", _artists); + + return Task.CompletedTask; + } + + protected override async Task OnParametersSetAsync() + { + await RefreshData(); + await InvokeAsync(StateHasChanged); + } protected override async Task RefreshData() { _query = "state:FileNotFound"; if (!string.IsNullOrWhiteSpace(_query)) { - _movies = await Mediator.Send(new QuerySearchIndexMovies($"type:movie AND ({_query})", 1, 50), CancellationToken); - _shows = await Mediator.Send(new QuerySearchIndexShows($"type:show AND ({_query})", 1, 50), CancellationToken); - _seasons = await Mediator.Send(new QuerySearchIndexSeasons($"type:season AND ({_query})", 1, 50), CancellationToken); - _episodes = await Mediator.Send(new QuerySearchIndexEpisodes($"type:episode AND ({_query})", 1, 50), CancellationToken); - _musicVideos = await Mediator.Send(new QuerySearchIndexMusicVideos($"type:music_video AND ({_query})", 1, 50), CancellationToken); - _otherVideos = await Mediator.Send(new QuerySearchIndexOtherVideos($"type:other_video AND ({_query})", 1, 50), CancellationToken); - _songs = await Mediator.Send(new QuerySearchIndexSongs($"type:song AND ({_query})", 1, 50), CancellationToken); - _images = await Mediator.Send(new QuerySearchIndexImages($"type:image AND ({_query})", 1, 50), CancellationToken); - _artists = await Mediator.Send(new QuerySearchIndexArtists($"type:artist AND ({_query})", 1, 50), CancellationToken); + if (!ApplicationState.TryTakeFromJson("_movies", out _movies)) + { + _movies = await Mediator.Send(new QuerySearchIndexMovies($"type:movie AND ({_query})", 1, 50), _cts.Token); + } + + if (!ApplicationState.TryTakeFromJson("_shows", out _shows)) + { + _shows = await Mediator.Send(new QuerySearchIndexShows($"type:show AND ({_query})", 1, 50), _cts.Token); + } + + if (!ApplicationState.TryTakeFromJson("_seasons", out _seasons)) + { + _seasons = await Mediator.Send(new QuerySearchIndexSeasons($"type:season AND ({_query})", 1, 50), _cts.Token); + } + + if (!ApplicationState.TryTakeFromJson("_episodes", out _episodes)) + { + _episodes = await Mediator.Send(new QuerySearchIndexEpisodes($"type:episode AND ({_query})", 1, 50), _cts.Token); + } + + if (!ApplicationState.TryTakeFromJson("_musicVideos", out _musicVideos)) + { + _musicVideos = await Mediator.Send(new QuerySearchIndexMusicVideos($"type:music_video AND ({_query})", 1, 50), _cts.Token); + } + + if (!ApplicationState.TryTakeFromJson("_otherVideos", out _otherVideos)) + { + _otherVideos = await Mediator.Send(new QuerySearchIndexOtherVideos($"type:other_video AND ({_query})", 1, 50), _cts.Token); + } + + if (!ApplicationState.TryTakeFromJson("_songs", out _songs)) + { + _songs = await Mediator.Send(new QuerySearchIndexSongs($"type:song AND ({_query})", 1, 50), _cts.Token); + } + + if (!ApplicationState.TryTakeFromJson("_images", out _images)) + { + _images = await Mediator.Send(new QuerySearchIndexImages($"type:image AND ({_query})", 1, 50), _cts.Token); + } + + if (!ApplicationState.TryTakeFromJson("_artists", out _artists)) + { + _artists = await Mediator.Send(new QuerySearchIndexArtists($"type:artist AND ({_query})", 1, 50), _cts.Token); + } } } private bool IsNotEmpty => - _movies?.Count > 0 || _shows?.Count > 0 || _seasons?.Count > 0 || _episodes?.Count > 0 || _musicVideos?.Count > 0 || _otherVideos?.Count > 0 || _songs?.Count > 0 || _artists?.Count > 0 || _images?.Count > 0; + _movies?.Cards.Count > 0 || _shows?.Cards.Count > 0 || _seasons?.Cards.Count > 0 || _episodes?.Cards.Count > 0 || _musicVideos?.Cards.Count > 0 || _otherVideos?.Cards.Count > 0 || _songs?.Cards.Count > 0 || _artists?.Cards.Count > 0 || _images?.Cards.Count > 0; private void SelectClicked(MediaCardViewModel card, MouseEventArgs e) { @@ -558,7 +636,7 @@ .Append(imageIds) .ToList()); - Either addResult = await Mediator.Send(request, CancellationToken); + Either addResult = await Mediator.Send(request, _cts.Token); await addResult.Match( Left: error => { @@ -628,11 +706,11 @@ var parameters = new DialogParameters { { "EntityType", entityType }, { "EntityName", entityName } }; var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; - IDialogReference dialog = Dialog.Show("Delete From Database", parameters, options); + IDialogReference dialog = await Dialog.ShowAsync("Delete From Database", parameters, options); DialogResult result = await dialog.Result; - if (!result.Canceled) + if (result is not null && !result.Canceled) { - await Mediator.Send(request, CancellationToken); + await Mediator.Send(request, _cts.Token); await RefreshData(); } } @@ -645,12 +723,22 @@ var parameters = new DialogParameters { { "EntityType", count.ToString() }, { "EntityName", "missing items" } }; var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; - IDialogReference dialog = Dialog.Show("Delete From Database", parameters, options); + IDialogReference dialog = await Dialog.ShowAsync("Delete From Database", parameters, options); DialogResult result = await dialog.Result; - if (!result.Canceled) + if (result is not null && !result.Canceled) { - await Mediator.Send(new EmptyTrash(), CancellationToken); - await RefreshData(); + Either emptyTrashResult = await Mediator.Send(new EmptyTrash(), _cts.Token); + foreach (BaseError error in emptyTrashResult.LeftToSeq()) + { + Snackbar.Add(error.Value, Severity.Error); + Logger.LogError("Unexpected error emptying trash: {Error}", error.Value); + } + + if (emptyTrashResult.IsRight) + { + await RefreshData(); + await InvokeAsync(StateHasChanged); + } } }