Browse Source

optimize searching for shows, seasons and movies

pull/2768/head
Jason Dove 7 months ago
parent
commit
e6421826a5
No known key found for this signature in database
  1. 1
      CHANGELOG.md
  2. 21
      ErsatzTV.Application/Search/Queries/SearchMoviesHandler.cs
  3. 41
      ErsatzTV.Application/Search/Queries/SearchTelevisionSeasonsHandler.cs
  4. 22
      ErsatzTV.Application/Search/Queries/SearchTelevisionShowsHandler.cs
  5. 25
      ErsatzTV.Application/Search/Queries/SearchUsingSearchIndexHandler.cs
  6. 9
      ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs
  7. 2
      ErsatzTV.Infrastructure.Tests/Search/SearchQueryParserTests.cs
  8. 34
      ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs
  9. 47
      ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs
  10. 3
      ErsatzTV.Infrastructure/Search/Models/ElasticSearchItem.cs
  11. 9
      ErsatzTV.Infrastructure/Search/SearchQueryParser.cs

1
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 - Respect `z_index` (draw order) on all graphics element types
- Restore default UI font that was erroneously removed in v26.1.1 - 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` - 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 ## [26.1.1] - 2026-01-08
### Fixed ### Fixed

21
ErsatzTV.Application/Search/Queries/SearchMoviesHandler.cs

@ -1,23 +1,32 @@
using System.Collections.Immutable;
using System.Globalization; using System.Globalization;
using Bugsnag;
using ErsatzTV.Application.MediaItems; using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Search;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Search; namespace ErsatzTV.Application.Search;
public class SearchMoviesHandler(IDbContextFactory<TvContext> dbContextFactory) public class SearchMoviesHandler(
: IRequestHandler<SearchMovies, List<NamedMediaItemViewModel>> IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: SearchUsingSearchIndexHandler(client, searchIndex), IRequestHandler<SearchMovies, List<NamedMediaItemViewModel>>
{ {
public async Task<List<NamedMediaItemViewModel>> Handle(SearchMovies request, CancellationToken cancellationToken) public async Task<List<NamedMediaItemViewModel>> Handle(SearchMovies request, CancellationToken cancellationToken)
{ {
ImmutableHashSet<int> ids = await Search(LuceneSearchIndex.MovieType, request.Query, cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.MovieMetadata return await dbContext.MovieMetadata
.TagWithCallSite()
.AsNoTracking() .AsNoTracking()
.Where(m => EF.Functions.Like(m.Title + " " + m.Year, $"%{request.Query}%")) .Where(mm => ids.Contains(mm.MovieId))
.OrderBy(m => m.Title) .OrderBy(mm => mm.Title)
.ThenBy(m => m.Year) .ThenBy(mm => mm.Year)
.Take(10)
.ToListAsync(cancellationToken) .ToListAsync(cancellationToken)
.Map(list => list.Map(ToNamedMediaItem).ToList()); .Map(list => list.Map(ToNamedMediaItem).ToList());
} }

41
ErsatzTV.Application/Search/Queries/SearchTelevisionSeasonsHandler.cs

@ -1,30 +1,45 @@
using System.Collections.Immutable;
using System.Globalization; using System.Globalization;
using Bugsnag;
using ErsatzTV.Application.MediaItems; using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Search;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Search; namespace ErsatzTV.Application.Search;
public class SearchTelevisionSeasonsHandler(IDbContextFactory<TvContext> dbContextFactory) public class SearchTelevisionSeasonsHandler(
: IRequestHandler<SearchTelevisionSeasons, List<NamedMediaItemViewModel>> IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: SearchUsingSearchIndexHandler(client, searchIndex),
IRequestHandler<SearchTelevisionSeasons, List<NamedMediaItemViewModel>>
{ {
public async Task<List<NamedMediaItemViewModel>> Handle( public async Task<List<NamedMediaItemViewModel>> Handle(
SearchTelevisionSeasons request, SearchTelevisionSeasons request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
ImmutableHashSet<int> ids = await Search(LuceneSearchIndex.SeasonType, request.Query, cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await (from season in dbContext.Set<Season>() return await dbContext.SeasonMetadata
join seasonMetadata in dbContext.Set<SeasonMetadata>() .TagWithCallSite()
on season.Id equals seasonMetadata.SeasonId .AsNoTracking()
join showMetadata in dbContext.Set<ShowMetadata>() .Include(s => s.Season)
on season.ShowId equals showMetadata.ShowId .ThenInclude(s => s.Show)
where EF.Functions.Like(showMetadata.Title + " " + seasonMetadata.Title, $"%{request.Query}%") .ThenInclude(s => s.ShowMetadata)
orderby showMetadata.Title, season.SeasonNumber .Where(sm => ids.Contains(sm.SeasonId))
select new TelevisionSeason(season.Id, showMetadata.Title, showMetadata.Year, season.SeasonNumber))
.Take(20)
.ToListAsync(cancellationToken) .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) => private static NamedMediaItemViewModel ToNamedMediaItem(TelevisionSeason season) =>

22
ErsatzTV.Application/Search/Queries/SearchTelevisionShowsHandler.cs

@ -1,25 +1,35 @@
using System.Collections.Immutable;
using System.Globalization; using System.Globalization;
using Bugsnag;
using ErsatzTV.Application.MediaItems; using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Search;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Search; namespace ErsatzTV.Application.Search;
public class SearchTelevisionShowsHandler(IDbContextFactory<TvContext> dbContextFactory) public class SearchTelevisionShowsHandler(
: IRequestHandler<SearchTelevisionShows, List<NamedMediaItemViewModel>> IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: SearchUsingSearchIndexHandler(client, searchIndex),
IRequestHandler<SearchTelevisionShows, List<NamedMediaItemViewModel>>
{ {
public async Task<List<NamedMediaItemViewModel>> Handle( public async Task<List<NamedMediaItemViewModel>> Handle(
SearchTelevisionShows request, SearchTelevisionShows request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
ImmutableHashSet<int> ids = await Search(LuceneSearchIndex.ShowType, request.Query, cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.ShowMetadata return await dbContext.ShowMetadata
.TagWithCallSite()
.AsNoTracking() .AsNoTracking()
.Where(s => EF.Functions.Like(s.Title + " " + s.Year, $"%{request.Query}%")) .Where(sm => ids.Contains(sm.ShowId))
.OrderBy(s => s.Title) .OrderBy(sm => sm.Title)
.ThenBy(s => s.Year) .ThenBy(sm => sm.Year)
.Take(10)
.ToListAsync(cancellationToken) .ToListAsync(cancellationToken)
.Map(list => list.Map(ToNamedMediaItem).ToList()); .Map(list => list.Map(ToNamedMediaItem).ToList());
} }

25
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<ImmutableHashSet<int>> 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();
}
}

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

@ -45,5 +45,14 @@ public interface ISearchIndex : IDisposable
int limit, int limit,
CancellationToken cancellationToken); CancellationToken cancellationToken);
Task<SearchResult> Search(
IClient client,
string query,
string smartCollectionName,
int skip,
int limit,
List<string> defaultFields,
CancellationToken cancellationToken);
void Commit(); void Commit();
} }

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

@ -22,7 +22,7 @@ public class SearchQueryParserTests
ISmartCollectionCache smartCollectionCache = Substitute.For<ISmartCollectionCache>(); ISmartCollectionCache smartCollectionCache = Substitute.For<ISmartCollectionCache>();
var parser = new SearchQueryParser(smartCollectionCache, Substitute.For<ILogger<SearchQueryParser>>()); var parser = new SearchQueryParser(smartCollectionCache, Substitute.For<ILogger<SearchQueryParser>>());
Query result = await parser.ParseQuery(input, null, CancellationToken.None); Query result = await parser.ParseQuery(input, null, [LuceneSearchIndex.TitleField], CancellationToken.None);
result.ToString().ShouldBe(expected); result.ToString().ShouldBe(expected);
} }
} }

34
ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs

@ -48,7 +48,7 @@ public class ElasticSearchIndex : ISearchIndex
return exists.IsValidResponse; return exists.IsValidResponse;
} }
public int Version => 48; public int Version => 49;
public async Task<bool> Initialize( public async Task<bool> Initialize(
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
@ -167,18 +167,38 @@ public class ElasticSearchIndex : ISearchIndex
return deleteResponse.IsValidResponse; return deleteResponse.IsValidResponse;
} }
public Task<SearchResult> 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<SearchResult> Search( public async Task<SearchResult> Search(
IClient client, IClient client,
string query, string query,
string smartCollectionName, string smartCollectionName,
int skip, int skip,
int limit, int limit,
List<string> defaultFields,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var items = new List<MinimalElasticSearchItem>(); var items = new List<MinimalElasticSearchItem>();
var totalCount = 0; var totalCount = 0;
Query parsedQuery = await _searchQueryParser.ParseQuery(query, smartCollectionName, cancellationToken); Query parsedQuery = await _searchQueryParser.ParseQuery(
query,
smartCollectionName,
defaultFields,
cancellationToken);
ES.SearchResponse<MinimalElasticSearchItem> response = await _client.SearchAsync<MinimalElasticSearchItem>( ES.SearchResponse<MinimalElasticSearchItem> response = await _client.SearchAsync<MinimalElasticSearchItem>(
s => s.Indices(IndexName) s => s.Indices(IndexName)
@ -225,6 +245,7 @@ public class ElasticSearchIndex : ISearchIndex
.Text(t => t.LibraryName, t => t.Store(false)) .Text(t => t.LibraryName, t => t.Store(false))
.Keyword(t => t.LibraryId, t => t.Store(false)) .Keyword(t => t.LibraryId, t => t.Store(false))
.Keyword(t => t.TitleAndYear, 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.JumpLetter, t => t.Store())
.Keyword(t => t.State, t => t.Store(false)) .Keyword(t => t.State, t => t.Store(false))
.Text(t => t.MetadataKind, 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, LibraryName = movie.LibraryPath.Library.Name,
LibraryId = movie.LibraryPath.Library.Id, LibraryId = movie.LibraryPath.Library.Id,
TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata), TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata),
TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata),
JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata), JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata),
State = movie.State.ToString(), State = movie.State.ToString(),
MetadataKind = metadata.MetadataKind.ToString(), MetadataKind = metadata.MetadataKind.ToString(),
@ -374,6 +396,7 @@ public class ElasticSearchIndex : ISearchIndex
LibraryName = show.LibraryPath.Library.Name, LibraryName = show.LibraryPath.Library.Name,
LibraryId = show.LibraryPath.Library.Id, LibraryId = show.LibraryPath.Library.Id,
TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata), TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata),
TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata),
JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata), JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata),
State = show.State.ToString(), State = show.State.ToString(),
MetadataKind = metadata.MetadataKind.ToString(), MetadataKind = metadata.MetadataKind.ToString(),
@ -440,6 +463,7 @@ public class ElasticSearchIndex : ISearchIndex
LibraryName = season.LibraryPath.Library.Name, LibraryName = season.LibraryPath.Library.Name,
LibraryId = season.LibraryPath.Library.Id, LibraryId = season.LibraryPath.Library.Id,
TitleAndYear = titleAndYear, TitleAndYear = titleAndYear,
TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata),
JumpLetter = LuceneSearchIndex.GetJumpLetter(showMetadata), JumpLetter = LuceneSearchIndex.GetJumpLetter(showMetadata),
State = season.State.ToString(), State = season.State.ToString(),
SeasonNumber = season.SeasonNumber, SeasonNumber = season.SeasonNumber,
@ -498,6 +522,7 @@ public class ElasticSearchIndex : ISearchIndex
LibraryName = artist.LibraryPath.Library.Name, LibraryName = artist.LibraryPath.Library.Name,
LibraryId = artist.LibraryPath.Library.Id, LibraryId = artist.LibraryPath.Library.Id,
TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata), TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata),
TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata),
JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata), JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata),
State = artist.State.ToString(), State = artist.State.ToString(),
MetadataKind = metadata.MetadataKind.ToString(), MetadataKind = metadata.MetadataKind.ToString(),
@ -545,6 +570,7 @@ public class ElasticSearchIndex : ISearchIndex
LibraryName = musicVideo.LibraryPath.Library.Name, LibraryName = musicVideo.LibraryPath.Library.Name,
LibraryId = musicVideo.LibraryPath.Library.Id, LibraryId = musicVideo.LibraryPath.Library.Id,
TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata), TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata),
TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata),
JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata), JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata),
State = musicVideo.State.ToString(), State = musicVideo.State.ToString(),
MetadataKind = metadata.MetadataKind.ToString(), MetadataKind = metadata.MetadataKind.ToString(),
@ -626,6 +652,7 @@ public class ElasticSearchIndex : ISearchIndex
LibraryName = episode.LibraryPath.Library.Name, LibraryName = episode.LibraryPath.Library.Name,
LibraryId = episode.LibraryPath.Library.Id, LibraryId = episode.LibraryPath.Library.Id,
TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata), TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata),
TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata),
JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata), JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata),
State = episode.State.ToString(), State = episode.State.ToString(),
MetadataKind = metadata.MetadataKind.ToString(), MetadataKind = metadata.MetadataKind.ToString(),
@ -695,6 +722,7 @@ public class ElasticSearchIndex : ISearchIndex
LibraryName = otherVideo.LibraryPath.Library.Name, LibraryName = otherVideo.LibraryPath.Library.Name,
LibraryId = otherVideo.LibraryPath.Library.Id, LibraryId = otherVideo.LibraryPath.Library.Id,
TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata), TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata),
TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata),
JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata), JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata),
State = otherVideo.State.ToString(), State = otherVideo.State.ToString(),
MetadataKind = metadata.MetadataKind.ToString(), MetadataKind = metadata.MetadataKind.ToString(),
@ -751,6 +779,7 @@ public class ElasticSearchIndex : ISearchIndex
LibraryName = song.LibraryPath.Library.Name, LibraryName = song.LibraryPath.Library.Name,
LibraryId = song.LibraryPath.Library.Id, LibraryId = song.LibraryPath.Library.Id,
TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata), TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata),
TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata),
JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata), JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata),
State = song.State.ToString(), State = song.State.ToString(),
MetadataKind = metadata.MetadataKind.ToString(), MetadataKind = metadata.MetadataKind.ToString(),
@ -800,6 +829,7 @@ public class ElasticSearchIndex : ISearchIndex
LibraryName = image.LibraryPath.Library.Name, LibraryName = image.LibraryPath.Library.Name,
LibraryId = image.LibraryPath.Library.Id, LibraryId = image.LibraryPath.Library.Id,
TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata), TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata),
TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata),
JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata), JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata),
State = image.State.ToString(), State = image.State.ToString(),
MetadataKind = metadata.MetadataKind.ToString(), MetadataKind = metadata.MetadataKind.ToString(),

47
ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs

@ -90,6 +90,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
public const string SongType = "song"; public const string SongType = "song";
public const string ImageType = "image"; public const string ImageType = "image";
public const string RemoteStreamType = "remote_stream"; public const string RemoteStreamType = "remote_stream";
public const string TitleAndYearSearchField = "title_and_year_search";
private readonly string _cleanShutdownPath; private readonly string _cleanShutdownPath;
private readonly List<CultureInfo> _cultureInfos; private readonly List<CultureInfo> _cultureInfos;
@ -118,7 +119,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
return Task.FromResult(directoryExists && fileExists); return Task.FromResult(directoryExists && fileExists);
} }
public int Version => 49; public int Version => 50;
public async Task<bool> Initialize( public async Task<bool> Initialize(
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
@ -209,12 +210,29 @@ public sealed class LuceneSearchIndex : ISearchIndex
return Task.FromResult(true); return Task.FromResult(true);
} }
// default to title field only
public Task<SearchResult> Search(
IClient client,
string query,
string smartCollectionName,
int skip,
int limit,
CancellationToken cancellationToken) => Search(
client,
query,
smartCollectionName,
skip,
limit,
[TitleField],
cancellationToken);
public async Task<SearchResult> Search( public async Task<SearchResult> Search(
IClient client, IClient client,
string query, string query,
string smartCollectionName, string smartCollectionName,
int skip, int skip,
int limit, int limit,
List<string> defaultFields,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var metadata = new Dictionary<string, string> var metadata = new Dictionary<string, string>
@ -237,7 +255,11 @@ public sealed class LuceneSearchIndex : ISearchIndex
using DirectoryReader reader = _writer.GetReader(true); using DirectoryReader reader = _writer.GetReader(true);
var searcher = new IndexSearcher(reader); var searcher = new IndexSearcher(reader);
int hitsLimit = limit == 0 ? searcher.IndexReader.MaxDoc : skip + limit; 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 // TODO: figure out if this is actually needed
// var filter = new DuplicateFilter(TitleAndYearField); // var filter = new DuplicateFilter(TitleAndYearField);
var sort = new Sort(new SortField(SortTitleField, SortFieldType.STRING)); 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), movie.LibraryPath.Library.Id.ToString(CultureInfo.InvariantCulture),
Field.Store.NO), Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), 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(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new StringField(StateField, movie.State.ToString(), Field.Store.NO), new StringField(StateField, movie.State.ToString(), Field.Store.NO),
new TextField(MetadataKindField, metadata.MetadataKind.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), show.LibraryPath.Library.Id.ToString(CultureInfo.InvariantCulture),
Field.Store.NO), Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), 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(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new StringField(StateField, show.State.ToString(), Field.Store.NO), new StringField(StateField, show.State.ToString(), Field.Store.NO),
new TextField(MetadataKindField, metadata.MetadataKind.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), season.LibraryPath.Library.Id.ToString(CultureInfo.InvariantCulture),
Field.Store.NO), Field.Store.NO),
new StringField(TitleAndYearField, titleAndYear, 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(JumpLetterField, GetJumpLetter(showMetadata), Field.Store.YES),
new StringField(StateField, season.State.ToString(), Field.Store.NO), new StringField(StateField, season.State.ToString(), Field.Store.NO),
new Int32Field(SeasonNumberField, season.SeasonNumber, 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), artist.LibraryPath.Library.Id.ToString(CultureInfo.InvariantCulture),
Field.Store.NO), Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), 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(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new TextField(MetadataKindField, metadata.MetadataKind.ToString(), Field.Store.NO) 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), musicVideo.LibraryPath.Library.Id.ToString(CultureInfo.InvariantCulture),
Field.Store.NO), Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), 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(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new StringField(StateField, musicVideo.State.ToString(), Field.Store.NO), new StringField(StateField, musicVideo.State.ToString(), Field.Store.NO),
new TextField(MetadataKindField, metadata.MetadataKind.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), episode.LibraryPath.Library.Id.ToString(CultureInfo.InvariantCulture),
Field.Store.NO), Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), 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(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new StringField(StateField, episode.State.ToString(), Field.Store.NO), new StringField(StateField, episode.State.ToString(), Field.Store.NO),
new Int32Field(SeasonNumberField, episode.Season?.SeasonNumber ?? 0, 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), otherVideo.LibraryPath.Library.Id.ToString(CultureInfo.InvariantCulture),
Field.Store.NO), Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), 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(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new StringField(StateField, otherVideo.State.ToString(), Field.Store.NO), new StringField(StateField, otherVideo.State.ToString(), Field.Store.NO),
new TextField(MetadataKindField, metadata.MetadataKind.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), song.LibraryPath.Library.Id.ToString(CultureInfo.InvariantCulture),
Field.Store.NO), Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), 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(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new StringField(StateField, song.State.ToString(), Field.Store.NO), new StringField(StateField, song.State.ToString(), Field.Store.NO),
new TextField(MetadataKindField, metadata.MetadataKind.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), image.LibraryPath.Library.Id.ToString(CultureInfo.InvariantCulture),
Field.Store.NO), Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), 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(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new StringField(StateField, image.State.ToString(), Field.Store.NO), new StringField(StateField, image.State.ToString(), Field.Store.NO),
new TextField(MetadataKindField, metadata.MetadataKind.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), remoteStream.LibraryPath.Library.Id.ToString(CultureInfo.InvariantCulture),
Field.Store.NO), Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), 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(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new StringField(StateField, remoteStream.State.ToString(), Field.Store.NO), new StringField(StateField, remoteStream.State.ToString(), Field.Store.NO),
new TextField(MetadataKindField, metadata.MetadataKind.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() _ => $"{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) => private static string Title(Core.Domain.Metadata metadata) =>
(metadata.Title ?? string.Empty).Replace(' ', '_'); (metadata.Title ?? string.Empty).Replace(' ', '_');

3
ErsatzTV.Infrastructure/Search/Models/ElasticSearchItem.cs

@ -19,6 +19,9 @@ public class ElasticSearchItem : MinimalElasticSearchItem
[JsonPropertyName(LuceneSearchIndex.TitleAndYearField)] [JsonPropertyName(LuceneSearchIndex.TitleAndYearField)]
public string TitleAndYear { get; set; } public string TitleAndYear { get; set; }
[JsonPropertyName(LuceneSearchIndex.TitleAndYearSearchField)]
public string TitleAndYearSearch { get; set; }
[JsonPropertyName(LuceneSearchIndex.StateField)] [JsonPropertyName(LuceneSearchIndex.StateField)]
public string State { get; set; } public string State { get; set; }

9
ErsatzTV.Infrastructure/Search/SearchQueryParser.cs

@ -41,6 +41,7 @@ public partial class SearchQueryParser(ISmartCollectionCache smartCollectionCach
{ LuceneSearchIndex.TagFullField, keywordAnalyzer }, { LuceneSearchIndex.TagFullField, keywordAnalyzer },
{ LuceneSearchIndex.CollectionField, lowercaseKeywordAnalyzer }, { LuceneSearchIndex.CollectionField, lowercaseKeywordAnalyzer },
{ LuceneSearchIndex.TitleAndYearSearchField, lowercaseKeywordAnalyzer },
{ LuceneSearchIndex.PlotField, new StandardAnalyzer(LuceneSearchIndex.AppLuceneVersion) } { LuceneSearchIndex.PlotField, new StandardAnalyzer(LuceneSearchIndex.AppLuceneVersion) }
}; };
@ -48,7 +49,11 @@ public partial class SearchQueryParser(ISmartCollectionCache smartCollectionCach
return new PerFieldAnalyzerWrapper(defaultAnalyzer, customAnalyzers); return new PerFieldAnalyzerWrapper(defaultAnalyzer, customAnalyzers);
} }
public async Task<Query> ParseQuery(string query, string smartCollectionName, CancellationToken cancellationToken) public async Task<Query> ParseQuery(
string query,
string smartCollectionName,
List<string> defaultFields,
CancellationToken cancellationToken)
{ {
string parsedQuery = query; string parsedQuery = query;
@ -92,7 +97,7 @@ public partial class SearchQueryParser(ISmartCollectionCache smartCollectionCach
var parser = new CustomMultiFieldQueryParser( var parser = new CustomMultiFieldQueryParser(
LuceneSearchIndex.AppLuceneVersion, LuceneSearchIndex.AppLuceneVersion,
[LuceneSearchIndex.TitleField], defaultFields.ToArray(),
analyzerWrapper) analyzerWrapper)
{ {
AllowLeadingWildcard = true AllowLeadingWildcard = true

Loading…
Cancel
Save