Browse Source

detect cycles in smart collection queries (#2059)

pull/2060/head
Jason Dove 1 day ago committed by GitHub
parent
commit
b90463e3af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 2
      ErsatzTV.Application/Maintenance/Commands/EmptyTrashHandler.cs
  3. 9
      ErsatzTV.Application/MediaCollections/Commands/CreateSmartCollectionHandler.cs
  4. 9
      ErsatzTV.Application/MediaCollections/Commands/DeleteSmartCollectionHandler.cs
  5. 7
      ErsatzTV.Application/MediaCollections/Commands/UpdateSmartCollectionHandler.cs
  6. 2
      ErsatzTV.Application/Search/Queries/QuerySearchIndexAllItemsHandler.cs
  7. 1
      ErsatzTV.Application/Search/Queries/QuerySearchIndexArtistsHandler.cs
  8. 1
      ErsatzTV.Application/Search/Queries/QuerySearchIndexEpisodesHandler.cs
  9. 1
      ErsatzTV.Application/Search/Queries/QuerySearchIndexImagesHandler.cs
  10. 1
      ErsatzTV.Application/Search/Queries/QuerySearchIndexMoviesHandler.cs
  11. 1
      ErsatzTV.Application/Search/Queries/QuerySearchIndexMusicVideosHandler.cs
  12. 1
      ErsatzTV.Application/Search/Queries/QuerySearchIndexOtherVideosHandler.cs
  13. 1
      ErsatzTV.Application/Search/Queries/QuerySearchIndexSeasonsHandler.cs
  14. 1
      ErsatzTV.Application/Search/Queries/QuerySearchIndexShowsHandler.cs
  15. 1
      ErsatzTV.Application/Search/Queries/QuerySearchIndexSongsHandler.cs
  16. 2
      ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs
  17. 2
      ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs
  18. 2
      ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs
  19. 2
      ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.cs
  20. 2
      ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutMarathonHelper.cs
  21. 10
      ErsatzTV.Core/Search/ISmartCollectionCache.cs
  22. 7
      ErsatzTV.Infrastructure.Tests/Search/SearchQueryParserTests.cs
  23. 8
      ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs
  24. 51
      ErsatzTV.Infrastructure/Search/AdjGraph.cs
  25. 4
      ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs
  26. 4
      ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs
  27. 66
      ErsatzTV.Infrastructure/Search/SearchQueryParser.cs
  28. 91
      ErsatzTV.Infrastructure/Search/SmartCollectionCache.cs
  29. 16
      ErsatzTV/Pages/Search.razor
  30. 4
      ErsatzTV/Services/RunOnce/RebuildSearchIndexService.cs
  31. 9
      ErsatzTV/Shared/SaveAsSmartCollectionDialog.razor
  32. 2
      ErsatzTV/Startup.cs

1
CHANGELOG.md

@ -45,6 +45,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -45,6 +45,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Allow searching by `smart_collection` (name)
- Quotes are *always* required when using this feature
- 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)

2
ErsatzTV.Application/Maintenance/Commands/EmptyTrashHandler.cs

@ -26,7 +26,7 @@ public class EmptyTrashHandler : IRequestHandler<EmptyTrash, Either<BaseError, U @@ -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

9
ErsatzTV.Application/MediaCollections/Commands/CreateSmartCollectionHandler.cs

@ -1,6 +1,7 @@ @@ -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 : @@ -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 : @@ -35,6 +41,7 @@ public class CreateSmartCollectionHandler :
await dbContext.SmartCollections.AddAsync(smartCollection);
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
await _smartCollectionCache.Refresh();
return ProjectToViewModel(smartCollection);
}

9
ErsatzTV.Application/MediaCollections/Commands/DeleteSmartCollectionHandler.cs

@ -1,6 +1,7 @@ @@ -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 @@ -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 @@ -32,6 +38,7 @@ public class DeleteSmartCollectionHandler : IRequestHandler<DeleteSmartCollectio
dbContext.SmartCollections.Remove(smartCollection);
await dbContext.SaveChangesAsync();
_searchTargets.SearchTargetsChanged();
await _smartCollectionCache.Refresh();
return Unit.Default;
}

7
ErsatzTV.Application/MediaCollections/Commands/UpdateSmartCollectionHandler.cs

@ -5,6 +5,7 @@ using ErsatzTV.Core.Domain; @@ -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 @@ -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 @@ -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))

2
ErsatzTV.Application/Search/Queries/QuerySearchIndexAllItemsHandler.cs

@ -30,5 +30,5 @@ public class QuerySearchIndexAllItemsHandler : IRequestHandler<QuerySearchIndexA @@ -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();
}

1
ErsatzTV.Application/Search/Queries/QuerySearchIndexArtistsHandler.cs

@ -27,6 +27,7 @@ public class QuerySearchIndexArtistsHandler : IRequestHandler<QuerySearchIndexAr @@ -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);

1
ErsatzTV.Application/Search/Queries/QuerySearchIndexEpisodesHandler.cs

@ -58,6 +58,7 @@ public class @@ -58,6 +58,7 @@ public class
SearchResult searchResult = await _searchIndex.Search(
_client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);

1
ErsatzTV.Application/Search/Queries/QuerySearchIndexImagesHandler.cs

@ -17,6 +17,7 @@ public class QuerySearchIndexImagesHandler(IClient client, ISearchIndex searchIn @@ -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);

1
ErsatzTV.Application/Search/Queries/QuerySearchIndexMoviesHandler.cs

@ -34,6 +34,7 @@ public class QuerySearchIndexMoviesHandler : IRequestHandler<QuerySearchIndexMov @@ -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);

1
ErsatzTV.Application/Search/Queries/QuerySearchIndexMusicVideosHandler.cs

