Browse Source

add elasticsearch search index provider (#1370)

* wip

* first pass at elasticsearch; movies kind of work

* use field name constants

* properly sort search results

* fix some crashes

* fix page map/jump letters

* optimize page map using terms aggregation

* index all item types

* optionally use elastic search

* code cleanup

* automatically rebuild lucene index after improper shutdown

* update changelog
pull/1371/head
Jason Dove 2 years ago committed by GitHub
parent
commit
c62cc98c9f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      CHANGELOG.md
  2. 18
      ErsatzTV.Application/Maintenance/Commands/EmptyTrashHandler.cs
  3. 9
      ErsatzTV.Application/Search/Commands/RebuildSearchIndexHandler.cs
  4. 24
      ErsatzTV.Application/Search/Queries/QuerySearchIndexAllItemsHandler.cs
  5. 2
      ErsatzTV.Application/Search/Queries/QuerySearchIndexArtistsHandler.cs
  6. 2
      ErsatzTV.Application/Search/Queries/QuerySearchIndexEpisodesHandler.cs
  7. 2
      ErsatzTV.Application/Search/Queries/QuerySearchIndexMoviesHandler.cs
  8. 2
      ErsatzTV.Application/Search/Queries/QuerySearchIndexMusicVideosHandler.cs
  9. 2
      ErsatzTV.Application/Search/Queries/QuerySearchIndexOtherVideosHandler.cs
  10. 2
      ErsatzTV.Application/Search/Queries/QuerySearchIndexSeasonsHandler.cs
  11. 2
      ErsatzTV.Application/Search/Queries/QuerySearchIndexShowsHandler.cs
  12. 2
      ErsatzTV.Application/Search/Queries/QuerySearchIndexSongsHandler.cs
  13. 2
      ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs
  14. 3
      ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs
  15. 18
      ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs
  16. 2
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
  17. 10
      ErsatzTV.Infrastructure/Search/CustomMultiFieldQueryParser.cs
  18. 22
      ErsatzTV.Infrastructure/Search/CustomQueryParser.cs
  19. 828
      ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs
  20. 108
      ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs
  21. 111
      ErsatzTV.Infrastructure/Search/Models/ElasticSearchItem.cs
  22. 18
      ErsatzTV.Infrastructure/Search/Models/MinimalElasticSearchItem.cs
  23. 2
      ErsatzTV.Scanner/Program.cs
  24. 9
      ErsatzTV/Extensions/NavigationManagerExtensions.cs
  25. 7
      ErsatzTV/Pages/ArtistList.razor
  26. 7
      ErsatzTV/Pages/EpisodeList.razor
  27. 16
      ErsatzTV/Pages/MovieList.razor
  28. 7
      ErsatzTV/Pages/MusicVideoList.razor
  29. 7
      ErsatzTV/Pages/OtherVideoList.razor
  30. 7
      ErsatzTV/Pages/SongList.razor
  31. 7
      ErsatzTV/Pages/TelevisionSeasonSearchResults.razor
  32. 7
      ErsatzTV/Pages/TelevisionShowList.razor
  33. 20
      ErsatzTV/SearchHelper.cs
  34. 14
      ErsatzTV/Startup.cs

8
CHANGELOG.md

@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Added
- Automatically rebuild search index after improper shutdown
- Add *experimental* support for Elasticsearch as search index backend
- No query changes should be needed since ES is backed by lucene and supports the same query syntax
- This can be configured using the following env vars (note the double underscore separator `__`)
- `ELASTICSEARCH__URI` (e.g. `http://localhost:9200`)
- `ELASTICSEARCH__INDEXNAME` (default is `ersatztv`)
### Fixed ### Fixed
- Fix subtitle scaling when using QSV hardware acceleration - Fix subtitle scaling when using QSV hardware acceleration
- Fix log viewer crash when log file contains invalid data - Fix log viewer crash when log file contains invalid data

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

@ -29,21 +29,21 @@ public class EmptyTrashHandler : IRequestHandler<EmptyTrash, Either<BaseError, U
{ {
string[] types = string[] types =
{ {
SearchIndex.MovieType, LuceneSearchIndex.MovieType,
SearchIndex.ShowType, LuceneSearchIndex.ShowType,
SearchIndex.SeasonType, LuceneSearchIndex.SeasonType,
SearchIndex.EpisodeType, LuceneSearchIndex.EpisodeType,
SearchIndex.MusicVideoType, LuceneSearchIndex.MusicVideoType,
SearchIndex.OtherVideoType, LuceneSearchIndex.OtherVideoType,
SearchIndex.SongType, LuceneSearchIndex.SongType,
SearchIndex.ArtistType LuceneSearchIndex.ArtistType
}; };
var ids = new List<int>(); var ids = new List<int>();
foreach (string type in types) foreach (string type in types)
{ {
SearchResult result = _searchIndex.Search(_client, $"type:{type} AND (state:FileNotFound)", 0, 0); SearchResult result = await _searchIndex.Search(_client, $"type:{type} AND (state:FileNotFound)", 0, 0);
ids.AddRange(result.Items.Map(i => i.Id)); ids.AddRange(result.Items.Map(i => i.Id));
} }

9
ErsatzTV.Application/Search/Commands/RebuildSearchIndexHandler.cs

@ -42,13 +42,16 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex>
{ {
_logger.LogInformation("Initializing search index"); _logger.LogInformation("Initializing search index");
bool indexFolderExists = Directory.Exists(FileSystemLayout.SearchIndexFolder); bool indexExists = await _searchIndex.IndexExists();
await _searchIndex.Initialize(_localFileSystem, _configElementRepository); if (!await _searchIndex.Initialize(_localFileSystem, _configElementRepository))
{
indexExists = false;
}
_logger.LogInformation("Done initializing search index"); _logger.LogInformation("Done initializing search index");
if (!indexFolderExists || if (!indexExists ||
await _configElementRepository.GetValue<int>(ConfigElementKey.SearchIndexVersion) < await _configElementRepository.GetValue<int>(ConfigElementKey.SearchIndexVersion) <
_searchIndex.Version) _searchIndex.Version)
{ {

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

@ -15,19 +15,19 @@ public class QuerySearchIndexAllItemsHandler : IRequestHandler<QuerySearchIndexA
_searchIndex = searchIndex; _searchIndex = searchIndex;
} }
public Task<SearchResultAllItemsViewModel> Handle( public async Task<SearchResultAllItemsViewModel> Handle(
QuerySearchIndexAllItems request, QuerySearchIndexAllItems request,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
new SearchResultAllItemsViewModel( new(
GetIds(SearchIndex.MovieType, request.Query), await GetIds(LuceneSearchIndex.MovieType, request.Query),
GetIds(SearchIndex.ShowType, request.Query), await GetIds(LuceneSearchIndex.ShowType, request.Query),
GetIds(SearchIndex.SeasonType, request.Query), await GetIds(LuceneSearchIndex.SeasonType, request.Query),
GetIds(SearchIndex.EpisodeType, request.Query), await GetIds(LuceneSearchIndex.EpisodeType, request.Query),
GetIds(SearchIndex.ArtistType, request.Query), await GetIds(LuceneSearchIndex.ArtistType, request.Query),
GetIds(SearchIndex.MusicVideoType, request.Query), await GetIds(LuceneSearchIndex.MusicVideoType, request.Query),
GetIds(SearchIndex.OtherVideoType, request.Query), await GetIds(LuceneSearchIndex.OtherVideoType, request.Query),
GetIds(SearchIndex.SongType, request.Query)).AsTask(); await GetIds(LuceneSearchIndex.SongType, request.Query));
private List<int> GetIds(string type, string query) => private async Task<List<int>> GetIds(string type, string query) =>
_searchIndex.Search(_client, $"type:{type} AND ({query})", 0, 0).Items.Map(i => i.Id).ToList(); (await _searchIndex.Search(_client, $"type:{type} AND ({query})", 0, 0)).Items.Map(i => i.Id).ToList();
} }

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

@ -24,7 +24,7 @@ public class QuerySearchIndexArtistsHandler : IRequestHandler<QuerySearchIndexAr
QuerySearchIndexArtists request, QuerySearchIndexArtists request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
SearchResult searchResult = _searchIndex.Search( SearchResult searchResult = await _searchIndex.Search(
_client, _client,
request.Query, request.Query,
(request.PageNumber - 1) * request.PageSize, (request.PageNumber - 1) * request.PageSize,

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

@ -55,7 +55,7 @@ public class
QuerySearchIndexEpisodes request, QuerySearchIndexEpisodes request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
SearchResult searchResult = _searchIndex.Search( SearchResult searchResult = await _searchIndex.Search(
_client, _client,
request.Query, request.Query,
(request.PageNumber - 1) * request.PageSize, (request.PageNumber - 1) * request.PageSize,

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

@ -31,7 +31,7 @@ public class QuerySearchIndexMoviesHandler : IRequestHandler<QuerySearchIndexMov
QuerySearchIndexMovies request, QuerySearchIndexMovies request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
SearchResult searchResult = _searchIndex.Search( SearchResult searchResult = await _searchIndex.Search(
_client, _client,
request.Query, request.Query,
(request.PageNumber - 1) * request.PageSize, (request.PageNumber - 1) * request.PageSize,

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

@ -42,7 +42,7 @@ public class
QuerySearchIndexMusicVideos request, QuerySearchIndexMusicVideos request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
SearchResult searchResult = _searchIndex.Search( SearchResult searchResult = await _searchIndex.Search(
_client, _client,
request.Query, request.Query,
(request.PageNumber - 1) * request.PageSize, (request.PageNumber - 1) * request.PageSize,

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

@ -29,7 +29,7 @@ public class
QuerySearchIndexOtherVideos request, QuerySearchIndexOtherVideos request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
SearchResult searchResult = _searchIndex.Search( SearchResult searchResult = await _searchIndex.Search(
_client, _client,
request.Query, request.Query,
(request.PageNumber - 1) * request.PageSize, (request.PageNumber - 1) * request.PageSize,

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

@ -32,7 +32,7 @@ public class
QuerySearchIndexSeasons request, QuerySearchIndexSeasons request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
SearchResult searchResult = _searchIndex.Search( SearchResult searchResult = await _searchIndex.Search(
_client, _client,
request.Query, request.Query,
(request.PageNumber - 1) * request.PageSize, (request.PageNumber - 1) * request.PageSize,

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

@ -32,7 +32,7 @@ public class
QuerySearchIndexShows request, QuerySearchIndexShows request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
SearchResult searchResult = _searchIndex.Search( SearchResult searchResult = await _searchIndex.Search(
_client, _client,
request.Query, request.Query,
(request.PageNumber - 1) * request.PageSize, (request.PageNumber - 1) * request.PageSize,

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

@ -24,7 +24,7 @@ public class QuerySearchIndexSongsHandler : IRequestHandler<QuerySearchIndexSong
QuerySearchIndexSongs request, QuerySearchIndexSongs request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
SearchResult searchResult = _searchIndex.Search( SearchResult searchResult = await _searchIndex.Search(
_client, _client,
request.Query, request.Query,
(request.PageNumber - 1) * request.PageSize, (request.PageNumber - 1) * request.PageSize,

2
ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs

@ -89,7 +89,7 @@ public class ScheduleIntegrationTests
services.AddScoped<IConfigElementRepository, ConfigElementRepository>(); services.AddScoped<IConfigElementRepository, ConfigElementRepository>();
services.AddScoped<IFallbackMetadataProvider, FallbackMetadataProvider>(); services.AddScoped<IFallbackMetadataProvider, FallbackMetadataProvider>();
services.AddSingleton<ISearchIndex, SearchIndex>(); services.AddSingleton<ISearchIndex, LuceneSearchIndex>();
services.AddSingleton(_ => Substitute.For<IClient>()); services.AddSingleton(_ => Substitute.For<IClient>());

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

@ -10,6 +10,7 @@ namespace ErsatzTV.Core.Interfaces.Search;
public interface ISearchIndex : IDisposable public interface ISearchIndex : IDisposable
{ {
public int Version { get; } public int Version { get; }
Task<bool> IndexExists();
Task<bool> Initialize(ILocalFileSystem localFileSystem, IConfigElementRepository configElementRepository); Task<bool> Initialize(ILocalFileSystem localFileSystem, IConfigElementRepository configElementRepository);
Task<Unit> Rebuild(ICachingSearchRepository searchRepository, IFallbackMetadataProvider fallbackMetadataProvider); Task<Unit> Rebuild(ICachingSearchRepository searchRepository, IFallbackMetadataProvider fallbackMetadataProvider);
@ -24,6 +25,6 @@ public interface ISearchIndex : IDisposable
List<MediaItem> items); List<MediaItem> items);
Task<Unit> RemoveItems(IEnumerable<int> ids); Task<Unit> RemoveItems(IEnumerable<int> ids);
SearchResult Search(IClient client, string query, int skip, int limit, string searchField = ""); Task<SearchResult> Search(IClient client, string query, int skip, int limit, string searchField = "");
void Commit(); void Commit();
} }

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

@ -100,51 +100,51 @@ public class MediaCollectionRepository : IMediaCollectionRepository
foreach (SmartCollection collection in maybeCollection) foreach (SmartCollection collection in maybeCollection)
{ {
SearchResult searchResults = _searchIndex.Search(_client, collection.Query, 0, 0); SearchResult searchResults = await _searchIndex.Search(_client, collection.Query, 0, 0);
var movieIds = searchResults.Items var movieIds = searchResults.Items
.Filter(i => i.Type == SearchIndex.MovieType) .Filter(i => i.Type == LuceneSearchIndex.MovieType)
.Map(i => i.Id) .Map(i => i.Id)
.ToList(); .ToList();
result.AddRange(await GetMovieItems(dbContext, movieIds)); result.AddRange(await GetMovieItems(dbContext, movieIds));
foreach (int showId in searchResults.Items.Filter(i => i.Type == SearchIndex.ShowType).Map(i => i.Id)) foreach (int showId in searchResults.Items.Filter(i => i.Type == LuceneSearchIndex.ShowType).Map(i => i.Id))
{ {
result.AddRange(await GetShowItemsFromShowId(dbContext, showId)); result.AddRange(await GetShowItemsFromShowId(dbContext, showId));
} }
foreach (int seasonId in searchResults.Items.Filter(i => i.Type == SearchIndex.SeasonType) foreach (int seasonId in searchResults.Items.Filter(i => i.Type == LuceneSearchIndex.SeasonType)
.Map(i => i.Id)) .Map(i => i.Id))
{ {
result.AddRange(await GetSeasonItemsFromSeasonId(dbContext, seasonId)); result.AddRange(await GetSeasonItemsFromSeasonId(dbContext, seasonId));
} }
foreach (int artistId in searchResults.Items.Filter(i => i.Type == SearchIndex.ArtistType) foreach (int artistId in searchResults.Items.Filter(i => i.Type == LuceneSearchIndex.ArtistType)
.Map(i => i.Id)) .Map(i => i.Id))
{ {
result.AddRange(await GetArtistItemsFromArtistId(dbContext, artistId)); result.AddRange(await GetArtistItemsFromArtistId(dbContext, artistId));
} }
var musicVideoIds = searchResults.Items var musicVideoIds = searchResults.Items
.Filter(i => i.Type == SearchIndex.MusicVideoType) .Filter(i => i.Type == LuceneSearchIndex.MusicVideoType)
.Map(i => i.Id) .Map(i => i.Id)
.ToList(); .ToList();
result.AddRange(await GetMusicVideoItems(dbContext, musicVideoIds)); result.AddRange(await GetMusicVideoItems(dbContext, musicVideoIds));
var episodeIds = searchResults.Items var episodeIds = searchResults.Items
.Filter(i => i.Type == SearchIndex.EpisodeType) .Filter(i => i.Type == LuceneSearchIndex.EpisodeType)
.Map(i => i.Id) .Map(i => i.Id)
.ToList(); .ToList();
result.AddRange(await GetEpisodeItems(dbContext, episodeIds)); result.AddRange(await GetEpisodeItems(dbContext, episodeIds));
var otherVideoIds = searchResults.Items var otherVideoIds = searchResults.Items
.Filter(i => i.Type == SearchIndex.OtherVideoType) .Filter(i => i.Type == LuceneSearchIndex.OtherVideoType)
.Map(i => i.Id) .Map(i => i.Id)
.ToList(); .ToList();
result.AddRange(await GetOtherVideoItems(dbContext, otherVideoIds)); result.AddRange(await GetOtherVideoItems(dbContext, otherVideoIds));
var songIds = searchResults.Items var songIds = searchResults.Items
.Filter(i => i.Type == SearchIndex.SongType) .Filter(i => i.Type == LuceneSearchIndex.SongType)
.Map(i => i.Id) .Map(i => i.Id)
.ToList(); .ToList();
result.AddRange(await GetSongItems(dbContext, songIds)); result.AddRange(await GetSongItems(dbContext, songIds));

2
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -11,6 +11,7 @@
<PackageReference Include="Blurhash.ImageSharp" Version="3.0.0" /> <PackageReference Include="Blurhash.ImageSharp" Version="3.0.0" />
<PackageReference Include="CliWrap" Version="3.6.4" /> <PackageReference Include="CliWrap" Version="3.6.4" />
<PackageReference Include="Dapper" Version="2.0.143" /> <PackageReference Include="Dapper" Version="2.0.143" />
<PackageReference Include="Elastic.Clients.Elasticsearch" Version="8.9.1" />
<PackageReference Include="Jint" Version="3.0.0-beta-2050" /> <PackageReference Include="Jint" Version="3.0.0-beta-2050" />
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00016" /> <PackageReference Include="Lucene.Net" Version="4.8.0-beta00016" />
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00016" /> <PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00016" />
@ -35,4 +36,5 @@
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" /> <ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

10
ErsatzTV.Infrastructure/Search/CustomMultiFieldQueryParser.cs

@ -29,7 +29,7 @@ public class CustomMultiFieldQueryParser : MultiFieldQueryParser
if (field == "released_onthisday") if (field == "released_onthisday")
{ {
var todayString = DateTime.Today.ToString("*MMdd"); var todayString = DateTime.Today.ToString("*MMdd");
return base.GetWildcardQuery(SearchIndex.ReleaseDateField, todayString); return base.GetWildcardQuery(LuceneSearchIndex.ReleaseDateField, todayString);
} }
if (CustomQueryParser.NumericFields.Contains(field) && int.TryParse(queryText, out int val)) if (CustomQueryParser.NumericFields.Contains(field) && int.TryParse(queryText, out int val))
@ -49,14 +49,14 @@ public class CustomMultiFieldQueryParser : MultiFieldQueryParser
var todayString = DateTime.UtcNow.ToString("yyyyMMdd"); var todayString = DateTime.UtcNow.ToString("yyyyMMdd");
var dateString = start.ToString("yyyyMMdd"); var dateString = start.ToString("yyyyMMdd");
return base.GetRangeQuery(SearchIndex.ReleaseDateField, dateString, todayString, true, true); return base.GetRangeQuery(LuceneSearchIndex.ReleaseDateField, dateString, todayString, true, true);
} }
if (field == "released_notinthelast" && CustomQueryParser.ParseStart(queryText, out DateTime finish)) if (field == "released_notinthelast" && CustomQueryParser.ParseStart(queryText, out DateTime finish))
{ {
var dateString = finish.ToString("yyyyMMdd"); var dateString = finish.ToString("yyyyMMdd");
return base.GetRangeQuery(SearchIndex.ReleaseDateField, "00000000", dateString, false, false); return base.GetRangeQuery(LuceneSearchIndex.ReleaseDateField, "00000000", dateString, false, false);
} }
if (field == "added_inthelast" && CustomQueryParser.ParseStart(queryText, out DateTime addedStart)) if (field == "added_inthelast" && CustomQueryParser.ParseStart(queryText, out DateTime addedStart))
@ -64,14 +64,14 @@ public class CustomMultiFieldQueryParser : MultiFieldQueryParser
var todayString = DateTime.UtcNow.ToString("yyyyMMdd"); var todayString = DateTime.UtcNow.ToString("yyyyMMdd");
var dateString = addedStart.ToString("yyyyMMdd"); var dateString = addedStart.ToString("yyyyMMdd");
return base.GetRangeQuery(SearchIndex.AddedDateField, dateString, todayString, true, true); return base.GetRangeQuery(LuceneSearchIndex.AddedDateField, dateString, todayString, true, true);
} }
if (field == "added_notinthelast" && CustomQueryParser.ParseStart(queryText, out DateTime addedFinish)) if (field == "added_notinthelast" && CustomQueryParser.ParseStart(queryText, out DateTime addedFinish))
{ {
var dateString = addedFinish.ToString("yyyyMMdd"); var dateString = addedFinish.ToString("yyyyMMdd");
return base.GetRangeQuery(SearchIndex.AddedDateField, "00000000", dateString, false, false); return base.GetRangeQuery(LuceneSearchIndex.AddedDateField, "00000000", dateString, false, false);
} }
return base.GetFieldQuery(field, queryText, slop); return base.GetFieldQuery(field, queryText, slop);

22
ErsatzTV.Infrastructure/Search/CustomQueryParser.cs

@ -12,12 +12,12 @@ public class CustomQueryParser : QueryParser
{ {
internal static readonly List<string> NumericFields = new() internal static readonly List<string> NumericFields = new()
{ {
SearchIndex.MinutesField, LuceneSearchIndex.MinutesField,
SearchIndex.HeightField, LuceneSearchIndex.HeightField,
SearchIndex.WidthField, LuceneSearchIndex.WidthField,
SearchIndex.SeasonNumberField, LuceneSearchIndex.SeasonNumberField,
SearchIndex.EpisodeNumberField, LuceneSearchIndex.EpisodeNumberField,
SearchIndex.VideoBitDepthField LuceneSearchIndex.VideoBitDepthField
}; };
public CustomQueryParser(LuceneVersion matchVersion, string f, Analyzer a) : base(matchVersion, f, a) public CustomQueryParser(LuceneVersion matchVersion, string f, Analyzer a) : base(matchVersion, f, a)
@ -37,7 +37,7 @@ public class CustomQueryParser : QueryParser
if (field == "released_onthisday") if (field == "released_onthisday")
{ {
var todayString = DateTime.Today.ToString("*MMdd"); var todayString = DateTime.Today.ToString("*MMdd");
return base.GetWildcardQuery(SearchIndex.ReleaseDateField, todayString); return base.GetWildcardQuery(LuceneSearchIndex.ReleaseDateField, todayString);
} }
if (NumericFields.Contains(field) && int.TryParse(queryText, out int val)) if (NumericFields.Contains(field) && int.TryParse(queryText, out int val))
@ -57,14 +57,14 @@ public class CustomQueryParser : QueryParser
var todayString = DateTime.UtcNow.ToString("yyyyMMdd"); var todayString = DateTime.UtcNow.ToString("yyyyMMdd");
var dateString = start.ToString("yyyyMMdd"); var dateString = start.ToString("yyyyMMdd");
return base.GetRangeQuery(SearchIndex.ReleaseDateField, dateString, todayString, true, true); return base.GetRangeQuery(LuceneSearchIndex.ReleaseDateField, dateString, todayString, true, true);
} }
if (field == "released_notinthelast" && ParseStart(queryText, out DateTime finish)) if (field == "released_notinthelast" && ParseStart(queryText, out DateTime finish))
{ {
var dateString = finish.ToString("yyyyMMdd"); var dateString = finish.ToString("yyyyMMdd");
return base.GetRangeQuery(SearchIndex.ReleaseDateField, "00000000", dateString, false, false); return base.GetRangeQuery(LuceneSearchIndex.ReleaseDateField, "00000000", dateString, false, false);
} }
if (field == "added_inthelast" && ParseStart(queryText, out DateTime addedStart)) if (field == "added_inthelast" && ParseStart(queryText, out DateTime addedStart))
@ -72,14 +72,14 @@ public class CustomQueryParser : QueryParser
var todayString = DateTime.UtcNow.ToString("yyyyMMdd"); var todayString = DateTime.UtcNow.ToString("yyyyMMdd");
var dateString = addedStart.ToString("yyyyMMdd"); var dateString = addedStart.ToString("yyyyMMdd");
return base.GetRangeQuery(SearchIndex.AddedDateField, dateString, todayString, true, true); return base.GetRangeQuery(LuceneSearchIndex.AddedDateField, dateString, todayString, true, true);
} }
if (field == "added_notinthelast" && ParseStart(queryText, out DateTime addedFinish)) if (field == "added_notinthelast" && ParseStart(queryText, out DateTime addedFinish))
{ {
var dateString = addedFinish.ToString("yyyyMMdd"); var dateString = addedFinish.ToString("yyyyMMdd");
return base.GetRangeQuery(SearchIndex.AddedDateField, "00000000", dateString, false, false); return base.GetRangeQuery(LuceneSearchIndex.AddedDateField, "00000000", dateString, false, false);
} }
return base.GetFieldQuery(field, queryText, slop); return base.GetFieldQuery(field, queryText, slop);

828
ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs

@ -0,0 +1,828 @@
using System.Globalization;
using Bugsnag;
using Elastic.Clients.Elasticsearch;
using Elastic.Clients.Elasticsearch.Aggregations;
using Elastic.Clients.Elasticsearch.IndexManagement;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.Format;
using ErsatzTV.Infrastructure.Search.Models;
using Microsoft.Extensions.Logging;
using ExistsResponse = Elastic.Clients.Elasticsearch.IndexManagement.ExistsResponse;
using MediaStream = ErsatzTV.Core.Domain.MediaStream;
namespace ErsatzTV.Infrastructure.Search;
public class ElasticSearchIndex : ISearchIndex
{
public static Uri Uri;
public static string IndexName;
private readonly List<CultureInfo> _cultureInfos;
private readonly ILogger<ElasticSearchIndex> _logger;
private ElasticsearchClient _client;
public ElasticSearchIndex(ILogger<ElasticSearchIndex> logger)
{
_logger = logger;
_cultureInfos = CultureInfo.GetCultures(CultureTypes.NeutralCultures).ToList();
}
public void Dispose()
{
// do nothing
}
// not really used by elasticsearch
public Task<bool> IndexExists() => Task.FromResult(false);
public int Version => 36;
public async Task<bool> Initialize(
ILocalFileSystem localFileSystem,
IConfigElementRepository configElementRepository)
{
_client = new ElasticsearchClient(Uri);
ExistsResponse exists = await _client.Indices.ExistsAsync(IndexName);
if (!exists.IsValidResponse)
{
CreateIndexResponse createResponse = await CreateIndex();
return createResponse.IsValidResponse;
}
return true;
}
public async Task<Unit> Rebuild(
ICachingSearchRepository searchRepository,
IFallbackMetadataProvider fallbackMetadataProvider)
{
DeleteIndexResponse deleteResponse = await _client.Indices.DeleteAsync(IndexName);
if (!deleteResponse.IsValidResponse)
{
return Unit.Default;
}
CreateIndexResponse createResponse = await CreateIndex();
if (!createResponse.IsValidResponse)
{
return Unit.Default;
}
await foreach (MediaItem mediaItem in searchRepository.GetAllMediaItems())
{
await RebuildItem(searchRepository, fallbackMetadataProvider, mediaItem);
}
return Unit.Default;
}
public async Task<Unit> RebuildItems(
ICachingSearchRepository searchRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
IEnumerable<int> itemIds)
{
foreach (int id in itemIds)
{
foreach (MediaItem mediaItem in await searchRepository.GetItemToIndex(id))
{
await RebuildItem(searchRepository, fallbackMetadataProvider, mediaItem);
}
}
return Unit.Default;
}
public async Task<Unit> UpdateItems(
ICachingSearchRepository searchRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
List<MediaItem> items)
{
foreach (MediaItem item in items)
{
switch (item)
{
case Movie movie:
await UpdateMovie(searchRepository, movie);
break;
case Show show:
await UpdateShow(searchRepository, show);
break;
case Season season:
await UpdateSeason(searchRepository, season);
break;
case Artist artist:
await UpdateArtist(searchRepository, artist);
break;
case MusicVideo musicVideo:
await UpdateMusicVideo(searchRepository, musicVideo);
break;
case Episode episode:
await UpdateEpisode(searchRepository, fallbackMetadataProvider, episode);
break;
case OtherVideo otherVideo:
await UpdateOtherVideo(searchRepository, otherVideo);
break;
case Song song:
await UpdateSong(searchRepository, song);
break;
}
}
return Unit.Default;
}
public async Task<Unit> RemoveItems(IEnumerable<int> ids)
{
await _client.BulkAsync(
descriptor => descriptor
.Index(IndexName)
.DeleteMany(ids.Map(id => new Id(id)))
);
return Unit.Default;
}
public async Task<SearchResult> Search(IClient client, string query, int skip, int limit, string searchField = "")
{
var items = new List<MinimalElasticSearchItem>();
var totalCount = 0;
SearchResponse<MinimalElasticSearchItem> response = await _client.SearchAsync<MinimalElasticSearchItem>(
s => s.Index(IndexName)
.Sort(ss => ss.Field(f => f.SortTitle, fs => fs.Order(SortOrder.Asc)))
.From(skip)
.Size(limit)
.QueryLuceneSyntax(query));
if (response.IsValidResponse)
{
items.AddRange(response.Documents);
totalCount = (int)response.Total;
}
var searchResult = new SearchResult(items.Map(i => new SearchItem(i.Type, i.Id)).ToList(), totalCount);
if (limit > 0)
{
searchResult.PageMap = await GetSearchPageMap(query, limit);
}
return searchResult;
}
public void Commit()
{
// do nothing
}
private async Task<CreateIndexResponse> CreateIndex() =>
await _client.Indices.CreateAsync<ElasticSearchItem>(
IndexName,
i => i.Mappings(
m => m.Properties(
p => p
.Keyword(t => t.Type, t => t.Store())
.Text(t => t.Title, t => t.Store(false))
.Keyword(t => t.SortTitle, t => t.Store(false))
.Text(t => t.LibraryName, t => t.Store(false))
.Keyword(t => t.LibraryId, t => t.Store(false))
.Keyword(t => t.TitleAndYear, 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))
.Text(t => t.Language, t => t.Store(false))
.IntegerNumber(t => t.Height, t => t.Store(false))
.IntegerNumber(t => t.Width, t => t.Store(false))
.Keyword(t => t.VideoCodec, t => t.Store(false))
.IntegerNumber(t => t.VideoBitDepth, t => t.Store(false))
.Keyword(t => t.VideoDynamicRange, t => t.Store(false))
.Keyword(t => t.ContentRating, t => t.Store(false))
.Keyword(t => t.ReleaseDate, t => t.Store(false))
.Keyword(t => t.AddedDate, t => t.Store(false))
.Text(t => t.Plot, t => t.Store(false))
.Text(t => t.Genre, t => t.Store(false))
.Text(t => t.Tag, t => t.Store(false))
.Text(t => t.Studio, t => t.Store(false))
.Text(t => t.Actor, t => t.Store(false))
.Text(t => t.Director, t => t.Store(false))
.Text(t => t.Writer, t => t.Store(false))
.Keyword(t => t.TraktList, t => t.Store(false))
.IntegerNumber(t => t.SeasonNumber, t => t.Store(false))
.Text(t => t.ShowTitle, t => t.Store(false))
.Text(t => t.ShowGenre, t => t.Store(false))
.Text(t => t.ShowTag, t => t.Store(false))
.Text(t => t.Style, t => t.Store(false))
.Text(t => t.Mood, t => t.Store(false))
.Text(t => t.Album, t => t.Store(false))
.Text(t => t.Artist, t => t.Store(false))
.IntegerNumber(t => t.EpisodeNumber, t => t.Store(false))
.Text(t => t.AlbumArtist, t => t.Store(false))
)));
private async Task RebuildItem(
ISearchRepository searchRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
MediaItem mediaItem)
{
switch (mediaItem)
{
case Movie movie:
await UpdateMovie(searchRepository, movie);
break;
case Show show:
await UpdateShow(searchRepository, show);
break;
case Season season:
await UpdateSeason(searchRepository, season);
break;
case Artist artist:
await UpdateArtist(searchRepository, artist);
break;
case MusicVideo musicVideo:
await UpdateMusicVideo(searchRepository, musicVideo);
break;
case Episode episode:
await UpdateEpisode(searchRepository, fallbackMetadataProvider, episode);
break;
case OtherVideo otherVideo:
await UpdateOtherVideo(searchRepository, otherVideo);
break;
case Song song:
await UpdateSong(searchRepository, song);
break;
}
}
private async Task UpdateMovie(ISearchRepository searchRepository, Movie movie)
{
foreach (MovieMetadata metadata in movie.MovieMetadata.HeadOrNone())
{
try
{
var doc = new ElasticSearchItem
{
Id = movie.Id,
Type = LuceneSearchIndex.MovieType,
Title = metadata.Title,
SortTitle = metadata.SortTitle.ToLowerInvariant(),
LibraryName = movie.LibraryPath.Library.Name,
LibraryId = movie.LibraryPath.Library.Id,
TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata),
JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata),
State = movie.State.ToString(),
MetadataKind = metadata.MetadataKind.ToString(),
Language = await GetLanguages(searchRepository, movie.MediaVersions),
ContentRating = GetContentRatings(metadata.ContentRating),
ReleaseDate = GetReleaseDate(metadata.ReleaseDate),
AddedDate = GetAddedDate(metadata.DateAdded),
Plot = metadata.Plot ?? string.Empty,
Genre = metadata.Genres.Map(g => g.Name).ToList(),
Tag = metadata.Tags.Map(t => t.Name).ToList(),
Studio = metadata.Studios.Map(s => s.Name).ToList(),
Actor = metadata.Actors.Map(a => a.Name).ToList(),
Director = metadata.Directors.Map(d => d.Name).ToList(),
Writer = metadata.Writers.Map(w => w.Name).ToList(),
TraktList = movie.TraktListItems.Map(t => t.TraktList.TraktId.ToString()).ToList()
};
AddStatistics(doc, movie.MediaVersions);
foreach ((string key, List<string> value) in GetMetadataGuids(metadata))
{
doc.AdditionalProperties.Add(key, value);
}
await _client.IndexAsync(doc, IndexName);
}
catch (Exception ex)
{
metadata.Movie = null;
_logger.LogWarning(ex, "Error indexing movie with metadata {@Metadata}", metadata);
}
}
}
private async Task UpdateShow(ISearchRepository searchRepository, Show show)
{
foreach (ShowMetadata metadata in show.ShowMetadata.HeadOrNone())
{
try
{
var doc = new ElasticSearchItem
{
Id = show.Id,
Type = LuceneSearchIndex.ShowType,
Title = metadata.Title,
SortTitle = metadata.SortTitle.ToLowerInvariant(),
LibraryName = show.LibraryPath.Library.Name,
LibraryId = show.LibraryPath.Library.Id,
TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata),
JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata),
State = show.State.ToString(),
MetadataKind = metadata.MetadataKind.ToString(),
Language = await GetLanguages(searchRepository, await searchRepository.GetLanguagesForShow(show)),
ContentRating = GetContentRatings(metadata.ContentRating),
ReleaseDate = GetReleaseDate(metadata.ReleaseDate),
AddedDate = GetAddedDate(metadata.DateAdded),
Plot = metadata.Plot ?? string.Empty,
Genre = metadata.Genres.Map(g => g.Name).ToList(),
Tag = metadata.Tags.Map(t => t.Name).ToList(),
Studio = metadata.Studios.Map(s => s.Name).ToList(),
Actor = metadata.Actors.Map(a => a.Name).ToList(),
TraktList = show.TraktListItems.Map(t => t.TraktList.TraktId.ToString()).ToList()
};
foreach ((string key, List<string> value) in GetMetadataGuids(metadata))
{
doc.AdditionalProperties.Add(key, value);
}
await _client.IndexAsync(doc, IndexName);
}
catch (Exception ex)
{
metadata.Show = null;
_logger.LogWarning(ex, "Error indexing show with metadata {@Metadata}", metadata);
}
}
}
private async Task UpdateSeason(ISearchRepository searchRepository, Season season)
{
foreach (SeasonMetadata metadata in season.SeasonMetadata.HeadOrNone())
foreach (ShowMetadata showMetadata in season.Show.ShowMetadata.HeadOrNone())
{
try
{
var seasonTitle = $"{showMetadata.Title} - S{season.SeasonNumber}";
string sortTitle = $"{showMetadata.SortTitle}_{season.SeasonNumber:0000}"
.ToLowerInvariant();
string titleAndYear = $"{showMetadata.Title}_{showMetadata.Year}_{season.SeasonNumber}"
.ToLowerInvariant();
var doc = new ElasticSearchItem
{
Id = season.Id,
Type = LuceneSearchIndex.SeasonType,
Title = seasonTitle,
SortTitle = sortTitle,
LibraryName = season.LibraryPath.Library.Name,
LibraryId = season.LibraryPath.Library.Id,
TitleAndYear = titleAndYear,
JumpLetter = LuceneSearchIndex.GetJumpLetter(showMetadata),
State = season.State.ToString(),
SeasonNumber = season.SeasonNumber,
ShowTitle = showMetadata.Title,
ShowGenre = showMetadata.Genres.Map(g => g.Name).ToList(),
ShowTag = showMetadata.Tags.Map(t => t.Name).ToList(),
Language = await GetLanguages(
searchRepository,
await searchRepository.GetLanguagesForSeason(season)),
ContentRating = GetContentRatings(showMetadata.ContentRating),
ReleaseDate = GetReleaseDate(metadata.ReleaseDate),
AddedDate = GetAddedDate(metadata.DateAdded),
TraktList = season.TraktListItems.Map(t => t.TraktList.TraktId.ToString()).ToList(),
Tag = metadata.Tags.Map(a => a.Name).ToList()
};
foreach ((string key, List<string> value) in GetMetadataGuids(metadata))
{
doc.AdditionalProperties.Add(key, value);
}
await _client.IndexAsync(doc, IndexName);
}
catch (Exception ex)
{
metadata.Season = null;
_logger.LogWarning(ex, "Error indexing season with metadata {@Metadata}", metadata);
}
}
}
private async Task UpdateArtist(ISearchRepository searchRepository, Artist artist)
{
foreach (ArtistMetadata metadata in artist.ArtistMetadata.HeadOrNone())
{
try
{
var doc = new ElasticSearchItem
{
Id = artist.Id,
Type = LuceneSearchIndex.ArtistType,
Title = metadata.Title,
SortTitle = metadata.SortTitle.ToLowerInvariant(),
LibraryName = artist.LibraryPath.Library.Name,
LibraryId = artist.LibraryPath.Library.Id,
TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata),
JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata),
State = artist.State.ToString(),
MetadataKind = metadata.MetadataKind.ToString(),
Language = await GetLanguages(
searchRepository,
await searchRepository.GetLanguagesForArtist(artist)),
AddedDate = GetAddedDate(metadata.DateAdded),
Genre = metadata.Genres.Map(g => g.Name).ToList(),
Style = metadata.Styles.Map(t => t.Name).ToList(),
Mood = metadata.Moods.Map(s => s.Name).ToList()
};
foreach ((string key, List<string> value) in GetMetadataGuids(metadata))
{
doc.AdditionalProperties.Add(key, value);
}
await _client.IndexAsync(doc, IndexName);
}
catch (Exception ex)
{
metadata.Artist = null;
_logger.LogWarning(ex, "Error indexing artist with metadata {@Metadata}", metadata);
}
}
}
private async Task UpdateMusicVideo(ISearchRepository searchRepository, MusicVideo musicVideo)
{
foreach (MusicVideoMetadata metadata in musicVideo.MusicVideoMetadata.HeadOrNone())
{
try
{
var doc = new ElasticSearchItem
{
Id = musicVideo.Id,
Type = LuceneSearchIndex.MusicVideoType,
Title = metadata.Title,
SortTitle = metadata.SortTitle.ToLowerInvariant(),
LibraryName = musicVideo.LibraryPath.Library.Name,
LibraryId = musicVideo.LibraryPath.Library.Id,
TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata),
JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata),
State = musicVideo.State.ToString(),
MetadataKind = metadata.MetadataKind.ToString(),
Language = await GetLanguages(searchRepository, musicVideo.MediaVersions),
ReleaseDate = GetReleaseDate(metadata.ReleaseDate),
AddedDate = GetAddedDate(metadata.DateAdded),
Album = metadata.Album ?? string.Empty,
Plot = metadata.Plot ?? string.Empty,
Genre = metadata.Genres.Map(g => g.Name).ToList(),
Tag = metadata.Tags.Map(t => t.Name).ToList(),
Studio = metadata.Studios.Map(s => s.Name).ToList()
};
var artists = new System.Collections.Generic.HashSet<string>();
if (musicVideo.Artist != null)
{
foreach (ArtistMetadata artistMetadata in musicVideo.Artist.ArtistMetadata)
{
artists.Add(artistMetadata.Title);
}
}
foreach (MusicVideoArtist artist in metadata.Artists)
{
artists.Add(artist.Name);
}
doc.Artist = artists.ToList();
AddStatistics(doc, musicVideo.MediaVersions);
foreach ((string key, List<string> value) in GetMetadataGuids(metadata))
{
doc.AdditionalProperties.Add(key, value);
}
await _client.IndexAsync(doc, IndexName);
}
catch (Exception ex)
{
metadata.MusicVideo = null;
_logger.LogWarning(ex, "Error indexing music video with metadata {@Metadata}", metadata);
}
}
}
private async Task UpdateEpisode(
ISearchRepository searchRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
Episode episode)
{
// try to load metadata here, since episodes without metadata won't index
if (episode.EpisodeMetadata.Count == 0)
{
episode.EpisodeMetadata ??= new List<EpisodeMetadata>();
episode.EpisodeMetadata = fallbackMetadataProvider.GetFallbackMetadata(episode);
foreach (EpisodeMetadata metadata in episode.EpisodeMetadata)
{
metadata.Episode = episode;
}
}
foreach (EpisodeMetadata metadata in episode.EpisodeMetadata.HeadOrNone())
{
try
{
var doc = new ElasticSearchItem
{
Id = episode.Id,
Type = LuceneSearchIndex.EpisodeType,
Title = metadata.Title,
SortTitle = metadata.SortTitle.ToLowerInvariant(),
LibraryName = episode.LibraryPath.Library.Name,
LibraryId = episode.LibraryPath.Library.Id,
TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata),
JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata),
State = episode.State.ToString(),
MetadataKind = metadata.MetadataKind.ToString(),
SeasonNumber = episode.Season?.SeasonNumber ?? 0,
EpisodeNumber = metadata.EpisodeNumber,
Language = await GetLanguages(searchRepository, episode.MediaVersions),
ReleaseDate = GetReleaseDate(metadata.ReleaseDate),
AddedDate = GetAddedDate(metadata.DateAdded),
Plot = metadata.Plot ?? string.Empty,
Genre = metadata.Genres.Map(g => g.Name).ToList(),
Tag = metadata.Tags.Map(t => t.Name).ToList(),
Studio = metadata.Studios.Map(s => s.Name).ToList(),
Actor = metadata.Actors.Map(a => a.Name).ToList(),
Director = metadata.Directors.Map(d => d.Name).ToList(),
Writer = metadata.Writers.Map(w => w.Name).ToList(),
TraktList = episode.TraktListItems.Map(t => t.TraktList.TraktId.ToString()).ToList()
};
// add some show fields to help filter episodes within a particular show
foreach (ShowMetadata showMetadata in Optional(episode.Season?.Show?.ShowMetadata).Flatten())
{
doc.ShowTitle = showMetadata.Title;
doc.ShowGenre = showMetadata.Genres.Map(g => g.Name).ToList();
doc.ShowTag = showMetadata.Tags.Map(t => t.Name).ToList();
}
AddStatistics(doc, episode.MediaVersions);
foreach ((string key, List<string> value) in GetMetadataGuids(metadata))
{
doc.AdditionalProperties.Add(key, value);
}
await _client.IndexAsync(doc, IndexName);
}
catch (Exception ex)
{
metadata.Episode = null;
_logger.LogWarning(ex, "Error indexing episode with metadata {@Metadata}", metadata);
}
}
}
private async Task UpdateOtherVideo(ISearchRepository searchRepository, OtherVideo otherVideo)
{
foreach (OtherVideoMetadata metadata in otherVideo.OtherVideoMetadata.HeadOrNone())
{
try
{
var doc = new ElasticSearchItem
{
Id = otherVideo.Id,
Type = LuceneSearchIndex.OtherVideoType,
Title = metadata.Title,
SortTitle = metadata.SortTitle.ToLowerInvariant(),
LibraryName = otherVideo.LibraryPath.Library.Name,
LibraryId = otherVideo.LibraryPath.Library.Id,
TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata),
JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata),
State = otherVideo.State.ToString(),
MetadataKind = metadata.MetadataKind.ToString(),
Language = await GetLanguages(searchRepository, otherVideo.MediaVersions),
ContentRating = GetContentRatings(metadata.ContentRating),
ReleaseDate = GetReleaseDate(metadata.ReleaseDate),
AddedDate = GetAddedDate(metadata.DateAdded),
Plot = metadata.Plot ?? string.Empty,
Genre = metadata.Genres.Map(g => g.Name).ToList(),
Tag = metadata.Tags.Map(t => t.Name).ToList(),
Studio = metadata.Studios.Map(s => s.Name).ToList(),
Actor = metadata.Actors.Map(a => a.Name).ToList(),
Director = metadata.Directors.Map(d => d.Name).ToList(),
Writer = metadata.Writers.Map(w => w.Name).ToList()
};
AddStatistics(doc, otherVideo.MediaVersions);
foreach ((string key, List<string> value) in GetMetadataGuids(metadata))
{
doc.AdditionalProperties.Add(key, value);
}
await _client.IndexAsync(doc, IndexName);
}
catch (Exception ex)
{
metadata.OtherVideo = null;
_logger.LogWarning(ex, "Error indexing other video with metadata {@Metadata}", metadata);
}
}
}
private async Task UpdateSong(ISearchRepository searchRepository, Song song)
{
foreach (SongMetadata metadata in song.SongMetadata.HeadOrNone())
{
try
{
var doc = new ElasticSearchItem
{
Id = song.Id,
Type = LuceneSearchIndex.SongType,
Title = metadata.Title,
SortTitle = metadata.SortTitle.ToLowerInvariant(),
LibraryName = song.LibraryPath.Library.Name,
LibraryId = song.LibraryPath.Library.Id,
TitleAndYear = LuceneSearchIndex.GetTitleAndYear(metadata),
JumpLetter = LuceneSearchIndex.GetJumpLetter(metadata),
State = song.State.ToString(),
MetadataKind = metadata.MetadataKind.ToString(),
Language = await GetLanguages(searchRepository, song.MediaVersions),
AddedDate = GetAddedDate(metadata.DateAdded),
Album = metadata.Album ?? string.Empty,
Artist = !string.IsNullOrWhiteSpace(metadata.Artist) ? new List<string> { metadata.Artist } : null,
AlbumArtist = metadata.AlbumArtist,
Genre = metadata.Genres.Map(g => g.Name).ToList(),
Tag = metadata.Tags.Map(t => t.Name).ToList()
};
AddStatistics(doc, song.MediaVersions);
foreach ((string key, List<string> value) in GetMetadataGuids(metadata))
{
doc.AdditionalProperties.Add(key, value);
}
await _client.IndexAsync(doc, IndexName);
}
catch (Exception ex)
{
metadata.Song = null;
_logger.LogWarning(ex, "Error indexing song with metadata {@Metadata}", metadata);
}
}
}
private static string GetReleaseDate(DateTime? metadataReleaseDate) => metadataReleaseDate?.ToString("yyyyMMdd");
private static string GetAddedDate(DateTime metadataAddedDate) => metadataAddedDate.ToString("yyyyMMdd");
private static List<string> GetContentRatings(string metadataContentRating)
{
var contentRatings = new List<string>();
if (!string.IsNullOrWhiteSpace(metadataContentRating))
{
foreach (string contentRating in metadataContentRating.Split("/")
.Map(x => x.Trim()).Where(x => !string.IsNullOrWhiteSpace(x)))
{
contentRatings.Add(contentRating);
}
}
return contentRatings;
}
private async Task<List<string>> GetLanguages(
ISearchRepository searchRepository,
IEnumerable<MediaVersion> mediaVersions)
{
var result = new List<string>();
foreach (MediaVersion version in mediaVersions.HeadOrNone())
{
var mediaCodes = version.Streams
.Filter(ms => ms.MediaStreamKind == MediaStreamKind.Audio)
.Map(ms => ms.Language)
.Distinct()
.ToList();
result.AddRange(await GetLanguages(searchRepository, mediaCodes));
}
return result;
}
private async Task<List<string>> GetLanguages(ISearchRepository searchRepository, List<string> mediaCodes)
{
var englishNames = new System.Collections.Generic.HashSet<string>();
foreach (string code in await searchRepository.GetAllLanguageCodes(mediaCodes))
{
Option<CultureInfo> maybeCultureInfo = _cultureInfos.Find(
ci => string.Equals(ci.ThreeLetterISOLanguageName, code, StringComparison.OrdinalIgnoreCase));
foreach (CultureInfo cultureInfo in maybeCultureInfo)
{
englishNames.Add(cultureInfo.EnglishName);
}
}
return englishNames.ToList();
}
private static void AddStatistics(ElasticSearchItem doc, IEnumerable<MediaVersion> mediaVersions)
{
foreach (MediaVersion version in mediaVersions.HeadOrNone())
{
doc.Minutes = (int)Math.Ceiling(version.Duration.TotalMinutes);
foreach (MediaStream videoStream in version.Streams
.Filter(s => s.MediaStreamKind == MediaStreamKind.Video)
.HeadOrNone())
{
doc.Height = version.Height;
doc.Width = version.Width;
if (!string.IsNullOrWhiteSpace(videoStream.Codec))
{
doc.VideoCodec = videoStream.Codec;
}
Option<IPixelFormat> maybePixelFormat =
AvailablePixelFormats.ForPixelFormat(videoStream.PixelFormat, null);
foreach (IPixelFormat pixelFormat in maybePixelFormat)
{
doc.VideoBitDepth = pixelFormat.BitDepth;
}
var colorParams = new ColorParams(
videoStream.ColorRange,
videoStream.ColorSpace,
videoStream.ColorTransfer,
videoStream.ColorPrimaries);
doc.VideoDynamicRange = colorParams.IsHdr ? "hdr" : "sdr";
}
}
}
private static Dictionary<string, List<string>> GetMetadataGuids(Metadata metadata)
{
var result = new Dictionary<string, List<string>>();
foreach (MetadataGuid guid in metadata.Guids)
{
string[] split = (guid.Guid ?? string.Empty).Split("://");
if (split.Length == 2 && !string.IsNullOrWhiteSpace(split[1]))
{
string key = split[0];
string value = split[1].ToLowerInvariant();
if (!result.ContainsKey(key))
{
result.Add(key, new List<string>());
}
result[key].Add(value);
}
}
return result;
}
private async Task<Option<SearchPageMap>> GetSearchPageMap(string query, int limit)
{
SearchResponse<MinimalElasticSearchItem> response = await _client.SearchAsync<MinimalElasticSearchItem>(
s => s.Index(IndexName)
.Size(0)
.Sort(ss => ss.Field(f => f.SortTitle, fs => fs.Order(SortOrder.Asc)))
.Aggregations(a => a.Terms("count", v => v.Field(i => i.JumpLetter).Size(30)))
.QueryLuceneSyntax(query));
if (!response.IsValidResponse)
{
return Option<SearchPageMap>.None;
}
var letters = new List<char>
{
'#', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
'u', 'v', 'w', 'x', 'y', 'z'
};
var map = letters.ToDictionary(letter => letter, _ => 0);
if (response.Aggregations?.Values.Head() is StringTermsAggregate aggregate)
{
// start on page 1
int total = limit;
foreach (char letter in letters)
{
map[letter] = total / limit;
Option<StringTermsBucket> maybeBucket = aggregate.Buckets.Find(b => b.Key == letter.ToString());
total += maybeBucket.Sum(bucket => (int)bucket.DocCount);
}
}
return new SearchPageMap(map);
}
}

108
ErsatzTV.Infrastructure/Search/SearchIndex.cs → ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs

@ -28,40 +28,40 @@ using Query = Lucene.Net.Search.Query;
namespace ErsatzTV.Infrastructure.Search; namespace ErsatzTV.Infrastructure.Search;
public sealed class SearchIndex : ISearchIndex public sealed class LuceneSearchIndex : ISearchIndex
{ {
private const LuceneVersion AppLuceneVersion = LuceneVersion.LUCENE_48; private const LuceneVersion AppLuceneVersion = LuceneVersion.LUCENE_48;
private const string IdField = "id"; internal const string IdField = "id";
private const string TypeField = "type"; internal const string TypeField = "type";
private const string TitleField = "title"; internal const string TitleField = "title";
private const string SortTitleField = "sort_title"; internal const string SortTitleField = "sort_title";
private const string GenreField = "genre"; internal const string GenreField = "genre";
private const string TagField = "tag"; internal const string TagField = "tag";
private const string PlotField = "plot"; internal const string PlotField = "plot";
private const string LibraryNameField = "library_name"; internal const string LibraryNameField = "library_name";
private const string LibraryIdField = "library_id"; internal const string LibraryIdField = "library_id";
private const string TitleAndYearField = "title_and_year"; internal const string TitleAndYearField = "title_and_year";
private const string JumpLetterField = "jump_letter"; internal const string JumpLetterField = "jump_letter";
private const string StudioField = "studio"; internal const string StudioField = "studio";
private const string LanguageField = "language"; internal const string LanguageField = "language";
private const string StyleField = "style"; internal const string StyleField = "style";
private const string MoodField = "mood"; internal const string MoodField = "mood";
private const string ActorField = "actor"; internal const string ActorField = "actor";
private const string ContentRatingField = "content_rating"; internal const string ContentRatingField = "content_rating";
private const string DirectorField = "director"; internal const string DirectorField = "director";
private const string WriterField = "writer"; internal const string WriterField = "writer";
private const string TraktListField = "trakt_list"; internal const string TraktListField = "trakt_list";
private const string AlbumField = "album"; internal const string AlbumField = "album";
private const string ArtistField = "artist"; internal const string ArtistField = "artist";
private const string StateField = "state"; internal const string StateField = "state";
private const string AlbumArtistField = "album_artist"; internal const string AlbumArtistField = "album_artist";
private const string ShowTitleField = "show_title"; internal const string ShowTitleField = "show_title";
private const string ShowGenreField = "show_genre"; internal const string ShowGenreField = "show_genre";
private const string ShowTagField = "show_tag"; internal const string ShowTagField = "show_tag";
private const string MetadataKindField = "metadata_kind"; internal const string MetadataKindField = "metadata_kind";
private const string VideoCodecField = "video_codec"; internal const string VideoCodecField = "video_codec";
private const string VideoDynamicRange = "video_dynamic_range"; internal const string VideoDynamicRange = "video_dynamic_range";
internal const string MinutesField = "minutes"; internal const string MinutesField = "minutes";
internal const string HeightField = "height"; internal const string HeightField = "height";
@ -82,21 +82,31 @@ public sealed class SearchIndex : ISearchIndex
public const string SongType = "song"; public const string SongType = "song";
private readonly List<CultureInfo> _cultureInfos; private readonly List<CultureInfo> _cultureInfos;
private readonly string _cleanShutdownPath;
private readonly ILogger<SearchIndex> _logger; private readonly ILogger<LuceneSearchIndex> _logger;
private FSDirectory _directory; private FSDirectory _directory;
private bool _initialized; private bool _initialized;
private IndexWriter _writer; private IndexWriter _writer;
public SearchIndex(ILogger<SearchIndex> logger) public LuceneSearchIndex(ILogger<LuceneSearchIndex> logger)
{ {
_logger = logger; _logger = logger;
_cultureInfos = CultureInfo.GetCultures(CultureTypes.NeutralCultures).ToList(); _cultureInfos = CultureInfo.GetCultures(CultureTypes.NeutralCultures).ToList();
_cleanShutdownPath = Path.Combine(FileSystemLayout.SearchIndexFolder, ".clean-shutdown");
_initialized = false; _initialized = false;
} }
public int Version => 35; public Task<bool> IndexExists()
{
bool directoryExists = Directory.Exists(FileSystemLayout.SearchIndexFolder);
bool fileExists = File.Exists(_cleanShutdownPath);
return Task.FromResult(directoryExists && fileExists);
}
public int Version => 36;
public async Task<bool> Initialize( public async Task<bool> Initialize(
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
@ -114,6 +124,11 @@ public sealed class SearchIndex : ISearchIndex
localFileSystem.EnsureFolderExists(FileSystemLayout.SearchIndexFolder); localFileSystem.EnsureFolderExists(FileSystemLayout.SearchIndexFolder);
} }
if (File.Exists(_cleanShutdownPath))
{
File.Delete(_cleanShutdownPath);
}
_directory = FSDirectory.Open(FileSystemLayout.SearchIndexFolder); _directory = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
var analyzer = new StandardAnalyzer(AppLuceneVersion); var analyzer = new StandardAnalyzer(AppLuceneVersion);
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer) var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer)
@ -174,7 +189,7 @@ public sealed class SearchIndex : ISearchIndex
return Task.FromResult(Unit.Default); return Task.FromResult(Unit.Default);
} }
public SearchResult Search(IClient client, string searchQuery, int skip, int limit, string searchField = "") public Task<SearchResult> Search(IClient client, string searchQuery, int skip, int limit, string searchField = "")
{ {
var metadata = new Dictionary<string, string> var metadata = new Dictionary<string, string>
{ {
@ -189,7 +204,7 @@ public sealed class SearchIndex : ISearchIndex
if (string.IsNullOrWhiteSpace(searchQuery.Replace("*", string.Empty).Replace("?", string.Empty)) || if (string.IsNullOrWhiteSpace(searchQuery.Replace("*", string.Empty).Replace("?", string.Empty)) ||
_writer.MaxDoc == 0) _writer.MaxDoc == 0)
{ {
return new SearchResult(new List<SearchItem>(), 0); return Task.FromResult(new SearchResult(new List<SearchItem>(), 0));
} }
using DirectoryReader reader = _writer.GetReader(true); using DirectoryReader reader = _writer.GetReader(true);
@ -227,7 +242,7 @@ public sealed class SearchIndex : ISearchIndex
searchResult.PageMap = GetSearchPageMap(searcher, query, null, sort, limit); searchResult.PageMap = GetSearchPageMap(searcher, query, null, sort, limit);
} }
return searchResult; return Task.FromResult(searchResult);
} }
public void Commit() => _writer.Commit(); public void Commit() => _writer.Commit();
@ -236,6 +251,11 @@ public sealed class SearchIndex : ISearchIndex
{ {
_writer?.Dispose(); _writer?.Dispose();
_directory?.Dispose(); _directory?.Dispose();
using (File.Create(_cleanShutdownPath))
{
// do nothing
}
} }
public async Task<Unit> Rebuild( public async Task<Unit> Rebuild(
@ -270,7 +290,7 @@ public sealed class SearchIndex : ISearchIndex
return Unit.Default; return Unit.Default;
} }
private static bool ValidateDirectory(string folder) private bool ValidateDirectory(string folder)
{ {
try try
{ {
@ -284,7 +304,7 @@ public sealed class SearchIndex : ISearchIndex
{ {
using (DirectoryReader _ = w.GetReader(true)) using (DirectoryReader _ = w.GetReader(true))
{ {
return true; return File.Exists(_cleanShutdownPath);
} }
} }
} }
@ -629,7 +649,7 @@ public sealed class SearchIndex : ISearchIndex
new TextField(ShowTitleField, showMetadata.Title, Field.Store.NO) new TextField(ShowTitleField, showMetadata.Title, Field.Store.NO)
}; };
// add some show fields to help filter shows within a particular show // add some show fields to help filter seasons within a particular show
foreach (Genre genre in showMetadata.Genres) foreach (Genre genre in showMetadata.Genres)
{ {
doc.Add(new TextField(ShowGenreField, genre.Name, Field.Store.NO)); doc.Add(new TextField(ShowGenreField, genre.Name, Field.Store.NO));
@ -680,7 +700,7 @@ public sealed class SearchIndex : ISearchIndex
catch (Exception ex) catch (Exception ex)
{ {
metadata.Season = null; metadata.Season = null;
_logger.LogWarning(ex, "Error indexing show with metadata {@Metadata}", metadata); _logger.LogWarning(ex, "Error indexing season with metadata {@Metadata}", metadata);
} }
} }
} }
@ -1174,7 +1194,7 @@ public sealed class SearchIndex : ISearchIndex
string dynamicRange = colorParams.IsHdr ? "hdr" : "sdr"; string dynamicRange = colorParams.IsHdr ? "hdr" : "sdr";
doc.Add(new TextField(VideoDynamicRange, dynamicRange, Field.Store.NO)); doc.Add(new StringField(VideoDynamicRange, dynamicRange, Field.Store.NO));
} }
} }
} }
@ -1192,7 +1212,7 @@ public sealed class SearchIndex : ISearchIndex
} }
// this is used for filtering duplicate search results // this is used for filtering duplicate search results
private static string GetTitleAndYear(Metadata metadata) => internal static string GetTitleAndYear(Metadata metadata) =>
metadata switch metadata switch
{ {
EpisodeMetadata em => EpisodeMetadata em =>
@ -1212,7 +1232,7 @@ public sealed class SearchIndex : ISearchIndex
private static string Title(Metadata metadata) => private static string Title(Metadata metadata) =>
(metadata.Title ?? string.Empty).Replace(' ', '_'); (metadata.Title ?? string.Empty).Replace(' ', '_');
private static string GetJumpLetter(Metadata metadata) internal static string GetJumpLetter(Metadata metadata)
{ {
char c = (metadata.SortTitle ?? " ").ToLowerInvariant().Head(); char c = (metadata.SortTitle ?? " ").ToLowerInvariant().Head();
return c switch return c switch

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

@ -0,0 +1,111 @@
using System.Text.Json.Serialization;
namespace ErsatzTV.Infrastructure.Search.Models;
public class ElasticSearchItem : MinimalElasticSearchItem
{
[JsonExtensionData]
public Dictionary<string, object> AdditionalProperties { get; set; } = new();
[JsonPropertyName(LuceneSearchIndex.TitleField)]
public string Title { get; set; }
[JsonPropertyName(LuceneSearchIndex.LibraryNameField)]
public string LibraryName { get; set; }
[JsonPropertyName(LuceneSearchIndex.LibraryIdField)]
public int LibraryId { get; set; }
[JsonPropertyName(LuceneSearchIndex.TitleAndYearField)]
public string TitleAndYear { get; set; }
[JsonPropertyName(LuceneSearchIndex.StateField)]
public string State { get; set; }
[JsonPropertyName(LuceneSearchIndex.MetadataKindField)]
public string MetadataKind { get; set; }
[JsonPropertyName(LuceneSearchIndex.LanguageField)]
public List<string> Language { get; set; }
[JsonPropertyName(LuceneSearchIndex.MinutesField)]
public int Minutes { get; set; }
[JsonPropertyName(LuceneSearchIndex.HeightField)]
public int Height { get; set; }
[JsonPropertyName(LuceneSearchIndex.WidthField)]
public int Width { get; set; }
[JsonPropertyName(LuceneSearchIndex.VideoCodecField)]
public string VideoCodec { get; set; }
[JsonPropertyName(LuceneSearchIndex.VideoBitDepthField)]
public int VideoBitDepth { get; set; }
[JsonPropertyName(LuceneSearchIndex.VideoDynamicRange)]
public string VideoDynamicRange { get; set; }
[JsonPropertyName(LuceneSearchIndex.ContentRatingField)]
public List<string> ContentRating { get; set; }
[JsonPropertyName(LuceneSearchIndex.ReleaseDateField)]
public string ReleaseDate { get; set; }
[JsonPropertyName(LuceneSearchIndex.AddedDateField)]
public string AddedDate { get; set; }
[JsonPropertyName(LuceneSearchIndex.AlbumField)]
public string Album { get; set; }
[JsonPropertyName(LuceneSearchIndex.AlbumArtistField)]
public string AlbumArtist { get; set; }
[JsonPropertyName(LuceneSearchIndex.PlotField)]
public string Plot { get; set; }
[JsonPropertyName(LuceneSearchIndex.GenreField)]
public List<string> Genre { get; set; }
[JsonPropertyName(LuceneSearchIndex.TagField)]
public List<string> Tag { get; set; }
[JsonPropertyName(LuceneSearchIndex.StudioField)]
public List<string> Studio { get; set; }
[JsonPropertyName(LuceneSearchIndex.ArtistField)]
public List<string> Artist { get; set; }
[JsonPropertyName(LuceneSearchIndex.ActorField)]
public List<string> Actor { get; set; }
[JsonPropertyName(LuceneSearchIndex.DirectorField)]
public List<string> Director { get; set; }
[JsonPropertyName(LuceneSearchIndex.WriterField)]
public List<string> Writer { get; set; }
[JsonPropertyName(LuceneSearchIndex.TraktListField)]
public List<string> TraktList { get; set; }
[JsonPropertyName(LuceneSearchIndex.SeasonNumberField)]
public int SeasonNumber { get; set; }
[JsonPropertyName(LuceneSearchIndex.EpisodeNumberField)]
public int EpisodeNumber { get; set; }
[JsonPropertyName(LuceneSearchIndex.ShowTitleField)]
public string ShowTitle { get; set; }
[JsonPropertyName(LuceneSearchIndex.ShowGenreField)]
public List<string> ShowGenre { get; set; }
[JsonPropertyName(LuceneSearchIndex.ShowTagField)]
public List<string> ShowTag { get; set; }
[JsonPropertyName(LuceneSearchIndex.StyleField)]
public List<string> Style { get; set; }
[JsonPropertyName(LuceneSearchIndex.MoodField)]
public List<string> Mood { get; set; }
}

18
ErsatzTV.Infrastructure/Search/Models/MinimalElasticSearchItem.cs

@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace ErsatzTV.Infrastructure.Search.Models;
public class MinimalElasticSearchItem
{
[JsonPropertyName(LuceneSearchIndex.IdField)]
public int Id { get; set; }
[JsonPropertyName(LuceneSearchIndex.TypeField)]
public string Type { get; set; }
[JsonPropertyName(LuceneSearchIndex.SortTitleField)]
public string SortTitle { get; set; }
[JsonPropertyName(LuceneSearchIndex.JumpLetterField)]
public string JumpLetter { get; set; }
}

2
ErsatzTV.Scanner/Program.cs

@ -162,7 +162,7 @@ public class Program
services.AddSingleton<IPlexSecretStore, PlexSecretStore>(); services.AddSingleton<IPlexSecretStore, PlexSecretStore>();
services.AddSingleton<IEmbySecretStore, EmbySecretStore>(); services.AddSingleton<IEmbySecretStore, EmbySecretStore>();
services.AddSingleton<IJellyfinSecretStore, JellyfinSecretStore>(); services.AddSingleton<IJellyfinSecretStore, JellyfinSecretStore>();
services.AddSingleton<ISearchIndex, SearchIndex>(); services.AddSingleton<ISearchIndex, LuceneSearchIndex>();
services.AddSingleton<RecyclableMemoryStreamManager>(); services.AddSingleton<RecyclableMemoryStreamManager>();
// TODO: real bugsnag? // TODO: real bugsnag?
services.AddSingleton<IClient>(_ => new BugsnagNoopClient()); services.AddSingleton<IClient>(_ => new BugsnagNoopClient());

9
ErsatzTV/Extensions/NavigationManagerExtensions.cs

@ -5,15 +5,14 @@ namespace ErsatzTV.Extensions;
public static class NavigationManagerExtensions public static class NavigationManagerExtensions
{ {
public static ValueTask NavigateToFragmentAsync(this NavigationManager navigationManager, IJSRuntime jSRuntime) public static async Task NavigateToFragmentAsync(this NavigationManager navigationManager, IJSRuntime jSRuntime)
{ {
Uri uri = navigationManager.ToAbsoluteUri(navigationManager.Uri); Uri uri = navigationManager.ToAbsoluteUri(navigationManager.Uri);
if (uri.Fragment.Length == 0) if (uri.Fragment.Length > 0)
{ {
return default; await Task.Delay(250);
await jSRuntime.InvokeVoidAsync("blazorHelpers.scrollToFragment", uri.Fragment.Substring(1));
} }
return jSRuntime.InvokeVoidAsync("blazorHelpers.scrollToFragment", uri.Fragment.Substring(1));
} }
} }

7
ErsatzTV/Pages/ArtistList.razor

@ -77,10 +77,10 @@
[Parameter] [Parameter]
public int PageNumber { get; set; } public int PageNumber { get; set; }
private ArtistCardResultsViewModel _data; private ArtistCardResultsViewModel _data = new(0, new List<ArtistCardViewModel>(), None);
private string _query; private string _query;
protected override Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
if (PageNumber == 0) if (PageNumber == 0)
{ {
@ -88,7 +88,8 @@
} }
_query = _navigationManager.Uri.GetSearchQuery(); _query = _navigationManager.Uri.GetSearchQuery();
return RefreshData();
await RefreshData();
} }
protected override async Task RefreshData() protected override async Task RefreshData()

7
ErsatzTV/Pages/EpisodeList.razor

@ -77,10 +77,10 @@
[Parameter] [Parameter]
public int PageNumber { get; set; } public int PageNumber { get; set; }
private TelevisionEpisodeCardResultsViewModel _data; private TelevisionEpisodeCardResultsViewModel _data = new(0, new List<TelevisionEpisodeCardViewModel>(), None);
private string _query; private string _query;
protected override Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
if (PageNumber == 0) if (PageNumber == 0)
{ {
@ -88,7 +88,8 @@
} }
_query = _navigationManager.Uri.GetSearchQuery(); _query = _navigationManager.Uri.GetSearchQuery();
return RefreshData();
await RefreshData();
} }
protected override async Task RefreshData() protected override async Task RefreshData()

