mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* add letter bar with no links * use lucene for search, add paged search results * add search index version * index library_name; rebuild index when folder is missing * maintain index as local movies change * fix tests * maintain index as local shows change * maintain index as plex movies change * maintain index as plex shows change * code cleanup * add duplicate filter to search * add links to letter bar * code cleanuppull/88/head
69 changed files with 1351 additions and 324 deletions
@ -1,6 +1,8 @@
@@ -1,6 +1,8 @@
|
||||
using System.Collections.Generic; |
||||
using ErsatzTV.Core.Search; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Application.MediaCards |
||||
{ |
||||
public record MovieCardResultsViewModel(int Count, List<MovieCardViewModel> Cards); |
||||
public record MovieCardResultsViewModel(int Count, List<MovieCardViewModel> Cards, Option<SearchPageMap> PageMap); |
||||
} |
||||
|
||||
@ -1,6 +0,0 @@
@@ -1,6 +0,0 @@
|
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.MediaCards.Queries |
||||
{ |
||||
public record GetMovieCards(int PageNumber, int PageSize) : IRequest<MovieCardResultsViewModel>; |
||||
} |
||||
@ -1,30 +0,0 @@
@@ -1,30 +0,0 @@
|
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using LanguageExt; |
||||
using MediatR; |
||||
using static ErsatzTV.Application.MediaCards.Mapper; |
||||
|
||||
namespace ErsatzTV.Application.MediaCards.Queries |
||||
{ |
||||
public class |
||||
GetMovieCardsHandler : IRequestHandler<GetMovieCards, MovieCardResultsViewModel> |
||||
{ |
||||
private readonly IMovieRepository _movieRepository; |
||||
|
||||
public GetMovieCardsHandler(IMovieRepository movieRepository) => _movieRepository = movieRepository; |
||||
|
||||
public async Task<MovieCardResultsViewModel> Handle(GetMovieCards request, CancellationToken cancellationToken) |
||||
{ |
||||
int count = await _movieRepository.GetMovieCount(); |
||||
|
||||
List<MovieCardViewModel> results = await _movieRepository |
||||
.GetPagedMovies(request.PageNumber, request.PageSize) |
||||
.Map(list => list.Map(ProjectToViewModel).ToList()); |
||||
|
||||
return new MovieCardResultsViewModel(count, results); |
||||
} |
||||
} |
||||
} |
||||
@ -1,6 +0,0 @@
@@ -1,6 +0,0 @@
|
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.MediaCards.Queries |
||||
{ |
||||
public record GetTelevisionShowCards(int PageNumber, int PageSize) : IRequest<TelevisionShowCardResultsViewModel>; |
||||
} |
||||
@ -1,33 +0,0 @@
@@ -1,33 +0,0 @@
|
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using LanguageExt; |
||||
using MediatR; |
||||
using static ErsatzTV.Application.MediaCards.Mapper; |
||||
|
||||
namespace ErsatzTV.Application.MediaCards.Queries |
||||
{ |
||||
public class |
||||
GetTelevisionShowCardsHandler : IRequestHandler<GetTelevisionShowCards, TelevisionShowCardResultsViewModel> |
||||
{ |
||||
private readonly ITelevisionRepository _televisionRepository; |
||||
|
||||
public GetTelevisionShowCardsHandler(ITelevisionRepository televisionRepository) => |
||||
_televisionRepository = televisionRepository; |
||||
|
||||
public async Task<TelevisionShowCardResultsViewModel> Handle( |
||||
GetTelevisionShowCards request, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
int count = await _televisionRepository.GetShowCount(); |
||||
|
||||
List<TelevisionShowCardViewModel> results = await _televisionRepository |
||||
.GetPagedShows(request.PageNumber, request.PageSize) |
||||
.Map(list => list.Map(ProjectToViewModel).ToList()); |
||||
|
||||
return new TelevisionShowCardResultsViewModel(count, results); |
||||
} |
||||
} |
||||
} |
||||
@ -1,6 +1,11 @@
@@ -1,6 +1,11 @@
|
||||
using System.Collections.Generic; |
||||
using ErsatzTV.Core.Search; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Application.MediaCards |
||||
{ |
||||
public record TelevisionShowCardResultsViewModel(int Count, List<TelevisionShowCardViewModel> Cards); |
||||
public record TelevisionShowCardResultsViewModel( |
||||
int Count, |
||||
List<TelevisionShowCardViewModel> Cards, |
||||
Option<SearchPageMap> PageMap); |
||||
} |
||||
|
||||
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Application.Search.Commands |
||||
{ |
||||
public record RebuildSearchIndex : MediatR.IRequest<Unit>, IBackgroundServiceRequest; |
||||
} |
||||
@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
|
||||
using System.Collections.Generic; |
||||
using System.IO; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using ErsatzTV.Core.Interfaces.Search; |
||||
using LanguageExt; |
||||
using Microsoft.Extensions.Logging; |
||||
|
||||
namespace ErsatzTV.Application.Search.Commands |
||||
{ |
||||
public class RebuildSearchIndexHandler : MediatR.IRequestHandler<RebuildSearchIndex, Unit> |
||||
{ |
||||
private readonly IConfigElementRepository _configElementRepository; |
||||
private readonly ILogger<RebuildSearchIndexHandler> _logger; |
||||
private readonly ISearchIndex _searchIndex; |
||||
private readonly ISearchRepository _searchRepository; |
||||
|
||||
public RebuildSearchIndexHandler( |
||||
ISearchIndex searchIndex, |
||||
ISearchRepository searchRepository, |
||||
IConfigElementRepository configElementRepository, |
||||
ILogger<RebuildSearchIndexHandler> logger) |
||||
{ |
||||
_searchIndex = searchIndex; |
||||
_logger = logger; |
||||
_searchRepository = searchRepository; |
||||
_configElementRepository = configElementRepository; |
||||
} |
||||
|
||||
public async Task<Unit> Handle(RebuildSearchIndex request, CancellationToken cancellationToken) |
||||
{ |
||||
bool indexFolderExists = Directory.Exists(FileSystemLayout.SearchIndexFolder); |
||||
|
||||
if (!indexFolderExists || |
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.SearchIndexVersion) < |
||||
_searchIndex.Version) |
||||
{ |
||||
_logger.LogDebug("Migrating search index to version {Version}", _searchIndex.Version); |
||||
|
||||
List<MediaItem> items = await _searchRepository.GetItemsToIndex(); |
||||
await _searchIndex.Rebuild(items); |
||||
|
||||
Option<ConfigElement> maybeVersion = |
||||
await _configElementRepository.Get(ConfigElementKey.SearchIndexVersion); |
||||
await maybeVersion.Match( |
||||
version => |
||||
{ |
||||
version.Value = _searchIndex.Version.ToString(); |
||||
return _configElementRepository.Update(version); |
||||
}, |
||||
() => |
||||
{ |
||||
var configElement = new ConfigElement |
||||
{ |
||||
Key = ConfigElementKey.SearchIndexVersion.Key, |
||||
Value = _searchIndex.Version.ToString() |
||||
}; |
||||
return _configElementRepository.Add(configElement); |
||||
}); |
||||
|
||||
_logger.LogDebug("Done migrating search index"); |
||||
} |
||||
else |
||||
{ |
||||
_logger.LogDebug("Search index is already version {Version}", _searchIndex.Version); |
||||
} |
||||
|
||||
return Unit.Default; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
using ErsatzTV.Core.Search; |
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.Search.Queries |
||||
{ |
||||
public record QuerySearchIndex(string Query) : IRequest<SearchResult>; |
||||
} |
||||
@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
|
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Interfaces.Search; |
||||
using ErsatzTV.Core.Search; |
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.Search.Queries |
||||
{ |
||||
public class QuerySearchIndexHandler : IRequestHandler<QuerySearchIndex, SearchResult> |
||||
{ |
||||
private readonly ISearchIndex _searchIndex; |
||||
|
||||
public QuerySearchIndexHandler(ISearchIndex searchIndex) => _searchIndex = searchIndex; |
||||
|
||||
public Task<SearchResult> Handle(QuerySearchIndex request, CancellationToken cancellationToken) => |
||||
_searchIndex.Search(request.Query, 0, 100); |
||||
} |
||||
} |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Application.MediaCards; |
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.Search.Queries |
||||
{ |
||||
public record QuerySearchIndexMovies |
||||
(string Query, int PageNumber, int PageSize) : IRequest<MovieCardResultsViewModel>; |
||||
} |
||||
@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
|
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Application.MediaCards; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using ErsatzTV.Core.Interfaces.Search; |
||||
using ErsatzTV.Core.Search; |
||||
using LanguageExt; |
||||
using MediatR; |
||||
using static ErsatzTV.Application.MediaCards.Mapper; |
||||
|
||||
namespace ErsatzTV.Application.Search.Queries |
||||
{ |
||||
public class QuerySearchIndexMoviesHandler : IRequestHandler<QuerySearchIndexMovies, MovieCardResultsViewModel> |
||||
{ |
||||
private readonly IMovieRepository _movieRepository; |
||||
private readonly ISearchIndex _searchIndex; |
||||
|
||||
public QuerySearchIndexMoviesHandler(ISearchIndex searchIndex, IMovieRepository movieRepository) |
||||
{ |
||||
_searchIndex = searchIndex; |
||||
_movieRepository = movieRepository; |
||||
} |
||||
|
||||
public async Task<MovieCardResultsViewModel> Handle( |
||||
QuerySearchIndexMovies request, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
SearchResult searchResult = await _searchIndex.Search( |
||||
request.Query, |
||||
(request.PageNumber - 1) * request.PageSize, |
||||
request.PageSize); |
||||
|
||||
List<MovieCardViewModel> items = await _movieRepository |
||||
.GetMoviesForCards(searchResult.Items.Map(i => i.Id).ToList()) |
||||
.Map(list => list.Map(ProjectToViewModel).ToList()); |
||||
|
||||
return new MovieCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Application.MediaCards; |
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.Search.Queries |
||||
{ |
||||
public record QuerySearchIndexShows |
||||
(string Query, int PageNumber, int PageSize) : IRequest<TelevisionShowCardResultsViewModel>; |
||||
} |
||||
@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
|
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Application.MediaCards; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using ErsatzTV.Core.Interfaces.Search; |
||||
using ErsatzTV.Core.Search; |
||||
using LanguageExt; |
||||
using MediatR; |
||||
using static ErsatzTV.Application.MediaCards.Mapper; |
||||
|
||||
namespace ErsatzTV.Application.Search.Queries |
||||
{ |
||||
public class |
||||
QuerySearchIndexShowsHandler : IRequestHandler<QuerySearchIndexShows, TelevisionShowCardResultsViewModel> |
||||
{ |
||||
private readonly ISearchIndex _searchIndex; |
||||
private readonly ITelevisionRepository _televisionRepository; |
||||
|
||||
public QuerySearchIndexShowsHandler(ISearchIndex searchIndex, ITelevisionRepository televisionRepository) |
||||
{ |
||||
_searchIndex = searchIndex; |
||||
_televisionRepository = televisionRepository; |
||||
} |
||||
|
||||
public async Task<TelevisionShowCardResultsViewModel> Handle( |
||||
QuerySearchIndexShows request, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
SearchResult searchResult = await _searchIndex.Search( |
||||
request.Query, |
||||
(request.PageNumber - 1) * request.PageSize, |
||||
request.PageSize); |
||||
|
||||
List<TelevisionShowCardViewModel> items = await _televisionRepository |
||||
.GetShowsForCards(searchResult.Items.Map(i => i.Id).ToList()) |
||||
.Map(list => list.Map(ProjectToViewModel).ToList()); |
||||
|
||||
return new TelevisionShowCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic; |
||||
|
||||
namespace ErsatzTV.Application.Search |
||||
{ |
||||
public class SearchResultViewModel<T> |
||||
{ |
||||
public int TotalCount { get; set; } |
||||
public List<T> Items { get; set; } |
||||
} |
||||
} |
||||
@ -1,26 +1,26 @@
@@ -1,26 +1,26 @@
|
||||
using System.Collections.Generic; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Metadata; |
||||
|
||||
namespace ErsatzTV.Core.Tests.Fakes |
||||
{ |
||||
public class FakeMovieWithPath : Movie |
||||
public class FakeMovieWithPath : MediaItemScanResult<Movie> |
||||
{ |
||||
public FakeMovieWithPath(string path) |
||||
{ |
||||
Path = path; |
||||
|
||||
MediaVersions = new List<MediaVersion> |
||||
{ |
||||
new() |
||||
: base( |
||||
new Movie |
||||
{ |
||||
MediaFiles = new List<MediaFile> |
||||
MediaVersions = new List<MediaVersion> |
||||
{ |
||||
new() { Path = path } |
||||
new() |
||||
{ |
||||
MediaFiles = new List<MediaFile> |
||||
{ |
||||
new() { Path = path } |
||||
} |
||||
} |
||||
} |
||||
} |
||||
}; |
||||
} |
||||
|
||||
public string Path { get; } |
||||
}) => |
||||
IsAdded = true; |
||||
} |
||||
} |
||||
|
||||
@ -1,15 +1,14 @@
@@ -1,15 +1,14 @@
|
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Domain; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Metadata |
||||
{ |
||||
public interface ILocalMetadataProvider |
||||
{ |
||||
Task<ShowMetadata> GetMetadataForShow(string showFolder); |
||||
Task<Unit> RefreshSidecarMetadata(MediaItem mediaItem, string path); |
||||
Task<Unit> RefreshSidecarMetadata(Show televisionShow, string showFolder); |
||||
Task<Unit> RefreshFallbackMetadata(MediaItem mediaItem); |
||||
Task<Unit> RefreshFallbackMetadata(Show televisionShow, string showFolder); |
||||
Task<bool> RefreshSidecarMetadata(MediaItem mediaItem, string path); |
||||
Task<bool> RefreshSidecarMetadata(Show televisionShow, string showFolder); |
||||
Task<bool> RefreshFallbackMetadata(MediaItem mediaItem); |
||||
Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder); |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
|
||||
using System.Collections.Generic; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Search; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Search |
||||
{ |
||||
public interface ISearchIndex |
||||
{ |
||||
public int Version { get; } |
||||
Task<bool> Initialize(); |
||||
Task<Unit> Rebuild(List<MediaItem> items); |
||||
Task<Unit> AddItems(List<MediaItem> items); |
||||
Task<Unit> UpdateItems(List<MediaItem> items); |
||||
Task<Unit> RemoveItems(List<int> ids); |
||||
Task<SearchResult> Search(string query, int skip, int limit, string searchField = ""); |
||||
} |
||||
} |
||||
@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
|
||||
namespace ErsatzTV.Core.Metadata |
||||
{ |
||||
public class MediaItemScanResult<T> where T : MediaItem |
||||
{ |
||||
public MediaItemScanResult(T item) => Item = item; |
||||
|
||||
public T Item { get; } |
||||
|
||||
public bool IsAdded { get; set; } |
||||
public bool IsUpdated { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Core.Search |
||||
{ |
||||
public record SearchItem(int Id); |
||||
} |
||||
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
using System.Collections.Generic; |
||||
|
||||
namespace ErsatzTV.Core.Search |
||||
{ |
||||
public record SearchPageMap(Dictionary<char, int> PageMap); |
||||
} |
||||
@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Core.Search |
||||
{ |
||||
public record SearchResult(List<SearchItem> Items, int TotalCount) |
||||
{ |
||||
public Option<SearchPageMap> PageMap { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,287 @@
@@ -0,0 +1,287 @@
|
||||
using System; |
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Interfaces.Metadata; |
||||
using ErsatzTV.Core.Interfaces.Search; |
||||
using ErsatzTV.Core.Search; |
||||
using LanguageExt; |
||||
using LanguageExt.UnsafeValueAccess; |
||||
using Lucene.Net.Analysis.Standard; |
||||
using Lucene.Net.Documents; |
||||
using Lucene.Net.Index; |
||||
using Lucene.Net.QueryParsers.Classic; |
||||
using Lucene.Net.Sandbox.Queries; |
||||
using Lucene.Net.Search; |
||||
using Lucene.Net.Store; |
||||
using Lucene.Net.Util; |
||||
using Query = Lucene.Net.Search.Query; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Search |
||||
{ |
||||
public class SearchIndex : ISearchIndex |
||||
{ |
||||
private const LuceneVersion AppLuceneVersion = LuceneVersion.LUCENE_48; |
||||
|
||||
private const string IdField = "id"; |
||||
private const string TypeField = "type"; |
||||
private const string TitleField = "title"; |
||||
private const string SortTitleField = "sort_title"; |
||||
private const string GenreField = "genre"; |
||||
private const string TagField = "tag"; |
||||
private const string PlotField = "plot"; |
||||
private const string LibraryNameField = "library_name"; |
||||
private const string TitleAndYearField = "title_and_year"; |
||||
private const string JumpLetterField = "jump_letter"; |
||||
|
||||
private const string MovieType = "movie"; |
||||
private const string ShowType = "show"; |
||||
|
||||
private readonly ILocalFileSystem _localFileSystem; |
||||
|
||||
private readonly string[] _searchFields = { TitleField, GenreField, TagField }; |
||||
|
||||
public SearchIndex(ILocalFileSystem localFileSystem) => _localFileSystem = localFileSystem; |
||||
|
||||
public int Version => 1; |
||||
|
||||
public Task<bool> Initialize() |
||||
{ |
||||
_localFileSystem.EnsureFolderExists(FileSystemLayout.SearchIndexFolder); |
||||
return Task.FromResult(true); |
||||
} |
||||
|
||||
public async Task<Unit> Rebuild(List<MediaItem> items) |
||||
{ |
||||
await Initialize(); |
||||
|
||||
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder); |
||||
var analyzer = new StandardAnalyzer(AppLuceneVersion); |
||||
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer) { OpenMode = OpenMode.CREATE }; |
||||
using var writer = new IndexWriter(dir, indexConfig); |
||||
|
||||
UpdateMovies(items.OfType<Movie>(), writer); |
||||
UpdateShows(items.OfType<Show>(), writer); |
||||
|
||||
return Unit.Default; |
||||
} |
||||
|
||||
public Task<Unit> AddItems(List<MediaItem> items) => UpdateItems(items); |
||||
|
||||
public Task<Unit> UpdateItems(List<MediaItem> items) |
||||
{ |
||||
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder); |
||||
var analyzer = new StandardAnalyzer(AppLuceneVersion); |
||||
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer) { OpenMode = OpenMode.APPEND }; |
||||
using var writer = new IndexWriter(dir, indexConfig); |
||||
|
||||
UpdateMovies(items.OfType<Movie>(), writer); |
||||
UpdateShows(items.OfType<Show>(), writer); |
||||
|
||||
return Task.FromResult(Unit.Default); |
||||
} |
||||
|
||||
public Task<Unit> RemoveItems(List<int> ids) |
||||
{ |
||||
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder); |
||||
var analyzer = new StandardAnalyzer(AppLuceneVersion); |
||||
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer) { OpenMode = OpenMode.APPEND }; |
||||
using var writer = new IndexWriter(dir, indexConfig); |
||||
|
||||
foreach (int id in ids) |
||||
{ |
||||
writer.DeleteDocuments(new Term(IdField, id.ToString())); |
||||
} |
||||
|
||||
return Task.FromResult(Unit.Default); |
||||
} |
||||
|
||||
public Task<SearchResult> Search(string searchQuery, int skip, int limit, string searchField = "") |
||||
{ |
||||
if (string.IsNullOrWhiteSpace(searchQuery.Replace("*", string.Empty).Replace("?", string.Empty))) |
||||
{ |
||||
return new SearchResult(new List<SearchItem>(), 0).AsTask(); |
||||
} |
||||
|
||||
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder); |
||||
using var reader = DirectoryReader.Open(dir); |
||||
var searcher = new IndexSearcher(reader); |
||||
int hitsLimit = skip + limit; |
||||
using var analyzer = new StandardAnalyzer(AppLuceneVersion); |
||||
QueryParser parser = !string.IsNullOrWhiteSpace(searchField) |
||||
? new QueryParser(AppLuceneVersion, searchField, analyzer) |
||||
: new MultiFieldQueryParser(AppLuceneVersion, _searchFields, analyzer); |
||||
Query query = ParseQuery(searchQuery, parser); |
||||
var filter = new DuplicateFilter(TitleAndYearField); |
||||
var sort = new Sort(new SortField(SortTitleField, SortFieldType.STRING)); |
||||
TopFieldDocs topDocs = searcher.Search(query, filter, hitsLimit, sort, true, true); |
||||
IEnumerable<ScoreDoc> selectedHits = topDocs.ScoreDocs.Skip(skip).Take(limit); |
||||
|
||||
var searchResult = new SearchResult( |
||||
selectedHits.Map(d => ProjectToSearchItem(searcher.Doc(d.Doc))).ToList(), |
||||
topDocs.TotalHits); |
||||
|
||||
searchResult.PageMap = GetSearchPageMap(searcher, query, filter, sort, limit); |
||||
|
||||
return searchResult.AsTask(); |
||||
} |
||||
|
||||
private static Option<SearchPageMap> GetSearchPageMap( |
||||
IndexSearcher searcher, |
||||
Query query, |
||||
DuplicateFilter filter, |
||||
Sort sort, |
||||
int limit) |
||||
{ |
||||
ScoreDoc[] allDocs = searcher.Search(query, filter, int.MaxValue, sort, true, true).ScoreDocs; |
||||
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); |
||||
|
||||
var current = 0; |
||||
var page = 0; |
||||
while (current < allDocs.Length) |
||||
{ |
||||
// walk up by page size (limit)
|
||||
page++; |
||||
current += limit; |
||||
if (current > allDocs.Length) |
||||
{ |
||||
current = allDocs.Length; |
||||
} |
||||
|
||||
char jumpLetter = searcher.Doc(allDocs[current - 1].Doc).Get(JumpLetterField).Head(); |
||||
foreach (char letter in letters.Where(l => letters.IndexOf(l) <= letters.IndexOf(jumpLetter))) |
||||
{ |
||||
if (map[letter] == 0) |
||||
{ |
||||
map[letter] = page; |
||||
} |
||||
} |
||||
} |
||||
|
||||
int max = map.Values.Max(); |
||||
foreach (char letter in letters.Where(letter => map[letter] == 0)) |
||||
{ |
||||
map[letter] = max; |
||||
} |
||||
|
||||
return new SearchPageMap(map); |
||||
} |
||||
|
||||
private static void UpdateMovies(IEnumerable<Movie> movies, IndexWriter writer) |
||||
{ |
||||
foreach (Movie movie in movies) |
||||
{ |
||||
Option<MovieMetadata> maybeMetadata = movie.MovieMetadata.HeadOrNone(); |
||||
if (maybeMetadata.IsSome) |
||||
{ |
||||
MovieMetadata metadata = maybeMetadata.ValueUnsafe(); |
||||
|
||||
var doc = new Document |
||||
{ |
||||
new StringField(IdField, movie.Id.ToString(), Field.Store.YES), |
||||
new StringField(TypeField, MovieType, Field.Store.NO), |
||||
new TextField(TitleField, metadata.Title, Field.Store.NO), |
||||
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO), |
||||
new TextField(LibraryNameField, movie.LibraryPath.Library.Name, Field.Store.NO), |
||||
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO), |
||||
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES) |
||||
}; |
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Plot)) |
||||
{ |
||||
doc.Add(new TextField(PlotField, metadata.Plot ?? string.Empty, Field.Store.NO)); |
||||
} |
||||
|
||||
foreach (Genre genre in metadata.Genres) |
||||
{ |
||||
doc.Add(new TextField(GenreField, genre.Name, Field.Store.NO)); |
||||
} |
||||
|
||||
foreach (Tag tag in metadata.Tags) |
||||
{ |
||||
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO)); |
||||
} |
||||
|
||||
writer.UpdateDocument(new Term(IdField, movie.Id.ToString()), doc); |
||||
} |
||||
} |
||||
} |
||||
|
||||
private static void UpdateShows(IEnumerable<Show> shows, IndexWriter writer) |
||||
{ |
||||
foreach (Show show in shows) |
||||
{ |
||||
Option<ShowMetadata> maybeMetadata = show.ShowMetadata.HeadOrNone(); |
||||
if (maybeMetadata.IsSome) |
||||
{ |
||||
ShowMetadata metadata = maybeMetadata.ValueUnsafe(); |
||||
|
||||
var doc = new Document |
||||
{ |
||||
new StringField(IdField, show.Id.ToString(), Field.Store.YES), |
||||
new StringField(TypeField, ShowType, Field.Store.NO), |
||||
new TextField(TitleField, metadata.Title, Field.Store.NO), |
||||
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO), |
||||
new TextField(LibraryNameField, show.LibraryPath.Library.Name, Field.Store.NO), |
||||
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO), |
||||
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES) |
||||
}; |
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Plot)) |
||||
{ |
||||
doc.Add(new TextField(PlotField, metadata.Plot ?? string.Empty, Field.Store.NO)); |
||||
} |
||||
|
||||
foreach (Genre genre in metadata.Genres) |
||||
{ |
||||
doc.Add(new TextField(GenreField, genre.Name, Field.Store.NO)); |
||||
} |
||||
|
||||
foreach (Tag tag in metadata.Tags) |
||||
{ |
||||
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO)); |
||||
} |
||||
|
||||
writer.UpdateDocument(new Term(IdField, show.Id.ToString()), doc); |
||||
} |
||||
} |
||||
} |
||||
|
||||
private SearchItem ProjectToSearchItem(Document doc) => new(Convert.ToInt32(doc.Get(IdField))); |
||||
|
||||
private Query ParseQuery(string searchQuery, QueryParser parser) |
||||
{ |
||||
Query query; |
||||
try |
||||
{ |
||||
query = parser.Parse(searchQuery.Trim()); |
||||
} |
||||
catch (ParseException) |
||||
{ |
||||
query = parser.Parse(QueryParserBase.Escape(searchQuery.Trim())); |
||||
} |
||||
|
||||
return query; |
||||
} |
||||
|
||||
private static string GetTitleAndYear(Metadata metadata) => |
||||
$"{metadata.Title}_{metadata.Year}"; |
||||
|
||||
private static string GetJumpLetter(Metadata metadata) |
||||
{ |
||||
char c = metadata.SortTitle.ToLowerInvariant().Head(); |
||||
return c switch |
||||
{ |
||||
(>= 'a' and <= 'z') => c.ToString(), |
||||
_ => "#" |
||||
}; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
|
||||
@using ErsatzTV.Core.Search |
||||
@using Microsoft.AspNetCore.WebUtilities |
||||
<div class="letter-bar-container"> |
||||
<div class="letter-bar"> |
||||
<MudLink Href="@GetLinkForLetter('#')">#</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('a')">A</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('b')">B</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('c')">C</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('d')">D</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('e')">E</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('f')">F</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('g')">G</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('h')">H</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('i')">I</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('j')">J</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('k')">K</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('l')">L</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('m')">M</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('n')">N</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('o')">O</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('p')">P</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('q')">Q</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('r')">R</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('s')">S</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('t')">T</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('u')">U</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('v')">V</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('w')">W</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('x')">X</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('y')">Y</MudLink> |
||||
<MudLink Href="@GetLinkForLetter('z')">Z</MudLink> |
||||
</div> |
||||
</div> |
||||
|
||||
@code { |
||||
|
||||
[Parameter] |
||||
public SearchPageMap PageMap { get; set; } |
||||
|
||||
[Parameter] |
||||
public string BaseUri { get; set; } |
||||
|
||||
[Parameter] |
||||
public string Query { get; set; } |
||||
|
||||
private string GetLinkForLetter(char letter) |
||||
{ |
||||
var uri = $"{BaseUri}/page/{PageMap.PageMap[letter]}"; |
||||
if (!string.IsNullOrWhiteSpace(Query)) |
||||
{ |
||||
uri = QueryHelpers.AddQueryString(uri, "query", Query); |
||||
} |
||||
return uri; |
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue