detect cycles in smart collection queries (#2059)
This commit is contained in:
+2
-1
@@ -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
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user