16
ErsatzTV/Pages/MovieList.razor

@ -6,8 +6,7 @@
@using ErsatzTV.Application.MediaCollections @using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.Search @using ErsatzTV.Application.Search
@inherits MultiSelectBase<MovieList> @inherits MultiSelectBase<MovieList>
@inject NavigationManager _navigationManager @inject NavigationManager NavigationManager
@inject ChannelWriter<IBackgroundServiceRequest> _channel
<MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;"> <MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6"> <div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6">
@ -76,18 +75,19 @@
[Parameter] [Parameter]
public int PageNumber { get; set; } public int PageNumber { get; set; }
private MovieCardResultsViewModel _data; private MovieCardResultsViewModel _data = new(0, new List<MovieCardViewModel>(), None);
private string _query; private string _query;
protected override Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
if (PageNumber == 0) if (PageNumber == 0)
{ {
PageNumber = 1; PageNumber = 1;
} }
_query = _navigationManager.Uri.GetSearchQuery(); _query = NavigationManager.Uri.GetSearchQuery();
return RefreshData();
await RefreshData();
} }
protected override async Task RefreshData() protected override async Task RefreshData()
@ -104,7 +104,7 @@
(string key, string value) = _query.EncodeQuery(); (string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}"; uri = $"{uri}?{key}={value}";
} }
_navigationManager.NavigateTo(uri); NavigationManager.NavigateTo(uri);
} }
private void NextPage() private void NextPage()
@ -115,7 +115,7 @@
(string key, string value) = _query.EncodeQuery(); (string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}"; uri = $"{uri}?{key}={value}";
} }
_navigationManager.NavigateTo(uri); NavigationManager.NavigateTo(uri);
} }
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e) private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)

