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 @@ |
|||||||
using System.Collections.Generic; |
using System.Collections.Generic; |
||||||
|
using ErsatzTV.Core.Search; |
||||||
|
using LanguageExt; |
||||||
|
|
||||||
namespace ErsatzTV.Application.MediaCards |
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 @@ |
|||||||
using MediatR; |
|
||||||
|
|
||||||
namespace ErsatzTV.Application.MediaCards.Queries |
|
||||||
{ |
|
||||||
public record GetMovieCards(int PageNumber, int PageSize) : IRequest<MovieCardResultsViewModel>; |
|
||||||
} |
|
||||||
@ -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 @@ |
|||||||
using MediatR; |
|
||||||
|
|
||||||
namespace ErsatzTV.Application.MediaCards.Queries |
|
||||||
{ |
|
||||||
public record GetTelevisionShowCards(int PageNumber, int PageSize) : IRequest<TelevisionShowCardResultsViewModel>; |
|
||||||
} |
|
||||||
@ -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 @@ |
|||||||
using System.Collections.Generic; |
using System.Collections.Generic; |
||||||
|
using ErsatzTV.Core.Search; |
||||||
|
using LanguageExt; |
||||||
|
|
||||||
namespace ErsatzTV.Application.MediaCards |
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 @@ |
|||||||
|
using LanguageExt; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Search.Commands |
||||||
|
{ |
||||||
|
public record RebuildSearchIndex : MediatR.IRequest<Unit>, IBackgroundServiceRequest; |
||||||
|
} |
||||||
@ -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 @@ |
|||||||
|
using ErsatzTV.Core.Search; |
||||||
|
using MediatR; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Search.Queries |
||||||
|
{ |
||||||
|
public record QuerySearchIndex(string Query) : IRequest<SearchResult>; |
||||||
|
} |
||||||
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
using System.Collections.Generic; |
using System.Collections.Generic; |
||||||
using ErsatzTV.Core.Domain; |
using ErsatzTV.Core.Domain; |
||||||
|
using ErsatzTV.Core.Metadata; |
||||||
|
|
||||||
namespace ErsatzTV.Core.Tests.Fakes |
namespace ErsatzTV.Core.Tests.Fakes |
||||||
{ |
{ |
||||||
public class FakeMovieWithPath : Movie |
public class FakeMovieWithPath : MediaItemScanResult<Movie> |
||||||
{ |
{ |
||||||
public FakeMovieWithPath(string path) |
public FakeMovieWithPath(string path) |
||||||
{ |
: base( |
||||||
Path = path; |
new Movie |
||||||
|
|
||||||
MediaVersions = new List<MediaVersion> |
|
||||||
{ |
|
||||||
new() |
|
||||||
{ |
{ |
||||||
MediaFiles = new List<MediaFile> |
MediaVersions = new List<MediaVersion> |
||||||
{ |
{ |
||||||
new() { Path = path } |
new() |
||||||
|
{ |
||||||
|
MediaFiles = new List<MediaFile> |
||||||
|
{ |
||||||
|
new() { Path = path } |
||||||
|
} |
||||||
|
} |
||||||
} |
} |
||||||
} |
}) => |
||||||
}; |
IsAdded = true; |
||||||
} |
|
||||||
|
|
||||||
public string Path { get; } |
|
||||||
} |
} |
||||||
} |
} |
||||||
|
|||||||
@ -1,15 +1,14 @@ |
|||||||
using System.Threading.Tasks; |
using System.Threading.Tasks; |
||||||
using ErsatzTV.Core.Domain; |
using ErsatzTV.Core.Domain; |
||||||
using LanguageExt; |
|
||||||
|
|
||||||
namespace ErsatzTV.Core.Interfaces.Metadata |
namespace ErsatzTV.Core.Interfaces.Metadata |
||||||
{ |
{ |
||||||
public interface ILocalMetadataProvider |
public interface ILocalMetadataProvider |
||||||
{ |
{ |
||||||
Task<ShowMetadata> GetMetadataForShow(string showFolder); |
Task<ShowMetadata> GetMetadataForShow(string showFolder); |
||||||
Task<Unit> RefreshSidecarMetadata(MediaItem mediaItem, string path); |
Task<bool> RefreshSidecarMetadata(MediaItem mediaItem, string path); |
||||||
Task<Unit> RefreshSidecarMetadata(Show televisionShow, string showFolder); |
Task<bool> RefreshSidecarMetadata(Show televisionShow, string showFolder); |
||||||
Task<Unit> RefreshFallbackMetadata(MediaItem mediaItem); |
Task<bool> RefreshFallbackMetadata(MediaItem mediaItem); |
||||||
Task<Unit> RefreshFallbackMetadata(Show televisionShow, string showFolder); |
Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder); |
||||||
} |
} |
||||||
} |
} |
||||||
|
|||||||
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
namespace ErsatzTV.Core.Search |
||||||
|
{ |
||||||
|
public record SearchItem(int Id); |
||||||
|
} |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
using System.Collections.Generic; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Search |
||||||
|
{ |
||||||
|
public record SearchPageMap(Dictionary<char, int> PageMap); |
||||||
|
} |
||||||
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
@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