diff --git a/CHANGELOG.md b/CHANGELOG.md index 9348a478..5f23dd81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +44,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Collections synchronized from media servers are still indexed as `tag` - Allow searching by `smart_collection` (name) - Quotes are *always* required when using this feature - - e.g. `smart_collection:"one" NOT smart_collection:"two"` + - e.g. `smart_collection:"one" NOT smart_collection:"two"` + - Cycles will be detected and logged, and searches with cycles will not work as expected ### Changed - Start to make UI minimally responsive (functional on smaller screens) diff --git a/ErsatzTV.Application/Maintenance/Commands/EmptyTrashHandler.cs b/ErsatzTV.Application/Maintenance/Commands/EmptyTrashHandler.cs index e15549c1..32bf06e3 100644 --- a/ErsatzTV.Application/Maintenance/Commands/EmptyTrashHandler.cs +++ b/ErsatzTV.Application/Maintenance/Commands/EmptyTrashHandler.cs @@ -26,7 +26,7 @@ public class EmptyTrashHandler : IRequestHandler i.Id).ToList(); // ElasticSearch remove items may fail, so do that first diff --git a/ErsatzTV.Application/MediaCollections/Commands/CreateSmartCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/CreateSmartCollectionHandler.cs index 039502e4..53a9e9d3 100644 --- a/ErsatzTV.Application/MediaCollections/Commands/CreateSmartCollectionHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Commands/CreateSmartCollectionHandler.cs @@ -1,6 +1,7 @@ using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Search; +using ErsatzTV.Core.Search; using ErsatzTV.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using static ErsatzTV.Application.MediaCollections.Mapper; @@ -12,11 +13,16 @@ public class CreateSmartCollectionHandler : { private readonly IDbContextFactory _dbContextFactory; private readonly ISearchTargets _searchTargets; + private readonly ISmartCollectionCache _smartCollectionCache; - public CreateSmartCollectionHandler(IDbContextFactory dbContextFactory, ISearchTargets searchTargets) + public CreateSmartCollectionHandler( + IDbContextFactory dbContextFactory, + ISearchTargets searchTargets, + ISmartCollectionCache smartCollectionCache) { _dbContextFactory = dbContextFactory; _searchTargets = searchTargets; + _smartCollectionCache = smartCollectionCache; } public async Task> Handle( @@ -35,6 +41,7 @@ public class CreateSmartCollectionHandler : await dbContext.SmartCollections.AddAsync(smartCollection); await dbContext.SaveChangesAsync(); _searchTargets.SearchTargetsChanged(); + await _smartCollectionCache.Refresh(); return ProjectToViewModel(smartCollection); } diff --git a/ErsatzTV.Application/MediaCollections/Commands/DeleteSmartCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/DeleteSmartCollectionHandler.cs index 2ef84b10..c2936205 100644 --- a/ErsatzTV.Application/MediaCollections/Commands/DeleteSmartCollectionHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Commands/DeleteSmartCollectionHandler.cs @@ -1,6 +1,7 @@ using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Search; +using ErsatzTV.Core.Search; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; @@ -11,11 +12,16 @@ public class DeleteSmartCollectionHandler : IRequestHandler _dbContextFactory; private readonly ISearchTargets _searchTargets; + private readonly ISmartCollectionCache _smartCollectionCache; - public DeleteSmartCollectionHandler(IDbContextFactory dbContextFactory, ISearchTargets searchTargets) + public DeleteSmartCollectionHandler( + IDbContextFactory dbContextFactory, + ISearchTargets searchTargets, + ISmartCollectionCache smartCollectionCache) { _dbContextFactory = dbContextFactory; _searchTargets = searchTargets; + _smartCollectionCache = smartCollectionCache; } public async Task> Handle( @@ -32,6 +38,7 @@ public class DeleteSmartCollectionHandler : IRequestHandler _dbContextFactory; private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly ISearchTargets _searchTargets; + private readonly ISmartCollectionCache _smartCollectionCache; public UpdateSmartCollectionHandler( IDbContextFactory dbContextFactory, IMediaCollectionRepository mediaCollectionRepository, ChannelWriter channel, - ISearchTargets searchTargets) + ISearchTargets searchTargets, + ISmartCollectionCache smartCollectionCache) { _dbContextFactory = dbContextFactory; _mediaCollectionRepository = mediaCollectionRepository; _channel = channel; _searchTargets = searchTargets; + _smartCollectionCache = smartCollectionCache; } public async Task> Handle( @@ -47,6 +51,7 @@ public class UpdateSmartCollectionHandler : IRequestHandler 0) { _searchTargets.SearchTargetsChanged(); + await _smartCollectionCache.Refresh(); // refresh all playouts that use this smart collection foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingSmartCollection(request.Id)) diff --git a/ErsatzTV.Application/Search/Queries/QuerySearchIndexAllItemsHandler.cs b/ErsatzTV.Application/Search/Queries/QuerySearchIndexAllItemsHandler.cs index fc84d4e1..b0b879e4 100644 --- a/ErsatzTV.Application/Search/Queries/QuerySearchIndexAllItemsHandler.cs +++ b/ErsatzTV.Application/Search/Queries/QuerySearchIndexAllItemsHandler.cs @@ -30,5 +30,5 @@ public class QuerySearchIndexAllItemsHandler : IRequestHandler> GetIds(string type, string query) => - (await _searchIndex.Search(_client, $"type:{type} AND ({query})", 0, 0)).Items.Map(i => i.Id).ToList(); + (await _searchIndex.Search(_client, $"type:{type} AND ({query})", string.Empty, 0, 0)).Items.Map(i => i.Id).ToList(); } diff --git a/ErsatzTV.Application/Search/Queries/QuerySearchIndexArtistsHandler.cs b/ErsatzTV.Application/Search/Queries/QuerySearchIndexArtistsHandler.cs index 035eaa30..bece6eaf 100644 --- a/ErsatzTV.Application/Search/Queries/QuerySearchIndexArtistsHandler.cs +++ b/ErsatzTV.Application/Search/Queries/QuerySearchIndexArtistsHandler.cs @@ -27,6 +27,7 @@ public class QuerySearchIndexArtistsHandler : IRequestHandler> GetMultiCollectionItemsByName(string name) => throw new NotSupportedException(); public Task> GetSmartCollectionItems(int id) => _data[id].ToList().AsTask(); public Task> GetSmartCollectionItemsByName(string name) => throw new NotSupportedException(); - public Task> GetSmartCollectionItems(string query) => throw new NotSupportedException(); + public Task> GetSmartCollectionItems(string query, string smartCollectionName) => throw new NotSupportedException(); public Task> GetShowItemsByShowGuids(List guids) => throw new NotSupportedException(); public Task> GetPlaylistItems(int id) => throw new NotSupportedException(); public Task> GetMovie(int id) => throw new NotSupportedException(); diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs index 1455805d..68c56b73 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs @@ -15,7 +15,7 @@ public interface IMediaCollectionRepository Task> GetMultiCollectionItemsByName(string name); Task> GetSmartCollectionItems(int id); Task> GetSmartCollectionItemsByName(string name); - Task> GetSmartCollectionItems(string query); + Task> GetSmartCollectionItems(string query, string smartCollectionName); Task> GetShowItemsByShowGuids(List guids); Task> GetPlaylistItems(int id); Task> GetMovie(int id); diff --git a/ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs b/ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs index a55196a1..c80dad19 100644 --- a/ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs +++ b/ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs @@ -25,6 +25,6 @@ public interface ISearchIndex : IDisposable List items); Task RemoveItems(IEnumerable ids); - Task Search(IClient client, string query, int skip, int limit); + Task Search(IClient client, string query, string smartCollectionName, int skip, int limit); void Commit(); } diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.cs index 5c4c9169..043da5f1 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.cs @@ -64,7 +64,7 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor switch (content) { case YamlPlayoutContentSearchItem search: - items = await mediaCollectionRepository.GetSmartCollectionItems(search.Query); + items = await mediaCollectionRepository.GetSmartCollectionItems(search.Query, string.Empty); break; case YamlPlayoutContentShowItem show: items = await mediaCollectionRepository.GetShowItemsByShowGuids( diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutMarathonHelper.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutMarathonHelper.cs index 0ad243b5..98f8add9 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutMarathonHelper.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutMarathonHelper.cs @@ -29,7 +29,7 @@ public class YamlPlayoutMarathonHelper(IMediaCollectionRepository mediaCollectio // grab items from each search foreach (string query in marathon.Searches) { - allMediaItems.AddRange(await mediaCollectionRepository.GetSmartCollectionItems(query)); + allMediaItems.AddRange(await mediaCollectionRepository.GetSmartCollectionItems(query, string.Empty)); } List> groups = []; diff --git a/ErsatzTV.Core/Search/ISmartCollectionCache.cs b/ErsatzTV.Core/Search/ISmartCollectionCache.cs new file mode 100644 index 00000000..5e1eed55 --- /dev/null +++ b/ErsatzTV.Core/Search/ISmartCollectionCache.cs @@ -0,0 +1,10 @@ +namespace ErsatzTV.Core.Search; + +public interface ISmartCollectionCache +{ + Task Refresh(); + + Task HasCycle(string name); + + Task> GetQuery(string name); +} diff --git a/ErsatzTV.Infrastructure.Tests/Search/SearchQueryParserTests.cs b/ErsatzTV.Infrastructure.Tests/Search/SearchQueryParserTests.cs index 039c35a0..e9f340b1 100644 --- a/ErsatzTV.Infrastructure.Tests/Search/SearchQueryParserTests.cs +++ b/ErsatzTV.Infrastructure.Tests/Search/SearchQueryParserTests.cs @@ -1,6 +1,8 @@ +using ErsatzTV.Core.Search; using ErsatzTV.Infrastructure.Search; using Shouldly; using Lucene.Net.Search; +using NSubstitute; using NUnit.Framework; namespace ErsatzTV.Infrastructure.Tests.Search; @@ -16,9 +18,10 @@ public class SearchQueryParserTests [TestCase("content_rating:\"TV-14\"", "content_rating:TV-14")] public async Task Test(string input, string expected) { - var parser = new SearchQueryParser(null); + ISmartCollectionCache smartCollectionCache = Substitute.For(); + var parser = new SearchQueryParser(smartCollectionCache); - Query result = await parser.ParseQuery(input); + Query result = await parser.ParseQuery(input, null); result.ToString().ShouldBe(expected); } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs index 8f0e32de..96f6e21f 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs @@ -398,7 +398,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository foreach (SmartCollection collection in maybeCollection) { - return await GetSmartCollectionItems(collection.Query); + return await GetSmartCollectionItems(collection.Query, collection.Name); } return []; @@ -415,20 +415,20 @@ public class MediaCollectionRepository : IMediaCollectionRepository foreach (SmartCollection collection in maybeCollection) { - return await GetSmartCollectionItems(collection.Query); + return await GetSmartCollectionItems(collection.Query, collection.Name); } return []; } - public async Task> GetSmartCollectionItems(string query) + public async Task> GetSmartCollectionItems(string query, string smartCollectionName) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); var result = new List(); // elasticsearch doesn't like when we ask for a limit of zero, so use 10,000 - SearchResult searchResults = await _searchIndex.Search(_client, query, 0, 10_000); + SearchResult searchResults = await _searchIndex.Search(_client, query, smartCollectionName, 0, 10_000); var movieIds = searchResults.Items .Filter(i => i.Type == LuceneSearchIndex.MovieType) diff --git a/ErsatzTV.Infrastructure/Search/AdjGraph.cs b/ErsatzTV.Infrastructure/Search/AdjGraph.cs new file mode 100644 index 00000000..b23d8fed --- /dev/null +++ b/ErsatzTV.Infrastructure/Search/AdjGraph.cs @@ -0,0 +1,51 @@ +namespace ErsatzTV.Infrastructure.Search; + +public class AdjGraph +{ + private readonly List _edges = []; + + public void Clear() + { + _edges.Clear(); + } + + public void AddEdge(string from, string to) + { + _edges.Add(new Edge(from.ToLowerInvariant(), to.ToLowerInvariant())); + } + + public bool HasCycle(string from) + { + var visited = new System.Collections.Generic.HashSet(); + var stack = new System.Collections.Generic.HashSet(); + return HasCycleImpl(from.ToLowerInvariant(), visited, stack); + } + + private bool HasCycleImpl(string node, ISet visited, ISet stack) + { + if (stack.Contains(node)) + { + return true; + } + + if (!visited.Add(node)) + { + return false; + } + + stack.Add(node); + + foreach (Edge edge in _edges.Where(e => e.From == node)) + { + if (HasCycleImpl(edge.To, visited, stack)) + { + return true; + } + } + + stack.Remove(node); + return false; + } + + private record Edge(string From, string To); +} diff --git a/ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs b/ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs index ac0cb44f..fd1459dd 100644 --- a/ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs +++ b/ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs @@ -163,12 +163,12 @@ public class ElasticSearchIndex : ISearchIndex return deleteResponse.IsValidResponse; } - public async Task Search(IClient client, string query, int skip, int limit) + public async Task Search(IClient client, string query, string smartCollectionName, int skip, int limit) { var items = new List(); var totalCount = 0; - Query parsedQuery = await _searchQueryParser.ParseQuery(query); + Query parsedQuery = await _searchQueryParser.ParseQuery(query, smartCollectionName); SearchResponse response = await _client.SearchAsync( s => s.Indices(IndexName) diff --git a/ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs b/ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs index 90a9df08..2000892b 100644 --- a/ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs +++ b/ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs @@ -200,7 +200,7 @@ public sealed class LuceneSearchIndex : ISearchIndex return Task.FromResult(true); } - public async Task Search(IClient client, string query, int skip, int limit) + public async Task Search(IClient client, string query, string smartCollectionName, int skip, int limit) { var metadata = new Dictionary { @@ -220,7 +220,7 @@ public sealed class LuceneSearchIndex : ISearchIndex using DirectoryReader reader = _writer.GetReader(true); var searcher = new IndexSearcher(reader); int hitsLimit = limit == 0 ? searcher.IndexReader.MaxDoc : skip + limit; - Query parsedQuery = await _searchQueryParser.ParseQuery(query); + Query parsedQuery = await _searchQueryParser.ParseQuery(query, smartCollectionName); // TODO: figure out if this is actually needed // var filter = new DuplicateFilter(TitleAndYearField); var sort = new Sort(new SortField(SortTitleField, SortFieldType.STRING)); diff --git a/ErsatzTV.Infrastructure/Search/SearchQueryParser.cs b/ErsatzTV.Infrastructure/Search/SearchQueryParser.cs index f609e1e7..5148ae5d 100644 --- a/ErsatzTV.Infrastructure/Search/SearchQueryParser.cs +++ b/ErsatzTV.Infrastructure/Search/SearchQueryParser.cs @@ -1,18 +1,17 @@ using System.Text.RegularExpressions; -using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Core.Search; using Lucene.Net.Analysis; using Lucene.Net.Analysis.Core; using Lucene.Net.Analysis.Miscellaneous; using Lucene.Net.Analysis.Standard; using Lucene.Net.QueryParsers.Classic; using Lucene.Net.Search; -using Microsoft.EntityFrameworkCore; using Serilog; using Query = Lucene.Net.Search.Query; namespace ErsatzTV.Infrastructure.Search; -public partial class SearchQueryParser(IDbContextFactory dbContextFactory) +public partial class SearchQueryParser(ISmartCollectionCache smartCollectionCache) { static SearchQueryParser() => BooleanQuery.MaxClauseCount = 1024 * 4; @@ -47,22 +46,41 @@ public partial class SearchQueryParser(IDbContextFactory dbContextFac return new PerFieldAnalyzerWrapper(defaultAnalyzer, customAnalyzers); } - public async Task ParseQuery(string query) + public async Task ParseQuery(string query, string smartCollectionName) { string parsedQuery = query; - var replaceCount = 0; - while (parsedQuery.Contains("smart_collection")) + if (!string.IsNullOrWhiteSpace(smartCollectionName) && await smartCollectionCache.HasCycle(smartCollectionName)) { - if (replaceCount > 10) + Log.Logger.Error("Smart collection {Name} contains a cycle; will not evaluate", smartCollectionName); + } + else + { + var replaceCount = 0; + while (parsedQuery.Contains("smart_collection")) { - Log.Logger.Warning("smart_collection query is nested too deep; giving up"); - break; + if (replaceCount > 100) + { + Log.Logger.Warning("smart_collection query is nested too deep; giving up"); + break; + } + + ReplaceResult replaceResult = await ReplaceSmartCollections(parsedQuery); + if (replaceResult.Fatal) + { + break; + } + + if (parsedQuery == replaceResult.Query) + { + Log.Logger.Warning("Failed to replace smart_collection in query; is the syntax correct? Quotes are required. Giving up..."); + break; + } + + parsedQuery = replaceResult.Query; + + replaceCount++; } - - parsedQuery = await ReplaceSmartCollections(parsedQuery); - - replaceCount++; } using Analyzer analyzerWrapper = AnalyzerWrapper(); @@ -81,26 +99,34 @@ public partial class SearchQueryParser(IDbContextFactory dbContextFac return result; } - private async Task ReplaceSmartCollections(string query) + private async Task ReplaceSmartCollections(string query) { try { - Regex regex = SmartCollectionRegex(); - await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); - Dictionary smartCollectionMap = await dbContext.SmartCollections - .ToDictionaryAsync(x => x.Name, x => x.Query, StringComparer.OrdinalIgnoreCase); - return regex.Replace(query, match => + string result = query; + + foreach (Match match in SmartCollectionRegex().Matches(query)) { string smartCollectionName = match.Groups[1].Value; - return smartCollectionMap.TryGetValue(smartCollectionName, out string smartCollectionQuery) - ? $"({smartCollectionQuery})" - : match.Value; - }); + if (await smartCollectionCache.HasCycle(smartCollectionName)) + { + Log.Logger.Error("Smart collection {Name} contains a cycle; will not evaluate", smartCollectionName); + return new ReplaceResult(query, true); + } + + Option maybeQuery = await smartCollectionCache.GetQuery(smartCollectionName); + foreach (string smartCollectionQuery in maybeQuery) + { + result = result.Replace(match.Value, $"({smartCollectionQuery})"); + } + } + + return new ReplaceResult(result, false); } catch (Exception ex) { - Console.WriteLine(ex); - return query; + Log.Logger.Warning(ex, "Unexpected exception replacing smart collections in search query"); + return new ReplaceResult(query, true); } } @@ -122,5 +148,7 @@ public partial class SearchQueryParser(IDbContextFactory dbContextFac [GeneratedRegex(""" smart_collection:"([^"]+)" """)] - private static partial Regex SmartCollectionRegex(); + internal static partial Regex SmartCollectionRegex(); + + private record ReplaceResult(string Query, bool Fatal); } diff --git a/ErsatzTV.Infrastructure/Search/SmartCollectionCache.cs b/ErsatzTV.Infrastructure/Search/SmartCollectionCache.cs new file mode 100644 index 00000000..3090abc0 --- /dev/null +++ b/ErsatzTV.Infrastructure/Search/SmartCollectionCache.cs @@ -0,0 +1,91 @@ +using System.Text.RegularExpressions; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Search; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Infrastructure.Search; + +public sealed class SmartCollectionCache(IDbContextFactory dbContextFactory) + : ISmartCollectionCache, IDisposable +{ + private readonly Dictionary _data = new(StringComparer.OrdinalIgnoreCase); + private readonly SemaphoreSlim _semaphoreSlim = new(1, 1); + private readonly AdjGraph _graph = new(); + + public async Task Refresh() + { + await _semaphoreSlim.WaitAsync(); + + try + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); + List smartCollections = await dbContext.SmartCollections + .AsNoTracking() + .ToListAsync(); + + _data.Clear(); + _graph.Clear(); + + foreach (SmartCollection smartCollection in smartCollections) + { + var data = new SmartCollectionData(smartCollection.Query); + _data.Add(smartCollection.Name, data); + + foreach (Match match in SearchQueryParser.SmartCollectionRegex().Matches(smartCollection.Query)) + { + string otherCollectionName = match.Groups[1].Value; + _graph.AddEdge(smartCollection.Name, otherCollectionName); + } + } + + foreach (SmartCollection smartCollection in smartCollections) + { + SmartCollectionData data = _data[smartCollection.Name]; + data.HasCycle = _graph.HasCycle(smartCollection.Name); + } + } + finally + { + _semaphoreSlim.Release(); + } + } + + public async Task HasCycle(string name) + { + await _semaphoreSlim.WaitAsync(); + + try + { + return _data.TryGetValue(name, out SmartCollectionData data) && data.HasCycle; + } + finally + { + _semaphoreSlim.Release(); + } + } + + public async Task> GetQuery(string name) + { + await _semaphoreSlim.WaitAsync(); + + try + { + return _data.TryGetValue(name, out SmartCollectionData data) ? data.Query : Option.None; + } + finally + { + _semaphoreSlim.Release(); + } + } + + public void Dispose() + { + _semaphoreSlim.Dispose(); + } + + private record SmartCollectionData(string Query) + { + public bool HasCycle { get; set; } + } +} diff --git a/ErsatzTV/Pages/Search.razor b/ErsatzTV/Pages/Search.razor index 5fc8bb16..9b8058fb 100644 --- a/ErsatzTV/Pages/Search.razor +++ b/ErsatzTV/Pages/Search.razor @@ -830,29 +830,37 @@ private async Task SaveAsSmartCollection(MouseEventArgs _) { var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + var parameters = new DialogParameters + { + { x => x.Query, _query } + }; - IDialogReference dialog = await Dialog.ShowAsync("Save As Smart Collection", options); + IDialogReference dialog = await Dialog.ShowAsync("Save As Smart Collection", parameters, options); DialogResult result = await dialog.Result; - if (!result.Canceled && result.Data is SmartCollectionViewModel collection) + if (result is { Canceled: false, Data: Tuple collectionResult }) { - var request = new UpdateSmartCollection( - collection.Id, - _query); + // Item2 => update is needed + if (collectionResult.Item2) + { + var request = new UpdateSmartCollection( + collectionResult.Item1.Id, + _query); - Either updateResult = await Mediator.Send(request, CancellationToken); - updateResult.Match( - Left: error => - { - Snackbar.Add($"Unexpected error saving smart collection: {error.Value}"); - Logger.LogError("Unexpected error saving smart collection: {Error}", error.Value); - }, - Right: _ => - { - Snackbar.Add( - $"Saved smart collection {collection.Name}", - Severity.Success); - ClearSelection(); - }); + Either updateResult = await Mediator.Send(request, CancellationToken); + updateResult.Match( + Left: error => + { + Snackbar.Add($"Unexpected error saving smart collection: {error.Value}"); + Logger.LogError("Unexpected error saving smart collection: {Error}", error.Value); + }, + Right: _ => + { + Snackbar.Add( + $"Saved smart collection {collectionResult.Item1.Name}", + Severity.Success); + ClearSelection(); + }); + } } } diff --git a/ErsatzTV/Services/RunOnce/RebuildSearchIndexService.cs b/ErsatzTV/Services/RunOnce/RebuildSearchIndexService.cs index 7c7ea865..e0f8a8c3 100644 --- a/ErsatzTV/Services/RunOnce/RebuildSearchIndexService.cs +++ b/ErsatzTV/Services/RunOnce/RebuildSearchIndexService.cs @@ -1,5 +1,6 @@ using ErsatzTV.Application.Search; using ErsatzTV.Core; +using ErsatzTV.Core.Search; using MediatR; namespace ErsatzTV.Services.RunOnce; @@ -34,5 +35,8 @@ public class RebuildSearchIndexService : BackgroundService using IServiceScope scope = _serviceScopeFactory.CreateScope(); IMediator mediator = scope.ServiceProvider.GetRequiredService(); await mediator.Send(new RebuildSearchIndex(), stoppingToken); + + ISmartCollectionCache cache = scope.ServiceProvider.GetRequiredService(); + await cache.Refresh(); } } diff --git a/ErsatzTV/Shared/SaveAsSmartCollectionDialog.razor b/ErsatzTV/Shared/SaveAsSmartCollectionDialog.razor index 55d0802b..427ec6c4 100644 --- a/ErsatzTV/Shared/SaveAsSmartCollectionDialog.razor +++ b/ErsatzTV/Shared/SaveAsSmartCollectionDialog.razor @@ -43,6 +43,9 @@ [CascadingParameter] IMudDialogInstance MudDialog { get; set; } + [Parameter] + public string Query { get; set; } + private readonly SmartCollectionViewModel _newCollection = new(-1, "(New Collection)", string.Empty); private string _newCollectionName; @@ -88,13 +91,13 @@ if (_selectedCollection == _newCollection) { Either maybeResult = - await Mediator.Send(new CreateSmartCollection(string.Empty, _newCollectionName), _cts.Token); + await Mediator.Send(new CreateSmartCollection(Query, _newCollectionName), _cts.Token); maybeResult.Match( collection => { MemoryCache.Set("SaveAsSmartCollectionDialog.SelectedCollectionId", collection.Id); - MudDialog.Close(DialogResult.Ok(collection)); + MudDialog.Close(DialogResult.Ok(new Tuple(collection, false))); }, error => { @@ -106,7 +109,7 @@ else { MemoryCache.Set("SaveAsSmartCollectionDialog.SelectedCollectionId", _selectedCollection.Id); - MudDialog.Close(DialogResult.Ok(_selectedCollection)); + MudDialog.Close(DialogResult.Ok(new Tuple(_selectedCollection, true))); } } diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 179aac23..c9b6c78f 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -38,6 +38,7 @@ using ErsatzTV.Core.Plex; using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling.BlockScheduling; using ErsatzTV.Core.Scheduling.YamlScheduling; +using ErsatzTV.Core.Search; using ErsatzTV.Core.Trakt; using ErsatzTV.FFmpeg.Capabilities; using ErsatzTV.FFmpeg.Pipeline; @@ -609,6 +610,7 @@ public class Startup services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); if (SearchHelper.IsElasticSearchEnabled)