7
ErsatzTV/Pages/MusicVideoList.razor

@ -77,10 +77,10 @@
[Parameter] [Parameter]
public int PageNumber { get; set; } public int PageNumber { get; set; }
private MusicVideoCardResultsViewModel _data; private MusicVideoCardResultsViewModel _data = new(0, new List<MusicVideoCardViewModel>(), None);
private string _query; private string _query;
protected override Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
if (PageNumber == 0) if (PageNumber == 0)
{ {
@ -88,7 +88,8 @@
} }
_query = _navigationManager.Uri.GetSearchQuery(); _query = _navigationManager.Uri.GetSearchQuery();
return RefreshData();
await RefreshData();
} }
protected override async Task RefreshData() protected override async Task RefreshData()

7
ErsatzTV/Pages/OtherVideoList.razor

@ -77,10 +77,10 @@
[Parameter] [Parameter]
public int PageNumber { get; set; } public int PageNumber { get; set; }
private OtherVideoCardResultsViewModel _data; private OtherVideoCardResultsViewModel _data = new(0, new List<OtherVideoCardViewModel>(), None);
private string _query; private string _query;
protected override Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
if (PageNumber == 0) if (PageNumber == 0)
{ {
@ -88,7 +88,8 @@
} }
_query = _navigationManager.Uri.GetSearchQuery(); _query = _navigationManager.Uri.GetSearchQuery();
return RefreshData();
await RefreshData();
} }
protected override async Task RefreshData() protected override async Task RefreshData()

