detect cycles in smart collection queries (#2059)

This commit is contained in:
Jason Dove
2025-06-23 01:45:17 +00:00
committed by GitHub
parent 1fb27e3cfa
commit b90463e3af
32 changed files with 300 additions and 71 deletions
+2 -1
View File
@@ -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)
@@ -26,7 +26,7 @@ public class EmptyTrashHandler : IRequestHandler<EmptyTrash, Either<BaseError, U
EmptyTrash request,
CancellationToken cancellationToken)
{
SearchResult result = await _searchIndex.Search(_client, "state:FileNotFound", 0, 10_000);
SearchResult result = await _searchIndex.Search(_client, "state:FileNotFound", string.Empty, 0, 10_000);
var ids = result.Items.Map(i => i.Id).ToList();
// ElasticSearch remove items may fail, so do that first
@@ -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<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
private readonly ISmartCollectionCache _smartCollectionCache;
public CreateSmartCollectionHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
public CreateSmartCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
ISearchTargets searchTargets,
ISmartCollectionCache smartCollectionCache)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
_smartCollectionCache = smartCollectionCache;
}
public async Task<Either<BaseError, SmartCollectionViewModel>> Handle(
@@ -35,6 +41,7 @@ public class CreateSmartCollectionHandler :
await dbContext.SmartCollections.AddAsync(smartCollection);
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
await _smartCollectionCache.Refresh();
return ProjectToViewModel(smartCollection);
}
@@ -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<DeleteSmartCollectio
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
private readonly ISmartCollectionCache _smartCollectionCache;
public DeleteSmartCollectionHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
public DeleteSmartCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
ISearchTargets searchTargets,
ISmartCollectionCache smartCollectionCache)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
_smartCollectionCache = smartCollectionCache;
}
public async Task<Either<BaseError, Unit>> Handle(
@@ -32,6 +38,7 @@ public class DeleteSmartCollectionHandler : IRequestHandler<DeleteSmartCollectio
dbContext.SmartCollections.Remove(smartCollection);
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
await _smartCollectionCache.Refresh();
return Unit.Default;
}
@@ -5,6 +5,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -17,17 +18,20 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ISearchTargets _searchTargets;
private readonly ISmartCollectionCache _smartCollectionCache;
public UpdateSmartCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel,
ISearchTargets searchTargets)
ISearchTargets searchTargets,
ISmartCollectionCache smartCollectionCache)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
_searchTargets = searchTargets;
_smartCollectionCache = smartCollectionCache;
}
public async Task<Either<BaseError, Unit>> Handle(
@@ -47,6 +51,7 @@ public class UpdateSmartCollectionHandler : IRequestHandler<UpdateSmartCollectio
if (await dbContext.SaveChangesAsync() > 0)
{
_searchTargets.SearchTargetsChanged();
await _smartCollectionCache.Refresh();
// refresh all playouts that use this smart collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingSmartCollection(request.Id))
@@ -30,5 +30,5 @@ public class QuerySearchIndexAllItemsHandler : IRequestHandler<QuerySearchIndexA
await GetIds(LuceneSearchIndex.ImageType, request.Query));
private async Task<List<int>> 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();
}
@@ -27,6 +27,7 @@ public class QuerySearchIndexArtistsHandler : IRequestHandler<QuerySearchIndexAr
SearchResult searchResult = await _searchIndex.Search(
_client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
@@ -58,6 +58,7 @@ public class
SearchResult searchResult = await _searchIndex.Search(
_client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
@@ -17,6 +17,7 @@ public class QuerySearchIndexImagesHandler(IClient client, ISearchIndex searchIn
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
@@ -34,6 +34,7 @@ public class QuerySearchIndexMoviesHandler : IRequestHandler<QuerySearchIndexMov
SearchResult searchResult = await _searchIndex.Search(
_client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
@@ -45,6 +45,7 @@ public class
SearchResult searchResult = await _searchIndex.Search(
_client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
@@ -32,6 +32,7 @@ public class
SearchResult searchResult = await _searchIndex.Search(
_client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
@@ -35,6 +35,7 @@ public class
SearchResult searchResult = await _searchIndex.Search(
_client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
@@ -35,6 +35,7 @@ public class
SearchResult searchResult = await _searchIndex.Search(
_client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
@@ -27,6 +27,7 @@ public class QuerySearchIndexSongsHandler : IRequestHandler<QuerySearchIndexSong
SearchResult searchResult = await _searchIndex.Search(
_client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
@@ -28,7 +28,7 @@ public class FakeMediaCollectionRepository : IMediaCollectionRepository
public Task<List<MediaItem>> GetMultiCollectionItemsByName(string name) => throw new NotSupportedException();
public Task<List<MediaItem>> GetSmartCollectionItems(int id) => _data[id].ToList().AsTask();
public Task<List<MediaItem>> GetSmartCollectionItemsByName(string name) => throw new NotSupportedException();
public Task<List<MediaItem>> GetSmartCollectionItems(string query) => throw new NotSupportedException();
public Task<List<MediaItem>> GetSmartCollectionItems(string query, string smartCollectionName) => throw new NotSupportedException();
public Task<List<MediaItem>> GetShowItemsByShowGuids(List<string> guids) => throw new NotSupportedException();
public Task<List<MediaItem>> GetPlaylistItems(int id) => throw new NotSupportedException();
public Task<List<Movie>> GetMovie(int id) => throw new NotSupportedException();
@@ -15,7 +15,7 @@ public interface IMediaCollectionRepository
Task<List<MediaItem>> GetMultiCollectionItemsByName(string name);
Task<List<MediaItem>> GetSmartCollectionItems(int id);
Task<List<MediaItem>> GetSmartCollectionItemsByName(string name);
Task<List<MediaItem>> GetSmartCollectionItems(string query);
Task<List<MediaItem>> GetSmartCollectionItems(string query, string smartCollectionName);
Task<List<MediaItem>> GetShowItemsByShowGuids(List<string> guids);
Task<List<MediaItem>> GetPlaylistItems(int id);
Task<List<Movie>> GetMovie(int id);
@@ -25,6 +25,6 @@ public interface ISearchIndex : IDisposable
List<MediaItem> items);
Task<bool> RemoveItems(IEnumerable<int> ids);
Task<SearchResult> Search(IClient client, string query, int skip, int limit);
Task<SearchResult> Search(IClient client, string query, string smartCollectionName, int skip, int limit);
void Commit();
}
@@ -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(
@@ -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<IGrouping<GroupKey, MediaItem>> groups = [];
@@ -0,0 +1,10 @@
namespace ErsatzTV.Core.Search;
public interface ISmartCollectionCache
{
Task Refresh();
Task<bool> HasCycle(string name);
Task<Option<string>> GetQuery(string name);
}
@@ -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<ISmartCollectionCache>();
var parser = new SearchQueryParser(smartCollectionCache);
Query result = await parser.ParseQuery(input);
Query result = await parser.ParseQuery(input, null);
result.ToString().ShouldBe(expected);
}
}
@@ -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<List<MediaItem>> GetSmartCollectionItems(string query)
public async Task<List<MediaItem>> GetSmartCollectionItems(string query, string smartCollectionName)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
var result = new List<MediaItem>();
// 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)
@@ -0,0 +1,51 @@
namespace ErsatzTV.Infrastructure.Search;
public class AdjGraph
{
private readonly List<Edge> _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<string>();
var stack = new System.Collections.Generic.HashSet<string>();
return HasCycleImpl(from.ToLowerInvariant(), visited, stack);
}
private bool HasCycleImpl(string node, ISet<string> visited, ISet<string> 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);
}
@@ -163,12 +163,12 @@ public class ElasticSearchIndex : ISearchIndex
return deleteResponse.IsValidResponse;
}
public async Task<SearchResult> Search(IClient client, string query, int skip, int limit)
public async Task<SearchResult> Search(IClient client, string query, string smartCollectionName, int skip, int limit)
{
var items = new List<MinimalElasticSearchItem>();
var totalCount = 0;
Query parsedQuery = await _searchQueryParser.ParseQuery(query);
Query parsedQuery = await _searchQueryParser.ParseQuery(query, smartCollectionName);
SearchResponse<MinimalElasticSearchItem> response = await _client.SearchAsync<MinimalElasticSearchItem>(
s => s.Indices(IndexName)
@@ -200,7 +200,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
return Task.FromResult(true);
}
public async Task<SearchResult> Search(IClient client, string query, int skip, int limit)
public async Task<SearchResult> Search(IClient client, string query, string smartCollectionName, int skip, int limit)
{
var metadata = new Dictionary<string, string>
{
@@ -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));
@@ -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<TvContext> dbContextFactory)
public partial class SearchQueryParser(ISmartCollectionCache smartCollectionCache)
{
static SearchQueryParser() => BooleanQuery.MaxClauseCount = 1024 * 4;
@@ -47,22 +46,41 @@ public partial class SearchQueryParser(IDbContextFactory<TvContext> dbContextFac
return new PerFieldAnalyzerWrapper(defaultAnalyzer, customAnalyzers);
}
public async Task<Query> ParseQuery(string query)
public async Task<Query> 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<TvContext> dbContextFac
return result;
}
private async Task<string> ReplaceSmartCollections(string query)
private async Task<ReplaceResult> ReplaceSmartCollections(string query)
{
try
{
Regex regex = SmartCollectionRegex();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
Dictionary<string, string> 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<string> 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<TvContext> dbContextFac
[GeneratedRegex("""
smart_collection:"([^"]+)"
""")]
private static partial Regex SmartCollectionRegex();
internal static partial Regex SmartCollectionRegex();
private record ReplaceResult(string Query, bool Fatal);
}
@@ -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<TvContext> dbContextFactory)
: ISmartCollectionCache, IDisposable
{
private readonly Dictionary<string, SmartCollectionData> _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<SmartCollection> 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<bool> HasCycle(string name)
{
await _semaphoreSlim.WaitAsync();
try
{
return _data.TryGetValue(name, out SmartCollectionData data) && data.HasCycle;
}
finally
{
_semaphoreSlim.Release();
}
}
public async Task<Option<string>> GetQuery(string name)
{
await _semaphoreSlim.WaitAsync();
try
{
return _data.TryGetValue(name, out SmartCollectionData data) ? data.Query : Option<string>.None;
}
finally
{
_semaphoreSlim.Release();
}
}
public void Dispose()
{
_semaphoreSlim.Dispose();
}
private record SmartCollectionData(string Query)
{
public bool HasCycle { get; set; }
}
}
+29 -21
View File
@@ -830,29 +830,37 @@
private async Task SaveAsSmartCollection(MouseEventArgs _)
{
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<SaveAsSmartCollectionDialog>("Save As Smart Collection", options);
DialogResult result = await dialog.Result;
if (!result.Canceled && result.Data is SmartCollectionViewModel collection)
var parameters = new DialogParameters<SaveAsSmartCollectionDialog>
{
var request = new UpdateSmartCollection(
collection.Id,
_query);
{ x => x.Query, _query }
};
Either<BaseError, Unit> 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();
});
IDialogReference dialog = await Dialog.ShowAsync<SaveAsSmartCollectionDialog>("Save As Smart Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: Tuple<SmartCollectionViewModel, bool> collectionResult })
{
// Item2 => update is needed
if (collectionResult.Item2)
{
var request = new UpdateSmartCollection(
collectionResult.Item1.Id,
_query);
Either<BaseError, Unit> 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();
});
}
}
}
@@ -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<IMediator>();
await mediator.Send(new RebuildSearchIndex(), stoppingToken);
ISmartCollectionCache cache = scope.ServiceProvider.GetRequiredService<ISmartCollectionCache>();
await cache.Refresh();
}
}
@@ -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<BaseError, SmartCollectionViewModel> 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<SmartCollectionViewModel, bool>(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<SmartCollectionViewModel, bool>(_selectedCollection, true)));
}
}
+2
View File
@@ -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<ITraktApiClient, TraktApiClient>();
services.AddSingleton<IEntityLocker, EntityLocker>();
services.AddSingleton<ISearchTargets, SearchTargets>();
services.AddSingleton<ISmartCollectionCache, SmartCollectionCache>();
services.AddSingleton<SearchQueryParser>();
if (SearchHelper.IsElasticSearchEnabled)