From 2efcbca2dae9e0d5d9e46de05c3d786089e9b83a Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Sat, 20 Mar 2021 15:49:50 +0000 Subject: [PATCH] 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 --- .../Commands/DeleteLocalLibraryPathHandler.cs | 19 +- .../MediaCards/MovieCardResultsViewModel.cs | 4 +- .../MediaCards/Queries/GetMovieCards.cs | 6 - .../Queries/GetMovieCardsHandler.cs | 30 -- .../Queries/GetTelevisionShowCards.cs | 6 - .../Queries/GetTelevisionShowCardsHandler.cs | 33 -- .../TelevisionShowCardResultsViewModel.cs | 7 +- ...ocalMediaSource.cs => ScanLocalLibrary.cs} | 0 .../UpdatePlexLibraryPreferencesHandler.cs | 12 +- .../Search/Commands/RebuildSearchIndex.cs | 6 + .../Commands/RebuildSearchIndexHandler.cs | 74 +++++ .../Search/Queries/QuerySearchIndex.cs | 7 + .../Search/Queries/QuerySearchIndexHandler.cs | 18 ++ .../Search/Queries/QuerySearchIndexMovies.cs | 8 + .../Queries/QuerySearchIndexMoviesHandler.cs | 42 +++ .../Search/Queries/QuerySearchIndexShows.cs | 8 + .../Queries/QuerySearchIndexShowsHandler.cs | 43 +++ .../Search/SearchResultViewModel.cs | 10 + ...layoutItemProcessByChannelNumberHandler.cs | 2 +- .../Fakes/FakeLocalFileSystem.cs | 2 + .../Fakes/FakeMovieWithPath.cs | 28 +- .../Fakes/FakeTelevisionRepository.cs | 15 +- .../Metadata/MovieFolderScannerTests.cs | 68 +++-- ErsatzTV.Core/Domain/ConfigElementKey.cs | 1 + ErsatzTV.Core/FileSystemLayout.cs | 1 + .../Interfaces/Metadata/ILocalFileSystem.cs | 1 + .../Metadata/ILocalMetadataProvider.cs | 9 +- .../Metadata/ILocalStatisticsProvider.cs | 2 +- .../Repositories/ILibraryRepository.cs | 1 + .../Repositories/IMediaSourceRepository.cs | 2 +- .../Repositories/IMetadataRepository.cs | 4 +- .../Repositories/IMovieRepository.cs | 12 +- .../Repositories/ISearchRepository.cs | 1 + .../Repositories/ITelevisionRepository.cs | 17 +- .../Interfaces/Search/ISearchIndex.cs | 19 ++ ErsatzTV.Core/Metadata/LocalFileSystem.cs | 10 + ErsatzTV.Core/Metadata/LocalFolderScanner.cs | 19 +- .../Metadata/LocalMetadataProvider.cs | 46 +-- .../Metadata/LocalStatisticsProvider.cs | 11 +- ErsatzTV.Core/Metadata/MediaItemScanResult.cs | 14 + ErsatzTV.Core/Metadata/MovieFolderScanner.cs | 54 +++- .../Metadata/TelevisionFolderScanner.cs | 55 +++- ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs | 69 +++-- .../Plex/PlexTelevisionLibraryScanner.cs | 49 ++- ErsatzTV.Core/Search/SearchItem.cs | 4 + ErsatzTV.Core/Search/SearchPageMap.cs | 6 + ErsatzTV.Core/Search/SearchResult.cs | 10 + .../Data/Repositories/LibraryRepository.cs | 6 + .../Repositories/MediaSourceRepository.cs | 31 +- .../Data/Repositories/MetadataRepository.cs | 11 +- .../Data/Repositories/MovieRepository.cs | 76 +++-- .../Data/Repositories/SearchRepository.cs | 18 ++ .../Data/Repositories/TelevisionRepository.cs | 85 ++++-- .../ErsatzTV.Infrastructure.csproj | 3 + .../Plex/PlexServerApiClient.cs | 6 +- ErsatzTV.Infrastructure/Search/SearchIndex.cs | 287 ++++++++++++++++++ ErsatzTV/Pages/ChannelEditor.razor | 2 +- ErsatzTV/Pages/FFmpeg.razor | 2 +- ErsatzTV/Pages/Movie.razor | 4 +- ErsatzTV/Pages/MovieList.razor | 48 ++- ErsatzTV/Pages/ScheduleItemsEditor.razor | 2 +- ErsatzTV/Pages/Search.razor | 95 ++++-- ErsatzTV/Pages/TelevisionSeasonList.razor | 4 +- ErsatzTV/Pages/TelevisionShowList.razor | 48 ++- ErsatzTV/Services/SchedulerService.cs | 5 + ErsatzTV/Services/WorkerService.cs | 4 + ErsatzTV/Shared/LetterBar.razor | 56 ++++ ErsatzTV/Startup.cs | 3 + ErsatzTV/wwwroot/css/site.css | 14 + 69 files changed, 1351 insertions(+), 324 deletions(-) delete mode 100644 ErsatzTV.Application/MediaCards/Queries/GetMovieCards.cs delete mode 100644 ErsatzTV.Application/MediaCards/Queries/GetMovieCardsHandler.cs delete mode 100644 ErsatzTV.Application/MediaCards/Queries/GetTelevisionShowCards.cs delete mode 100644 ErsatzTV.Application/MediaCards/Queries/GetTelevisionShowCardsHandler.cs rename ErsatzTV.Application/MediaSources/Commands/{ScanLocalMediaSource.cs => ScanLocalLibrary.cs} (100%) create mode 100644 ErsatzTV.Application/Search/Commands/RebuildSearchIndex.cs create mode 100644 ErsatzTV.Application/Search/Commands/RebuildSearchIndexHandler.cs create mode 100644 ErsatzTV.Application/Search/Queries/QuerySearchIndex.cs create mode 100644 ErsatzTV.Application/Search/Queries/QuerySearchIndexHandler.cs create mode 100644 ErsatzTV.Application/Search/Queries/QuerySearchIndexMovies.cs create mode 100644 ErsatzTV.Application/Search/Queries/QuerySearchIndexMoviesHandler.cs create mode 100644 ErsatzTV.Application/Search/Queries/QuerySearchIndexShows.cs create mode 100644 ErsatzTV.Application/Search/Queries/QuerySearchIndexShowsHandler.cs create mode 100644 ErsatzTV.Application/Search/SearchResultViewModel.cs create mode 100644 ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs create mode 100644 ErsatzTV.Core/Metadata/MediaItemScanResult.cs create mode 100644 ErsatzTV.Core/Search/SearchItem.cs create mode 100644 ErsatzTV.Core/Search/SearchPageMap.cs create mode 100644 ErsatzTV.Core/Search/SearchResult.cs create mode 100644 ErsatzTV.Infrastructure/Search/SearchIndex.cs create mode 100644 ErsatzTV/Shared/LetterBar.razor diff --git a/ErsatzTV.Application/Libraries/Commands/DeleteLocalLibraryPathHandler.cs b/ErsatzTV.Application/Libraries/Commands/DeleteLocalLibraryPathHandler.cs index 7fc6d7ee0..d6935f3f7 100644 --- a/ErsatzTV.Application/Libraries/Commands/DeleteLocalLibraryPathHandler.cs +++ b/ErsatzTV.Application/Libraries/Commands/DeleteLocalLibraryPathHandler.cs @@ -1,8 +1,10 @@ -using System.Threading; +using System.Collections.Generic; +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; namespace ErsatzTV.Application.Libraries.Commands @@ -11,9 +13,13 @@ namespace ErsatzTV.Application.Libraries.Commands DeleteLocalLibraryPathHandler : MediatR.IRequestHandler> { private readonly ILibraryRepository _libraryRepository; + private readonly ISearchIndex _searchIndex; - public DeleteLocalLibraryPathHandler(ILibraryRepository libraryRepository) => + public DeleteLocalLibraryPathHandler(ILibraryRepository libraryRepository, ISearchIndex searchIndex) + { _libraryRepository = libraryRepository; + _searchIndex = searchIndex; + } public Task> Handle( DeleteLocalLibraryPath request, @@ -22,8 +28,13 @@ namespace ErsatzTV.Application.Libraries.Commands .MapT(DoDeletion) .Bind(t => t.ToEitherAsync()); - private Task DoDeletion(LibraryPath libraryPath) => - _libraryRepository.DeleteLocalPath(libraryPath.Id).ToUnit(); + private async Task DoDeletion(LibraryPath libraryPath) + { + List ids = await _libraryRepository.GetMediaIdsByLocalPath(libraryPath.Id); + await _searchIndex.RemoveItems(ids); + await _libraryRepository.DeleteLocalPath(libraryPath.Id); + return Unit.Default; + } private async Task> MediaSourceMustExist(DeleteLocalLibraryPath request) => (await _libraryRepository.GetPath(request.LocalLibraryPathId)) diff --git a/ErsatzTV.Application/MediaCards/MovieCardResultsViewModel.cs b/ErsatzTV.Application/MediaCards/MovieCardResultsViewModel.cs index f4062bbac..6f07386f2 100644 --- a/ErsatzTV.Application/MediaCards/MovieCardResultsViewModel.cs +++ b/ErsatzTV.Application/MediaCards/MovieCardResultsViewModel.cs @@ -1,6 +1,8 @@ using System.Collections.Generic; +using ErsatzTV.Core.Search; +using LanguageExt; namespace ErsatzTV.Application.MediaCards { - public record MovieCardResultsViewModel(int Count, List Cards); + public record MovieCardResultsViewModel(int Count, List Cards, Option PageMap); } diff --git a/ErsatzTV.Application/MediaCards/Queries/GetMovieCards.cs b/ErsatzTV.Application/MediaCards/Queries/GetMovieCards.cs deleted file mode 100644 index 67efe3c92..000000000 --- a/ErsatzTV.Application/MediaCards/Queries/GetMovieCards.cs +++ /dev/null @@ -1,6 +0,0 @@ -using MediatR; - -namespace ErsatzTV.Application.MediaCards.Queries -{ - public record GetMovieCards(int PageNumber, int PageSize) : IRequest; -} diff --git a/ErsatzTV.Application/MediaCards/Queries/GetMovieCardsHandler.cs b/ErsatzTV.Application/MediaCards/Queries/GetMovieCardsHandler.cs deleted file mode 100644 index eed22c7cf..000000000 --- a/ErsatzTV.Application/MediaCards/Queries/GetMovieCardsHandler.cs +++ /dev/null @@ -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 - { - private readonly IMovieRepository _movieRepository; - - public GetMovieCardsHandler(IMovieRepository movieRepository) => _movieRepository = movieRepository; - - public async Task Handle(GetMovieCards request, CancellationToken cancellationToken) - { - int count = await _movieRepository.GetMovieCount(); - - List results = await _movieRepository - .GetPagedMovies(request.PageNumber, request.PageSize) - .Map(list => list.Map(ProjectToViewModel).ToList()); - - return new MovieCardResultsViewModel(count, results); - } - } -} diff --git a/ErsatzTV.Application/MediaCards/Queries/GetTelevisionShowCards.cs b/ErsatzTV.Application/MediaCards/Queries/GetTelevisionShowCards.cs deleted file mode 100644 index 1e7903240..000000000 --- a/ErsatzTV.Application/MediaCards/Queries/GetTelevisionShowCards.cs +++ /dev/null @@ -1,6 +0,0 @@ -using MediatR; - -namespace ErsatzTV.Application.MediaCards.Queries -{ - public record GetTelevisionShowCards(int PageNumber, int PageSize) : IRequest; -} diff --git a/ErsatzTV.Application/MediaCards/Queries/GetTelevisionShowCardsHandler.cs b/ErsatzTV.Application/MediaCards/Queries/GetTelevisionShowCardsHandler.cs deleted file mode 100644 index ca8465d3f..000000000 --- a/ErsatzTV.Application/MediaCards/Queries/GetTelevisionShowCardsHandler.cs +++ /dev/null @@ -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 - { - private readonly ITelevisionRepository _televisionRepository; - - public GetTelevisionShowCardsHandler(ITelevisionRepository televisionRepository) => - _televisionRepository = televisionRepository; - - public async Task Handle( - GetTelevisionShowCards request, - CancellationToken cancellationToken) - { - int count = await _televisionRepository.GetShowCount(); - - List results = await _televisionRepository - .GetPagedShows(request.PageNumber, request.PageSize) - .Map(list => list.Map(ProjectToViewModel).ToList()); - - return new TelevisionShowCardResultsViewModel(count, results); - } - } -} diff --git a/ErsatzTV.Application/MediaCards/TelevisionShowCardResultsViewModel.cs b/ErsatzTV.Application/MediaCards/TelevisionShowCardResultsViewModel.cs index 228085f1a..1d8cee3c4 100644 --- a/ErsatzTV.Application/MediaCards/TelevisionShowCardResultsViewModel.cs +++ b/ErsatzTV.Application/MediaCards/TelevisionShowCardResultsViewModel.cs @@ -1,6 +1,11 @@ using System.Collections.Generic; +using ErsatzTV.Core.Search; +using LanguageExt; namespace ErsatzTV.Application.MediaCards { - public record TelevisionShowCardResultsViewModel(int Count, List Cards); + public record TelevisionShowCardResultsViewModel( + int Count, + List Cards, + Option PageMap); } diff --git a/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs b/ErsatzTV.Application/MediaSources/Commands/ScanLocalLibrary.cs similarity index 100% rename from ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs rename to ErsatzTV.Application/MediaSources/Commands/ScanLocalLibrary.cs diff --git a/ErsatzTV.Application/Plex/Commands/UpdatePlexLibraryPreferencesHandler.cs b/ErsatzTV.Application/Plex/Commands/UpdatePlexLibraryPreferencesHandler.cs index ee81b4962..b7eea5bd2 100644 --- a/ErsatzTV.Application/Plex/Commands/UpdatePlexLibraryPreferencesHandler.cs +++ b/ErsatzTV.Application/Plex/Commands/UpdatePlexLibraryPreferencesHandler.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using ErsatzTV.Core; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Search; using LanguageExt; namespace ErsatzTV.Application.Plex.Commands @@ -13,16 +14,23 @@ namespace ErsatzTV.Application.Plex.Commands Either> { private readonly IMediaSourceRepository _mediaSourceRepository; + private readonly ISearchIndex _searchIndex; - public UpdatePlexLibraryPreferencesHandler(IMediaSourceRepository mediaSourceRepository) => + public UpdatePlexLibraryPreferencesHandler( + IMediaSourceRepository mediaSourceRepository, + ISearchIndex searchIndex) + { _mediaSourceRepository = mediaSourceRepository; + _searchIndex = searchIndex; + } public async Task> Handle( UpdatePlexLibraryPreferences request, CancellationToken cancellationToken) { var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList(); - await _mediaSourceRepository.DisablePlexLibrarySync(toDisable); + List ids = await _mediaSourceRepository.DisablePlexLibrarySync(toDisable); + await _searchIndex.RemoveItems(ids); IEnumerable toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id); await _mediaSourceRepository.EnablePlexLibrarySync(toEnable); diff --git a/ErsatzTV.Application/Search/Commands/RebuildSearchIndex.cs b/ErsatzTV.Application/Search/Commands/RebuildSearchIndex.cs new file mode 100644 index 000000000..7eafb3e17 --- /dev/null +++ b/ErsatzTV.Application/Search/Commands/RebuildSearchIndex.cs @@ -0,0 +1,6 @@ +using LanguageExt; + +namespace ErsatzTV.Application.Search.Commands +{ + public record RebuildSearchIndex : MediatR.IRequest, IBackgroundServiceRequest; +} diff --git a/ErsatzTV.Application/Search/Commands/RebuildSearchIndexHandler.cs b/ErsatzTV.Application/Search/Commands/RebuildSearchIndexHandler.cs new file mode 100644 index 000000000..9f19741be --- /dev/null +++ b/ErsatzTV.Application/Search/Commands/RebuildSearchIndexHandler.cs @@ -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 + { + private readonly IConfigElementRepository _configElementRepository; + private readonly ILogger _logger; + private readonly ISearchIndex _searchIndex; + private readonly ISearchRepository _searchRepository; + + public RebuildSearchIndexHandler( + ISearchIndex searchIndex, + ISearchRepository searchRepository, + IConfigElementRepository configElementRepository, + ILogger logger) + { + _searchIndex = searchIndex; + _logger = logger; + _searchRepository = searchRepository; + _configElementRepository = configElementRepository; + } + + public async Task Handle(RebuildSearchIndex request, CancellationToken cancellationToken) + { + bool indexFolderExists = Directory.Exists(FileSystemLayout.SearchIndexFolder); + + if (!indexFolderExists || + await _configElementRepository.GetValue(ConfigElementKey.SearchIndexVersion) < + _searchIndex.Version) + { + _logger.LogDebug("Migrating search index to version {Version}", _searchIndex.Version); + + List items = await _searchRepository.GetItemsToIndex(); + await _searchIndex.Rebuild(items); + + Option 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; + } + } +} diff --git a/ErsatzTV.Application/Search/Queries/QuerySearchIndex.cs b/ErsatzTV.Application/Search/Queries/QuerySearchIndex.cs new file mode 100644 index 000000000..a6e8a210c --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/QuerySearchIndex.cs @@ -0,0 +1,7 @@ +using ErsatzTV.Core.Search; +using MediatR; + +namespace ErsatzTV.Application.Search.Queries +{ + public record QuerySearchIndex(string Query) : IRequest; +} diff --git a/ErsatzTV.Application/Search/Queries/QuerySearchIndexHandler.cs b/ErsatzTV.Application/Search/Queries/QuerySearchIndexHandler.cs new file mode 100644 index 000000000..c93f7b65e --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/QuerySearchIndexHandler.cs @@ -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 + { + private readonly ISearchIndex _searchIndex; + + public QuerySearchIndexHandler(ISearchIndex searchIndex) => _searchIndex = searchIndex; + + public Task Handle(QuerySearchIndex request, CancellationToken cancellationToken) => + _searchIndex.Search(request.Query, 0, 100); + } +} diff --git a/ErsatzTV.Application/Search/Queries/QuerySearchIndexMovies.cs b/ErsatzTV.Application/Search/Queries/QuerySearchIndexMovies.cs new file mode 100644 index 000000000..19b2a37c1 --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/QuerySearchIndexMovies.cs @@ -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; +} diff --git a/ErsatzTV.Application/Search/Queries/QuerySearchIndexMoviesHandler.cs b/ErsatzTV.Application/Search/Queries/QuerySearchIndexMoviesHandler.cs new file mode 100644 index 000000000..81062125d --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/QuerySearchIndexMoviesHandler.cs @@ -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 + { + private readonly IMovieRepository _movieRepository; + private readonly ISearchIndex _searchIndex; + + public QuerySearchIndexMoviesHandler(ISearchIndex searchIndex, IMovieRepository movieRepository) + { + _searchIndex = searchIndex; + _movieRepository = movieRepository; + } + + public async Task Handle( + QuerySearchIndexMovies request, + CancellationToken cancellationToken) + { + SearchResult searchResult = await _searchIndex.Search( + request.Query, + (request.PageNumber - 1) * request.PageSize, + request.PageSize); + + List 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); + } + } +} diff --git a/ErsatzTV.Application/Search/Queries/QuerySearchIndexShows.cs b/ErsatzTV.Application/Search/Queries/QuerySearchIndexShows.cs new file mode 100644 index 000000000..156a1af48 --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/QuerySearchIndexShows.cs @@ -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; +} diff --git a/ErsatzTV.Application/Search/Queries/QuerySearchIndexShowsHandler.cs b/ErsatzTV.Application/Search/Queries/QuerySearchIndexShowsHandler.cs new file mode 100644 index 000000000..3385066a6 --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/QuerySearchIndexShowsHandler.cs @@ -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 + { + private readonly ISearchIndex _searchIndex; + private readonly ITelevisionRepository _televisionRepository; + + public QuerySearchIndexShowsHandler(ISearchIndex searchIndex, ITelevisionRepository televisionRepository) + { + _searchIndex = searchIndex; + _televisionRepository = televisionRepository; + } + + public async Task Handle( + QuerySearchIndexShows request, + CancellationToken cancellationToken) + { + SearchResult searchResult = await _searchIndex.Search( + request.Query, + (request.PageNumber - 1) * request.PageSize, + request.PageSize); + + List 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); + } + } +} diff --git a/ErsatzTV.Application/Search/SearchResultViewModel.cs b/ErsatzTV.Application/Search/SearchResultViewModel.cs new file mode 100644 index 000000000..59e487b9a --- /dev/null +++ b/ErsatzTV.Application/Search/SearchResultViewModel.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace ErsatzTV.Application.Search +{ + public class SearchResultViewModel + { + public int TotalCount { get; set; } + public List Items { get; set; } + } +} diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs index 1a0dba983..efdb6df76 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs @@ -19,11 +19,11 @@ namespace ErsatzTV.Application.Streaming.Queries public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler { + private readonly IConfigElementRepository _configElementRepository; private readonly FFmpegProcessService _ffmpegProcessService; private readonly ILocalFileSystem _localFileSystem; private readonly ILogger _logger; private readonly IMediaSourceRepository _mediaSourceRepository; - private readonly IConfigElementRepository _configElementRepository; private readonly IPlayoutRepository _playoutRepository; public GetPlayoutItemProcessByChannelNumberHandler( diff --git a/ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs b/ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs index 172c73918..80962876c 100644 --- a/ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs +++ b/ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs @@ -36,6 +36,8 @@ namespace ErsatzTV.Core.Tests.Fakes _folders = allFolders.Distinct().Map(f => new FakeFolderEntry(f)).ToList(); } + public Unit EnsureFolderExists(string folder) => Unit.Default; + public DateTime GetLastWriteTime(string path) => Optional(_files.SingleOrDefault(f => f.Path == path)) .Map(f => f.LastWriteTime) diff --git a/ErsatzTV.Core.Tests/Fakes/FakeMovieWithPath.cs b/ErsatzTV.Core.Tests/Fakes/FakeMovieWithPath.cs index f6d096eed..b4eb8e941 100644 --- a/ErsatzTV.Core.Tests/Fakes/FakeMovieWithPath.cs +++ b/ErsatzTV.Core.Tests/Fakes/FakeMovieWithPath.cs @@ -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 { public FakeMovieWithPath(string path) - { - Path = path; - - MediaVersions = new List - { - new() + : base( + new Movie { - MediaFiles = new List + MediaVersions = new List { - new() { Path = path } + new() + { + MediaFiles = new List + { + new() { Path = path } + } + } } - } - }; - } - - public string Path { get; } + }) => + IsAdded = true; } } diff --git a/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs b/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs index 27d450aff..b046ea670 100644 --- a/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs +++ b/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Metadata; using LanguageExt; namespace ErsatzTV.Core.Tests.Fakes @@ -20,6 +21,8 @@ namespace ErsatzTV.Core.Tests.Fakes public Task> GetPagedShows(int pageNumber, int pageSize) => throw new NotSupportedException(); + public Task> GetShowsForCards(List ids) => throw new NotSupportedException(); + public Task> GetShowItems(int showId) => throw new NotSupportedException(); public Task> GetAllSeasons() => throw new NotSupportedException(); @@ -43,7 +46,7 @@ namespace ErsatzTV.Core.Tests.Fakes public Task> GetShowByMetadata(int libraryPathId, ShowMetadata metadata) => throw new NotSupportedException(); - public Task> + public Task>> AddShow(int libraryPathId, string showFolder, ShowMetadata metadata) => throw new NotSupportedException(); @@ -59,9 +62,11 @@ namespace ErsatzTV.Core.Tests.Fakes public Task DeleteEmptySeasons(LibraryPath libraryPath) => throw new NotSupportedException(); - public Task DeleteEmptyShows(LibraryPath libraryPath) => throw new NotSupportedException(); + public Task> DeleteEmptyShows(LibraryPath libraryPath) => throw new NotSupportedException(); - public Task> GetOrAddPlexShow(PlexLibrary library, PlexShow item) => + public Task>> GetOrAddPlexShow( + PlexLibrary library, + PlexShow item) => throw new NotSupportedException(); public Task> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item) => @@ -70,9 +75,9 @@ namespace ErsatzTV.Core.Tests.Fakes public Task> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item) => throw new NotSupportedException(); - public Task AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException(); + public Task AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException(); - public Task RemoveMissingPlexShows(PlexLibrary library, List showKeys) => + public Task> RemoveMissingPlexShows(PlexLibrary library, List showKeys) => throw new NotSupportedException(); public Task RemoveMissingPlexSeasons(string showKey, List seasonKeys) => diff --git a/ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs b/ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs index 9534c66c8..b91057218 100644 --- a/ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs +++ b/ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs @@ -9,6 +9,7 @@ using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Tests.Fakes; using FluentAssertions; @@ -44,20 +45,24 @@ namespace ErsatzTV.Core.Tests.Metadata _movieRepository = new Mock(); _movieRepository.Setup(x => x.GetOrAdd(It.IsAny(), It.IsAny())) .Returns( - (LibraryPath _, string path) => Right(new FakeMovieWithPath(path)).AsTask()); + (LibraryPath _, string path) => + Right>(new FakeMovieWithPath(path)).AsTask()); _movieRepository.Setup(x => x.FindMoviePaths(It.IsAny())) .Returns(new List().AsEnumerable().AsTask()); _localStatisticsProvider = new Mock(); _localMetadataProvider = new Mock(); + _localStatisticsProvider.Setup(x => x.RefreshStatistics(It.IsAny(), It.IsAny())) + .Returns((_, _) => Right(true).AsTask()); + // fallback metadata adds metadata to a movie, so we need to replicate that here _localMetadataProvider.Setup(x => x.RefreshFallbackMetadata(It.IsAny())) .Returns( (MediaItem mediaItem) => { ((Movie) mediaItem).MovieMetadata = new List { new() }; - return Unit.Default.AsTask(); + return Task.FromResult(true); }); _imageCache = new Mock(); @@ -104,11 +109,14 @@ namespace ErsatzTV.Core.Tests.Metadata _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once); _localStatisticsProvider.Verify( - x => x.RefreshStatistics(FFprobePath, It.Is(i => i.Path == moviePath)), + x => x.RefreshStatistics( + FFprobePath, + It.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)), Times.Once); _localMetadataProvider.Verify( - x => x.RefreshFallbackMetadata(It.Is(i => i.Path == moviePath)), + x => x.RefreshFallbackMetadata( + It.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)), Times.Once); } @@ -137,11 +145,15 @@ namespace ErsatzTV.Core.Tests.Metadata _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once); _localStatisticsProvider.Verify( - x => x.RefreshStatistics(FFprobePath, It.Is(i => i.Path == moviePath)), + x => x.RefreshStatistics( + FFprobePath, + It.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)), Times.Once); _localMetadataProvider.Verify( - x => x.RefreshSidecarMetadata(It.Is(i => i.Path == moviePath), metadataPath), + x => x.RefreshSidecarMetadata( + It.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath), + metadataPath), Times.Once); } @@ -170,11 +182,15 @@ namespace ErsatzTV.Core.Tests.Metadata _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once); _localStatisticsProvider.Verify( - x => x.RefreshStatistics(FFprobePath, It.Is(i => i.Path == moviePath)), + x => x.RefreshStatistics( + FFprobePath, + It.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)), Times.Once); _localMetadataProvider.Verify( - x => x.RefreshSidecarMetadata(It.Is(i => i.Path == moviePath), metadataPath), + x => x.RefreshSidecarMetadata( + It.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath), + metadataPath), Times.Once); } @@ -207,11 +223,14 @@ namespace ErsatzTV.Core.Tests.Metadata _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once); _localStatisticsProvider.Verify( - x => x.RefreshStatistics(FFprobePath, It.Is(i => i.Path == moviePath)), + x => x.RefreshStatistics( + FFprobePath, + It.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)), Times.Once); _localMetadataProvider.Verify( - x => x.RefreshFallbackMetadata(It.Is(i => i.Path == moviePath)), + x => x.RefreshFallbackMetadata( + It.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)), Times.Once); _imageCache.Verify( @@ -248,11 +267,14 @@ namespace ErsatzTV.Core.Tests.Metadata _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once); _localStatisticsProvider.Verify( - x => x.RefreshStatistics(FFprobePath, It.Is(i => i.Path == moviePath)), + x => x.RefreshStatistics( + FFprobePath, + It.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)), Times.Once); _localMetadataProvider.Verify( - x => x.RefreshFallbackMetadata(It.Is(i => i.Path == moviePath)), + x => x.RefreshFallbackMetadata( + It.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)), Times.Once); _imageCache.Verify( @@ -288,11 +310,14 @@ namespace ErsatzTV.Core.Tests.Metadata _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once); _localStatisticsProvider.Verify( - x => x.RefreshStatistics(FFprobePath, It.Is(i => i.Path == moviePath)), + x => x.RefreshStatistics( + FFprobePath, + It.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)), Times.Once); _localMetadataProvider.Verify( - x => x.RefreshFallbackMetadata(It.Is(i => i.Path == moviePath)), + x => x.RefreshFallbackMetadata( + It.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)), Times.Once); } @@ -324,11 +349,14 @@ namespace ErsatzTV.Core.Tests.Metadata _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once); _localStatisticsProvider.Verify( - x => x.RefreshStatistics(FFprobePath, It.Is(i => i.Path == moviePath)), + x => x.RefreshStatistics( + FFprobePath, + It.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)), Times.Once); _localMetadataProvider.Verify( - x => x.RefreshFallbackMetadata(It.Is(i => i.Path == moviePath)), + x => x.RefreshFallbackMetadata( + It.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)), Times.Once); } @@ -354,11 +382,14 @@ namespace ErsatzTV.Core.Tests.Metadata _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once); _localStatisticsProvider.Verify( - x => x.RefreshStatistics(FFprobePath, It.Is(i => i.Path == moviePath)), + x => x.RefreshStatistics( + FFprobePath, + It.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)), Times.Once); _localMetadataProvider.Verify( - x => x.RefreshFallbackMetadata(It.Is(i => i.Path == moviePath)), + x => x.RefreshFallbackMetadata( + It.Is(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)), Times.Once); } @@ -419,6 +450,7 @@ namespace ErsatzTV.Core.Tests.Metadata _localMetadataProvider.Object, new Mock().Object, _imageCache.Object, + new Mock().Object, new Mock>().Object ); } diff --git a/ErsatzTV.Core/Domain/ConfigElementKey.cs b/ErsatzTV.Core/Domain/ConfigElementKey.cs index 7b397baf0..ab08e9a19 100644 --- a/ErsatzTV.Core/Domain/ConfigElementKey.cs +++ b/ErsatzTV.Core/Domain/ConfigElementKey.cs @@ -11,5 +11,6 @@ public static ConfigElementKey FFmpegDefaultProfileId => new("ffmpeg.default_profile_id"); public static ConfigElementKey FFmpegDefaultResolutionId => new("ffmpeg.default_resolution_id"); public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports"); + public static ConfigElementKey SearchIndexVersion => new("search_index.version"); } } diff --git a/ErsatzTV.Core/FileSystemLayout.cs b/ErsatzTV.Core/FileSystemLayout.cs index f1525d5dc..5b71c0ede 100644 --- a/ErsatzTV.Core/FileSystemLayout.cs +++ b/ErsatzTV.Core/FileSystemLayout.cs @@ -20,6 +20,7 @@ namespace ErsatzTV.Core public static readonly string PlexSecretsPath = Path.Combine(AppDataFolder, "plex-secrets.json"); public static readonly string FFmpegReportsFolder = Path.Combine(AppDataFolder, "ffmpeg-reports"); + public static readonly string SearchIndexFolder = Path.Combine(AppDataFolder, "search-index"); public static readonly string ArtworkCacheFolder = Path.Combine(AppDataFolder, "cache", "artwork"); diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs index e28155ace..ff6d84dc3 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs @@ -8,6 +8,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata { public interface ILocalFileSystem { + Unit EnsureFolderExists(string folder); DateTime GetLastWriteTime(string path); bool IsLibraryPathAccessible(LibraryPath libraryPath); IEnumerable ListSubdirectories(string folder); diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs index c4f29c4f3..156cbe72c 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs @@ -1,15 +1,14 @@ using System.Threading.Tasks; using ErsatzTV.Core.Domain; -using LanguageExt; namespace ErsatzTV.Core.Interfaces.Metadata { public interface ILocalMetadataProvider { Task GetMetadataForShow(string showFolder); - Task RefreshSidecarMetadata(MediaItem mediaItem, string path); - Task RefreshSidecarMetadata(Show televisionShow, string showFolder); - Task RefreshFallbackMetadata(MediaItem mediaItem); - Task RefreshFallbackMetadata(Show televisionShow, string showFolder); + Task RefreshSidecarMetadata(MediaItem mediaItem, string path); + Task RefreshSidecarMetadata(Show televisionShow, string showFolder); + Task RefreshFallbackMetadata(MediaItem mediaItem); + Task RefreshFallbackMetadata(Show televisionShow, string showFolder); } } diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs index 1fce8a79c..315427fe6 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs @@ -6,6 +6,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata { public interface ILocalStatisticsProvider { - Task> RefreshStatistics(string ffprobePath, MediaItem mediaItem); + Task> RefreshStatistics(string ffprobePath, MediaItem mediaItem); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs index 961e57e51..ce0f89a23 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs @@ -15,6 +15,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories Task> GetLocalPaths(int libraryId); Task> GetPath(int libraryPathId); Task CountMediaItemsByPath(int libraryPathId); + Task> GetMediaIdsByLocalPath(int libraryPathId); Task DeleteLocalPath(int libraryPathId); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs index 5e28eefb2..6c829f2c7 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs @@ -24,7 +24,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories Task Update(PlexLibrary plexMediaSourceLibrary); Task Delete(int mediaSourceId); Task DeleteAllPlex(); - Task DisablePlexLibrarySync(List libraryIds); + Task> DisablePlexLibrarySync(List libraryIds); Task EnablePlexLibrarySync(IEnumerable libraryIds); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs index d4408f174..4497e96ce 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs @@ -6,11 +6,11 @@ namespace ErsatzTV.Core.Interfaces.Repositories { public interface IMetadataRepository { - Task RemoveGenre(Genre genre); + Task RemoveGenre(Genre genre); Task Update(Domain.Metadata metadata); Task Add(Domain.Metadata metadata); Task UpdateLocalStatistics(MediaVersion mediaVersion); - Task UpdatePlexStatistics(MediaVersion mediaVersion); + Task UpdatePlexStatistics(MediaVersion mediaVersion); Task UpdateArtworkPath(Artwork artwork); Task AddArtwork(Domain.Metadata metadata, Artwork artwork); Task RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind); diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs index 5ce15b2ba..636967a27 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Metadata; using LanguageExt; namespace ErsatzTV.Core.Interfaces.Repositories @@ -9,13 +10,14 @@ namespace ErsatzTV.Core.Interfaces.Repositories { Task AllMoviesExist(List movieIds); Task> GetMovie(int movieId); - Task> GetOrAdd(LibraryPath libraryPath, string path); - Task> GetOrAdd(PlexLibrary library, PlexMovie item); + Task>> GetOrAdd(LibraryPath libraryPath, string path); + Task>> GetOrAdd(PlexLibrary library, PlexMovie item); Task GetMovieCount(); Task> GetPagedMovies(int pageNumber, int pageSize); + Task> GetMoviesForCards(List ids); Task> FindMoviePaths(LibraryPath libraryPath); - Task DeleteByPath(LibraryPath libraryPath, string path); - Task AddGenre(MovieMetadata metadata, Genre genre); - Task RemoveMissingPlexMovies(PlexLibrary library, List movieKeys); + Task> DeleteByPath(LibraryPath libraryPath, string path); + Task AddGenre(MovieMetadata metadata, Genre genre); + Task> RemoveMissingPlexMovies(PlexLibrary library, List movieKeys); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/ISearchRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/ISearchRepository.cs index c9acb78ec..c26bffde2 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/ISearchRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/ISearchRepository.cs @@ -6,6 +6,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories { public interface ISearchRepository { + public Task> GetItemsToIndex(); public Task> SearchMediaItemsByTitle(string query); public Task> SearchMediaItemsByGenre(string genre); public Task> SearchMediaItemsByTag(string tag); diff --git a/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs index b5750cc60..03e72eba3 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Metadata; using LanguageExt; namespace ErsatzTV.Core.Interfaces.Repositories @@ -12,6 +13,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories Task> GetShow(int showId); Task GetShowCount(); Task> GetPagedShows(int pageNumber, int pageSize); + Task> GetShowsForCards(List ids); Task> GetShowItems(int showId); Task> GetAllSeasons(); Task> GetSeason(int seasonId); @@ -22,18 +24,23 @@ namespace ErsatzTV.Core.Interfaces.Repositories Task GetEpisodeCount(int seasonId); Task> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize); Task> GetShowByMetadata(int libraryPathId, ShowMetadata metadata); - Task> AddShow(int libraryPathId, string showFolder, ShowMetadata metadata); + + Task>> AddShow( + int libraryPathId, + string showFolder, + ShowMetadata metadata); + Task> GetOrAddSeason(Show show, int libraryPathId, int seasonNumber); Task> GetOrAddEpisode(Season season, LibraryPath libraryPath, string path); Task> FindEpisodePaths(LibraryPath libraryPath); Task DeleteByPath(LibraryPath libraryPath, string path); Task DeleteEmptySeasons(LibraryPath libraryPath); - Task DeleteEmptyShows(LibraryPath libraryPath); - Task> GetOrAddPlexShow(PlexLibrary library, PlexShow item); + Task> DeleteEmptyShows(LibraryPath libraryPath); + Task>> GetOrAddPlexShow(PlexLibrary library, PlexShow item); Task> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item); Task> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item); - Task AddGenre(ShowMetadata metadata, Genre genre); - Task RemoveMissingPlexShows(PlexLibrary library, List showKeys); + Task AddGenre(ShowMetadata metadata, Genre genre); + Task> RemoveMissingPlexShows(PlexLibrary library, List showKeys); Task RemoveMissingPlexSeasons(string showKey, List seasonKeys); Task RemoveMissingPlexEpisodes(string seasonKey, List episodeKeys); Task SetEpisodeNumber(Episode episode, int episodeNumber); diff --git a/ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs b/ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs new file mode 100644 index 000000000..d0b82b525 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs @@ -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 Initialize(); + Task Rebuild(List items); + Task AddItems(List items); + Task UpdateItems(List items); + Task RemoveItems(List ids); + Task Search(string query, int skip, int limit, string searchField = ""); + } +} diff --git a/ErsatzTV.Core/Metadata/LocalFileSystem.cs b/ErsatzTV.Core/Metadata/LocalFileSystem.cs index 25f892983..6b2c81201 100644 --- a/ErsatzTV.Core/Metadata/LocalFileSystem.cs +++ b/ErsatzTV.Core/Metadata/LocalFileSystem.cs @@ -11,6 +11,16 @@ namespace ErsatzTV.Core.Metadata { public class LocalFileSystem : ILocalFileSystem { + public Unit EnsureFolderExists(string folder) + { + if (!Directory.Exists(folder)) + { + Directory.CreateDirectory(folder); + } + + return Unit.Default; + } + public DateTime GetLastWriteTime(string path) => Try(File.GetLastWriteTimeUtc(path)).IfFail(() => DateTime.MinValue); diff --git a/ErsatzTV.Core/Metadata/LocalFolderScanner.cs b/ErsatzTV.Core/Metadata/LocalFolderScanner.cs index 6cc9a69cd..216344b71 100644 --- a/ErsatzTV.Core/Metadata/LocalFolderScanner.cs +++ b/ErsatzTV.Core/Metadata/LocalFolderScanner.cs @@ -66,12 +66,14 @@ namespace ErsatzTV.Core.Metadata _logger = logger; } - protected async Task> UpdateStatistics(T mediaItem, string ffprobePath) + protected async Task>> UpdateStatistics( + MediaItemScanResult mediaItem, + string ffprobePath) where T : MediaItem { try { - MediaVersion version = mediaItem switch + MediaVersion version = mediaItem.Item switch { Movie m => m.MediaVersions.Head(), Episode e => e.MediaVersions.Head(), @@ -83,9 +85,16 @@ namespace ErsatzTV.Core.Metadata if (version.DateUpdated < _localFileSystem.GetLastWriteTime(path)) { _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path); - Either refreshResult = - await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem); - refreshResult.IfLeft( + Either refreshResult = + await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem.Item); + refreshResult.Match( + result => + { + if (result) + { + mediaItem.IsUpdated = true; + } + }, error => _logger.LogWarning( "Unable to refresh {Attribute} for media item {Path}. Error: {Error}", diff --git a/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs b/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs index f2ba13f09..98a03cb0d 100644 --- a/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs +++ b/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs @@ -22,19 +22,16 @@ namespace ErsatzTV.Core.Metadata private readonly ILocalFileSystem _localFileSystem; private readonly ILogger _logger; - private readonly IMediaItemRepository _mediaItemRepository; private readonly IMetadataRepository _metadataRepository; private readonly ITelevisionRepository _televisionRepository; public LocalMetadataProvider( - IMediaItemRepository mediaItemRepository, IMetadataRepository metadataRepository, ITelevisionRepository televisionRepository, IFallbackMetadataProvider fallbackMetadataProvider, ILocalFileSystem localFileSystem, ILogger logger) { - _mediaItemRepository = mediaItemRepository; _metadataRepository = metadataRepository; _televisionRepository = televisionRepository; _fallbackMetadataProvider = fallbackMetadataProvider; @@ -65,40 +62,47 @@ namespace ErsatzTV.Core.Metadata }); } - public Task RefreshSidecarMetadata(MediaItem mediaItem, string path) => + public Task RefreshSidecarMetadata(MediaItem mediaItem, string path) => mediaItem switch { Episode e => LoadMetadata(e, path) - .Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(e, metadata))), + .Bind( + maybeMetadata => maybeMetadata.Match( + metadata => ApplyMetadataUpdate(e, metadata), + () => Task.FromResult(false))), Movie m => LoadMetadata(m, path) - .Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(m, metadata))), - _ => Task.FromResult(Unit.Default) + .Bind( + maybeMetadata => maybeMetadata.Match( + metadata => ApplyMetadataUpdate(m, metadata), + () => Task.FromResult(false))), + _ => Task.FromResult(false) }; - public Task RefreshSidecarMetadata(Show televisionShow, string showFolder) => + public Task RefreshSidecarMetadata(Show televisionShow, string showFolder) => LoadMetadata(televisionShow, showFolder).Bind( - maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(televisionShow, metadata))); + maybeMetadata => maybeMetadata.Match( + metadata => ApplyMetadataUpdate(televisionShow, metadata), + () => Task.FromResult(false))); - public Task RefreshFallbackMetadata(MediaItem mediaItem) => + public Task RefreshFallbackMetadata(MediaItem mediaItem) => mediaItem switch { - Episode e => ApplyMetadataUpdate(e, _fallbackMetadataProvider.GetFallbackMetadata(e)) - .ToUnit(), - Movie m => ApplyMetadataUpdate(m, _fallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(), - _ => Task.FromResult(Unit.Default) + Episode e => ApplyMetadataUpdate(e, _fallbackMetadataProvider.GetFallbackMetadata(e)), + Movie m => ApplyMetadataUpdate(m, _fallbackMetadataProvider.GetFallbackMetadata(m)), + _ => Task.FromResult(false) }; - public Task RefreshFallbackMetadata(Show televisionShow, string showFolder) => - ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder)) - .ToUnit(); + public Task RefreshFallbackMetadata(Show televisionShow, string showFolder) => + ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder)); - private async Task ApplyMetadataUpdate(Episode episode, Tuple metadataEpisodeNumber) + private async Task ApplyMetadataUpdate(Episode episode, Tuple metadataEpisodeNumber) { (EpisodeMetadata metadata, int episodeNumber) = metadataEpisodeNumber; if (episode.EpisodeNumber != episodeNumber) { await _televisionRepository.SetEpisodeNumber(episode, episodeNumber); } + await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match( existing => { @@ -128,9 +132,11 @@ namespace ErsatzTV.Core.Metadata return _metadataRepository.Add(metadata); }); + + return true; } - private Task ApplyMetadataUpdate(Movie movie, MovieMetadata metadata) => + private Task ApplyMetadataUpdate(Movie movie, MovieMetadata metadata) => Optional(movie.MovieMetadata).Flatten().HeadOrNone().Match( existing => { @@ -185,7 +191,7 @@ namespace ErsatzTV.Core.Metadata return _metadataRepository.Add(metadata); }); - private Task ApplyMetadataUpdate(Show show, ShowMetadata metadata) => + private Task ApplyMetadataUpdate(Show show, ShowMetadata metadata) => Optional(show.ShowMetadata).Flatten().HeadOrNone().Match( existing => { diff --git a/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs b/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs index 8eacf5003..f5b92cb68 100644 --- a/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs +++ b/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs @@ -17,22 +17,19 @@ namespace ErsatzTV.Core.Metadata { private readonly ILocalFileSystem _localFileSystem; private readonly ILogger _logger; - private readonly IMediaItemRepository _mediaItemRepository; private readonly IMetadataRepository _metadataRepository; public LocalStatisticsProvider( - IMediaItemRepository mediaItemRepository, IMetadataRepository metadataRepository, ILocalFileSystem localFileSystem, ILogger logger) { - _mediaItemRepository = mediaItemRepository; _metadataRepository = metadataRepository; _localFileSystem = localFileSystem; _logger = logger; } - public async Task> RefreshStatistics(string ffprobePath, MediaItem mediaItem) + public async Task> RefreshStatistics(string ffprobePath, MediaItem mediaItem) { try { @@ -48,10 +45,10 @@ namespace ErsatzTV.Core.Metadata async ffprobe => { MediaVersion version = ProjectToMediaVersion(ffprobe); - await ApplyVersionUpdate(mediaItem, version, filePath); - return Right(Unit.Default); + bool result = await ApplyVersionUpdate(mediaItem, version, filePath); + return Right(result); }, - error => Task.FromResult(Left(error))); + error => Task.FromResult(Left(error))); } catch (Exception ex) { diff --git a/ErsatzTV.Core/Metadata/MediaItemScanResult.cs b/ErsatzTV.Core/Metadata/MediaItemScanResult.cs new file mode 100644 index 000000000..42706f587 --- /dev/null +++ b/ErsatzTV.Core/Metadata/MediaItemScanResult.cs @@ -0,0 +1,14 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Metadata +{ + public class MediaItemScanResult where T : MediaItem + { + public MediaItemScanResult(T item) => Item = item; + + public T Item { get; } + + public bool IsAdded { get; set; } + public bool IsUpdated { get; set; } + } +} diff --git a/ErsatzTV.Core/Metadata/MovieFolderScanner.cs b/ErsatzTV.Core/Metadata/MovieFolderScanner.cs index 2afdd23d1..cec6ced14 100644 --- a/ErsatzTV.Core/Metadata/MovieFolderScanner.cs +++ b/ErsatzTV.Core/Metadata/MovieFolderScanner.cs @@ -8,6 +8,7 @@ using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Search; using LanguageExt; using Microsoft.Extensions.Logging; using static LanguageExt.Prelude; @@ -21,6 +22,7 @@ namespace ErsatzTV.Core.Metadata private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILogger _logger; private readonly IMovieRepository _movieRepository; + private readonly ISearchIndex _searchIndex; public MovieFolderScanner( ILocalFileSystem localFileSystem, @@ -29,12 +31,14 @@ namespace ErsatzTV.Core.Metadata ILocalMetadataProvider localMetadataProvider, IMetadataRepository metadataRepository, IImageCache imageCache, + ISearchIndex searchIndex, ILogger logger) : base(localFileSystem, localStatisticsProvider, metadataRepository, imageCache, logger) { _localFileSystem = localFileSystem; _movieRepository = movieRepository; _localMetadataProvider = localMetadataProvider; + _searchIndex = searchIndex; _logger = logger; } @@ -72,19 +76,33 @@ namespace ErsatzTV.Core.Metadata continue; } - foreach (string file in allFiles.OrderBy(identity)) { // TODO: figure out how to rebuild playlists - Either maybeMovie = await _movieRepository + Either> maybeMovie = await _movieRepository .GetOrAdd(libraryPath, file) - .BindT(movie => UpdateStatistics(movie, ffprobePath).MapT(_ => movie)) + .BindT(movie => UpdateStatistics(movie, ffprobePath)) .BindT(UpdateMetadata) .BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster)) .BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt)); - maybeMovie.IfLeft( - error => _logger.LogWarning("Error processing movie at {Path}: {Error}", file, error.Value)); + await maybeMovie.Match( + async result => + { + if (result.IsAdded) + { + await _searchIndex.AddItems(new List { result.Item }); + } + else if (result.IsUpdated) + { + await _searchIndex.UpdateItems(new List { result.Item }); + } + }, + error => + { + _logger.LogWarning("Error processing movie at {Path}: {Error}", file, error.Value); + return Task.CompletedTask; + }); } } @@ -93,17 +111,20 @@ namespace ErsatzTV.Core.Metadata if (!_localFileSystem.FileExists(path)) { _logger.LogInformation("Removing missing movie at {Path}", path); - await _movieRepository.DeleteByPath(libraryPath, path); + List ids = await _movieRepository.DeleteByPath(libraryPath, path); + await _searchIndex.RemoveItems(ids); } } return Unit.Default; } - private async Task> UpdateMetadata(Movie movie) + private async Task>> UpdateMetadata( + MediaItemScanResult result) { try { + Movie movie = result.Item; await LocateNfoFile(movie).Match( async nfoFile => { @@ -115,7 +136,10 @@ namespace ErsatzTV.Core.Metadata if (shouldUpdate) { _logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile); - await _localMetadataProvider.RefreshSidecarMetadata(movie, nfoFile); + if (await _localMetadataProvider.RefreshSidecarMetadata(movie, nfoFile)) + { + result.IsUpdated = true; + } } }, async () => @@ -124,11 +148,14 @@ namespace ErsatzTV.Core.Metadata { string path = movie.MediaVersions.Head().MediaFiles.Head().Path; _logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path); - await _localMetadataProvider.RefreshFallbackMetadata(movie); + if (await _localMetadataProvider.RefreshFallbackMetadata(movie)) + { + result.IsUpdated = true; + } } }); - return movie; + return result; } catch (Exception ex) { @@ -136,10 +163,13 @@ namespace ErsatzTV.Core.Metadata } } - private async Task> UpdateArtwork(Movie movie, ArtworkKind artworkKind) + private async Task>> UpdateArtwork( + MediaItemScanResult result, + ArtworkKind artworkKind) { try { + Movie movie = result.Item; await LocateArtwork(movie, artworkKind).IfSomeAsync( async posterFile => { @@ -147,7 +177,7 @@ namespace ErsatzTV.Core.Metadata await RefreshArtwork(posterFile, metadata, artworkKind); }); - return movie; + return result; } catch (Exception ex) { diff --git a/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs b/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs index c8b97a2df..2abbc3b59 100644 --- a/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs +++ b/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs @@ -8,6 +8,7 @@ using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Search; using LanguageExt; using Microsoft.Extensions.Logging; using static LanguageExt.Prelude; @@ -19,6 +20,7 @@ namespace ErsatzTV.Core.Metadata private readonly ILocalFileSystem _localFileSystem; private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILogger _logger; + private readonly ISearchIndex _searchIndex; private readonly ITelevisionRepository _televisionRepository; public TelevisionFolderScanner( @@ -28,6 +30,7 @@ namespace ErsatzTV.Core.Metadata ILocalMetadataProvider localMetadataProvider, IMetadataRepository metadataRepository, IImageCache imageCache, + ISearchIndex searchIndex, ILogger logger) : base( localFileSystem, localStatisticsProvider, @@ -38,6 +41,7 @@ namespace ErsatzTV.Core.Metadata _localFileSystem = localFileSystem; _televisionRepository = televisionRepository; _localMetadataProvider = localMetadataProvider; + _searchIndex = searchIndex; _logger = logger; } @@ -55,14 +59,26 @@ namespace ErsatzTV.Core.Metadata foreach (string showFolder in allShowFolders) { - Either maybeShow = + Either> maybeShow = await FindOrCreateShow(libraryPath.Id, showFolder) .BindT(show => UpdateMetadataForShow(show, showFolder)) .BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Poster)) .BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.FanArt)); await maybeShow.Match( - show => ScanSeasons(libraryPath, ffprobePath, show, showFolder), + async result => + { + if (result.IsAdded) + { + await _searchIndex.AddItems(new List { result.Item }); + } + else if (result.IsUpdated) + { + await _searchIndex.UpdateItems(new List { result.Item }); + } + + await ScanSeasons(libraryPath, ffprobePath, result.Item, showFolder); + }, _ => Task.FromResult(Unit.Default)); } @@ -76,19 +92,20 @@ namespace ErsatzTV.Core.Metadata } await _televisionRepository.DeleteEmptySeasons(libraryPath); - await _televisionRepository.DeleteEmptyShows(libraryPath); + List ids = await _televisionRepository.DeleteEmptyShows(libraryPath); + await _searchIndex.RemoveItems(ids); return Unit.Default; } - private async Task> FindOrCreateShow( + private async Task>> FindOrCreateShow( int libraryPathId, string showFolder) { ShowMetadata metadata = await _localMetadataProvider.GetMetadataForShow(showFolder); Option maybeShow = await _televisionRepository.GetShowByMetadata(libraryPathId, metadata); return await maybeShow.Match( - show => Right(show).AsTask(), + show => Right>(new MediaItemScanResult(show)).AsTask(), async () => await _televisionRepository.AddShow(libraryPathId, showFolder, metadata)); } @@ -130,7 +147,9 @@ namespace ErsatzTV.Core.Metadata // TODO: figure out how to rebuild playlists Either maybeEpisode = await _televisionRepository .GetOrAddEpisode(season, libraryPath, file) - .BindT(episode => UpdateStatistics(episode, ffprobePath).MapT(_ => episode)) + .BindT( + episode => UpdateStatistics(new MediaItemScanResult(episode), ffprobePath) + .MapT(_ => episode)) .BindT(UpdateMetadata) .BindT(UpdateThumbnail); @@ -141,12 +160,13 @@ namespace ErsatzTV.Core.Metadata return Unit.Default; } - private async Task> UpdateMetadataForShow( - Show show, + private async Task>> UpdateMetadataForShow( + MediaItemScanResult result, string showFolder) { try { + Show show = result.Item; await LocateNfoFileForShow(showFolder).Match( async nfoFile => { @@ -158,7 +178,10 @@ namespace ErsatzTV.Core.Metadata if (shouldUpdate) { _logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile); - await _localMetadataProvider.RefreshSidecarMetadata(show, nfoFile); + if (await _localMetadataProvider.RefreshSidecarMetadata(show, nfoFile)) + { + result.IsUpdated = true; + } } }, async () => @@ -166,11 +189,14 @@ namespace ErsatzTV.Core.Metadata if (!Optional(show.ShowMetadata).Flatten().Any()) { _logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", showFolder); - await _localMetadataProvider.RefreshFallbackMetadata(show, showFolder); + if (await _localMetadataProvider.RefreshFallbackMetadata(show, showFolder)) + { + result.IsUpdated = true; + } } }); - return show; + return result; } catch (Exception ex) { @@ -215,13 +241,14 @@ namespace ErsatzTV.Core.Metadata } } - private async Task> UpdateArtworkForShow( - Show show, + private async Task>> UpdateArtworkForShow( + MediaItemScanResult result, string showFolder, ArtworkKind artworkKind) { try { + Show show = result.Item; await LocateArtworkForShow(showFolder, artworkKind).IfSomeAsync( async posterFile => { @@ -229,7 +256,7 @@ namespace ErsatzTV.Core.Metadata await RefreshArtwork(posterFile, metadata, artworkKind); }); - return show; + return result; } catch (Exception ex) { diff --git a/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs b/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs index 8e2c25b36..bbc8e6b75 100644 --- a/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs +++ b/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs @@ -4,9 +4,10 @@ using System.Threading.Tasks; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Search; +using ErsatzTV.Core.Metadata; using LanguageExt; using Microsoft.Extensions.Logging; -using static LanguageExt.Prelude; namespace ErsatzTV.Core.Plex { @@ -16,17 +17,20 @@ namespace ErsatzTV.Core.Plex private readonly IMetadataRepository _metadataRepository; private readonly IMovieRepository _movieRepository; private readonly IPlexServerApiClient _plexServerApiClient; + private readonly ISearchIndex _searchIndex; public PlexMovieLibraryScanner( IPlexServerApiClient plexServerApiClient, IMovieRepository movieRepository, IMetadataRepository metadataRepository, + ISearchIndex searchIndex, ILogger logger) : base(metadataRepository) { _plexServerApiClient = plexServerApiClient; _movieRepository = movieRepository; _metadataRepository = metadataRepository; + _searchIndex = searchIndex; _logger = logger; } @@ -46,21 +50,37 @@ namespace ErsatzTV.Core.Plex foreach (PlexMovie incoming in movieEntries) { // TODO: figure out how to rebuild playlists - Either maybeMovie = await _movieRepository + Either> maybeMovie = await _movieRepository .GetOrAdd(plexMediaSourceLibrary, incoming) .BindT(existing => UpdateStatistics(existing, incoming, connection, token)) .BindT(existing => UpdateMetadata(existing, incoming)) .BindT(existing => UpdateArtwork(existing, incoming)); - maybeMovie.IfLeft( - error => _logger.LogWarning( - "Error processing plex movie at {Key}: {Error}", - incoming.Key, - error.Value)); + await maybeMovie.Match( + async result => + { + if (result.IsAdded) + { + await _searchIndex.AddItems(new List { result.Item }); + } + else if (result.IsUpdated) + { + await _searchIndex.UpdateItems(new List { result.Item }); + } + }, + error => + { + _logger.LogWarning( + "Error processing plex movie at {Key}: {Error}", + incoming.Key, + error.Value); + return Task.CompletedTask; + }); } var movieKeys = movieEntries.Map(s => s.Key).ToList(); - await _movieRepository.RemoveMissingPlexMovies(plexMediaSourceLibrary, movieKeys); + List ids = await _movieRepository.RemoveMissingPlexMovies(plexMediaSourceLibrary, movieKeys); + await _searchIndex.RemoveItems(ids); }, error => { @@ -75,12 +95,13 @@ namespace ErsatzTV.Core.Plex return Unit.Default; } - private async Task> UpdateStatistics( - PlexMovie existing, + private async Task>> UpdateStatistics( + MediaItemScanResult result, PlexMovie incoming, PlexConnection connection, PlexServerAuthToken token) { + PlexMovie existing = result.Item; MediaVersion existingVersion = existing.MediaVersions.Head(); MediaVersion incomingVersion = incoming.MediaVersions.Head(); @@ -102,11 +123,14 @@ namespace ErsatzTV.Core.Plex _ => Task.CompletedTask); } - return Right(existing); + return result; } - private async Task> UpdateMetadata(PlexMovie existing, PlexMovie incoming) + private async Task>> UpdateMetadata( + MediaItemScanResult result, + PlexMovie incoming) { + PlexMovie existing = result.Item; MovieMetadata existingMetadata = existing.MovieMetadata.Head(); MovieMetadata incomingMetadata = incoming.MovieMetadata.Head(); @@ -117,7 +141,10 @@ namespace ErsatzTV.Core.Plex .ToList()) { existingMetadata.Genres.Remove(genre); - await _metadataRepository.RemoveGenre(genre); + if (await _metadataRepository.RemoveGenre(genre)) + { + result.IsUpdated = true; + } } foreach (Genre genre in incomingMetadata.Genres @@ -125,17 +152,23 @@ namespace ErsatzTV.Core.Plex .ToList()) { existingMetadata.Genres.Add(genre); - await _movieRepository.AddGenre(existingMetadata, genre); + if (await _movieRepository.AddGenre(existingMetadata, genre)) + { + result.IsUpdated = true; + } } - + // TODO: update other metadata? } - return existing; + return result; } - private async Task> UpdateArtwork(PlexMovie existing, PlexMovie incoming) + private async Task>> UpdateArtwork( + MediaItemScanResult result, + PlexMovie incoming) { + PlexMovie existing = result.Item; MovieMetadata existingMetadata = existing.MovieMetadata.Head(); MovieMetadata incomingMetadata = incoming.MovieMetadata.Head(); @@ -145,7 +178,7 @@ namespace ErsatzTV.Core.Plex await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt); } - return existing; + return result; } } } diff --git a/ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs b/ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs index d5de30869..b16c8d16e 100644 --- a/ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs +++ b/ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs @@ -4,6 +4,8 @@ using System.Threading.Tasks; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Search; +using ErsatzTV.Core.Metadata; using LanguageExt; using Microsoft.Extensions.Logging; using static LanguageExt.Prelude; @@ -15,18 +17,21 @@ namespace ErsatzTV.Core.Plex private readonly ILogger _logger; private readonly IMetadataRepository _metadataRepository; private readonly IPlexServerApiClient _plexServerApiClient; + private readonly ISearchIndex _searchIndex; private readonly ITelevisionRepository _televisionRepository; public PlexTelevisionLibraryScanner( IPlexServerApiClient plexServerApiClient, ITelevisionRepository televisionRepository, IMetadataRepository metadataRepository, + ISearchIndex searchIndex, ILogger logger) : base(metadataRepository) { _plexServerApiClient = plexServerApiClient; _televisionRepository = televisionRepository; _metadataRepository = metadataRepository; + _searchIndex = searchIndex; _logger = logger; } @@ -46,13 +51,25 @@ namespace ErsatzTV.Core.Plex foreach (PlexShow incoming in showEntries) { // TODO: figure out how to rebuild playlists - Either maybeShow = await _televisionRepository + Either> maybeShow = await _televisionRepository .GetOrAddPlexShow(plexMediaSourceLibrary, incoming) .BindT(existing => UpdateMetadata(existing, incoming)) .BindT(existing => UpdateArtwork(existing, incoming)); await maybeShow.Match( - async show => await ScanSeasons(plexMediaSourceLibrary, show, connection, token), + async result => + { + if (result.IsAdded) + { + await _searchIndex.AddItems(new List { result.Item }); + } + else if (result.IsUpdated) + { + await _searchIndex.UpdateItems(new List { result.Item }); + } + + await ScanSeasons(plexMediaSourceLibrary, result.Item, connection, token); + }, error => { _logger.LogWarning( @@ -64,7 +81,9 @@ namespace ErsatzTV.Core.Plex } var showKeys = showEntries.Map(s => s.Key).ToList(); - await _televisionRepository.RemoveMissingPlexShows(plexMediaSourceLibrary, showKeys); + List ids = + await _televisionRepository.RemoveMissingPlexShows(plexMediaSourceLibrary, showKeys); + await _searchIndex.RemoveItems(ids); return Unit.Default; }, @@ -79,8 +98,11 @@ namespace ErsatzTV.Core.Plex }); } - private Task> UpdateMetadata(PlexShow existing, PlexShow incoming) + private async Task>> UpdateMetadata( + MediaItemScanResult result, + PlexShow incoming) { + PlexShow existing = result.Item; ShowMetadata existingMetadata = existing.ShowMetadata.Head(); ShowMetadata incomingMetadata = incoming.ShowMetadata.Head(); @@ -93,7 +115,10 @@ namespace ErsatzTV.Core.Plex .ToList()) { existingMetadata.Genres.Remove(genre); - _metadataRepository.RemoveGenre(genre); + if (await _metadataRepository.RemoveGenre(genre)) + { + result.IsUpdated = true; + } } foreach (Genre genre in incomingMetadata.Genres @@ -101,15 +126,21 @@ namespace ErsatzTV.Core.Plex .ToList()) { existingMetadata.Genres.Add(genre); - _televisionRepository.AddGenre(existingMetadata, genre); + if (await _televisionRepository.AddGenre(existingMetadata, genre)) + { + result.IsUpdated = true; + } } } - return Right(existing).AsTask(); + return result; } - private async Task> UpdateArtwork(PlexShow existing, PlexShow incoming) + private async Task>> UpdateArtwork( + MediaItemScanResult result, + PlexShow incoming) { + PlexShow existing = result.Item; ShowMetadata existingMetadata = existing.ShowMetadata.Head(); ShowMetadata incomingMetadata = incoming.ShowMetadata.Head(); @@ -119,7 +150,7 @@ namespace ErsatzTV.Core.Plex await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt); } - return existing; + return result; } private async Task> ScanSeasons( diff --git a/ErsatzTV.Core/Search/SearchItem.cs b/ErsatzTV.Core/Search/SearchItem.cs new file mode 100644 index 000000000..71ff0a13c --- /dev/null +++ b/ErsatzTV.Core/Search/SearchItem.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Core.Search +{ + public record SearchItem(int Id); +} diff --git a/ErsatzTV.Core/Search/SearchPageMap.cs b/ErsatzTV.Core/Search/SearchPageMap.cs new file mode 100644 index 000000000..51b60bc0c --- /dev/null +++ b/ErsatzTV.Core/Search/SearchPageMap.cs @@ -0,0 +1,6 @@ +using System.Collections.Generic; + +namespace ErsatzTV.Core.Search +{ + public record SearchPageMap(Dictionary PageMap); +} diff --git a/ErsatzTV.Core/Search/SearchResult.cs b/ErsatzTV.Core/Search/SearchResult.cs new file mode 100644 index 000000000..fceca199f --- /dev/null +++ b/ErsatzTV.Core/Search/SearchResult.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using LanguageExt; + +namespace ErsatzTV.Core.Search +{ + public record SearchResult(List Items, int TotalCount) + { + public Option PageMap { get; set; } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs index 0fb0a901d..efdc9a8c7 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs @@ -84,6 +84,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @"SELECT COUNT(*) FROM MediaItem WHERE LibraryPathId = @LibraryPathId", new { LibraryPathId = libraryPathId }); + public Task> GetMediaIdsByLocalPath(int libraryPathId) => + _dbConnection.QueryAsync( + @"SELECT Id FROM MediaItem WHERE LibraryPathId = @LibraryPathId", + new { LibraryPathId = libraryPathId }) + .Map(result => result.ToList()); + public async Task DeleteLocalPath(int libraryPathId) { await using TvContext context = _dbContextFactory.CreateDbContext(); diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs index 6c7069aa3..5325600ef 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs @@ -112,8 +112,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories int? id = await _dbConnection.QuerySingleAsync( @"SELECT L.MediaSourceId FROM Library L INNER JOIN PlexLibrary PL on L.Id = PL.Id - WHERE L.Id = @PlexLibraryId", new { PlexLibraryId = plexLibraryId }); - + WHERE L.Id = @PlexLibraryId", + new { PlexLibraryId = plexLibraryId }); + await using TvContext context = _dbContextFactory.CreateDbContext(); return await context.PlexMediaSources .Include(p => p.Connections) @@ -176,18 +177,18 @@ namespace ErsatzTV.Infrastructure.Data.Repositories public async Task DeleteAllPlex() { await using TvContext context = _dbContextFactory.CreateDbContext(); - + List allMediaSources = await context.PlexMediaSources.ToListAsync(); context.PlexMediaSources.RemoveRange(allMediaSources); - + List allPlexLibraries = await context.PlexLibraries.ToListAsync(); context.PlexLibraries.RemoveRange(allPlexLibraries); - + await context.SaveChangesAsync(); return Unit.Default; } - public async Task DisablePlexLibrarySync(List libraryIds) + public async Task> DisablePlexLibrarySync(List libraryIds) { await _dbConnection.ExecuteAsync( "UPDATE PlexLibrary SET ShouldSyncItems = 0 WHERE Id IN @ids", @@ -197,6 +198,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories "UPDATE Library SET LastScan = null WHERE Id IN @ids", new { ids = libraryIds }); + List movieIds = await _dbConnection.QueryAsync( + @"SELECT m.Id FROM MediaItem m + INNER JOIN PlexMovie pm ON pm.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId + INNER JOIN Library l ON l.Id = lp.LibraryId + WHERE l.Id IN @ids", + new { ids = libraryIds }).Map(result => result.ToList()); + await _dbConnection.ExecuteAsync( @"DELETE FROM MediaItem WHERE Id IN (SELECT m.Id FROM MediaItem m @@ -224,6 +233,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories WHERE l.Id IN @ids)", new { ids = libraryIds }); + List showIds = await _dbConnection.QueryAsync( + @"SELECT m.Id FROM MediaItem m + INNER JOIN PlexShow ps ON ps.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId + INNER JOIN Library l ON l.Id = lp.LibraryId + WHERE l.Id IN @ids", + new { ids = libraryIds }).Map(result => result.ToList()); + await _dbConnection.ExecuteAsync( @"DELETE FROM MediaItem WHERE Id IN (SELECT m.Id FROM MediaItem m @@ -232,6 +249,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories INNER JOIN Library l ON l.Id = lp.LibraryId WHERE l.Id IN @ids)", new { ids = libraryIds }); + + return movieIds.Append(showIds).ToList(); } public Task EnablePlexLibrarySync(IEnumerable libraryIds) => diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs index 42518bf89..e3b984a5c 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs @@ -19,9 +19,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories _dbConnection = dbConnection; } - public Task RemoveGenre(Genre genre) => - _dbConnection.ExecuteAsync("DELETE FROM Genre WHERE Id = @GenreId", new { GenreId = genre.Id }).ToUnit(); - public async Task Update(Metadata metadata) { await using TvContext dbContext = _dbContextFactory.CreateDbContext(); @@ -53,7 +50,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories return await dbContext.SaveChangesAsync() > 0; } - public Task UpdatePlexStatistics(MediaVersion mediaVersion) => + public Task UpdatePlexStatistics(MediaVersion mediaVersion) => _dbConnection.ExecuteAsync( @"UPDATE MediaVersion SET SampleAspectRatio = @SampleAspectRatio, @@ -66,7 +63,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories mediaVersion.VideoScanKind, mediaVersion.DateUpdated, MediaVersionId = mediaVersion.Id - }).ToUnit(); + }).Map(result => result > 0); public Task UpdateArtworkPath(Artwork artwork) => _dbConnection.ExecuteAsync( @@ -111,5 +108,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @"DELETE FROM Artwork WHERE ArtworkKind = @ArtworkKind AND (MovieMetadataId = @Id OR ShowMetadataId = @Id OR SeasonMetadataId = @Id OR EpisodeMetadataId = @Id)", new { ArtworkKind = artworkKind, metadata.Id }).ToUnit(); + + public Task RemoveGenre(Genre genre) => + _dbConnection.ExecuteAsync("DELETE FROM Genre WHERE Id = @GenreId", new { GenreId = genre.Id }) + .Map(result => result > 0); } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs index d979996d0..1fe0fb51a 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs @@ -7,6 +7,7 @@ using Dapper; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Metadata; using LanguageExt; using Microsoft.EntityFrameworkCore; using static LanguageExt.Prelude; @@ -45,7 +46,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .Map(Optional); } - public async Task> GetOrAdd(LibraryPath libraryPath, string path) + public async Task>> GetOrAdd(LibraryPath libraryPath, string path) { await using TvContext dbContext = _dbContextFactory.CreateDbContext(); Option maybeExisting = await dbContext.Movies @@ -56,17 +57,22 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .Include(i => i.MovieMetadata) .ThenInclude(mm => mm.Tags) .Include(i => i.LibraryPath) + .ThenInclude(lp => lp.Library) .Include(i => i.MediaVersions) .ThenInclude(mv => mv.MediaFiles) .OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path) .SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path); return await maybeExisting.Match( - mediaItem => Right(mediaItem).AsTask(), + mediaItem => + Right>( + new MediaItemScanResult(mediaItem) { IsAdded = false }).AsTask(), async () => await AddMovie(dbContext, libraryPath.Id, path)); } - public async Task> GetOrAdd(PlexLibrary library, PlexMovie item) + public async Task>> GetOrAdd( + PlexLibrary library, + PlexMovie item) { await using TvContext context = _dbContextFactory.CreateDbContext(); Option maybeExisting = await context.PlexMovies @@ -74,14 +80,20 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .Include(i => i.MovieMetadata) .ThenInclude(mm => mm.Genres) .Include(i => i.MovieMetadata) + .ThenInclude(mm => mm.Tags) + .Include(i => i.MovieMetadata) .ThenInclude(mm => mm.Artwork) .Include(i => i.MediaVersions) .ThenInclude(mv => mv.MediaFiles) + .Include(i => i.LibraryPath) + .ThenInclude(lp => lp.Library) .OrderBy(i => i.Key) .SingleOrDefaultAsync(i => i.Key == item.Key); return await maybeExisting.Match( - plexMovie => Right(plexMovie).AsTask(), + plexMovie => + Right>( + new MediaItemScanResult(plexMovie) { IsAdded = true }).AsTask(), async () => await AddPlexMovie(context, library, item)); } @@ -104,6 +116,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .ToListAsync(); } + public async Task> GetMoviesForCards(List ids) + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + return await dbContext.MovieMetadata + .AsNoTracking() + .Filter(mm => ids.Contains(mm.MovieId)) + .Include(mm => mm.Artwork) + .OrderBy(mm => mm.SortTitle) + .ToListAsync(); + } + public Task> FindMoviePaths(LibraryPath libraryPath) => _dbConnection.QueryAsync( @"SELECT MF.Path @@ -114,17 +137,18 @@ namespace ErsatzTV.Infrastructure.Data.Repositories WHERE MI.LibraryPathId = @LibraryPathId", new { LibraryPathId = libraryPath.Id }); - public async Task DeleteByPath(LibraryPath libraryPath, string path) + public async Task> DeleteByPath(LibraryPath libraryPath, string path) { await using TvContext dbContext = _dbContextFactory.CreateDbContext(); - IEnumerable ids = await _dbConnection.QueryAsync( - @"SELECT M.Id + List ids = await _dbConnection.QueryAsync( + @"SELECT M.Id FROM Movie M INNER JOIN MediaItem MI on M.Id = MI.Id INNER JOIN MediaVersion MV on M.Id = MV.MovieId INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path", - new { LibraryPathId = libraryPath.Id, Path = path }); + new { LibraryPathId = libraryPath.Id, Path = path }) + .Map(result => result.ToList()); foreach (int movieId in ids) { @@ -132,26 +156,36 @@ namespace ErsatzTV.Infrastructure.Data.Repositories dbContext.Movies.Remove(movie); } - await dbContext.SaveChangesAsync(); - - return Unit.Default; + bool changed = await dbContext.SaveChangesAsync() > 0; + return changed ? ids : new List(); } - public Task AddGenre(MovieMetadata metadata, Genre genre) => + public Task AddGenre(MovieMetadata metadata, Genre genre) => _dbConnection.ExecuteAsync( "INSERT INTO Genre (Name, MovieMetadataId) VALUES (@Name, @MetadataId)", - new { genre.Name, MetadataId = metadata.Id }).ToUnit(); + new { genre.Name, MetadataId = metadata.Id }).Map(result => result > 0); - public Task RemoveMissingPlexMovies(PlexLibrary library, List movieKeys) => - _dbConnection.ExecuteAsync( + public async Task> RemoveMissingPlexMovies(PlexLibrary library, List movieKeys) + { + List ids = await _dbConnection.QueryAsync( + @"SELECT m.Id FROM MediaItem m + INNER JOIN PlexMovie pm ON pm.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId + WHERE lp.LibraryId = @LibraryId AND pm.Key not in @Keys", + new { LibraryId = library.Id, Keys = movieKeys }).Map(result => result.ToList()); + + await _dbConnection.ExecuteAsync( @"DELETE FROM MediaItem WHERE Id IN (SELECT m.Id FROM MediaItem m INNER JOIN PlexMovie pm ON pm.Id = m.Id INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId WHERE lp.LibraryId = @LibraryId AND pm.Key not in @Keys)", - new { LibraryId = library.Id, Keys = movieKeys }).ToUnit(); + new { LibraryId = library.Id, Keys = movieKeys }); + + return ids; + } - private static async Task> AddMovie( + private static async Task>> AddMovie( TvContext dbContext, int libraryPathId, string path) @@ -175,7 +209,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories await dbContext.Movies.AddAsync(movie); await dbContext.SaveChangesAsync(); await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync(); - return movie; + await dbContext.Entry(movie.LibraryPath).Reference(lp => lp.Library).LoadAsync(); + return new MediaItemScanResult(movie) { IsAdded = true }; } catch (Exception ex) { @@ -183,7 +218,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories } } - private async Task> AddPlexMovie( + private async Task>> AddPlexMovie( TvContext context, PlexLibrary library, PlexMovie item) @@ -195,7 +230,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories await context.PlexMovies.AddAsync(item); await context.SaveChangesAsync(); await context.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); - return item; + await context.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); + return new MediaItemScanResult(item) { IsAdded = true }; } catch (Exception ex) { diff --git a/ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs index 44206411c..181b76f30 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs @@ -21,6 +21,24 @@ namespace ErsatzTV.Infrastructure.Data.Repositories _dbConnection = dbConnection; } + public async Task> GetItemsToIndex() + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + return await dbContext.MediaItems + .AsNoTracking() + .Include(mi => mi.LibraryPath) + .ThenInclude(lp => lp.Library) + .Include(mi => (mi as Movie).MovieMetadata) + .ThenInclude(mm => mm.Genres) + .Include(mi => (mi as Movie).MovieMetadata) + .ThenInclude(mm => mm.Tags) + .Include(mi => (mi as Show).ShowMetadata) + .ThenInclude(mm => mm.Genres) + .Include(mi => (mi as Show).ShowMetadata) + .ThenInclude(mm => mm.Tags) + .ToListAsync(); + } + public async Task> SearchMediaItemsByTitle(string query) { List ids = await _dbConnection.QueryAsync( diff --git a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs index c9f2ddec2..e28ddfa04 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs @@ -7,6 +7,7 @@ using Dapper; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Metadata; using LanguageExt; using Microsoft.EntityFrameworkCore; using static LanguageExt.Prelude; @@ -82,6 +83,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .ToListAsync(); } + public async Task> GetShowsForCards(List ids) + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + return await dbContext.ShowMetadata + .AsNoTracking() + .Filter(sm => ids.Contains(sm.ShowId)) + .Include(sm => sm.Artwork) + .OrderBy(sm => sm.SortTitle) + .ToListAsync(); + } + public async Task> GetAllSeasons() { await using TvContext dbContext = _dbContextFactory.CreateDbContext(); @@ -201,6 +213,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .ThenInclude(sm => sm.Genres) .Include(s => s.ShowMetadata) .ThenInclude(sm => sm.Tags) + .Include(s => s.LibraryPath) + .ThenInclude(lp => lp.Library) .OrderBy(s => s.Id) .SingleOrDefaultAsync(s => s.Id == id) .Map(Optional); @@ -208,7 +222,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories () => Option.None.AsTask()); } - public async Task> AddShow(int libraryPathId, string showFolder, ShowMetadata metadata) + public async Task>> AddShow( + int libraryPathId, + string showFolder, + ShowMetadata metadata) { await using TvContext dbContext = _dbContextFactory.CreateDbContext(); @@ -226,8 +243,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories await dbContext.Shows.AddAsync(show); await dbContext.SaveChangesAsync(); + await dbContext.Entry(show).Reference(s => s.LibraryPath).LoadAsync(); + await dbContext.Entry(show.LibraryPath).Reference(lp => lp.Library).LoadAsync(); - return show; + return new MediaItemScanResult(show) { IsAdded = true }; } catch (Exception ex) { @@ -314,19 +333,22 @@ namespace ErsatzTV.Infrastructure.Data.Repositories return Unit.Default; } - public async Task DeleteEmptyShows(LibraryPath libraryPath) + public async Task> DeleteEmptyShows(LibraryPath libraryPath) { await using TvContext dbContext = _dbContextFactory.CreateDbContext(); List shows = await dbContext.Shows .Filter(s => s.LibraryPathId == libraryPath.Id) .Filter(s => s.Seasons.Count == 0) .ToListAsync(); + var ids = shows.Map(s => s.Id).ToList(); dbContext.Shows.RemoveRange(shows); await dbContext.SaveChangesAsync(); - return Unit.Default; + return ids; } - public async Task> GetOrAddPlexShow(PlexLibrary library, PlexShow item) + public async Task>> GetOrAddPlexShow( + PlexLibrary library, + PlexShow item) { await using TvContext dbContext = _dbContextFactory.CreateDbContext(); Option maybeExisting = await dbContext.PlexShows @@ -334,12 +356,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .Include(i => i.ShowMetadata) .ThenInclude(mm => mm.Genres) .Include(i => i.ShowMetadata) + .ThenInclude(mm => mm.Tags) + .Include(i => i.ShowMetadata) .ThenInclude(mm => mm.Artwork) + .Include(i => i.LibraryPath) + .ThenInclude(lp => lp.Library) .OrderBy(i => i.Key) .SingleOrDefaultAsync(i => i.Key == item.Key); return await maybeExisting.Match( - plexShow => Right(plexShow).AsTask(), + plexShow => Right>( + new MediaItemScanResult(plexShow) { IsAdded = true }).AsTask(), async () => await AddPlexShow(dbContext, library, item)); } @@ -375,20 +402,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories async () => await AddPlexEpisode(dbContext, library, item)); } - public Task AddGenre(ShowMetadata metadata, Genre genre) => - _dbConnection.ExecuteAsync( - "INSERT INTO Genre (Name, SeasonMetadataId) VALUES (@Name, @MetadataId)", - new { genre.Name, MetadataId = metadata.Id }).ToUnit(); - - public Task RemoveMissingPlexShows(PlexLibrary library, List showKeys) => - _dbConnection.ExecuteAsync( - @"DELETE FROM MediaItem WHERE Id IN - (SELECT m.Id FROM MediaItem m - INNER JOIN PlexShow ps ON ps.Id = m.Id - INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId - WHERE lp.LibraryId = @LibraryId AND ps.Key not in @Keys)", - new { LibraryId = library.Id, Keys = showKeys }).ToUnit(); - public Task RemoveMissingPlexSeasons(string showKey, List seasonKeys) => _dbConnection.ExecuteAsync( @"DELETE FROM MediaItem WHERE Id IN @@ -414,7 +427,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories episode.EpisodeNumber = episodeNumber; await _dbConnection.ExecuteAsync( @"UPDATE Episode SET EpisodeNumber = @EpisodeNumber WHERE Id = @Id", - new { EpisodeNumber = episodeNumber, Id = episode.Id }); + new { EpisodeNumber = episodeNumber, episode.Id }); return Unit.Default; } @@ -449,6 +462,31 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .ToListAsync(); } + public Task AddGenre(ShowMetadata metadata, Genre genre) => + _dbConnection.ExecuteAsync( + "INSERT INTO Genre (Name, SeasonMetadataId) VALUES (@Name, @MetadataId)", + new { genre.Name, MetadataId = metadata.Id }).Map(result => result > 0); + + public async Task> RemoveMissingPlexShows(PlexLibrary library, List showKeys) + { + List ids = await _dbConnection.QueryAsync( + @"SELECT m.Id FROM MediaItem m + INNER JOIN PlexShow ps ON ps.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId + WHERE lp.LibraryId = @LibraryId AND ps.Key not in @Keys", + new { LibraryId = library.Id, Keys = showKeys }).Map(result => result.ToList()); + + await _dbConnection.ExecuteAsync( + @"DELETE FROM MediaItem WHERE Id IN + (SELECT m.Id FROM MediaItem m + INNER JOIN PlexShow ps ON ps.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId + WHERE lp.LibraryId = @LibraryId AND ps.Key not in @Keys)", + new { LibraryId = library.Id, Keys = showKeys }); + + return ids; + } + private static async Task> AddSeason( TvContext dbContext, Show show, @@ -519,7 +557,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories } } - private static async Task> AddPlexShow( + private static async Task>> AddPlexShow( TvContext dbContext, PlexLibrary library, PlexShow item) @@ -531,7 +569,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories await dbContext.PlexShows.AddAsync(item); await dbContext.SaveChangesAsync(); await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); - return item; + await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); + return new MediaItemScanResult(item) { IsAdded = true }; } catch (Exception ex) { diff --git a/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj index c303add0f..3d685ebe4 100644 --- a/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj +++ b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj @@ -7,6 +7,9 @@ + + + all diff --git a/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs index d069baa35..87d17765a 100644 --- a/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs +++ b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs @@ -176,7 +176,8 @@ namespace ErsatzTV.Infrastructure.Plex Tagline = response.Tagline, DateAdded = dateAdded, DateUpdated = lastWriteTime, - Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList() + Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList(), + Tags = new List() }; if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate)) @@ -277,7 +278,8 @@ namespace ErsatzTV.Infrastructure.Plex Tagline = response.Tagline, DateAdded = dateAdded, DateUpdated = lastWriteTime, - Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList() + Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList(), + Tags = new List() }; if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate)) diff --git a/ErsatzTV.Infrastructure/Search/SearchIndex.cs b/ErsatzTV.Infrastructure/Search/SearchIndex.cs new file mode 100644 index 000000000..588c334d4 --- /dev/null +++ b/ErsatzTV.Infrastructure/Search/SearchIndex.cs @@ -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 Initialize() + { + _localFileSystem.EnsureFolderExists(FileSystemLayout.SearchIndexFolder); + return Task.FromResult(true); + } + + public async Task Rebuild(List 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(), writer); + UpdateShows(items.OfType(), writer); + + return Unit.Default; + } + + public Task AddItems(List items) => UpdateItems(items); + + public Task UpdateItems(List 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(), writer); + UpdateShows(items.OfType(), writer); + + return Task.FromResult(Unit.Default); + } + + public Task RemoveItems(List 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 Search(string searchQuery, int skip, int limit, string searchField = "") + { + if (string.IsNullOrWhiteSpace(searchQuery.Replace("*", string.Empty).Replace("?", string.Empty))) + { + return new SearchResult(new List(), 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 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 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 + { + '#', '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 movies, IndexWriter writer) + { + foreach (Movie movie in movies) + { + Option 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 shows, IndexWriter writer) + { + foreach (Show show in shows) + { + Option 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(), + _ => "#" + }; + } + } +} diff --git a/ErsatzTV/Pages/ChannelEditor.razor b/ErsatzTV/Pages/ChannelEditor.razor index c2971d59e..0daef331c 100644 --- a/ErsatzTV/Pages/ChannelEditor.razor +++ b/ErsatzTV/Pages/ChannelEditor.razor @@ -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 Logger @inject ISnackbar Snackbar diff --git a/ErsatzTV/Pages/FFmpeg.razor b/ErsatzTV/Pages/FFmpeg.razor index 73210d9bb..1ae50db1a 100644 --- a/ErsatzTV/Pages/FFmpeg.razor +++ b/ErsatzTV/Pages/FFmpeg.razor @@ -33,7 +33,7 @@ + @bind-Checked="@_ffmpegSettings.SaveReports"/> diff --git a/ErsatzTV/Pages/Movie.razor b/ErsatzTV/Pages/Movie.razor index 7b6cfac8e..ad60d4708 100644 --- a/ErsatzTV/Pages/Movie.razor +++ b/ErsatzTV/Pages/Movie.razor @@ -49,7 +49,7 @@
@foreach (string genre in _movie.Genres.OrderBy(g => g)) { - + }
} @@ -59,7 +59,7 @@
@foreach (string tag in _movie.Tags.OrderBy(t => t)) { - + }
} diff --git a/ErsatzTV/Pages/MovieList.razor b/ErsatzTV/Pages/MovieList.razor index ec7494933..5ce4cea6e 100644 --- a/ErsatzTV/Pages/MovieList.razor +++ b/ErsatzTV/Pages/MovieList.razor @@ -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 @inject NavigationManager NavigationManager @@ -32,7 +35,8 @@ } else { -
+ @_query +
+@if (_data.PageMap.IsSome) +{ + +} @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) { diff --git a/ErsatzTV/Pages/ScheduleItemsEditor.razor b/ErsatzTV/Pages/ScheduleItemsEditor.razor index 4aa12ae2b..ac355aece 100644 --- a/ErsatzTV/Pages/ScheduleItemsEditor.razor +++ b/ErsatzTV/Pages/ScheduleItemsEditor.razor @@ -84,7 +84,7 @@ @if (_selectedItem is not null) {
-
+
diff --git a/ErsatzTV/Pages/Search.razor b/ErsatzTV/Pages/Search.razor index 62f9a1877..4a0a506a2 100644 --- a/ErsatzTV/Pages/Search.razor +++ b/ErsatzTV/Pages/Search.razor @@ -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 { @_query - @_data.MovieCards.Count Movies - @_data.ShowCards.Count Shows + if (_movies.Count > 0) + { + @_movies.Count Movies + } + else + { + 0 Movies + } + + if (_shows.Count > 0) + { + @_shows.Count Shows + } + else + { + 0 Shows + } }
- @if (_data?.MovieCards.Any() == true) + @if (_movies.Count > 0) { - - Movies - +
+ + Movies + + @if (_movies.Count > 50) + { + See All >> + } +
- @foreach (MovieCardViewModel card in _data.MovieCards.OrderBy(m => m.SortTitle)) + @foreach (MovieCardViewModel card in _movies.Cards.OrderBy(m => m.SortTitle)) { } - @if (_data?.ShowCards.Any() == true) + @if (_shows.Count > 0) { - - Shows - +
+ + Shows + + @if (_shows.Count > 50) + { + See All >> + } +
- @foreach (TelevisionShowCardViewModel card in _data.ShowCards.OrderBy(s => s.SortTitle)) + @foreach (TelevisionShowCardViewModel card in _shows.Cards.OrderBy(s => s.SortTitle)) { 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 GetSortedItems() { - return _data.MovieCards.OrderBy(m => m.SortTitle) - .Append(_data.ShowCards.OrderBy(s => s.SortTitle)) + return _movies.Cards.OrderBy(m => m.SortTitle) + .Append(_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; + } + } \ No newline at end of file diff --git a/ErsatzTV/Pages/TelevisionSeasonList.razor b/ErsatzTV/Pages/TelevisionSeasonList.razor index 787b1581e..144c508b1 100644 --- a/ErsatzTV/Pages/TelevisionSeasonList.razor +++ b/ErsatzTV/Pages/TelevisionSeasonList.razor @@ -64,7 +64,7 @@
@foreach (string genre in _show.Genres.OrderBy(g => g)) { - + }
} @@ -74,7 +74,7 @@
@foreach (string tag in _show.Tags.OrderBy(t => t)) { - + }
} diff --git a/ErsatzTV/Pages/TelevisionShowList.razor b/ErsatzTV/Pages/TelevisionShowList.razor index 952216394..f7ce8f84c 100644 --- a/ErsatzTV/Pages/TelevisionShowList.razor +++ b/ErsatzTV/Pages/TelevisionShowList.razor @@ -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 @inject NavigationManager NavigationManager @@ -32,7 +35,8 @@ } else { -
+ @_query +
+@if (_data.PageMap.IsSome) +{ + +} @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) { diff --git a/ErsatzTV/Services/SchedulerService.cs b/ErsatzTV/Services/SchedulerService.cs index 98d2808ff..7a933390a 100644 --- a/ErsatzTV/Services/SchedulerService.cs +++ b/ErsatzTV/Services/SchedulerService.cs @@ -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); } } diff --git a/ErsatzTV/Services/WorkerService.cs b/ErsatzTV/Services/WorkerService.cs index 57eaa870a..0a2e7b5d2 100644 --- a/ErsatzTV/Services/WorkerService.cs +++ b/ErsatzTV/Services/WorkerService.cs @@ -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) diff --git a/ErsatzTV/Shared/LetterBar.razor b/ErsatzTV/Shared/LetterBar.razor new file mode 100644 index 000000000..739243131 --- /dev/null +++ b/ErsatzTV/Shared/LetterBar.razor @@ -0,0 +1,56 @@ +@using ErsatzTV.Core.Search +@using Microsoft.AspNetCore.WebUtilities +
+
+ # + 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 +
+
+ +@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; + } + +} \ No newline at end of file diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 89c66ebf8..9301aeafa 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -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(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddHostedService(); services.AddHostedService(); diff --git a/ErsatzTV/wwwroot/css/site.css b/ErsatzTV/wwwroot/css/site.css index da09f2f36..900b79a7d 100644 --- a/ErsatzTV/wwwroot/css/site.css +++ b/ErsatzTV/wwwroot/css/site.css @@ -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; } \ No newline at end of file