@ -45,6 +45,7 @@ public class @@ -45,6 +45,7 @@ public class
SearchResult searchResult = await _searchIndex.Search(
_client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);

1
ErsatzTV.Application/Search/Queries/QuerySearchIndexOtherVideosHandler.cs

@ -32,6 +32,7 @@ public class @@ -32,6 +32,7 @@ public class
SearchResult searchResult = await _searchIndex.Search(
_client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);

1
ErsatzTV.Application/Search/Queries/QuerySearchIndexSeasonsHandler.cs

@ -35,6 +35,7 @@ public class @@ -35,6 +35,7 @@ public class
SearchResult searchResult = await _searchIndex.Search(
_client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);

1
ErsatzTV.Application/Search/Queries/QuerySearchIndexShowsHandler.cs

@ -35,6 +35,7 @@ public class @@ -35,6 +35,7 @@ public class
SearchResult searchResult = await _searchIndex.Search(
_client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);

1
ErsatzTV.Application/Search/Queries/QuerySearchIndexSongsHandler.cs

@ -27,6 +27,7 @@ public class QuerySearchIndexSongsHandler : IRequestHandler<QuerySearchIndexSong @@ -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);

2
ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs

@ -28,7 +28,7 @@ public class FakeMediaCollectionRepository : IMediaCollectionRepository @@ -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();

2
ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs

@ -15,7 +15,7 @@ public interface IMediaCollectionRepository @@ -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);

2
ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs

@ -25,6 +25,6 @@ public interface ISearchIndex : IDisposable @@ -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();
}

2
ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.cs

@ -64,7 +64,7 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor @@ -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(

2
ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutMarathonHelper.cs

@ -29,7 +29,7 @@ public class YamlPlayoutMarathonHelper(IMediaCollectionRepository mediaCollectio @@ -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 = [];

10
ErsatzTV.Core/Search/ISmartCollectionCache.cs

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
namespace ErsatzTV.Core.Search;
public interface ISmartCollectionCache
{
Task Refresh();
Task<bool> HasCycle(string name);
Task<Option<string>> GetQuery(string name);
}

7
ErsatzTV.Infrastructure.Tests/Search/SearchQueryParserTests.cs

@ -1,6 +1,8 @@ @@ -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 @@ -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);
}
}

8
ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs

@ -398,7 +398,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository @@ -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 @@ -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)

51
ErsatzTV.Infrastructure/Search/AdjGraph.cs

@ -0,0 +1,51 @@ @@ -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);
}

4
ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs

@ -163,12 +163,12 @@ public class ElasticSearchIndex : ISearchIndex @@ -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)

4
ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs

@ -200,7 +200,7 @@ public sealed class LuceneSearchIndex : ISearchIndex @@ -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 @@ -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));

66
ErsatzTV.Infrastructure/Search/SearchQueryParser.cs

@ -1,18 +1,17 @@ @@ -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,23 +46,42 @@ public partial class SearchQueryParser(IDbContextFactory<TvContext> dbContextFac @@ -47,23 +46,42 @@ 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;
if (!string.IsNullOrWhiteSpace(smartCollectionName) && await smartCollectionCache.HasCycle(smartCollectionName))
{
Log.Logger.Error("Smart collection {Name} contains a cycle; will not evaluate", smartCollectionName);
}
else
{
var replaceCount = 0;
while (parsedQuery.Contains("smart_collection"))
{
if (replaceCount > 10)
if (replaceCount > 100)
{
Log.Logger.Warning("smart_collection query is nested too deep; giving up");
break;
}
parsedQuery = await ReplaceSmartCollections(parsedQuery);
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++;
}
}
using Analyzer analyzerWrapper = AnalyzerWrapper();
@ -81,26 +99,34 @@ public partial class SearchQueryParser(IDbContextFactory<TvContext> dbContextFac @@ -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 @@ -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);
}

91
ErsatzTV.Infrastructure/Search/SmartCollectionCache.cs

@ -0,0 +1,91 @@ @@ -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; }
}
}

16
ErsatzTV/Pages/Search.razor

@ -830,13 +830,20 @@ @@ -830,13 +830,20 @@
private async Task SaveAsSmartCollection(MouseEventArgs _)
{
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
var parameters = new DialogParameters<SaveAsSmartCollectionDialog>
{
{ x => x.Query, _query }
};
IDialogReference dialog = await Dialog.ShowAsync<SaveAsSmartCollectionDialog>("Save As Smart Collection", options);
IDialogReference dialog = await Dialog.ShowAsync<SaveAsSmartCollectionDialog>("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<SmartCollectionViewModel, bool> collectionResult })
{
// Item2 => update is needed
if (collectionResult.Item2)
{
var request = new UpdateSmartCollection(
collection.Id,
collectionResult.Item1.Id,
_query);
Either<BaseError, Unit> updateResult = await Mediator.Send(request, CancellationToken);
@ -849,11 +856,12 @@ @@ -849,11 +856,12 @@
Right: _ =>
{
Snackbar.Add(
$"Saved smart collection {collection.Name}",
$"Saved smart collection {collectionResult.Item1.Name}",
Severity.Success);
ClearSelection();
});
}
}
}
}

4
ErsatzTV/Services/RunOnce/RebuildSearchIndexService.cs

@ -1,5 +1,6 @@ @@ -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 @@ -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();
}
}

9
ErsatzTV/Shared/SaveAsSmartCollectionDialog.razor

@ -43,6 +43,9 @@ @@ -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 @@ @@ -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 @@ @@ -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
ErsatzTV/Startup.cs

@ -38,6 +38,7 @@ using ErsatzTV.Core.Plex; @@ -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 @@ -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)

Loading…
Cancel
Save