search overhaul (#87)

* 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 cleanup
This commit is contained in:
Jason Dove
2021-03-20 15:49:50 +00:00
committed by GitHub
parent f96efa9b2f
commit 2efcbca2da
69 changed files with 1351 additions and 324 deletions
+1 -1
View File
@@ -1,11 +1,11 @@
@page "/channels/{Id:int?}"
@page "/channels/add"
@using static LanguageExt.Prelude
@using ErsatzTV.Application.FFmpegProfiles
@using ErsatzTV.Application.FFmpegProfiles.Queries
@using ErsatzTV.Application.Images.Commands
@using ErsatzTV.Application.Channels
@using ErsatzTV.Application.Channels.Queries
@using static LanguageExt.Prelude
@inject NavigationManager NavigationManager
@inject ILogger<ChannelEditor> Logger
@inject ISnackbar Snackbar
+1 -1
View File
@@ -33,7 +33,7 @@
<MudSwitch T="bool"
Label="Save troubleshooting reports to disk"
Color="Color.Primary"
@bind-Checked="@_ffmpegSettings.SaveReports" />
@bind-Checked="@_ffmpegSettings.SaveReports"/>
</MudElement>
</MudForm>
</MudCardContent>
+2 -2
View File
@@ -49,7 +49,7 @@
<div class="mb-2">
@foreach (string genre in _movie.Genres.OrderBy(g => g))
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a{genre}")"/>
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a%22{genre.ToLowerInvariant()}%22")"/>
}
</div>
}
@@ -59,7 +59,7 @@
<div>
@foreach (string tag in _movie.Tags.OrderBy(t => t))
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a{tag}")"/>
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a%22{tag.ToLowerInvariant()}%22")"/>
}
</div>
}
+42 -6
View File
@@ -1,9 +1,12 @@
@page "/media/movies"
@page "/media/movies/page/{PageNumber:int}"
@using LanguageExt.UnsafeValueAccess
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.Extensions.Primitives
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCards.Queries
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.Search.Queries
@using Unit = LanguageExt.Unit
@inherits MultiSelectBase<MovieList>
@inject NavigationManager NavigationManager
@@ -32,7 +35,8 @@
}
else
{
<div style="margin-left: auto; margin-right: auto; max-width: 300px;">
<MudText Style="margin-bottom: auto; margin-top: auto; width: 33%">@_query</MudText>
<div style="max-width: 300px; width: 33%;">
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
OnClick="@PrevPage"
@@ -63,6 +67,12 @@
}
</MudContainer>
</MudContainer>
@if (_data.PageMap.IsSome)
{
<LetterBar PageMap="@_data.PageMap.ValueUnsafe()"
BaseUri="/media/movies"
Query="@_query"/>
}
@code {
private static int PageSize => 100;
@@ -71,6 +81,7 @@
public int PageNumber { get; set; }
private MovieCardResultsViewModel _data;
private string _query;
protected override Task OnParametersSetAsync()
{
@@ -79,15 +90,40 @@
PageNumber = 1;
}
string query = new Uri(NavigationManager.Uri).Query;
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
{
_query = value;
}
return RefreshData();
}
protected override async Task RefreshData() =>
_data = await Mediator.Send(new GetMovieCards(PageNumber, PageSize));
protected override async Task RefreshData()
{
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:movie" : $"type:movie AND ({_query})";
_data = await Mediator.Send(new QuerySearchIndexMovies(searchQuery, PageNumber, PageSize));
}
private void PrevPage() => NavigationManager.NavigateTo($"/media/movies/page/{PageNumber - 1}");
private void PrevPage()
{
var uri = $"/media/movies/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
}
NavigationManager.NavigateTo(uri);
}
private void NextPage() => NavigationManager.NavigateTo($"/media/movies/page/{PageNumber + 1}");
private void NextPage()
{
var uri = $"/media/movies/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
}
NavigationManager.NavigateTo(uri);
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
+1 -1
View File
@@ -84,7 +84,7 @@
@if (_selectedItem is not null)
{
<div style="display: flex; flex-direction: row;" class="mt-6">
<div style="max-width: 400px; flex-grow: 1">
<div style="flex-grow: 1; max-width: 400px;">
<EditForm Model="_selectedItem">
<FluentValidator/>
<MudCard>
+71 -24
View File
@@ -1,8 +1,8 @@
@page "/search"
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCards.Queries
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.Search.Queries
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.Extensions.Primitives
@using Unit = LanguageExt.Unit
@@ -34,23 +34,43 @@
else
{
<MudText>@_query</MudText>
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#movies")">@_data.MovieCards.Count Movies</MudLink>
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#shows")">@_data.ShowCards.Count Shows</MudLink>
if (_movies.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#movies")">@_movies.Count Movies</MudLink>
}
else
{
<MudText Class="ml-4">0 Movies</MudText>
}
if (_shows.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#shows")">@_shows.Count Shows</MudLink>
}
else
{
<MudText Class="ml-4">0 Shows</MudText>
}
}
</div>
</MudPaper>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Style="margin-top: 96px">
@if (_data?.MovieCards.Any() == true)
@if (_movies.Count > 0)
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })">
Movies
</MudText>
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })">
Movies
</MudText>
@if (_movies.Count > 50)
{
<MudLink Href="@GetMoviesLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MovieCardViewModel card in _data.MovieCards.OrderBy(m => m.SortTitle))
@foreach (MovieCardViewModel card in _movies.Cards.OrderBy(m => m.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/movies/{card.MovieId}")"
@@ -62,17 +82,22 @@
</MudContainer>
}
@if (_data?.ShowCards.Any() == true)
@if (_shows.Count > 0)
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })">
Shows
</MudText>
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })">
Shows
</MudText>
@if (_shows.Count > 50)
{
<MudLink Href="@GetShowsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionShowCardViewModel card in _data.ShowCards.OrderBy(s => s.SortTitle))
@foreach (TelevisionShowCardViewModel card in _shows.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
@@ -87,7 +112,8 @@
@code {
private string _query;
private SearchCardResultsViewModel _data;
private MovieCardResultsViewModel _movies;
private TelevisionShowCardResultsViewModel _shows;
protected override async Task OnInitializedAsync()
{
@@ -96,8 +122,9 @@
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
{
_query = value;
Either<BaseError, SearchCardResultsViewModel> maybeResults = await Mediator.Send(new GetSearchCards(_query));
maybeResults.IfRight(results => _data = results);
_movies = await Mediator.Send(new QuerySearchIndexMovies($"type:movie AND ({_query})", 1, 50));
_shows = await Mediator.Send(new QuerySearchIndexShows($"type:show AND ({_query})", 1, 50));
}
}
@@ -105,8 +132,8 @@
{
List<MediaCardViewModel> GetSortedItems()
{
return _data.MovieCards.OrderBy(m => m.SortTitle)
.Append<MediaCardViewModel>(_data.ShowCards.OrderBy(s => s.SortTitle))
return _movies.Cards.OrderBy(m => m.SortTitle)
.Append<MediaCardViewModel>(_shows.Cards.OrderBy(s => s.SortTitle))
.ToList();
}
@@ -158,4 +185,24 @@
}
}
private string GetMoviesLink()
{
var uri = "/media/movies";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
}
return uri;
}
private string GetShowsLink()
{
var uri = "/media/tv/shows";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
}
return uri;
}
}
+2 -2
View File
@@ -64,7 +64,7 @@
<div class="mb-2">
@foreach (string genre in _show.Genres.OrderBy(g => g))
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a{genre}")"/>
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a%22{genre.ToLowerInvariant()}%22")"/>
}
</div>
}
@@ -74,7 +74,7 @@
<div>
@foreach (string tag in _show.Tags.OrderBy(t => t))
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a{tag}")"/>
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a%22{tag.ToLowerInvariant()}%22")"/>
}
</div>
}
+42 -6
View File
@@ -1,9 +1,12 @@
@page "/media/tv/shows"
@page "/media/tv/shows/page/{PageNumber:int}"
@using LanguageExt.UnsafeValueAccess
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.Extensions.Primitives
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCards.Queries
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.Search.Queries
@using Unit = LanguageExt.Unit
@inherits MultiSelectBase<TelevisionShowList>
@inject NavigationManager NavigationManager
@@ -32,7 +35,8 @@
}
else
{
<div style="margin-left: auto; margin-right: auto; max-width: 300px;">
<MudText Style="margin-bottom: auto; margin-top: auto; width: 33%">@_query</MudText>
<div style="max-width: 300px; width: 33%;">
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
OnClick="@PrevPage"
@@ -63,6 +67,12 @@
}
</MudContainer>
</MudContainer>
@if (_data.PageMap.IsSome)
{
<LetterBar PageMap="@_data.PageMap.ValueUnsafe()"
BaseUri="/media/tv/shows"
Query="@_query"/>
}
@code {
private static int PageSize => 100;
@@ -71,6 +81,7 @@
public int PageNumber { get; set; }
private TelevisionShowCardResultsViewModel _data;
private string _query;
protected override Task OnParametersSetAsync()
{
@@ -79,15 +90,40 @@
PageNumber = 1;
}
string query = new Uri(NavigationManager.Uri).Query;
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
{
_query = value;
}
return RefreshData();
}
protected override async Task RefreshData() =>
_data = await Mediator.Send(new GetTelevisionShowCards(PageNumber, PageSize));
protected override async Task RefreshData()
{
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:show" : $"type:show AND ({_query})";
_data = await Mediator.Send(new QuerySearchIndexShows(searchQuery, PageNumber, PageSize));
}
private void PrevPage() => NavigationManager.NavigateTo($"/media/tv/shows/page/{PageNumber - 1}");
private void PrevPage()
{
var uri = $"/media/tv/shows/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
}
NavigationManager.NavigateTo(uri);
}
private void NextPage() => NavigationManager.NavigateTo($"/media/tv/shows/page/{PageNumber + 1}");
private void NextPage()
{
var uri = $"/media/tv/shows/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
}
NavigationManager.NavigateTo(uri);
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
+5
View File
@@ -8,6 +8,7 @@ using ErsatzTV.Application;
using ErsatzTV.Application.MediaSources.Commands;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Application.Plex.Commands;
using ErsatzTV.Application.Search.Commands;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Infrastructure.Data;
@@ -54,6 +55,7 @@ namespace ErsatzTV.Services
private async Task DoWork(CancellationToken cancellationToken)
{
await RebuildSearchIndex(cancellationToken);
await BuildPlayouts(cancellationToken);
await ScanLocalMediaSources(cancellationToken);
await ScanPlexMediaSources(cancellationToken);
@@ -111,5 +113,8 @@ namespace ErsatzTV.Services
}
}
}
private async Task RebuildSearchIndex(CancellationToken cancellationToken) =>
await _channel.WriteAsync(new RebuildSearchIndex(), cancellationToken);
}
}
+4
View File
@@ -6,6 +6,7 @@ using ErsatzTV.Application;
using ErsatzTV.Application.MediaSources.Commands;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Application.Plex.Commands;
using ErsatzTV.Application.Search.Commands;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
@@ -82,6 +83,9 @@ namespace ErsatzTV.Services
synchronizePlexLibraryById.PlexLibraryId,
error.Value));
break;
case RebuildSearchIndex rebuildSearchIndex:
await mediator.Send(rebuildSearchIndex, cancellationToken);
break;
}
}
catch (Exception ex)
+56
View File
@@ -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;
}
}
+3
View File
@@ -15,6 +15,7 @@ using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex;
using ErsatzTV.Core.Scheduling;
@@ -24,6 +25,7 @@ using ErsatzTV.Infrastructure.Data.Repositories;
using ErsatzTV.Infrastructure.Images;
using ErsatzTV.Infrastructure.Locking;
using ErsatzTV.Infrastructure.Plex;
using ErsatzTV.Infrastructure.Search;
using ErsatzTV.Serialization;
using ErsatzTV.Services;
using FluentValidation.AspNetCore;
@@ -209,6 +211,7 @@ namespace ErsatzTV
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>();
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
services.AddScoped<ISearchIndex, SearchIndex>();
services.AddHostedService<PlexService>();
services.AddHostedService<FFmpegLocatorService>();
+14
View File
@@ -106,4 +106,18 @@
.media-item-subtitle {
color: #fff;
text-shadow: 1px 1px 5px #000;
}
.letter-bar-container {
height: calc(100vh - 128px);
position: fixed;
right: 1em;
top: 128px;
}
.letter-bar {
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-around;
}