7
ErsatzTV/Pages/SongList.razor

@ -77,10 +77,10 @@
[Parameter] [Parameter]
public int PageNumber { get; set; } public int PageNumber { get; set; }
private SongCardResultsViewModel _data; private SongCardResultsViewModel _data = new(0, new List<SongCardViewModel>(), None);
private string _query; private string _query;
protected override Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
if (PageNumber == 0) if (PageNumber == 0)
{ {
@ -88,7 +88,8 @@
} }
_query = _navigationManager.Uri.GetSearchQuery(); _query = _navigationManager.Uri.GetSearchQuery();
return RefreshData();
await RefreshData();
} }
protected override async Task RefreshData() protected override async Task RefreshData()

7
ErsatzTV/Pages/TelevisionSeasonSearchResults.razor

@ -76,10 +76,10 @@
[Parameter] [Parameter]
public int PageNumber { get; set; } public int PageNumber { get; set; }
private TelevisionSeasonCardResultsViewModel _data; private TelevisionSeasonCardResultsViewModel _data = new(0, new List<TelevisionSeasonCardViewModel>(), None);
private string _query; private string _query;
protected override Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
if (PageNumber == 0) if (PageNumber == 0)
{ {
@ -87,7 +87,8 @@
} }
_query = _navigationManager.Uri.GetSearchQuery(); _query = _navigationManager.Uri.GetSearchQuery();
return RefreshData();
await RefreshData();
} }
protected override async Task RefreshData() protected override async Task RefreshData()

