From e6421826a5a5bba55cd376438bd38f851e3d884a Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Mon, 12 Jan 2026 19:36:59 -0600 Subject: [PATCH] optimize searching for shows, seasons and movies --- CHANGELOG.md | 1 + .../Search/Queries/SearchMoviesHandler.cs | 21 ++++++--- .../Queries/SearchTelevisionSeasonsHandler.cs | 41 +++++++++++----- .../Queries/SearchTelevisionShowsHandler.cs | 22 ++++++--- .../Queries/SearchUsingSearchIndexHandler.cs | 25 ++++++++++ .../Interfaces/Search/ISearchIndex.cs | 9 ++++ .../Search/SearchQueryParserTests.cs | 2 +- .../Search/ElasticSearchIndex.cs | 34 +++++++++++++- .../Search/LuceneSearchIndex.cs | 47 ++++++++++++++++++- .../Search/Models/ElasticSearchItem.cs | 3 ++ .../Search/SearchQueryParser.cs | 9 +++- 11 files changed, 182 insertions(+), 32 deletions(-) create mode 100644 ErsatzTV.Application/Search/Queries/SearchUsingSearchIndexHandler.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bb82fea5..21c79b01c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Respect `z_index` (draw order) on all graphics element types - Restore default UI font that was erroneously removed in v26.1.1 - Use configured searching log level on startup, instead of the default log level of `Information` +- MySql: fix searching for shows and seasons in schedule items editor ## [26.1.1] - 2026-01-08 ### Fixed diff --git a/ErsatzTV.Application/Search/Queries/SearchMoviesHandler.cs b/ErsatzTV.Application/Search/Queries/SearchMoviesHandler.cs index 4548ec9bd..19d324cc8 100644 --- a/ErsatzTV.Application/Search/Queries/SearchMoviesHandler.cs +++ b/ErsatzTV.Application/Search/Queries/SearchMoviesHandler.cs @@ -1,23 +1,32 @@ +using System.Collections.Immutable; using System.Globalization; +using Bugsnag; using ErsatzTV.Application.MediaItems; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Search; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Application.Search; -public class SearchMoviesHandler(IDbContextFactory dbContextFactory) - : IRequestHandler> +public class SearchMoviesHandler( + IClient client, + ISearchIndex searchIndex, + IDbContextFactory dbContextFactory) + : SearchUsingSearchIndexHandler(client, searchIndex), IRequestHandler> { public async Task> Handle(SearchMovies request, CancellationToken cancellationToken) { + ImmutableHashSet ids = await Search(LuceneSearchIndex.MovieType, request.Query, cancellationToken); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); return await dbContext.MovieMetadata + .TagWithCallSite() .AsNoTracking() - .Where(m => EF.Functions.Like(m.Title + " " + m.Year, $"%{request.Query}%")) - .OrderBy(m => m.Title) - .ThenBy(m => m.Year) - .Take(10) + .Where(mm => ids.Contains(mm.MovieId)) + .OrderBy(mm => mm.Title) + .ThenBy(mm => mm.Year) .ToListAsync(cancellationToken) .Map(list => list.Map(ToNamedMediaItem).ToList()); } diff --git a/ErsatzTV.Application/Search/Queries/SearchTelevisionSeasonsHandler.cs b/ErsatzTV.Application/Search/Queries/SearchTelevisionSeasonsHandler.cs index c95320262..000a4f7f8 100644 --- a/ErsatzTV.Application/Search/Queries/SearchTelevisionSeasonsHandler.cs +++ b/ErsatzTV.Application/Search/Queries/SearchTelevisionSeasonsHandler.cs @@ -1,30 +1,45 @@ +using System.Collections.Immutable; using System.Globalization; +using Bugsnag; using ErsatzTV.Application.MediaItems; -using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Search; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Application.Search; -public class SearchTelevisionSeasonsHandler(IDbContextFactory dbContextFactory) - : IRequestHandler> +public class SearchTelevisionSeasonsHandler( + IClient client, + ISearchIndex searchIndex, + IDbContextFactory dbContextFactory) + : SearchUsingSearchIndexHandler(client, searchIndex), + IRequestHandler> { public async Task> Handle( SearchTelevisionSeasons request, CancellationToken cancellationToken) { + ImmutableHashSet ids = await Search(LuceneSearchIndex.SeasonType, request.Query, cancellationToken); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); - return await (from season in dbContext.Set() - join seasonMetadata in dbContext.Set() - on season.Id equals seasonMetadata.SeasonId - join showMetadata in dbContext.Set() - on season.ShowId equals showMetadata.ShowId - where EF.Functions.Like(showMetadata.Title + " " + seasonMetadata.Title, $"%{request.Query}%") - orderby showMetadata.Title, season.SeasonNumber - select new TelevisionSeason(season.Id, showMetadata.Title, showMetadata.Year, season.SeasonNumber)) - .Take(20) + return await dbContext.SeasonMetadata + .TagWithCallSite() + .AsNoTracking() + .Include(s => s.Season) + .ThenInclude(s => s.Show) + .ThenInclude(s => s.ShowMetadata) + .Where(sm => ids.Contains(sm.SeasonId)) .ToListAsync(cancellationToken) - .Map(list => list.Map(ToNamedMediaItem).ToList()); + .Map(list => list.Map(sm => new TelevisionSeason( + sm.SeasonId, + sm.Season.Show.ShowMetadata.HeadOrNone().Match(s => s.Title, string.Empty), + sm.Year, + sm.Season.SeasonNumber)) + .OrderBy(s => s.Title) + .ThenBy(s => s.SeasonNumber) + .Map(ToNamedMediaItem) + .ToList()); } private static NamedMediaItemViewModel ToNamedMediaItem(TelevisionSeason season) => diff --git a/ErsatzTV.Application/Search/Queries/SearchTelevisionShowsHandler.cs b/ErsatzTV.Application/Search/Queries/SearchTelevisionShowsHandler.cs index 8a955c3ad..0148fa313 100644 --- a/ErsatzTV.Application/Search/Queries/SearchTelevisionShowsHandler.cs +++ b/ErsatzTV.Application/Search/Queries/SearchTelevisionShowsHandler.cs @@ -1,25 +1,35 @@ +using System.Collections.Immutable; using System.Globalization; +using Bugsnag; using ErsatzTV.Application.MediaItems; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Search; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Application.Search; -public class SearchTelevisionShowsHandler(IDbContextFactory dbContextFactory) - : IRequestHandler> +public class SearchTelevisionShowsHandler( + IClient client, + ISearchIndex searchIndex, + IDbContextFactory dbContextFactory) + : SearchUsingSearchIndexHandler(client, searchIndex), + IRequestHandler> { public async Task> Handle( SearchTelevisionShows request, CancellationToken cancellationToken) { + ImmutableHashSet ids = await Search(LuceneSearchIndex.ShowType, request.Query, cancellationToken); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); return await dbContext.ShowMetadata + .TagWithCallSite() .AsNoTracking() - .Where(s => EF.Functions.Like(s.Title + " " + s.Year, $"%{request.Query}%")) - .OrderBy(s => s.Title) - .ThenBy(s => s.Year) - .Take(10) + .Where(sm => ids.Contains(sm.ShowId)) + .OrderBy(sm => sm.Title) + .ThenBy(sm => sm.Year) .ToListAsync(cancellationToken) .Map(list => list.Map(ToNamedMediaItem).ToList()); } diff --git a/ErsatzTV.Application/Search/Queries/SearchUsingSearchIndexHandler.cs b/ErsatzTV.Application/Search/Queries/SearchUsingSearchIndexHandler.cs new file mode 100644 index 000000000..286f8cac4 --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/SearchUsingSearchIndexHandler.cs @@ -0,0 +1,25 @@ +using System.Collections.Immutable; +using Bugsnag; +using ErsatzTV.Core.Interfaces.Search; +using ErsatzTV.Infrastructure.Search; + +namespace ErsatzTV.Application.Search; + +public abstract class SearchUsingSearchIndexHandler(IClient client, ISearchIndex searchIndex) +{ + private const int PageSize = 10; + + protected async Task> Search(string type, string query, CancellationToken cancellationToken) + { + var searchResult = await searchIndex.Search( + client, + $"type:{type} AND *{query.Replace(" ", @"\ ")}*", + string.Empty, + 0, + PageSize, + [LuceneSearchIndex.TitleAndYearSearchField], + cancellationToken); + + return searchResult.Items.Select(i => i.Id).ToImmutableHashSet(); + } +} diff --git a/ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs b/ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs index 0684a07d5..e03b0ec6c 100644 --- a/ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs +++ b/ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs @@ -45,5 +45,14 @@ public interface ISearchIndex : IDisposable int limit, CancellationToken cancellationToken); + Task Search( + IClient client, + string query, + string smartCollectionName, + int skip, + int limit, + List defaultFields, + CancellationToken cancellationToken); + void Commit(); } diff --git a/ErsatzTV.Infrastructure.Tests/Search/SearchQueryParserTests.cs b/ErsatzTV.Infrastructure.Tests/Search/SearchQueryParserTests.cs index fae24087f..44f078d62 100644 --- a/ErsatzTV.Infrastructure.Tests/Search/SearchQueryParserTests.cs +++ b/ErsatzTV.Infrastructure.Tests/Search/SearchQueryParserTests.cs @@ -22,7 +22,7 @@ public class SearchQueryParserTests ISmartCollectionCache smartCollectionCache = Substitute.For(); var parser = new SearchQueryParser(smartCollectionCache, Substitute.For>()); - Query result = await parser.ParseQuery(input, null, CancellationToken.None); + Query result = await parser.ParseQuery(input, null, [LuceneSearchIndex.TitleField], CancellationToken.None); result.ToString().ShouldBe(expected); } } diff --git a/ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs b/ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs index 0de9d7070..7c79fc668 100644 --- a/ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs +++ b/ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs @@ -48,7 +48,7 @@ public class ElasticSearchIndex : ISearchIndex return exists.IsValidResponse; } - public int Version => 48; + public int Version => 49; public async Task Initialize( ILocalFileSystem localFileSystem, @@ -167,18 +167,38 @@ public class ElasticSearchIndex : ISearchIndex return deleteResponse.IsValidResponse; } + public Task Search( + IClient client, + string query, + string smartCollectionName, + int skip, + int limit, + CancellationToken cancellationToken) => Search( + client, + query, + smartCollectionName, + skip, + limit, + [LuceneSearchIndex.TitleField], + cancellationToken); + public async Task Search( IClient client, string query, string smartCollectionName, int skip, int limit, + List defaultFields, CancellationToken cancellationToken) { var items = new List(); var totalCount = 0; - Query parsedQuery = await _searchQueryParser.ParseQuery(query, smartCollectionName, cancellationToken); + Query parsedQuery = await _searchQueryParser.ParseQuery( + query, + smartCollectionName, + defaultFields, + cancellationToken); ES.SearchResponse response = await _client.SearchAsync( s => s.Indices(IndexName) @@ -225,6 +245,7 @@ public class ElasticSearchIndex : ISearchIndex .Text(t => t.LibraryName, t => t.Store(false)) .Keyword(t => t.LibraryId, t => t.Store(false)) .Keyword(t => t.TitleAndYear, t => t.Store(false)) + .Text(t => t.TitleAndYearSearch, t => t.Store(false)) .Keyword(t => t.JumpLetter, t => t.Store()) .Keyword(t => t.State, t => t.Store(false)) .Text(t => t.MetadataKind, t => t.Store(false)) @@ -315,6 +336,7 @@ public class ElasticSearchIndex : ISearchIndex LibraryName = movie.LibraryPath.Library.Name, LibraryId = movie.LibraryPath.Library.Id, TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata), + TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata), JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata), State = movie.State.ToString(), MetadataKind = metadata.MetadataKind.ToString(), @@ -374,6 +396,7 @@ public class ElasticSearchIndex : ISearchIndex LibraryName = show.LibraryPath.Library.Name, LibraryId = show.LibraryPath.Library.Id, TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata), + TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata), JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata), State = show.State.ToString(), MetadataKind = metadata.MetadataKind.ToString(), @@ -440,6 +463,7 @@ public class ElasticSearchIndex : ISearchIndex LibraryName = season.LibraryPath.Library.Name, LibraryId = season.LibraryPath.Library.Id, TitleAndYear = titleAndYear, + TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata), JumpLetter = LuceneSearchIndex.GetJumpLetter(showMetadata), State = season.State.ToString(), SeasonNumber = season.SeasonNumber, @@ -498,6 +522,7 @@ public class ElasticSearchIndex : ISearchIndex LibraryName = artist.LibraryPath.Library.Name, LibraryId = artist.LibraryPath.Library.Id, TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata), + TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata), JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata), State = artist.State.ToString(), MetadataKind = metadata.MetadataKind.ToString(), @@ -545,6 +570,7 @@ public class ElasticSearchIndex : ISearchIndex LibraryName = musicVideo.LibraryPath.Library.Name, LibraryId = musicVideo.LibraryPath.Library.Id, TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata), + TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata), JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata), State = musicVideo.State.ToString(), MetadataKind = metadata.MetadataKind.ToString(), @@ -626,6 +652,7 @@ public class ElasticSearchIndex : ISearchIndex LibraryName = episode.LibraryPath.Library.Name, LibraryId = episode.LibraryPath.Library.Id, TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata), + TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata), JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata), State = episode.State.ToString(), MetadataKind = metadata.MetadataKind.ToString(), @@ -695,6 +722,7 @@ public class ElasticSearchIndex : ISearchIndex LibraryName = otherVideo.LibraryPath.Library.Name, LibraryId = otherVideo.LibraryPath.Library.Id, TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata), + TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata), JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata), State = otherVideo.State.ToString(), MetadataKind = metadata.MetadataKind.ToString(), @@ -751,6 +779,7 @@ public class ElasticSearchIndex : ISearchIndex LibraryName = song.LibraryPath.Library.Name, LibraryId = song.LibraryPath.Library.Id, TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata), + TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata), JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata), State = song.State.ToString(), MetadataKind = metadata.MetadataKind.ToString(), @@ -800,6 +829,7 @@ public class ElasticSearchIndex : ISearchIndex LibraryName = image.LibraryPath.Library.Name, LibraryId = image.LibraryPath.Library.Id, TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata), + TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata), JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata), State = image.State.ToString(), MetadataKind = metadata.MetadataKind.ToString(), diff --git a/ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs b/ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs index 5ec9e950f..c27fd531f 100644 --- a/ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs +++ b/ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs @@ -90,6 +90,7 @@ public sealed class LuceneSearchIndex : ISearchIndex public const string SongType = "song"; public const string ImageType = "image"; public const string RemoteStreamType = "remote_stream"; + public const string TitleAndYearSearchField = "title_and_year_search"; private readonly string _cleanShutdownPath; private readonly List _cultureInfos; @@ -118,7 +119,7 @@ public sealed class LuceneSearchIndex : ISearchIndex return Task.FromResult(directoryExists && fileExists); } - public int Version => 49; + public int Version => 50; public async Task Initialize( ILocalFileSystem localFileSystem, @@ -209,12 +210,29 @@ public sealed class LuceneSearchIndex : ISearchIndex return Task.FromResult(true); } + // default to title field only + public Task Search( + IClient client, + string query, + string smartCollectionName, + int skip, + int limit, + CancellationToken cancellationToken) => Search( + client, + query, + smartCollectionName, + skip, + limit, + [TitleField], + cancellationToken); + public async Task Search( IClient client, string query, string smartCollectionName, int skip, int limit, + List defaultFields, CancellationToken cancellationToken) { var metadata = new Dictionary @@ -237,7 +255,11 @@ 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, smartCollectionName, cancellationToken); + Query parsedQuery = await _searchQueryParser.ParseQuery( + query, + smartCollectionName, + defaultFields, + cancellationToken); // TODO: figure out if this is actually needed // var filter = new DuplicateFilter(TitleAndYearField); var sort = new Sort(new SortField(SortTitleField, SortFieldType.STRING)); @@ -445,6 +467,7 @@ public sealed class LuceneSearchIndex : ISearchIndex movie.LibraryPath.Library.Id.ToString(CultureInfo.InvariantCulture), Field.Store.NO), new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO), + new TextField(TitleAndYearSearchField, GetTitleAndYearSearch(metadata), Field.Store.NO), new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES), new StringField(StateField, movie.State.ToString(), Field.Store.NO), new TextField(MetadataKindField, metadata.MetadataKind.ToString(), Field.Store.NO) @@ -642,6 +665,7 @@ public sealed class LuceneSearchIndex : ISearchIndex show.LibraryPath.Library.Id.ToString(CultureInfo.InvariantCulture), Field.Store.NO), new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO), + new TextField(TitleAndYearSearchField, GetTitleAndYearSearch(metadata), Field.Store.NO), new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES), new StringField(StateField, show.State.ToString(), Field.Store.NO), new TextField(MetadataKindField, metadata.MetadataKind.ToString(), Field.Store.NO) @@ -765,6 +789,7 @@ public sealed class LuceneSearchIndex : ISearchIndex season.LibraryPath.Library.Id.ToString(CultureInfo.InvariantCulture), Field.Store.NO), new StringField(TitleAndYearField, titleAndYear, Field.Store.NO), + new TextField(TitleAndYearSearchField, GetTitleAndYearSearch(metadata), Field.Store.NO), new StringField(JumpLetterField, GetJumpLetter(showMetadata), Field.Store.YES), new StringField(StateField, season.State.ToString(), Field.Store.NO), new Int32Field(SeasonNumberField, season.SeasonNumber, Field.Store.NO), @@ -879,6 +904,7 @@ public sealed class LuceneSearchIndex : ISearchIndex artist.LibraryPath.Library.Id.ToString(CultureInfo.InvariantCulture), Field.Store.NO), new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO), + new TextField(TitleAndYearSearchField, GetTitleAndYearSearch(metadata), Field.Store.NO), new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES), new TextField(MetadataKindField, metadata.MetadataKind.ToString(), Field.Store.NO) }; @@ -948,6 +974,7 @@ public sealed class LuceneSearchIndex : ISearchIndex musicVideo.LibraryPath.Library.Id.ToString(CultureInfo.InvariantCulture), Field.Store.NO), new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO), + new TextField(TitleAndYearSearchField, GetTitleAndYearSearch(metadata), Field.Store.NO), new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES), new StringField(StateField, musicVideo.State.ToString(), Field.Store.NO), new TextField(MetadataKindField, metadata.MetadataKind.ToString(), Field.Store.NO) @@ -1061,6 +1088,7 @@ public sealed class LuceneSearchIndex : ISearchIndex episode.LibraryPath.Library.Id.ToString(CultureInfo.InvariantCulture), Field.Store.NO), new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO), + new TextField(TitleAndYearSearchField, GetTitleAndYearSearch(metadata), Field.Store.NO), new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES), new StringField(StateField, episode.State.ToString(), Field.Store.NO), new Int32Field(SeasonNumberField, episode.Season?.SeasonNumber ?? 0, Field.Store.NO), @@ -1213,6 +1241,7 @@ public sealed class LuceneSearchIndex : ISearchIndex otherVideo.LibraryPath.Library.Id.ToString(CultureInfo.InvariantCulture), Field.Store.NO), new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO), + new TextField(TitleAndYearSearchField, GetTitleAndYearSearch(metadata), Field.Store.NO), new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES), new StringField(StateField, otherVideo.State.ToString(), Field.Store.NO), new TextField(MetadataKindField, metadata.MetadataKind.ToString(), Field.Store.NO) @@ -1317,6 +1346,7 @@ public sealed class LuceneSearchIndex : ISearchIndex song.LibraryPath.Library.Id.ToString(CultureInfo.InvariantCulture), Field.Store.NO), new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO), + new TextField(TitleAndYearSearchField, GetTitleAndYearSearch(metadata), Field.Store.NO), new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES), new StringField(StateField, song.State.ToString(), Field.Store.NO), new TextField(MetadataKindField, metadata.MetadataKind.ToString(), Field.Store.NO) @@ -1392,6 +1422,7 @@ public sealed class LuceneSearchIndex : ISearchIndex image.LibraryPath.Library.Id.ToString(CultureInfo.InvariantCulture), Field.Store.NO), new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO), + new TextField(TitleAndYearSearchField, GetTitleAndYearSearch(metadata), Field.Store.NO), new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES), new StringField(StateField, image.State.ToString(), Field.Store.NO), new TextField(MetadataKindField, metadata.MetadataKind.ToString(), Field.Store.NO) @@ -1465,6 +1496,7 @@ public sealed class LuceneSearchIndex : ISearchIndex remoteStream.LibraryPath.Library.Id.ToString(CultureInfo.InvariantCulture), Field.Store.NO), new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO), + new TextField(TitleAndYearSearchField, GetTitleAndYearSearch(metadata), Field.Store.NO), new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES), new StringField(StateField, remoteStream.State.ToString(), Field.Store.NO), new TextField(MetadataKindField, metadata.MetadataKind.ToString(), Field.Store.NO) @@ -1608,6 +1640,17 @@ public sealed class LuceneSearchIndex : ISearchIndex _ => $"{Title(metadata)}_{metadata.Year}".ToLowerInvariant() }; + internal static string GetTitleAndYearSearch(Core.Domain.Metadata metadata) => + metadata switch + { + MovieMetadata mm => $"{mm.Title ?? string.Empty} {mm.Year}".ToLowerInvariant(), + SeasonMetadata sm => + $"{sm.Season.Show.ShowMetadata.Head().Title ?? string.Empty} {sm.Season.SeasonMetadata.Head().Title}" + .ToLowerInvariant(), + ShowMetadata sm => $"{sm.Title ?? string.Empty} {sm.Year}".ToLowerInvariant(), + _ => $"{metadata.Title ?? string.Empty} {metadata.Year}".ToLowerInvariant() + }; + private static string Title(Core.Domain.Metadata metadata) => (metadata.Title ?? string.Empty).Replace(' ', '_'); diff --git a/ErsatzTV.Infrastructure/Search/Models/ElasticSearchItem.cs b/ErsatzTV.Infrastructure/Search/Models/ElasticSearchItem.cs index c2f4bc700..ec2d3917e 100644 --- a/ErsatzTV.Infrastructure/Search/Models/ElasticSearchItem.cs +++ b/ErsatzTV.Infrastructure/Search/Models/ElasticSearchItem.cs @@ -19,6 +19,9 @@ public class ElasticSearchItem : MinimalElasticSearchItem [JsonPropertyName(LuceneSearchIndex.TitleAndYearField)] public string TitleAndYear { get; set; } + [JsonPropertyName(LuceneSearchIndex.TitleAndYearSearchField)] + public string TitleAndYearSearch { get; set; } + [JsonPropertyName(LuceneSearchIndex.StateField)] public string State { get; set; } diff --git a/ErsatzTV.Infrastructure/Search/SearchQueryParser.cs b/ErsatzTV.Infrastructure/Search/SearchQueryParser.cs index d9c9730c6..08d97dbed 100644 --- a/ErsatzTV.Infrastructure/Search/SearchQueryParser.cs +++ b/ErsatzTV.Infrastructure/Search/SearchQueryParser.cs @@ -41,6 +41,7 @@ public partial class SearchQueryParser(ISmartCollectionCache smartCollectionCach { LuceneSearchIndex.TagFullField, keywordAnalyzer }, { LuceneSearchIndex.CollectionField, lowercaseKeywordAnalyzer }, + { LuceneSearchIndex.TitleAndYearSearchField, lowercaseKeywordAnalyzer }, { LuceneSearchIndex.PlotField, new StandardAnalyzer(LuceneSearchIndex.AppLuceneVersion) } }; @@ -48,7 +49,11 @@ public partial class SearchQueryParser(ISmartCollectionCache smartCollectionCach return new PerFieldAnalyzerWrapper(defaultAnalyzer, customAnalyzers); } - public async Task ParseQuery(string query, string smartCollectionName, CancellationToken cancellationToken) + public async Task ParseQuery( + string query, + string smartCollectionName, + List defaultFields, + CancellationToken cancellationToken) { string parsedQuery = query; @@ -92,7 +97,7 @@ public partial class SearchQueryParser(ISmartCollectionCache smartCollectionCach var parser = new CustomMultiFieldQueryParser( LuceneSearchIndex.AppLuceneVersion, - [LuceneSearchIndex.TitleField], + defaultFields.ToArray(), analyzerWrapper) { AllowLeadingWildcard = true