7
ErsatzTV/Pages/TelevisionShowList.razor

@ -76,10 +76,10 @@
[Parameter] [Parameter]
public int PageNumber { get; set; } public int PageNumber { get; set; }
private TelevisionShowCardResultsViewModel _data; private TelevisionShowCardResultsViewModel _data = new(0, new List<TelevisionShowCardViewModel>(), None);
private string _query; private string _query;
protected override Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
if (PageNumber == 0) if (PageNumber == 0)
{ {
@ -87,7 +87,8 @@
} }
_query = _navigationManager.Uri.GetSearchQuery(); _query = _navigationManager.Uri.GetSearchQuery();
return RefreshData();
await RefreshData();
} }
protected override async Task RefreshData() protected override async Task RefreshData()

20
ErsatzTV/SearchHelper.cs

@ -0,0 +1,20 @@
namespace ErsatzTV;
public static class SearchHelper
{
public static string ElasticSearchUri { get; private set; }
public static string ElasticSearchIndexName { get; private set; }
public static bool IsElasticSearchEnabled { get; private set; }
public static void Init(IConfiguration configuration)
{
ElasticSearchUri = configuration["ElasticSearch:Uri"];
ElasticSearchIndexName = configuration["ElasticSearch:IndexName"];
if (string.IsNullOrWhiteSpace(ElasticSearchIndexName))
{
ElasticSearchIndexName = "ersatztv";
}
IsElasticSearchEnabled = !string.IsNullOrWhiteSpace(ElasticSearchUri);
}
}

14
ErsatzTV/Startup.cs

@ -127,6 +127,7 @@ public class Startup
OidcHelper.Init(Configuration); OidcHelper.Init(Configuration);
JwtHelper.Init(Configuration); JwtHelper.Init(Configuration);
SearchHelper.Init(Configuration);
if (OidcHelper.IsEnabled) if (OidcHelper.IsEnabled)
{ {
@ -516,7 +517,18 @@ public class Startup
services.AddSingleton<IPlexTvApiClient, PlexTvApiClient>(); // TODO: does this need to be singleton? services.AddSingleton<IPlexTvApiClient, PlexTvApiClient>(); // TODO: does this need to be singleton?
services.AddSingleton<ITraktApiClient, TraktApiClient>(); services.AddSingleton<ITraktApiClient, TraktApiClient>();
services.AddSingleton<IEntityLocker, EntityLocker>(); services.AddSingleton<IEntityLocker, EntityLocker>();
services.AddSingleton<ISearchIndex, SearchIndex>();
if (SearchHelper.IsElasticSearchEnabled)
{
ElasticSearchIndex.Uri = new Uri(SearchHelper.ElasticSearchUri);
ElasticSearchIndex.IndexName = SearchHelper.ElasticSearchIndexName;
services.AddSingleton<ISearchIndex, ElasticSearchIndex>();
}
else
{
services.AddSingleton<ISearchIndex, LuceneSearchIndex>();
}
services.AddSingleton<IFFmpegSegmenterService, FFmpegSegmenterService>(); services.AddSingleton<IFFmpegSegmenterService, FFmpegSegmenterService>();
services.AddSingleton<ITempFilePool, TempFilePool>(); services.AddSingleton<ITempFilePool, TempFilePool>();
services.AddSingleton<IHlsPlaylistFilter, HlsPlaylistFilter>(); services.AddSingleton<IHlsPlaylistFilter, HlsPlaylistFilter>();

Loading…
Cancel
Save