Browse Source

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
pull/88/head
Jason Dove 5 years ago committed by GitHub
parent
commit
2efcbca2da
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 19
      ErsatzTV.Application/Libraries/Commands/DeleteLocalLibraryPathHandler.cs
  2. 4
      ErsatzTV.Application/MediaCards/MovieCardResultsViewModel.cs
  3. 6
      ErsatzTV.Application/MediaCards/Queries/GetMovieCards.cs
  4. 30
      ErsatzTV.Application/MediaCards/Queries/GetMovieCardsHandler.cs
  5. 6
      ErsatzTV.Application/MediaCards/Queries/GetTelevisionShowCards.cs
  6. 33
      ErsatzTV.Application/MediaCards/Queries/GetTelevisionShowCardsHandler.cs
  7. 7
      ErsatzTV.Application/MediaCards/TelevisionShowCardResultsViewModel.cs
  8. 0
      ErsatzTV.Application/MediaSources/Commands/ScanLocalLibrary.cs
  9. 12
      ErsatzTV.Application/Plex/Commands/UpdatePlexLibraryPreferencesHandler.cs
  10. 6
      ErsatzTV.Application/Search/Commands/RebuildSearchIndex.cs
  11. 74
      ErsatzTV.Application/Search/Commands/RebuildSearchIndexHandler.cs
  12. 7
      ErsatzTV.Application/Search/Queries/QuerySearchIndex.cs
  13. 18
      ErsatzTV.Application/Search/Queries/QuerySearchIndexHandler.cs
  14. 8
      ErsatzTV.Application/Search/Queries/QuerySearchIndexMovies.cs
  15. 42
      ErsatzTV.Application/Search/Queries/QuerySearchIndexMoviesHandler.cs
  16. 8
      ErsatzTV.Application/Search/Queries/QuerySearchIndexShows.cs
  17. 43
      ErsatzTV.Application/Search/Queries/QuerySearchIndexShowsHandler.cs
  18. 10
      ErsatzTV.Application/Search/SearchResultViewModel.cs
  19. 2
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  20. 2
      ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs
  21. 28
      ErsatzTV.Core.Tests/Fakes/FakeMovieWithPath.cs
  22. 15
      ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs
  23. 68
      ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs
  24. 1
      ErsatzTV.Core/Domain/ConfigElementKey.cs
  25. 1
      ErsatzTV.Core/FileSystemLayout.cs
  26. 1
      ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs
  27. 9
      ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs
  28. 2
      ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs
  29. 1
      ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs
  30. 2
      ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs
  31. 4
      ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs
  32. 12
      ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs
  33. 1
      ErsatzTV.Core/Interfaces/Repositories/ISearchRepository.cs
  34. 17
      ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs
  35. 19
      ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs
  36. 10
      ErsatzTV.Core/Metadata/LocalFileSystem.cs
  37. 19
      ErsatzTV.Core/Metadata/LocalFolderScanner.cs
  38. 46
      ErsatzTV.Core/Metadata/LocalMetadataProvider.cs
  39. 11
      ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs
  40. 14
      ErsatzTV.Core/Metadata/MediaItemScanResult.cs
  41. 54
      ErsatzTV.Core/Metadata/MovieFolderScanner.cs
  42. 55
      ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs
  43. 69
      ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs
  44. 49
      ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs
  45. 4
      ErsatzTV.Core/Search/SearchItem.cs
  46. 6
      ErsatzTV.Core/Search/SearchPageMap.cs
  47. 10
      ErsatzTV.Core/Search/SearchResult.cs
  48. 6
      ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs
  49. 31
      ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs
  50. 11
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  51. 76
      ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs
  52. 18
      ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs
  53. 85
      ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs
  54. 3
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
  55. 6
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs
  56. 287
      ErsatzTV.Infrastructure/Search/SearchIndex.cs
  57. 2
      ErsatzTV/Pages/ChannelEditor.razor
  58. 2
      ErsatzTV/Pages/FFmpeg.razor
  59. 4
      ErsatzTV/Pages/Movie.razor
  60. 48
      ErsatzTV/Pages/MovieList.razor
  61. 2
      ErsatzTV/Pages/ScheduleItemsEditor.razor
  62. 95
      ErsatzTV/Pages/Search.razor
  63. 4
      ErsatzTV/Pages/TelevisionSeasonList.razor
  64. 48
      ErsatzTV/Pages/TelevisionShowList.razor
  65. 5
      ErsatzTV/Services/SchedulerService.cs
  66. 4
      ErsatzTV/Services/WorkerService.cs
  67. 56
      ErsatzTV/Shared/LetterBar.razor
  68. 3
      ErsatzTV/Startup.cs
  69. 14
      ErsatzTV/wwwroot/css/site.css

19
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 System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt; using LanguageExt;
namespace ErsatzTV.Application.Libraries.Commands namespace ErsatzTV.Application.Libraries.Commands
@ -11,9 +13,13 @@ namespace ErsatzTV.Application.Libraries.Commands
DeleteLocalLibraryPathHandler : MediatR.IRequestHandler<DeleteLocalLibraryPath, Either<BaseError, Unit>> DeleteLocalLibraryPathHandler : MediatR.IRequestHandler<DeleteLocalLibraryPath, Either<BaseError, Unit>>
{ {
private readonly ILibraryRepository _libraryRepository; private readonly ILibraryRepository _libraryRepository;
private readonly ISearchIndex _searchIndex;
public DeleteLocalLibraryPathHandler(ILibraryRepository libraryRepository) => public DeleteLocalLibraryPathHandler(ILibraryRepository libraryRepository, ISearchIndex searchIndex)
{
_libraryRepository = libraryRepository; _libraryRepository = libraryRepository;
_searchIndex = searchIndex;
}
public Task<Either<BaseError, Unit>> Handle( public Task<Either<BaseError, Unit>> Handle(
DeleteLocalLibraryPath request, DeleteLocalLibraryPath request,
@ -22,8 +28,13 @@ namespace ErsatzTV.Application.Libraries.Commands
.MapT(DoDeletion) .MapT(DoDeletion)
.Bind(t => t.ToEitherAsync()); .Bind(t => t.ToEitherAsync());
private Task<Unit> DoDeletion(LibraryPath libraryPath) => private async Task<Unit> DoDeletion(LibraryPath libraryPath)
_libraryRepository.DeleteLocalPath(libraryPath.Id).ToUnit(); {
List<int> ids = await _libraryRepository.GetMediaIdsByLocalPath(libraryPath.Id);
await _searchIndex.RemoveItems(ids);
await _libraryRepository.DeleteLocalPath(libraryPath.Id);
return Unit.Default;
}
private async Task<Validation<BaseError, LibraryPath>> MediaSourceMustExist(DeleteLocalLibraryPath request) => private async Task<Validation<BaseError, LibraryPath>> MediaSourceMustExist(DeleteLocalLibraryPath request) =>
(await _libraryRepository.GetPath(request.LocalLibraryPathId)) (await _libraryRepository.GetPath(request.LocalLibraryPathId))

4
ErsatzTV.Application/MediaCards/MovieCardResultsViewModel.cs

@ -1,6 +1,8 @@
using System.Collections.Generic; using System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards namespace ErsatzTV.Application.MediaCards
{ {
public record MovieCardResultsViewModel(int Count, List<MovieCardViewModel> Cards); public record MovieCardResultsViewModel(int Count, List<MovieCardViewModel> Cards, Option<SearchPageMap> PageMap);
} }

6
ErsatzTV.Application/MediaCards/Queries/GetMovieCards.cs

@ -1,6 +0,0 @@
using MediatR;
namespace ErsatzTV.Application.MediaCards.Queries
{
public record GetMovieCards(int PageNumber, int PageSize) : IRequest<MovieCardResultsViewModel>;
}

30
ErsatzTV.Application/MediaCards/Queries/GetMovieCardsHandler.cs

@ -1,30 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.MediaCards.Queries
{
public class
GetMovieCardsHandler : IRequestHandler<GetMovieCards, MovieCardResultsViewModel>
{
private readonly IMovieRepository _movieRepository;
public GetMovieCardsHandler(IMovieRepository movieRepository) => _movieRepository = movieRepository;
public async Task<MovieCardResultsViewModel> Handle(GetMovieCards request, CancellationToken cancellationToken)
{
int count = await _movieRepository.GetMovieCount();
List<MovieCardViewModel> results = await _movieRepository
.GetPagedMovies(request.PageNumber, request.PageSize)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new MovieCardResultsViewModel(count, results);
}
}
}

6
ErsatzTV.Application/MediaCards/Queries/GetTelevisionShowCards.cs

@ -1,6 +0,0 @@
using MediatR;
namespace ErsatzTV.Application.MediaCards.Queries
{
public record GetTelevisionShowCards(int PageNumber, int PageSize) : IRequest<TelevisionShowCardResultsViewModel>;
}

33
ErsatzTV.Application/MediaCards/Queries/GetTelevisionShowCardsHandler.cs

@ -1,33 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.MediaCards.Queries
{
public class
GetTelevisionShowCardsHandler : IRequestHandler<GetTelevisionShowCards, TelevisionShowCardResultsViewModel>
{
private readonly ITelevisionRepository _televisionRepository;
public GetTelevisionShowCardsHandler(ITelevisionRepository televisionRepository) =>
_televisionRepository = televisionRepository;
public async Task<TelevisionShowCardResultsViewModel> Handle(
GetTelevisionShowCards request,
CancellationToken cancellationToken)
{
int count = await _televisionRepository.GetShowCount();
List<TelevisionShowCardViewModel> results = await _televisionRepository
.GetPagedShows(request.PageNumber, request.PageSize)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new TelevisionShowCardResultsViewModel(count, results);
}
}
}

7
ErsatzTV.Application/MediaCards/TelevisionShowCardResultsViewModel.cs

@ -1,6 +1,11 @@
using System.Collections.Generic; using System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards namespace ErsatzTV.Application.MediaCards
{ {
public record TelevisionShowCardResultsViewModel(int Count, List<TelevisionShowCardViewModel> Cards); public record TelevisionShowCardResultsViewModel(
int Count,
List<TelevisionShowCardViewModel> Cards,
Option<SearchPageMap> PageMap);
} }

0
ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs → ErsatzTV.Application/MediaSources/Commands/ScanLocalLibrary.cs

12
ErsatzTV.Application/Plex/Commands/UpdatePlexLibraryPreferencesHandler.cs

@ -4,6 +4,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt; using LanguageExt;
namespace ErsatzTV.Application.Plex.Commands namespace ErsatzTV.Application.Plex.Commands
@ -13,16 +14,23 @@ namespace ErsatzTV.Application.Plex.Commands
Either<BaseError, Unit>> Either<BaseError, Unit>>
{ {
private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly ISearchIndex _searchIndex;
public UpdatePlexLibraryPreferencesHandler(IMediaSourceRepository mediaSourceRepository) => public UpdatePlexLibraryPreferencesHandler(
IMediaSourceRepository mediaSourceRepository,
ISearchIndex searchIndex)
{
_mediaSourceRepository = mediaSourceRepository; _mediaSourceRepository = mediaSourceRepository;
_searchIndex = searchIndex;
}
public async Task<Either<BaseError, Unit>> Handle( public async Task<Either<BaseError, Unit>> Handle(
UpdatePlexLibraryPreferences request, UpdatePlexLibraryPreferences request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList(); var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList();
await _mediaSourceRepository.DisablePlexLibrarySync(toDisable); List<int> ids = await _mediaSourceRepository.DisablePlexLibrarySync(toDisable);
await _searchIndex.RemoveItems(ids);
IEnumerable<int> toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id); IEnumerable<int> toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id);
await _mediaSourceRepository.EnablePlexLibrarySync(toEnable); await _mediaSourceRepository.EnablePlexLibrarySync(toEnable);

6
ErsatzTV.Application/Search/Commands/RebuildSearchIndex.cs

@ -0,0 +1,6 @@
using LanguageExt;
namespace ErsatzTV.Application.Search.Commands
{
public record RebuildSearchIndex : MediatR.IRequest<Unit>, IBackgroundServiceRequest;
}

74
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<RebuildSearchIndex, Unit>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly ILogger<RebuildSearchIndexHandler> _logger;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
public RebuildSearchIndexHandler(
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IConfigElementRepository configElementRepository,
ILogger<RebuildSearchIndexHandler> logger)
{
_searchIndex = searchIndex;
_logger = logger;
_searchRepository = searchRepository;
_configElementRepository = configElementRepository;
}
public async Task<Unit> Handle(RebuildSearchIndex request, CancellationToken cancellationToken)
{
bool indexFolderExists = Directory.Exists(FileSystemLayout.SearchIndexFolder);
if (!indexFolderExists ||
await _configElementRepository.GetValue<int>(ConfigElementKey.SearchIndexVersion) <
_searchIndex.Version)
{
_logger.LogDebug("Migrating search index to version {Version}", _searchIndex.Version);
List<MediaItem> items = await _searchRepository.GetItemsToIndex();
await _searchIndex.Rebuild(items);
Option<ConfigElement> maybeVersion =
await _configElementRepository.Get(ConfigElementKey.SearchIndexVersion);
await maybeVersion.Match(
version =>
{
version.Value = _searchIndex.Version.ToString();
return _configElementRepository.Update(version);
},
() =>
{
var configElement = new ConfigElement
{
Key = ConfigElementKey.SearchIndexVersion.Key,
Value = _searchIndex.Version.ToString()
};
return _configElementRepository.Add(configElement);
});
_logger.LogDebug("Done migrating search index");
}
else
{
_logger.LogDebug("Search index is already version {Version}", _searchIndex.Version);
}
return Unit.Default;
}
}
}

7
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<SearchResult>;
}

18
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<QuerySearchIndex, SearchResult>
{
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexHandler(ISearchIndex searchIndex) => _searchIndex = searchIndex;
public Task<SearchResult> Handle(QuerySearchIndex request, CancellationToken cancellationToken) =>
_searchIndex.Search(request.Query, 0, 100);
}
}

8
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<MovieCardResultsViewModel>;
}

42
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<QuerySearchIndexMovies, MovieCardResultsViewModel>
{
private readonly IMovieRepository _movieRepository;
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexMoviesHandler(ISearchIndex searchIndex, IMovieRepository movieRepository)
{
_searchIndex = searchIndex;
_movieRepository = movieRepository;
}
public async Task<MovieCardResultsViewModel> Handle(
QuerySearchIndexMovies request,
CancellationToken cancellationToken)
{
SearchResult searchResult = await _searchIndex.Search(
request.Query,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
List<MovieCardViewModel> items = await _movieRepository
.GetMoviesForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(ProjectToViewModel).ToList());
return new MovieCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
}
}

8
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<TelevisionShowCardResultsViewModel>;
}

43
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<QuerySearchIndexShows, TelevisionShowCardResultsViewModel>
{
private readonly ISearchIndex _searchIndex;
private readonly ITelevisionRepository _televisionRepository;
public QuerySearchIndexShowsHandler(ISearchIndex searchIndex, ITelevisionRepository televisionRepository)
{
_searchIndex = searchIndex;
_televisionRepository = televisionRepository;
}
public async Task<TelevisionShowCardResultsViewModel> Handle(
QuerySearchIndexShows request,
CancellationToken cancellationToken)
{
SearchResult searchResult = await _searchIndex.Search(
request.Query,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
List<TelevisionShowCardViewModel> items = await _televisionRepository
.GetShowsForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(ProjectToViewModel).ToList());
return new TelevisionShowCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
}
}

10
ErsatzTV.Application/Search/SearchResultViewModel.cs

@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.Search
{
public class SearchResultViewModel<T>
{
public int TotalCount { get; set; }
public List<T> Items { get; set; }
}
}

2
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -19,11 +19,11 @@ namespace ErsatzTV.Application.Streaming.Queries
public class public class
GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber> GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber>
{ {
private readonly IConfigElementRepository _configElementRepository;
private readonly FFmpegProcessService _ffmpegProcessService; private readonly FFmpegProcessService _ffmpegProcessService;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger; private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IConfigElementRepository _configElementRepository;
private readonly IPlayoutRepository _playoutRepository; private readonly IPlayoutRepository _playoutRepository;
public GetPlayoutItemProcessByChannelNumberHandler( public GetPlayoutItemProcessByChannelNumberHandler(

2
ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs

@ -36,6 +36,8 @@ namespace ErsatzTV.Core.Tests.Fakes
_folders = allFolders.Distinct().Map(f => new FakeFolderEntry(f)).ToList(); _folders = allFolders.Distinct().Map(f => new FakeFolderEntry(f)).ToList();
} }
public Unit EnsureFolderExists(string folder) => Unit.Default;
public DateTime GetLastWriteTime(string path) => public DateTime GetLastWriteTime(string path) =>
Optional(_files.SingleOrDefault(f => f.Path == path)) Optional(_files.SingleOrDefault(f => f.Path == path))
.Map(f => f.LastWriteTime) .Map(f => f.LastWriteTime)

28
ErsatzTV.Core.Tests/Fakes/FakeMovieWithPath.cs

@ -1,26 +1,26 @@
using System.Collections.Generic; using System.Collections.Generic;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
namespace ErsatzTV.Core.Tests.Fakes namespace ErsatzTV.Core.Tests.Fakes
{ {
public class FakeMovieWithPath : Movie public class FakeMovieWithPath : MediaItemScanResult<Movie>
{ {
public FakeMovieWithPath(string path) public FakeMovieWithPath(string path)
{ : base(
Path = path; new Movie
MediaVersions = new List<MediaVersion>
{
new()
{ {
MediaFiles = new List<MediaFile> MediaVersions = new List<MediaVersion>
{ {
new() { Path = path } new()
{
MediaFiles = new List<MediaFile>
{
new() { Path = path }
}
}
} }
} }) =>
}; IsAdded = true;
}
public string Path { get; }
} }
} }

15
ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using LanguageExt; using LanguageExt;
namespace ErsatzTV.Core.Tests.Fakes namespace ErsatzTV.Core.Tests.Fakes
@ -20,6 +21,8 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize) => public Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize) =>
throw new NotSupportedException(); throw new NotSupportedException();
public Task<List<ShowMetadata>> GetShowsForCards(List<int> ids) => throw new NotSupportedException();
public Task<List<Episode>> GetShowItems(int showId) => throw new NotSupportedException(); public Task<List<Episode>> GetShowItems(int showId) => throw new NotSupportedException();
public Task<List<Season>> GetAllSeasons() => throw new NotSupportedException(); public Task<List<Season>> GetAllSeasons() => throw new NotSupportedException();
@ -43,7 +46,7 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata) => public Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata) =>
throw new NotSupportedException(); throw new NotSupportedException();
public Task<Either<BaseError, Show>> public Task<Either<BaseError, MediaItemScanResult<Show>>>
AddShow(int libraryPathId, string showFolder, ShowMetadata metadata) => AddShow(int libraryPathId, string showFolder, ShowMetadata metadata) =>
throw new NotSupportedException(); throw new NotSupportedException();
@ -59,9 +62,11 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath) => throw new NotSupportedException(); public Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath) => throw new NotSupportedException();
public Task<Unit> DeleteEmptyShows(LibraryPath libraryPath) => throw new NotSupportedException(); public Task<List<int>> DeleteEmptyShows(LibraryPath libraryPath) => throw new NotSupportedException();
public Task<Either<BaseError, PlexShow>> GetOrAddPlexShow(PlexLibrary library, PlexShow item) => public Task<Either<BaseError, MediaItemScanResult<PlexShow>>> GetOrAddPlexShow(
PlexLibrary library,
PlexShow item) =>
throw new NotSupportedException(); throw new NotSupportedException();
public Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item) => public Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item) =>
@ -70,9 +75,9 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item) => public Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item) =>
throw new NotSupportedException(); throw new NotSupportedException();
public Task<Unit> AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException(); public Task<bool> AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException();
public Task<Unit> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) => public Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) =>
throw new NotSupportedException(); throw new NotSupportedException();
public Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys) => public Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys) =>

68
ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs

@ -9,6 +9,7 @@ using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Tests.Fakes; using ErsatzTV.Core.Tests.Fakes;
using FluentAssertions; using FluentAssertions;
@ -44,20 +45,24 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository = new Mock<IMovieRepository>(); _movieRepository = new Mock<IMovieRepository>();
_movieRepository.Setup(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>())) _movieRepository.Setup(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>()))
.Returns( .Returns(
(LibraryPath _, string path) => Right<BaseError, Movie>(new FakeMovieWithPath(path)).AsTask()); (LibraryPath _, string path) =>
Right<BaseError, MediaItemScanResult<Movie>>(new FakeMovieWithPath(path)).AsTask());
_movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>())) _movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>()))
.Returns(new List<string>().AsEnumerable().AsTask()); .Returns(new List<string>().AsEnumerable().AsTask());
_localStatisticsProvider = new Mock<ILocalStatisticsProvider>(); _localStatisticsProvider = new Mock<ILocalStatisticsProvider>();
_localMetadataProvider = new Mock<ILocalMetadataProvider>(); _localMetadataProvider = new Mock<ILocalMetadataProvider>();
_localStatisticsProvider.Setup(x => x.RefreshStatistics(It.IsAny<string>(), It.IsAny<MediaItem>()))
.Returns<string, MediaItem>((_, _) => Right<BaseError, bool>(true).AsTask());
// fallback metadata adds metadata to a movie, so we need to replicate that here // fallback metadata adds metadata to a movie, so we need to replicate that here
_localMetadataProvider.Setup(x => x.RefreshFallbackMetadata(It.IsAny<MediaItem>())) _localMetadataProvider.Setup(x => x.RefreshFallbackMetadata(It.IsAny<MediaItem>()))
.Returns( .Returns(
(MediaItem mediaItem) => (MediaItem mediaItem) =>
{ {
((Movie) mediaItem).MovieMetadata = new List<MovieMetadata> { new() }; ((Movie) mediaItem).MovieMetadata = new List<MovieMetadata> { new() };
return Unit.Default.AsTask(); return Task.FromResult(true);
}); });
_imageCache = new Mock<IImageCache>(); _imageCache = new Mock<IImageCache>();
@ -104,11 +109,14 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify( _localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)), x => x.RefreshStatistics(
FFprobePath,
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once); Times.Once);
_localMetadataProvider.Verify( _localMetadataProvider.Verify(
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)), x => x.RefreshFallbackMetadata(
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once); Times.Once);
} }
@ -137,11 +145,15 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify( _localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)), x => x.RefreshStatistics(
FFprobePath,
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once); Times.Once);
_localMetadataProvider.Verify( _localMetadataProvider.Verify(
x => x.RefreshSidecarMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath), metadataPath), x => x.RefreshSidecarMetadata(
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath),
metadataPath),
Times.Once); Times.Once);
} }
@ -170,11 +182,15 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify( _localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)), x => x.RefreshStatistics(
FFprobePath,
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once); Times.Once);
_localMetadataProvider.Verify( _localMetadataProvider.Verify(
x => x.RefreshSidecarMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath), metadataPath), x => x.RefreshSidecarMetadata(
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath),
metadataPath),
Times.Once); Times.Once);
} }
@ -207,11 +223,14 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify( _localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)), x => x.RefreshStatistics(
FFprobePath,
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once); Times.Once);
_localMetadataProvider.Verify( _localMetadataProvider.Verify(
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)), x => x.RefreshFallbackMetadata(
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once); Times.Once);
_imageCache.Verify( _imageCache.Verify(
@ -248,11 +267,14 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify( _localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)), x => x.RefreshStatistics(
FFprobePath,
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once); Times.Once);
_localMetadataProvider.Verify( _localMetadataProvider.Verify(
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)), x => x.RefreshFallbackMetadata(
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once); Times.Once);
_imageCache.Verify( _imageCache.Verify(
@ -288,11 +310,14 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify( _localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)), x => x.RefreshStatistics(
FFprobePath,
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once); Times.Once);
_localMetadataProvider.Verify( _localMetadataProvider.Verify(
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)), x => x.RefreshFallbackMetadata(
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once); Times.Once);
} }
@ -324,11 +349,14 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify( _localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)), x => x.RefreshStatistics(
FFprobePath,
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once); Times.Once);
_localMetadataProvider.Verify( _localMetadataProvider.Verify(
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)), x => x.RefreshFallbackMetadata(
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once); Times.Once);
} }
@ -354,11 +382,14 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify( _localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)), x => x.RefreshStatistics(
FFprobePath,
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once); Times.Once);
_localMetadataProvider.Verify( _localMetadataProvider.Verify(
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)), x => x.RefreshFallbackMetadata(
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once); Times.Once);
} }
@ -419,6 +450,7 @@ namespace ErsatzTV.Core.Tests.Metadata
_localMetadataProvider.Object, _localMetadataProvider.Object,
new Mock<IMetadataRepository>().Object, new Mock<IMetadataRepository>().Object,
_imageCache.Object, _imageCache.Object,
new Mock<ISearchIndex>().Object,
new Mock<ILogger<MovieFolderScanner>>().Object new Mock<ILogger<MovieFolderScanner>>().Object
); );
} }

1
ErsatzTV.Core/Domain/ConfigElementKey.cs

@ -11,5 +11,6 @@
public static ConfigElementKey FFmpegDefaultProfileId => new("ffmpeg.default_profile_id"); public static ConfigElementKey FFmpegDefaultProfileId => new("ffmpeg.default_profile_id");
public static ConfigElementKey FFmpegDefaultResolutionId => new("ffmpeg.default_resolution_id"); public static ConfigElementKey FFmpegDefaultResolutionId => new("ffmpeg.default_resolution_id");
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports"); public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
} }
} }

1
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 PlexSecretsPath = Path.Combine(AppDataFolder, "plex-secrets.json");
public static readonly string FFmpegReportsFolder = Path.Combine(AppDataFolder, "ffmpeg-reports"); 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"); public static readonly string ArtworkCacheFolder = Path.Combine(AppDataFolder, "cache", "artwork");

1
ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs

@ -8,6 +8,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
{ {
public interface ILocalFileSystem public interface ILocalFileSystem
{ {
Unit EnsureFolderExists(string folder);
DateTime GetLastWriteTime(string path); DateTime GetLastWriteTime(string path);
bool IsLibraryPathAccessible(LibraryPath libraryPath); bool IsLibraryPathAccessible(LibraryPath libraryPath);
IEnumerable<string> ListSubdirectories(string folder); IEnumerable<string> ListSubdirectories(string folder);

9
ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs

@ -1,15 +1,14 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata namespace ErsatzTV.Core.Interfaces.Metadata
{ {
public interface ILocalMetadataProvider public interface ILocalMetadataProvider
{ {
Task<ShowMetadata> GetMetadataForShow(string showFolder); Task<ShowMetadata> GetMetadataForShow(string showFolder);
Task<Unit> RefreshSidecarMetadata(MediaItem mediaItem, string path); Task<bool> RefreshSidecarMetadata(MediaItem mediaItem, string path);
Task<Unit> RefreshSidecarMetadata(Show televisionShow, string showFolder); Task<bool> RefreshSidecarMetadata(Show televisionShow, string showFolder);
Task<Unit> RefreshFallbackMetadata(MediaItem mediaItem); Task<bool> RefreshFallbackMetadata(MediaItem mediaItem);
Task<Unit> RefreshFallbackMetadata(Show televisionShow, string showFolder); Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder);
} }
} }

2
ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs

@ -6,6 +6,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
{ {
public interface ILocalStatisticsProvider public interface ILocalStatisticsProvider
{ {
Task<Either<BaseError, Unit>> RefreshStatistics(string ffprobePath, MediaItem mediaItem); Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem);
} }
} }

1
ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs

@ -15,6 +15,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<LibraryPath>> GetLocalPaths(int libraryId); Task<List<LibraryPath>> GetLocalPaths(int libraryId);
Task<Option<LibraryPath>> GetPath(int libraryPathId); Task<Option<LibraryPath>> GetPath(int libraryPathId);
Task<int> CountMediaItemsByPath(int libraryPathId); Task<int> CountMediaItemsByPath(int libraryPathId);
Task<List<int>> GetMediaIdsByLocalPath(int libraryPathId);
Task DeleteLocalPath(int libraryPathId); Task DeleteLocalPath(int libraryPathId);
} }
} }

2
ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs

@ -24,7 +24,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task Update(PlexLibrary plexMediaSourceLibrary); Task Update(PlexLibrary plexMediaSourceLibrary);
Task Delete(int mediaSourceId); Task Delete(int mediaSourceId);
Task<Unit> DeleteAllPlex(); Task<Unit> DeleteAllPlex();
Task DisablePlexLibrarySync(List<int> libraryIds); Task<List<int>> DisablePlexLibrarySync(List<int> libraryIds);
Task EnablePlexLibrarySync(IEnumerable<int> libraryIds); Task EnablePlexLibrarySync(IEnumerable<int> libraryIds);
} }
} }

4
ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs

@ -6,11 +6,11 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{ {
public interface IMetadataRepository public interface IMetadataRepository
{ {
Task<Unit> RemoveGenre(Genre genre); Task<bool> RemoveGenre(Genre genre);
Task<bool> Update(Domain.Metadata metadata); Task<bool> Update(Domain.Metadata metadata);
Task<bool> Add(Domain.Metadata metadata); Task<bool> Add(Domain.Metadata metadata);
Task<bool> UpdateLocalStatistics(MediaVersion mediaVersion); Task<bool> UpdateLocalStatistics(MediaVersion mediaVersion);
Task<Unit> UpdatePlexStatistics(MediaVersion mediaVersion); Task<bool> UpdatePlexStatistics(MediaVersion mediaVersion);
Task<Unit> UpdateArtworkPath(Artwork artwork); Task<Unit> UpdateArtworkPath(Artwork artwork);
Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork); Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork);
Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind); Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind);

12
ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs

@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
using LanguageExt; using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Repositories namespace ErsatzTV.Core.Interfaces.Repositories
@ -9,13 +10,14 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{ {
Task<bool> AllMoviesExist(List<int> movieIds); Task<bool> AllMoviesExist(List<int> movieIds);
Task<Option<Movie>> GetMovie(int movieId); Task<Option<Movie>> GetMovie(int movieId);
Task<Either<BaseError, Movie>> GetOrAdd(LibraryPath libraryPath, string path); Task<Either<BaseError, MediaItemScanResult<Movie>>> GetOrAdd(LibraryPath libraryPath, string path);
Task<Either<BaseError, PlexMovie>> GetOrAdd(PlexLibrary library, PlexMovie item); Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> GetOrAdd(PlexLibrary library, PlexMovie item);
Task<int> GetMovieCount(); Task<int> GetMovieCount();
Task<List<MovieMetadata>> GetPagedMovies(int pageNumber, int pageSize); Task<List<MovieMetadata>> GetPagedMovies(int pageNumber, int pageSize);
Task<List<MovieMetadata>> GetMoviesForCards(List<int> ids);
Task<IEnumerable<string>> FindMoviePaths(LibraryPath libraryPath); Task<IEnumerable<string>> FindMoviePaths(LibraryPath libraryPath);
Task<Unit> DeleteByPath(LibraryPath libraryPath, string path); Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
Task<Unit> AddGenre(MovieMetadata metadata, Genre genre); Task<bool> AddGenre(MovieMetadata metadata, Genre genre);
Task<Unit> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys); Task<List<int>> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys);
} }
} }

1
ErsatzTV.Core/Interfaces/Repositories/ISearchRepository.cs

@ -6,6 +6,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{ {
public interface ISearchRepository public interface ISearchRepository
{ {
public Task<List<MediaItem>> GetItemsToIndex();
public Task<List<MediaItem>> SearchMediaItemsByTitle(string query); public Task<List<MediaItem>> SearchMediaItemsByTitle(string query);
public Task<List<MediaItem>> SearchMediaItemsByGenre(string genre); public Task<List<MediaItem>> SearchMediaItemsByGenre(string genre);
public Task<List<MediaItem>> SearchMediaItemsByTag(string tag); public Task<List<MediaItem>> SearchMediaItemsByTag(string tag);

17
ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs

@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
using LanguageExt; using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Repositories namespace ErsatzTV.Core.Interfaces.Repositories
@ -12,6 +13,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Option<Show>> GetShow(int showId); Task<Option<Show>> GetShow(int showId);
Task<int> GetShowCount(); Task<int> GetShowCount();
Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize); Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize);
Task<List<ShowMetadata>> GetShowsForCards(List<int> ids);
Task<List<Episode>> GetShowItems(int showId); Task<List<Episode>> GetShowItems(int showId);
Task<List<Season>> GetAllSeasons(); Task<List<Season>> GetAllSeasons();
Task<Option<Season>> GetSeason(int seasonId); Task<Option<Season>> GetSeason(int seasonId);
@ -22,18 +24,23 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<int> GetEpisodeCount(int seasonId); Task<int> GetEpisodeCount(int seasonId);
Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize); Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize);
Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata); Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata);
Task<Either<BaseError, Show>> AddShow(int libraryPathId, string showFolder, ShowMetadata metadata);
Task<Either<BaseError, MediaItemScanResult<Show>>> AddShow(
int libraryPathId,
string showFolder,
ShowMetadata metadata);
Task<Either<BaseError, Season>> GetOrAddSeason(Show show, int libraryPathId, int seasonNumber); Task<Either<BaseError, Season>> GetOrAddSeason(Show show, int libraryPathId, int seasonNumber);
Task<Either<BaseError, Episode>> GetOrAddEpisode(Season season, LibraryPath libraryPath, string path); Task<Either<BaseError, Episode>> GetOrAddEpisode(Season season, LibraryPath libraryPath, string path);
Task<IEnumerable<string>> FindEpisodePaths(LibraryPath libraryPath); Task<IEnumerable<string>> FindEpisodePaths(LibraryPath libraryPath);
Task<Unit> DeleteByPath(LibraryPath libraryPath, string path); Task<Unit> DeleteByPath(LibraryPath libraryPath, string path);
Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath); Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath);
Task<Unit> DeleteEmptyShows(LibraryPath libraryPath); Task<List<int>> DeleteEmptyShows(LibraryPath libraryPath);
Task<Either<BaseError, PlexShow>> GetOrAddPlexShow(PlexLibrary library, PlexShow item); Task<Either<BaseError, MediaItemScanResult<PlexShow>>> GetOrAddPlexShow(PlexLibrary library, PlexShow item);
Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item); Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item);
Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item); Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item);
Task<Unit> AddGenre(ShowMetadata metadata, Genre genre); Task<bool> AddGenre(ShowMetadata metadata, Genre genre);
Task<Unit> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys); Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys);
Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys); Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys);
Task<Unit> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys); Task<Unit> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys);
Task<Unit> SetEpisodeNumber(Episode episode, int episodeNumber); Task<Unit> SetEpisodeNumber(Episode episode, int episodeNumber);

19
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<bool> Initialize();
Task<Unit> Rebuild(List<MediaItem> items);
Task<Unit> AddItems(List<MediaItem> items);
Task<Unit> UpdateItems(List<MediaItem> items);
Task<Unit> RemoveItems(List<int> ids);
Task<SearchResult> Search(string query, int skip, int limit, string searchField = "");
}
}

10
ErsatzTV.Core/Metadata/LocalFileSystem.cs

@ -11,6 +11,16 @@ namespace ErsatzTV.Core.Metadata
{ {
public class LocalFileSystem : ILocalFileSystem public class LocalFileSystem : ILocalFileSystem
{ {
public Unit EnsureFolderExists(string folder)
{
if (!Directory.Exists(folder))
{
Directory.CreateDirectory(folder);
}
return Unit.Default;
}
public DateTime GetLastWriteTime(string path) => public DateTime GetLastWriteTime(string path) =>
Try(File.GetLastWriteTimeUtc(path)).IfFail(() => DateTime.MinValue); Try(File.GetLastWriteTimeUtc(path)).IfFail(() => DateTime.MinValue);

19
ErsatzTV.Core/Metadata/LocalFolderScanner.cs

@ -66,12 +66,14 @@ namespace ErsatzTV.Core.Metadata
_logger = logger; _logger = logger;
} }
protected async Task<Either<BaseError, T>> UpdateStatistics<T>(T mediaItem, string ffprobePath) protected async Task<Either<BaseError, MediaItemScanResult<T>>> UpdateStatistics<T>(
MediaItemScanResult<T> mediaItem,
string ffprobePath)
where T : MediaItem where T : MediaItem
{ {
try try
{ {
MediaVersion version = mediaItem switch MediaVersion version = mediaItem.Item switch
{ {
Movie m => m.MediaVersions.Head(), Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(), Episode e => e.MediaVersions.Head(),
@ -83,9 +85,16 @@ namespace ErsatzTV.Core.Metadata
if (version.DateUpdated < _localFileSystem.GetLastWriteTime(path)) if (version.DateUpdated < _localFileSystem.GetLastWriteTime(path))
{ {
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path); _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path);
Either<BaseError, Unit> refreshResult = Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem); await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem.Item);
refreshResult.IfLeft( refreshResult.Match(
result =>
{
if (result)
{
mediaItem.IsUpdated = true;
}
},
error => error =>
_logger.LogWarning( _logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}", "Unable to refresh {Attribute} for media item {Path}. Error: {Error}",

46
ErsatzTV.Core/Metadata/LocalMetadataProvider.cs

@ -22,19 +22,16 @@ namespace ErsatzTV.Core.Metadata
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<LocalMetadataProvider> _logger; private readonly ILogger<LocalMetadataProvider> _logger;
private readonly IMediaItemRepository _mediaItemRepository;
private readonly IMetadataRepository _metadataRepository; private readonly IMetadataRepository _metadataRepository;
private readonly ITelevisionRepository _televisionRepository; private readonly ITelevisionRepository _televisionRepository;
public LocalMetadataProvider( public LocalMetadataProvider(
IMediaItemRepository mediaItemRepository,
IMetadataRepository metadataRepository, IMetadataRepository metadataRepository,
ITelevisionRepository televisionRepository, ITelevisionRepository televisionRepository,
IFallbackMetadataProvider fallbackMetadataProvider, IFallbackMetadataProvider fallbackMetadataProvider,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ILogger<LocalMetadataProvider> logger) ILogger<LocalMetadataProvider> logger)
{ {
_mediaItemRepository = mediaItemRepository;
_metadataRepository = metadataRepository; _metadataRepository = metadataRepository;
_televisionRepository = televisionRepository; _televisionRepository = televisionRepository;
_fallbackMetadataProvider = fallbackMetadataProvider; _fallbackMetadataProvider = fallbackMetadataProvider;
@ -65,40 +62,47 @@ namespace ErsatzTV.Core.Metadata
}); });
} }
public Task<Unit> RefreshSidecarMetadata(MediaItem mediaItem, string path) => public Task<bool> RefreshSidecarMetadata(MediaItem mediaItem, string path) =>
mediaItem switch mediaItem switch
{ {
Episode e => LoadMetadata(e, path) 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) Movie m => LoadMetadata(m, path)
.Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(m, metadata))), .Bind(
_ => Task.FromResult(Unit.Default) maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(m, metadata),
() => Task.FromResult(false))),
_ => Task.FromResult(false)
}; };
public Task<Unit> RefreshSidecarMetadata(Show televisionShow, string showFolder) => public Task<bool> RefreshSidecarMetadata(Show televisionShow, string showFolder) =>
LoadMetadata(televisionShow, showFolder).Bind( LoadMetadata(televisionShow, showFolder).Bind(
maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(televisionShow, metadata))); maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(televisionShow, metadata),
() => Task.FromResult(false)));
public Task<Unit> RefreshFallbackMetadata(MediaItem mediaItem) => public Task<bool> RefreshFallbackMetadata(MediaItem mediaItem) =>
mediaItem switch mediaItem switch
{ {
Episode e => ApplyMetadataUpdate(e, _fallbackMetadataProvider.GetFallbackMetadata(e)) Episode e => ApplyMetadataUpdate(e, _fallbackMetadataProvider.GetFallbackMetadata(e)),
.ToUnit(), Movie m => ApplyMetadataUpdate(m, _fallbackMetadataProvider.GetFallbackMetadata(m)),
Movie m => ApplyMetadataUpdate(m, _fallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(), _ => Task.FromResult(false)
_ => Task.FromResult(Unit.Default)
}; };
public Task<Unit> RefreshFallbackMetadata(Show televisionShow, string showFolder) => public Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder) =>
ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder)) ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder));
.ToUnit();
private async Task ApplyMetadataUpdate(Episode episode, Tuple<EpisodeMetadata, int> metadataEpisodeNumber) private async Task<bool> ApplyMetadataUpdate(Episode episode, Tuple<EpisodeMetadata, int> metadataEpisodeNumber)
{ {
(EpisodeMetadata metadata, int episodeNumber) = metadataEpisodeNumber; (EpisodeMetadata metadata, int episodeNumber) = metadataEpisodeNumber;
if (episode.EpisodeNumber != episodeNumber) if (episode.EpisodeNumber != episodeNumber)
{ {
await _televisionRepository.SetEpisodeNumber(episode, episodeNumber); await _televisionRepository.SetEpisodeNumber(episode, episodeNumber);
} }
await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match( await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
existing => existing =>
{ {
@ -128,9 +132,11 @@ namespace ErsatzTV.Core.Metadata
return _metadataRepository.Add(metadata); return _metadataRepository.Add(metadata);
}); });
return true;
} }
private Task ApplyMetadataUpdate(Movie movie, MovieMetadata metadata) => private Task<bool> ApplyMetadataUpdate(Movie movie, MovieMetadata metadata) =>
Optional(movie.MovieMetadata).Flatten().HeadOrNone().Match( Optional(movie.MovieMetadata).Flatten().HeadOrNone().Match(
existing => existing =>
{ {
@ -185,7 +191,7 @@ namespace ErsatzTV.Core.Metadata
return _metadataRepository.Add(metadata); return _metadataRepository.Add(metadata);
}); });
private Task ApplyMetadataUpdate(Show show, ShowMetadata metadata) => private Task<bool> ApplyMetadataUpdate(Show show, ShowMetadata metadata) =>
Optional(show.ShowMetadata).Flatten().HeadOrNone().Match( Optional(show.ShowMetadata).Flatten().HeadOrNone().Match(
existing => existing =>
{ {

11
ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs

@ -17,22 +17,19 @@ namespace ErsatzTV.Core.Metadata
{ {
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<LocalStatisticsProvider> _logger; private readonly ILogger<LocalStatisticsProvider> _logger;
private readonly IMediaItemRepository _mediaItemRepository;
private readonly IMetadataRepository _metadataRepository; private readonly IMetadataRepository _metadataRepository;
public LocalStatisticsProvider( public LocalStatisticsProvider(
IMediaItemRepository mediaItemRepository,
IMetadataRepository metadataRepository, IMetadataRepository metadataRepository,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ILogger<LocalStatisticsProvider> logger) ILogger<LocalStatisticsProvider> logger)
{ {
_mediaItemRepository = mediaItemRepository;
_metadataRepository = metadataRepository; _metadataRepository = metadataRepository;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_logger = logger; _logger = logger;
} }
public async Task<Either<BaseError, Unit>> RefreshStatistics(string ffprobePath, MediaItem mediaItem) public async Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem)
{ {
try try
{ {
@ -48,10 +45,10 @@ namespace ErsatzTV.Core.Metadata
async ffprobe => async ffprobe =>
{ {
MediaVersion version = ProjectToMediaVersion(ffprobe); MediaVersion version = ProjectToMediaVersion(ffprobe);
await ApplyVersionUpdate(mediaItem, version, filePath); bool result = await ApplyVersionUpdate(mediaItem, version, filePath);
return Right<BaseError, Unit>(Unit.Default); return Right<BaseError, bool>(result);
}, },
error => Task.FromResult(Left<BaseError, Unit>(error))); error => Task.FromResult(Left<BaseError, bool>(error)));
} }
catch (Exception ex) catch (Exception ex)
{ {

14
ErsatzTV.Core/Metadata/MediaItemScanResult.cs

@ -0,0 +1,14 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Metadata
{
public class MediaItemScanResult<T> where T : MediaItem
{
public MediaItemScanResult(T item) => Item = item;
public T Item { get; }
public bool IsAdded { get; set; }
public bool IsUpdated { get; set; }
}
}

54
ErsatzTV.Core/Metadata/MovieFolderScanner.cs

@ -8,6 +8,7 @@ using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt; using LanguageExt;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude; using static LanguageExt.Prelude;
@ -21,6 +22,7 @@ namespace ErsatzTV.Core.Metadata
private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILogger<MovieFolderScanner> _logger; private readonly ILogger<MovieFolderScanner> _logger;
private readonly IMovieRepository _movieRepository; private readonly IMovieRepository _movieRepository;
private readonly ISearchIndex _searchIndex;
public MovieFolderScanner( public MovieFolderScanner(
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
@ -29,12 +31,14 @@ namespace ErsatzTV.Core.Metadata
ILocalMetadataProvider localMetadataProvider, ILocalMetadataProvider localMetadataProvider,
IMetadataRepository metadataRepository, IMetadataRepository metadataRepository,
IImageCache imageCache, IImageCache imageCache,
ISearchIndex searchIndex,
ILogger<MovieFolderScanner> logger) ILogger<MovieFolderScanner> logger)
: base(localFileSystem, localStatisticsProvider, metadataRepository, imageCache, logger) : base(localFileSystem, localStatisticsProvider, metadataRepository, imageCache, logger)
{ {
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_movieRepository = movieRepository; _movieRepository = movieRepository;
_localMetadataProvider = localMetadataProvider; _localMetadataProvider = localMetadataProvider;
_searchIndex = searchIndex;
_logger = logger; _logger = logger;
} }
@ -72,19 +76,33 @@ namespace ErsatzTV.Core.Metadata
continue; continue;
} }
foreach (string file in allFiles.OrderBy(identity)) foreach (string file in allFiles.OrderBy(identity))
{ {
// TODO: figure out how to rebuild playlists // TODO: figure out how to rebuild playlists
Either<BaseError, Movie> maybeMovie = await _movieRepository Either<BaseError, MediaItemScanResult<Movie>> maybeMovie = await _movieRepository
.GetOrAdd(libraryPath, file) .GetOrAdd(libraryPath, file)
.BindT(movie => UpdateStatistics(movie, ffprobePath).MapT(_ => movie)) .BindT(movie => UpdateStatistics(movie, ffprobePath))
.BindT(UpdateMetadata) .BindT(UpdateMetadata)
.BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster)) .BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster))
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt)); .BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt));
maybeMovie.IfLeft( await maybeMovie.Match(
error => _logger.LogWarning("Error processing movie at {Path}: {Error}", file, error.Value)); async result =>
{
if (result.IsAdded)
{
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(new List<MediaItem> { 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)) if (!_localFileSystem.FileExists(path))
{ {
_logger.LogInformation("Removing missing movie at {Path}", path); _logger.LogInformation("Removing missing movie at {Path}", path);
await _movieRepository.DeleteByPath(libraryPath, path); List<int> ids = await _movieRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(ids);
} }
} }
return Unit.Default; return Unit.Default;
} }
private async Task<Either<BaseError, Movie>> UpdateMetadata(Movie movie) private async Task<Either<BaseError, MediaItemScanResult<Movie>>> UpdateMetadata(
MediaItemScanResult<Movie> result)
{ {
try try
{ {
Movie movie = result.Item;
await LocateNfoFile(movie).Match( await LocateNfoFile(movie).Match(
async nfoFile => async nfoFile =>
{ {
@ -115,7 +136,10 @@ namespace ErsatzTV.Core.Metadata
if (shouldUpdate) if (shouldUpdate)
{ {
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile); _logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
await _localMetadataProvider.RefreshSidecarMetadata(movie, nfoFile); if (await _localMetadataProvider.RefreshSidecarMetadata(movie, nfoFile))
{
result.IsUpdated = true;
}
} }
}, },
async () => async () =>
@ -124,11 +148,14 @@ namespace ErsatzTV.Core.Metadata
{ {
string path = movie.MediaVersions.Head().MediaFiles.Head().Path; string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", 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) catch (Exception ex)
{ {
@ -136,10 +163,13 @@ namespace ErsatzTV.Core.Metadata
} }
} }
private async Task<Either<BaseError, Movie>> UpdateArtwork(Movie movie, ArtworkKind artworkKind) private async Task<Either<BaseError, MediaItemScanResult<Movie>>> UpdateArtwork(
MediaItemScanResult<Movie> result,
ArtworkKind artworkKind)
{ {
try try
{ {
Movie movie = result.Item;
await LocateArtwork(movie, artworkKind).IfSomeAsync( await LocateArtwork(movie, artworkKind).IfSomeAsync(
async posterFile => async posterFile =>
{ {
@ -147,7 +177,7 @@ namespace ErsatzTV.Core.Metadata
await RefreshArtwork(posterFile, metadata, artworkKind); await RefreshArtwork(posterFile, metadata, artworkKind);
}); });
return movie; return result;
} }
catch (Exception ex) catch (Exception ex)
{ {

55
ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs

@ -8,6 +8,7 @@ using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt; using LanguageExt;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude; using static LanguageExt.Prelude;
@ -19,6 +20,7 @@ namespace ErsatzTV.Core.Metadata
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILogger<TelevisionFolderScanner> _logger; private readonly ILogger<TelevisionFolderScanner> _logger;
private readonly ISearchIndex _searchIndex;
private readonly ITelevisionRepository _televisionRepository; private readonly ITelevisionRepository _televisionRepository;
public TelevisionFolderScanner( public TelevisionFolderScanner(
@ -28,6 +30,7 @@ namespace ErsatzTV.Core.Metadata
ILocalMetadataProvider localMetadataProvider, ILocalMetadataProvider localMetadataProvider,
IMetadataRepository metadataRepository, IMetadataRepository metadataRepository,
IImageCache imageCache, IImageCache imageCache,
ISearchIndex searchIndex,
ILogger<TelevisionFolderScanner> logger) : base( ILogger<TelevisionFolderScanner> logger) : base(
localFileSystem, localFileSystem,
localStatisticsProvider, localStatisticsProvider,
@ -38,6 +41,7 @@ namespace ErsatzTV.Core.Metadata
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_televisionRepository = televisionRepository; _televisionRepository = televisionRepository;
_localMetadataProvider = localMetadataProvider; _localMetadataProvider = localMetadataProvider;
_searchIndex = searchIndex;
_logger = logger; _logger = logger;
} }
@ -55,14 +59,26 @@ namespace ErsatzTV.Core.Metadata
foreach (string showFolder in allShowFolders) foreach (string showFolder in allShowFolders)
{ {
Either<BaseError, Show> maybeShow = Either<BaseError, MediaItemScanResult<Show>> maybeShow =
await FindOrCreateShow(libraryPath.Id, showFolder) await FindOrCreateShow(libraryPath.Id, showFolder)
.BindT(show => UpdateMetadataForShow(show, showFolder)) .BindT(show => UpdateMetadataForShow(show, showFolder))
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Poster)) .BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Poster))
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.FanArt)); .BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.FanArt));
await maybeShow.Match( await maybeShow.Match(
show => ScanSeasons(libraryPath, ffprobePath, show, showFolder), async result =>
{
if (result.IsAdded)
{
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
}
await ScanSeasons(libraryPath, ffprobePath, result.Item, showFolder);
},
_ => Task.FromResult(Unit.Default)); _ => Task.FromResult(Unit.Default));
} }
@ -76,19 +92,20 @@ namespace ErsatzTV.Core.Metadata
} }
await _televisionRepository.DeleteEmptySeasons(libraryPath); await _televisionRepository.DeleteEmptySeasons(libraryPath);
await _televisionRepository.DeleteEmptyShows(libraryPath); List<int> ids = await _televisionRepository.DeleteEmptyShows(libraryPath);
await _searchIndex.RemoveItems(ids);
return Unit.Default; return Unit.Default;
} }
private async Task<Either<BaseError, Show>> FindOrCreateShow( private async Task<Either<BaseError, MediaItemScanResult<Show>>> FindOrCreateShow(
int libraryPathId, int libraryPathId,
string showFolder) string showFolder)
{ {
ShowMetadata metadata = await _localMetadataProvider.GetMetadataForShow(showFolder); ShowMetadata metadata = await _localMetadataProvider.GetMetadataForShow(showFolder);
Option<Show> maybeShow = await _televisionRepository.GetShowByMetadata(libraryPathId, metadata); Option<Show> maybeShow = await _televisionRepository.GetShowByMetadata(libraryPathId, metadata);
return await maybeShow.Match( return await maybeShow.Match(
show => Right<BaseError, Show>(show).AsTask(), show => Right<BaseError, MediaItemScanResult<Show>>(new MediaItemScanResult<Show>(show)).AsTask(),
async () => await _televisionRepository.AddShow(libraryPathId, showFolder, metadata)); async () => await _televisionRepository.AddShow(libraryPathId, showFolder, metadata));
} }
@ -130,7 +147,9 @@ namespace ErsatzTV.Core.Metadata
// TODO: figure out how to rebuild playlists // TODO: figure out how to rebuild playlists
Either<BaseError, Episode> maybeEpisode = await _televisionRepository Either<BaseError, Episode> maybeEpisode = await _televisionRepository
.GetOrAddEpisode(season, libraryPath, file) .GetOrAddEpisode(season, libraryPath, file)
.BindT(episode => UpdateStatistics(episode, ffprobePath).MapT(_ => episode)) .BindT(
episode => UpdateStatistics(new MediaItemScanResult<Episode>(episode), ffprobePath)
.MapT(_ => episode))
.BindT(UpdateMetadata) .BindT(UpdateMetadata)
.BindT(UpdateThumbnail); .BindT(UpdateThumbnail);
@ -141,12 +160,13 @@ namespace ErsatzTV.Core.Metadata
return Unit.Default; return Unit.Default;
} }
private async Task<Either<BaseError, Show>> UpdateMetadataForShow( private async Task<Either<BaseError, MediaItemScanResult<Show>>> UpdateMetadataForShow(
Show show, MediaItemScanResult<Show> result,
string showFolder) string showFolder)
{ {
try try
{ {
Show show = result.Item;
await LocateNfoFileForShow(showFolder).Match( await LocateNfoFileForShow(showFolder).Match(
async nfoFile => async nfoFile =>
{ {
@ -158,7 +178,10 @@ namespace ErsatzTV.Core.Metadata
if (shouldUpdate) if (shouldUpdate)
{ {
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile); _logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
await _localMetadataProvider.RefreshSidecarMetadata(show, nfoFile); if (await _localMetadataProvider.RefreshSidecarMetadata(show, nfoFile))
{
result.IsUpdated = true;
}
} }
}, },
async () => async () =>
@ -166,11 +189,14 @@ namespace ErsatzTV.Core.Metadata
if (!Optional(show.ShowMetadata).Flatten().Any()) if (!Optional(show.ShowMetadata).Flatten().Any())
{ {
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", showFolder); _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) catch (Exception ex)
{ {
@ -215,13 +241,14 @@ namespace ErsatzTV.Core.Metadata
} }
} }
private async Task<Either<BaseError, Show>> UpdateArtworkForShow( private async Task<Either<BaseError, MediaItemScanResult<Show>>> UpdateArtworkForShow(
Show show, MediaItemScanResult<Show> result,
string showFolder, string showFolder,
ArtworkKind artworkKind) ArtworkKind artworkKind)
{ {
try try
{ {
Show show = result.Item;
await LocateArtworkForShow(showFolder, artworkKind).IfSomeAsync( await LocateArtworkForShow(showFolder, artworkKind).IfSomeAsync(
async posterFile => async posterFile =>
{ {
@ -229,7 +256,7 @@ namespace ErsatzTV.Core.Metadata
await RefreshArtwork(posterFile, metadata, artworkKind); await RefreshArtwork(posterFile, metadata, artworkKind);
}); });
return show; return result;
} }
catch (Exception ex) catch (Exception ex)
{ {

69
ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs

@ -4,9 +4,10 @@ using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata;
using LanguageExt; using LanguageExt;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Plex namespace ErsatzTV.Core.Plex
{ {
@ -16,17 +17,20 @@ namespace ErsatzTV.Core.Plex
private readonly IMetadataRepository _metadataRepository; private readonly IMetadataRepository _metadataRepository;
private readonly IMovieRepository _movieRepository; private readonly IMovieRepository _movieRepository;
private readonly IPlexServerApiClient _plexServerApiClient; private readonly IPlexServerApiClient _plexServerApiClient;
private readonly ISearchIndex _searchIndex;
public PlexMovieLibraryScanner( public PlexMovieLibraryScanner(
IPlexServerApiClient plexServerApiClient, IPlexServerApiClient plexServerApiClient,
IMovieRepository movieRepository, IMovieRepository movieRepository,
IMetadataRepository metadataRepository, IMetadataRepository metadataRepository,
ISearchIndex searchIndex,
ILogger<PlexMovieLibraryScanner> logger) ILogger<PlexMovieLibraryScanner> logger)
: base(metadataRepository) : base(metadataRepository)
{ {
_plexServerApiClient = plexServerApiClient; _plexServerApiClient = plexServerApiClient;
_movieRepository = movieRepository; _movieRepository = movieRepository;
_metadataRepository = metadataRepository; _metadataRepository = metadataRepository;
_searchIndex = searchIndex;
_logger = logger; _logger = logger;
} }
@ -46,21 +50,37 @@ namespace ErsatzTV.Core.Plex
foreach (PlexMovie incoming in movieEntries) foreach (PlexMovie incoming in movieEntries)
{ {
// TODO: figure out how to rebuild playlists // TODO: figure out how to rebuild playlists
Either<BaseError, PlexMovie> maybeMovie = await _movieRepository Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository
.GetOrAdd(plexMediaSourceLibrary, incoming) .GetOrAdd(plexMediaSourceLibrary, incoming)
.BindT(existing => UpdateStatistics(existing, incoming, connection, token)) .BindT(existing => UpdateStatistics(existing, incoming, connection, token))
.BindT(existing => UpdateMetadata(existing, incoming)) .BindT(existing => UpdateMetadata(existing, incoming))
.BindT(existing => UpdateArtwork(existing, incoming)); .BindT(existing => UpdateArtwork(existing, incoming));
maybeMovie.IfLeft( await maybeMovie.Match(
error => _logger.LogWarning( async result =>
"Error processing plex movie at {Key}: {Error}", {
incoming.Key, if (result.IsAdded)
error.Value)); {
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(new List<MediaItem> { 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(); var movieKeys = movieEntries.Map(s => s.Key).ToList();
await _movieRepository.RemoveMissingPlexMovies(plexMediaSourceLibrary, movieKeys); List<int> ids = await _movieRepository.RemoveMissingPlexMovies(plexMediaSourceLibrary, movieKeys);
await _searchIndex.RemoveItems(ids);
}, },
error => error =>
{ {
@ -75,12 +95,13 @@ namespace ErsatzTV.Core.Plex
return Unit.Default; return Unit.Default;
} }
private async Task<Either<BaseError, PlexMovie>> UpdateStatistics( private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateStatistics(
PlexMovie existing, MediaItemScanResult<PlexMovie> result,
PlexMovie incoming, PlexMovie incoming,
PlexConnection connection, PlexConnection connection,
PlexServerAuthToken token) PlexServerAuthToken token)
{ {
PlexMovie existing = result.Item;
MediaVersion existingVersion = existing.MediaVersions.Head(); MediaVersion existingVersion = existing.MediaVersions.Head();
MediaVersion incomingVersion = incoming.MediaVersions.Head(); MediaVersion incomingVersion = incoming.MediaVersions.Head();
@ -102,11 +123,14 @@ namespace ErsatzTV.Core.Plex
_ => Task.CompletedTask); _ => Task.CompletedTask);
} }
return Right<BaseError, PlexMovie>(existing); return result;
} }
private async Task<Either<BaseError, PlexMovie>> UpdateMetadata(PlexMovie existing, PlexMovie incoming) private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateMetadata(
MediaItemScanResult<PlexMovie> result,
PlexMovie incoming)
{ {
PlexMovie existing = result.Item;
MovieMetadata existingMetadata = existing.MovieMetadata.Head(); MovieMetadata existingMetadata = existing.MovieMetadata.Head();
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head(); MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
@ -117,7 +141,10 @@ namespace ErsatzTV.Core.Plex
.ToList()) .ToList())
{ {
existingMetadata.Genres.Remove(genre); existingMetadata.Genres.Remove(genre);
await _metadataRepository.RemoveGenre(genre); if (await _metadataRepository.RemoveGenre(genre))
{
result.IsUpdated = true;
}
} }
foreach (Genre genre in incomingMetadata.Genres foreach (Genre genre in incomingMetadata.Genres
@ -125,17 +152,23 @@ namespace ErsatzTV.Core.Plex
.ToList()) .ToList())
{ {
existingMetadata.Genres.Add(genre); existingMetadata.Genres.Add(genre);
await _movieRepository.AddGenre(existingMetadata, genre); if (await _movieRepository.AddGenre(existingMetadata, genre))
{
result.IsUpdated = true;
}
} }
// TODO: update other metadata? // TODO: update other metadata?
} }
return existing; return result;
} }
private async Task<Either<BaseError, PlexMovie>> UpdateArtwork(PlexMovie existing, PlexMovie incoming) private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateArtwork(
MediaItemScanResult<PlexMovie> result,
PlexMovie incoming)
{ {
PlexMovie existing = result.Item;
MovieMetadata existingMetadata = existing.MovieMetadata.Head(); MovieMetadata existingMetadata = existing.MovieMetadata.Head();
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head(); MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
@ -145,7 +178,7 @@ namespace ErsatzTV.Core.Plex
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt); await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
} }
return existing; return result;
} }
} }
} }

49
ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs

@ -4,6 +4,8 @@ using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata;
using LanguageExt; using LanguageExt;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude; using static LanguageExt.Prelude;
@ -15,18 +17,21 @@ namespace ErsatzTV.Core.Plex
private readonly ILogger<PlexTelevisionLibraryScanner> _logger; private readonly ILogger<PlexTelevisionLibraryScanner> _logger;
private readonly IMetadataRepository _metadataRepository; private readonly IMetadataRepository _metadataRepository;
private readonly IPlexServerApiClient _plexServerApiClient; private readonly IPlexServerApiClient _plexServerApiClient;
private readonly ISearchIndex _searchIndex;
private readonly ITelevisionRepository _televisionRepository; private readonly ITelevisionRepository _televisionRepository;
public PlexTelevisionLibraryScanner( public PlexTelevisionLibraryScanner(
IPlexServerApiClient plexServerApiClient, IPlexServerApiClient plexServerApiClient,
ITelevisionRepository televisionRepository, ITelevisionRepository televisionRepository,
IMetadataRepository metadataRepository, IMetadataRepository metadataRepository,
ISearchIndex searchIndex,
ILogger<PlexTelevisionLibraryScanner> logger) ILogger<PlexTelevisionLibraryScanner> logger)
: base(metadataRepository) : base(metadataRepository)
{ {
_plexServerApiClient = plexServerApiClient; _plexServerApiClient = plexServerApiClient;
_televisionRepository = televisionRepository; _televisionRepository = televisionRepository;
_metadataRepository = metadataRepository; _metadataRepository = metadataRepository;
_searchIndex = searchIndex;
_logger = logger; _logger = logger;
} }
@ -46,13 +51,25 @@ namespace ErsatzTV.Core.Plex
foreach (PlexShow incoming in showEntries) foreach (PlexShow incoming in showEntries)
{ {
// TODO: figure out how to rebuild playlists // TODO: figure out how to rebuild playlists
Either<BaseError, PlexShow> maybeShow = await _televisionRepository Either<BaseError, MediaItemScanResult<PlexShow>> maybeShow = await _televisionRepository
.GetOrAddPlexShow(plexMediaSourceLibrary, incoming) .GetOrAddPlexShow(plexMediaSourceLibrary, incoming)
.BindT(existing => UpdateMetadata(existing, incoming)) .BindT(existing => UpdateMetadata(existing, incoming))
.BindT(existing => UpdateArtwork(existing, incoming)); .BindT(existing => UpdateArtwork(existing, incoming));
await maybeShow.Match( await maybeShow.Match(
async show => await ScanSeasons(plexMediaSourceLibrary, show, connection, token), async result =>
{
if (result.IsAdded)
{
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
}
await ScanSeasons(plexMediaSourceLibrary, result.Item, connection, token);
},
error => error =>
{ {
_logger.LogWarning( _logger.LogWarning(
@ -64,7 +81,9 @@ namespace ErsatzTV.Core.Plex
} }
var showKeys = showEntries.Map(s => s.Key).ToList(); var showKeys = showEntries.Map(s => s.Key).ToList();
await _televisionRepository.RemoveMissingPlexShows(plexMediaSourceLibrary, showKeys); List<int> ids =
await _televisionRepository.RemoveMissingPlexShows(plexMediaSourceLibrary, showKeys);
await _searchIndex.RemoveItems(ids);
return Unit.Default; return Unit.Default;
}, },
@ -79,8 +98,11 @@ namespace ErsatzTV.Core.Plex
}); });
} }
private Task<Either<BaseError, PlexShow>> UpdateMetadata(PlexShow existing, PlexShow incoming) private async Task<Either<BaseError, MediaItemScanResult<PlexShow>>> UpdateMetadata(
MediaItemScanResult<PlexShow> result,
PlexShow incoming)
{ {
PlexShow existing = result.Item;
ShowMetadata existingMetadata = existing.ShowMetadata.Head(); ShowMetadata existingMetadata = existing.ShowMetadata.Head();
ShowMetadata incomingMetadata = incoming.ShowMetadata.Head(); ShowMetadata incomingMetadata = incoming.ShowMetadata.Head();
@ -93,7 +115,10 @@ namespace ErsatzTV.Core.Plex
.ToList()) .ToList())
{ {
existingMetadata.Genres.Remove(genre); existingMetadata.Genres.Remove(genre);
_metadataRepository.RemoveGenre(genre); if (await _metadataRepository.RemoveGenre(genre))
{
result.IsUpdated = true;
}
} }
foreach (Genre genre in incomingMetadata.Genres foreach (Genre genre in incomingMetadata.Genres
@ -101,15 +126,21 @@ namespace ErsatzTV.Core.Plex
.ToList()) .ToList())
{ {
existingMetadata.Genres.Add(genre); existingMetadata.Genres.Add(genre);
_televisionRepository.AddGenre(existingMetadata, genre); if (await _televisionRepository.AddGenre(existingMetadata, genre))
{
result.IsUpdated = true;
}
} }
} }
return Right<BaseError, PlexShow>(existing).AsTask(); return result;
} }
private async Task<Either<BaseError, PlexShow>> UpdateArtwork(PlexShow existing, PlexShow incoming) private async Task<Either<BaseError, MediaItemScanResult<PlexShow>>> UpdateArtwork(
MediaItemScanResult<PlexShow> result,
PlexShow incoming)
{ {
PlexShow existing = result.Item;
ShowMetadata existingMetadata = existing.ShowMetadata.Head(); ShowMetadata existingMetadata = existing.ShowMetadata.Head();
ShowMetadata incomingMetadata = incoming.ShowMetadata.Head(); ShowMetadata incomingMetadata = incoming.ShowMetadata.Head();
@ -119,7 +150,7 @@ namespace ErsatzTV.Core.Plex
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt); await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
} }
return existing; return result;
} }
private async Task<Either<BaseError, Unit>> ScanSeasons( private async Task<Either<BaseError, Unit>> ScanSeasons(

4
ErsatzTV.Core/Search/SearchItem.cs

@ -0,0 +1,4 @@
namespace ErsatzTV.Core.Search
{
public record SearchItem(int Id);
}

6
ErsatzTV.Core/Search/SearchPageMap.cs

@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Search
{
public record SearchPageMap(Dictionary<char, int> PageMap);
}

10
ErsatzTV.Core/Search/SearchResult.cs

@ -0,0 +1,10 @@
using System.Collections.Generic;
using LanguageExt;
namespace ErsatzTV.Core.Search
{
public record SearchResult(List<SearchItem> Items, int TotalCount)
{
public Option<SearchPageMap> PageMap { get; set; }
}
}

6
ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs

@ -84,6 +84,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
@"SELECT COUNT(*) FROM MediaItem WHERE LibraryPathId = @LibraryPathId", @"SELECT COUNT(*) FROM MediaItem WHERE LibraryPathId = @LibraryPathId",
new { LibraryPathId = libraryPathId }); new { LibraryPathId = libraryPathId });
public Task<List<int>> GetMediaIdsByLocalPath(int libraryPathId) =>
_dbConnection.QueryAsync<int>(
@"SELECT Id FROM MediaItem WHERE LibraryPathId = @LibraryPathId",
new { LibraryPathId = libraryPathId })
.Map(result => result.ToList());
public async Task DeleteLocalPath(int libraryPathId) public async Task DeleteLocalPath(int libraryPathId)
{ {
await using TvContext context = _dbContextFactory.CreateDbContext(); await using TvContext context = _dbContextFactory.CreateDbContext();

31
ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs

@ -112,8 +112,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
int? id = await _dbConnection.QuerySingleAsync<int?>( int? id = await _dbConnection.QuerySingleAsync<int?>(
@"SELECT L.MediaSourceId FROM Library L @"SELECT L.MediaSourceId FROM Library L
INNER JOIN PlexLibrary PL on L.Id = PL.Id 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(); await using TvContext context = _dbContextFactory.CreateDbContext();
return await context.PlexMediaSources return await context.PlexMediaSources
.Include(p => p.Connections) .Include(p => p.Connections)
@ -176,18 +177,18 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Unit> DeleteAllPlex() public async Task<Unit> DeleteAllPlex()
{ {
await using TvContext context = _dbContextFactory.CreateDbContext(); await using TvContext context = _dbContextFactory.CreateDbContext();
List<PlexMediaSource> allMediaSources = await context.PlexMediaSources.ToListAsync(); List<PlexMediaSource> allMediaSources = await context.PlexMediaSources.ToListAsync();
context.PlexMediaSources.RemoveRange(allMediaSources); context.PlexMediaSources.RemoveRange(allMediaSources);
List<PlexLibrary> allPlexLibraries = await context.PlexLibraries.ToListAsync(); List<PlexLibrary> allPlexLibraries = await context.PlexLibraries.ToListAsync();
context.PlexLibraries.RemoveRange(allPlexLibraries); context.PlexLibraries.RemoveRange(allPlexLibraries);
await context.SaveChangesAsync(); await context.SaveChangesAsync();
return Unit.Default; return Unit.Default;
} }
public async Task DisablePlexLibrarySync(List<int> libraryIds) public async Task<List<int>> DisablePlexLibrarySync(List<int> libraryIds)
{ {
await _dbConnection.ExecuteAsync( await _dbConnection.ExecuteAsync(
"UPDATE PlexLibrary SET ShouldSyncItems = 0 WHERE Id IN @ids", "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", "UPDATE Library SET LastScan = null WHERE Id IN @ids",
new { ids = libraryIds }); new { ids = libraryIds });
List<int> movieIds = await _dbConnection.QueryAsync<int>(
@"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( await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN @"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m (SELECT m.Id FROM MediaItem m
@ -224,6 +233,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)", WHERE l.Id IN @ids)",
new { ids = libraryIds }); new { ids = libraryIds });
List<int> showIds = await _dbConnection.QueryAsync<int>(
@"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( await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN @"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m (SELECT m.Id FROM MediaItem m
@ -232,6 +249,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
INNER JOIN Library l ON l.Id = lp.LibraryId INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)", WHERE l.Id IN @ids)",
new { ids = libraryIds }); new { ids = libraryIds });
return movieIds.Append(showIds).ToList();
} }
public Task EnablePlexLibrarySync(IEnumerable<int> libraryIds) => public Task EnablePlexLibrarySync(IEnumerable<int> libraryIds) =>

11
ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs

@ -19,9 +19,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
_dbConnection = dbConnection; _dbConnection = dbConnection;
} }
public Task<Unit> RemoveGenre(Genre genre) =>
_dbConnection.ExecuteAsync("DELETE FROM Genre WHERE Id = @GenreId", new { GenreId = genre.Id }).ToUnit();
public async Task<bool> Update(Metadata metadata) public async Task<bool> Update(Metadata metadata)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = _dbContextFactory.CreateDbContext();
@ -53,7 +50,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return await dbContext.SaveChangesAsync() > 0; return await dbContext.SaveChangesAsync() > 0;
} }
public Task<Unit> UpdatePlexStatistics(MediaVersion mediaVersion) => public Task<bool> UpdatePlexStatistics(MediaVersion mediaVersion) =>
_dbConnection.ExecuteAsync( _dbConnection.ExecuteAsync(
@"UPDATE MediaVersion SET @"UPDATE MediaVersion SET
SampleAspectRatio = @SampleAspectRatio, SampleAspectRatio = @SampleAspectRatio,
@ -66,7 +63,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
mediaVersion.VideoScanKind, mediaVersion.VideoScanKind,
mediaVersion.DateUpdated, mediaVersion.DateUpdated,
MediaVersionId = mediaVersion.Id MediaVersionId = mediaVersion.Id
}).ToUnit(); }).Map(result => result > 0);
public Task<Unit> UpdateArtworkPath(Artwork artwork) => public Task<Unit> UpdateArtworkPath(Artwork artwork) =>
_dbConnection.ExecuteAsync( _dbConnection.ExecuteAsync(
@ -111,5 +108,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
@"DELETE FROM Artwork WHERE ArtworkKind = @ArtworkKind AND (MovieMetadataId = @Id @"DELETE FROM Artwork WHERE ArtworkKind = @ArtworkKind AND (MovieMetadataId = @Id
OR ShowMetadataId = @Id OR SeasonMetadataId = @Id OR EpisodeMetadataId = @Id)", OR ShowMetadataId = @Id OR SeasonMetadataId = @Id OR EpisodeMetadataId = @Id)",
new { ArtworkKind = artworkKind, metadata.Id }).ToUnit(); new { ArtworkKind = artworkKind, metadata.Id }).ToUnit();
public Task<bool> RemoveGenre(Genre genre) =>
_dbConnection.ExecuteAsync("DELETE FROM Genre WHERE Id = @GenreId", new { GenreId = genre.Id })
.Map(result => result > 0);
} }
} }

76
ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs

@ -7,6 +7,7 @@ using Dapper;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using LanguageExt; using LanguageExt;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude; using static LanguageExt.Prelude;
@ -45,7 +46,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Map(Optional); .Map(Optional);
} }
public async Task<Either<BaseError, Movie>> GetOrAdd(LibraryPath libraryPath, string path) public async Task<Either<BaseError, MediaItemScanResult<Movie>>> GetOrAdd(LibraryPath libraryPath, string path)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<Movie> maybeExisting = await dbContext.Movies Option<Movie> maybeExisting = await dbContext.Movies
@ -56,17 +57,22 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.MovieMetadata) .Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Tags) .ThenInclude(mm => mm.Tags)
.Include(i => i.LibraryPath) .Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(i => i.MediaVersions) .Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path) .OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path); .SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
return await maybeExisting.Match( return await maybeExisting.Match(
mediaItem => Right<BaseError, Movie>(mediaItem).AsTask(), mediaItem =>
Right<BaseError, MediaItemScanResult<Movie>>(
new MediaItemScanResult<Movie>(mediaItem) { IsAdded = false }).AsTask(),
async () => await AddMovie(dbContext, libraryPath.Id, path)); async () => await AddMovie(dbContext, libraryPath.Id, path));
} }
public async Task<Either<BaseError, PlexMovie>> GetOrAdd(PlexLibrary library, PlexMovie item) public async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> GetOrAdd(
PlexLibrary library,
PlexMovie item)
{ {
await using TvContext context = _dbContextFactory.CreateDbContext(); await using TvContext context = _dbContextFactory.CreateDbContext();
Option<PlexMovie> maybeExisting = await context.PlexMovies Option<PlexMovie> maybeExisting = await context.PlexMovies
@ -74,14 +80,20 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.MovieMetadata) .Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Genres) .ThenInclude(mm => mm.Genres)
.Include(i => i.MovieMetadata) .Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Tags)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Artwork) .ThenInclude(mm => mm.Artwork)
.Include(i => i.MediaVersions) .Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.OrderBy(i => i.Key) .OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key); .SingleOrDefaultAsync(i => i.Key == item.Key);
return await maybeExisting.Match( return await maybeExisting.Match(
plexMovie => Right<BaseError, PlexMovie>(plexMovie).AsTask(), plexMovie =>
Right<BaseError, MediaItemScanResult<PlexMovie>>(
new MediaItemScanResult<PlexMovie>(plexMovie) { IsAdded = true }).AsTask(),
async () => await AddPlexMovie(context, library, item)); async () => await AddPlexMovie(context, library, item));
} }
@ -104,6 +116,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ToListAsync(); .ToListAsync();
} }
public async Task<List<MovieMetadata>> GetMoviesForCards(List<int> 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<IEnumerable<string>> FindMoviePaths(LibraryPath libraryPath) => public Task<IEnumerable<string>> FindMoviePaths(LibraryPath libraryPath) =>
_dbConnection.QueryAsync<string>( _dbConnection.QueryAsync<string>(
@"SELECT MF.Path @"SELECT MF.Path
@ -114,17 +137,18 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE MI.LibraryPathId = @LibraryPathId", WHERE MI.LibraryPathId = @LibraryPathId",
new { LibraryPathId = libraryPath.Id }); new { LibraryPathId = libraryPath.Id });
public async Task<Unit> DeleteByPath(LibraryPath libraryPath, string path) public async Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = _dbContextFactory.CreateDbContext();
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>( List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id @"SELECT M.Id
FROM Movie M FROM Movie M
INNER JOIN MediaItem MI on M.Id = MI.Id INNER JOIN MediaItem MI on M.Id = MI.Id
INNER JOIN MediaVersion MV on M.Id = MV.MovieId INNER JOIN MediaVersion MV on M.Id = MV.MovieId
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path", 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) foreach (int movieId in ids)
{ {
@ -132,26 +156,36 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
dbContext.Movies.Remove(movie); dbContext.Movies.Remove(movie);
} }
await dbContext.SaveChangesAsync(); bool changed = await dbContext.SaveChangesAsync() > 0;
return changed ? ids : new List<int>();
return Unit.Default;
} }
public Task<Unit> AddGenre(MovieMetadata metadata, Genre genre) => public Task<bool> AddGenre(MovieMetadata metadata, Genre genre) =>
_dbConnection.ExecuteAsync( _dbConnection.ExecuteAsync(
"INSERT INTO Genre (Name, MovieMetadataId) VALUES (@Name, @MetadataId)", "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<Unit> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys) => public async Task<List<int>> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys)
_dbConnection.ExecuteAsync( {
List<int> ids = await _dbConnection.QueryAsync<int>(
@"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 @"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m (SELECT m.Id FROM MediaItem m
INNER JOIN PlexMovie pm ON pm.Id = m.Id INNER JOIN PlexMovie pm ON pm.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
WHERE lp.LibraryId = @LibraryId AND pm.Key not in @Keys)", 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<Either<BaseError, Movie>> AddMovie( private static async Task<Either<BaseError, MediaItemScanResult<Movie>>> AddMovie(
TvContext dbContext, TvContext dbContext,
int libraryPathId, int libraryPathId,
string path) string path)
@ -175,7 +209,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await dbContext.Movies.AddAsync(movie); await dbContext.Movies.AddAsync(movie);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync(); 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>(movie) { IsAdded = true };
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -183,7 +218,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
} }
} }
private async Task<Either<BaseError, PlexMovie>> AddPlexMovie( private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> AddPlexMovie(
TvContext context, TvContext context,
PlexLibrary library, PlexLibrary library,
PlexMovie item) PlexMovie item)
@ -195,7 +230,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await context.PlexMovies.AddAsync(item); await context.PlexMovies.AddAsync(item);
await context.SaveChangesAsync(); await context.SaveChangesAsync();
await context.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); await context.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
return item; await context.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<PlexMovie>(item) { IsAdded = true };
} }
catch (Exception ex) catch (Exception ex)
{ {

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

@ -21,6 +21,24 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
_dbConnection = dbConnection; _dbConnection = dbConnection;
} }
public async Task<List<MediaItem>> 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<List<MediaItem>> SearchMediaItemsByTitle(string query) public async Task<List<MediaItem>> SearchMediaItemsByTitle(string query)
{ {
List<int> ids = await _dbConnection.QueryAsync<int>( List<int> ids = await _dbConnection.QueryAsync<int>(

85
ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs

@ -7,6 +7,7 @@ using Dapper;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using LanguageExt; using LanguageExt;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude; using static LanguageExt.Prelude;
@ -82,6 +83,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ToListAsync(); .ToListAsync();
} }
public async Task<List<ShowMetadata>> GetShowsForCards(List<int> 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<List<Season>> GetAllSeasons() public async Task<List<Season>> GetAllSeasons()
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = _dbContextFactory.CreateDbContext();
@ -201,6 +213,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(sm => sm.Genres) .ThenInclude(sm => sm.Genres)
.Include(s => s.ShowMetadata) .Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Tags) .ThenInclude(sm => sm.Tags)
.Include(s => s.LibraryPath)
.ThenInclude(lp => lp.Library)
.OrderBy(s => s.Id) .OrderBy(s => s.Id)
.SingleOrDefaultAsync(s => s.Id == id) .SingleOrDefaultAsync(s => s.Id == id)
.Map(Optional); .Map(Optional);
@ -208,7 +222,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
() => Option<Show>.None.AsTask()); () => Option<Show>.None.AsTask());
} }
public async Task<Either<BaseError, Show>> AddShow(int libraryPathId, string showFolder, ShowMetadata metadata) public async Task<Either<BaseError, MediaItemScanResult<Show>>> AddShow(
int libraryPathId,
string showFolder,
ShowMetadata metadata)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = _dbContextFactory.CreateDbContext();
@ -226,8 +243,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await dbContext.Shows.AddAsync(show); await dbContext.Shows.AddAsync(show);
await dbContext.SaveChangesAsync(); 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>(show) { IsAdded = true };
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -314,19 +333,22 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return Unit.Default; return Unit.Default;
} }
public async Task<Unit> DeleteEmptyShows(LibraryPath libraryPath) public async Task<List<int>> DeleteEmptyShows(LibraryPath libraryPath)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = _dbContextFactory.CreateDbContext();
List<Show> shows = await dbContext.Shows List<Show> shows = await dbContext.Shows
.Filter(s => s.LibraryPathId == libraryPath.Id) .Filter(s => s.LibraryPathId == libraryPath.Id)
.Filter(s => s.Seasons.Count == 0) .Filter(s => s.Seasons.Count == 0)
.ToListAsync(); .ToListAsync();
var ids = shows.Map(s => s.Id).ToList();
dbContext.Shows.RemoveRange(shows); dbContext.Shows.RemoveRange(shows);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
return Unit.Default; return ids;
} }
public async Task<Either<BaseError, PlexShow>> GetOrAddPlexShow(PlexLibrary library, PlexShow item) public async Task<Either<BaseError, MediaItemScanResult<PlexShow>>> GetOrAddPlexShow(
PlexLibrary library,
PlexShow item)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<PlexShow> maybeExisting = await dbContext.PlexShows Option<PlexShow> maybeExisting = await dbContext.PlexShows
@ -334,12 +356,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.ShowMetadata) .Include(i => i.ShowMetadata)
.ThenInclude(mm => mm.Genres) .ThenInclude(mm => mm.Genres)
.Include(i => i.ShowMetadata) .Include(i => i.ShowMetadata)
.ThenInclude(mm => mm.Tags)
.Include(i => i.ShowMetadata)
.ThenInclude(mm => mm.Artwork) .ThenInclude(mm => mm.Artwork)
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.OrderBy(i => i.Key) .OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key); .SingleOrDefaultAsync(i => i.Key == item.Key);
return await maybeExisting.Match( return await maybeExisting.Match(
plexShow => Right<BaseError, PlexShow>(plexShow).AsTask(), plexShow => Right<BaseError, MediaItemScanResult<PlexShow>>(
new MediaItemScanResult<PlexShow>(plexShow) { IsAdded = true }).AsTask(),
async () => await AddPlexShow(dbContext, library, item)); async () => await AddPlexShow(dbContext, library, item));
} }
@ -375,20 +402,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
async () => await AddPlexEpisode(dbContext, library, item)); async () => await AddPlexEpisode(dbContext, library, item));
} }
public Task<Unit> AddGenre(ShowMetadata metadata, Genre genre) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Genre (Name, SeasonMetadataId) VALUES (@Name, @MetadataId)",
new { genre.Name, MetadataId = metadata.Id }).ToUnit();
public Task<Unit> RemoveMissingPlexShows(PlexLibrary library, List<string> 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<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys) => public Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys) =>
_dbConnection.ExecuteAsync( _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN @"DELETE FROM MediaItem WHERE Id IN
@ -414,7 +427,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
episode.EpisodeNumber = episodeNumber; episode.EpisodeNumber = episodeNumber;
await _dbConnection.ExecuteAsync( await _dbConnection.ExecuteAsync(
@"UPDATE Episode SET EpisodeNumber = @EpisodeNumber WHERE Id = @Id", @"UPDATE Episode SET EpisodeNumber = @EpisodeNumber WHERE Id = @Id",
new { EpisodeNumber = episodeNumber, Id = episode.Id }); new { EpisodeNumber = episodeNumber, episode.Id });
return Unit.Default; return Unit.Default;
} }
@ -449,6 +462,31 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ToListAsync(); .ToListAsync();
} }
public Task<bool> 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<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"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<Either<BaseError, Season>> AddSeason( private static async Task<Either<BaseError, Season>> AddSeason(
TvContext dbContext, TvContext dbContext,
Show show, Show show,
@ -519,7 +557,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
} }
} }
private static async Task<Either<BaseError, PlexShow>> AddPlexShow( private static async Task<Either<BaseError, MediaItemScanResult<PlexShow>>> AddPlexShow(
TvContext dbContext, TvContext dbContext,
PlexLibrary library, PlexLibrary library,
PlexShow item) PlexShow item)
@ -531,7 +569,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await dbContext.PlexShows.AddAsync(item); await dbContext.PlexShows.AddAsync(item);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
return item; await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<PlexShow>(item) { IsAdded = true };
} }
catch (Exception ex) catch (Exception ex)
{ {

3
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -7,6 +7,9 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Dapper" Version="2.0.78" /> <PackageReference Include="Dapper" Version="2.0.78" />
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00013" />
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00013" />
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00013" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.4" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.4"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.4">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>

6
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -176,7 +176,8 @@ namespace ErsatzTV.Infrastructure.Plex
Tagline = response.Tagline, Tagline = response.Tagline,
DateAdded = dateAdded, DateAdded = dateAdded,
DateUpdated = lastWriteTime, 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<Tag>()
}; };
if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate)) if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate))
@ -277,7 +278,8 @@ namespace ErsatzTV.Infrastructure.Plex
Tagline = response.Tagline, Tagline = response.Tagline,
DateAdded = dateAdded, DateAdded = dateAdded,
DateUpdated = lastWriteTime, 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<Tag>()
}; };
if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate)) if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate))

287
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<bool> Initialize()
{
_localFileSystem.EnsureFolderExists(FileSystemLayout.SearchIndexFolder);
return Task.FromResult(true);
}
public async Task<Unit> Rebuild(List<MediaItem> items)
{
await Initialize();
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
var analyzer = new StandardAnalyzer(AppLuceneVersion);
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer) { OpenMode = OpenMode.CREATE };
using var writer = new IndexWriter(dir, indexConfig);
UpdateMovies(items.OfType<Movie>(), writer);
UpdateShows(items.OfType<Show>(), writer);
return Unit.Default;
}
public Task<Unit> AddItems(List<MediaItem> items) => UpdateItems(items);
public Task<Unit> UpdateItems(List<MediaItem> items)
{
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
var analyzer = new StandardAnalyzer(AppLuceneVersion);
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer) { OpenMode = OpenMode.APPEND };
using var writer = new IndexWriter(dir, indexConfig);
UpdateMovies(items.OfType<Movie>(), writer);
UpdateShows(items.OfType<Show>(), writer);
return Task.FromResult(Unit.Default);
}
public Task<Unit> RemoveItems(List<int> ids)
{
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
var analyzer = new StandardAnalyzer(AppLuceneVersion);
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer) { OpenMode = OpenMode.APPEND };
using var writer = new IndexWriter(dir, indexConfig);
foreach (int id in ids)
{
writer.DeleteDocuments(new Term(IdField, id.ToString()));
}
return Task.FromResult(Unit.Default);
}
public Task<SearchResult> Search(string searchQuery, int skip, int limit, string searchField = "")
{
if (string.IsNullOrWhiteSpace(searchQuery.Replace("*", string.Empty).Replace("?", string.Empty)))
{
return new SearchResult(new List<SearchItem>(), 0).AsTask();
}
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
using var reader = DirectoryReader.Open(dir);
var searcher = new IndexSearcher(reader);
int hitsLimit = skip + limit;
using var analyzer = new StandardAnalyzer(AppLuceneVersion);
QueryParser parser = !string.IsNullOrWhiteSpace(searchField)
? new QueryParser(AppLuceneVersion, searchField, analyzer)
: new MultiFieldQueryParser(AppLuceneVersion, _searchFields, analyzer);
Query query = ParseQuery(searchQuery, parser);
var filter = new DuplicateFilter(TitleAndYearField);
var sort = new Sort(new SortField(SortTitleField, SortFieldType.STRING));
TopFieldDocs topDocs = searcher.Search(query, filter, hitsLimit, sort, true, true);
IEnumerable<ScoreDoc> selectedHits = topDocs.ScoreDocs.Skip(skip).Take(limit);
var searchResult = new SearchResult(
selectedHits.Map(d => ProjectToSearchItem(searcher.Doc(d.Doc))).ToList(),
topDocs.TotalHits);
searchResult.PageMap = GetSearchPageMap(searcher, query, filter, sort, limit);
return searchResult.AsTask();
}
private static Option<SearchPageMap> GetSearchPageMap(
IndexSearcher searcher,
Query query,
DuplicateFilter filter,
Sort sort,
int limit)
{
ScoreDoc[] allDocs = searcher.Search(query, filter, int.MaxValue, sort, true, true).ScoreDocs;
var letters = new List<char>
{
'#', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
'u', 'v', 'w', 'x', 'y', 'z'
};
var map = letters.ToDictionary(letter => letter, _ => 0);
var current = 0;
var page = 0;
while (current < allDocs.Length)
{
// walk up by page size (limit)
page++;
current += limit;
if (current > allDocs.Length)
{
current = allDocs.Length;
}
char jumpLetter = searcher.Doc(allDocs[current - 1].Doc).Get(JumpLetterField).Head();
foreach (char letter in letters.Where(l => letters.IndexOf(l) <= letters.IndexOf(jumpLetter)))
{
if (map[letter] == 0)
{
map[letter] = page;
}
}
}
int max = map.Values.Max();
foreach (char letter in letters.Where(letter => map[letter] == 0))
{
map[letter] = max;
}
return new SearchPageMap(map);
}
private static void UpdateMovies(IEnumerable<Movie> movies, IndexWriter writer)
{
foreach (Movie movie in movies)
{
Option<MovieMetadata> maybeMetadata = movie.MovieMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
{
MovieMetadata metadata = maybeMetadata.ValueUnsafe();
var doc = new Document
{
new StringField(IdField, movie.Id.ToString(), Field.Store.YES),
new StringField(TypeField, MovieType, Field.Store.NO),
new TextField(TitleField, metadata.Title, Field.Store.NO),
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
new TextField(LibraryNameField, movie.LibraryPath.Library.Name, Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
};
if (!string.IsNullOrWhiteSpace(metadata.Plot))
{
doc.Add(new TextField(PlotField, metadata.Plot ?? string.Empty, Field.Store.NO));
}
foreach (Genre genre in metadata.Genres)
{
doc.Add(new TextField(GenreField, genre.Name, Field.Store.NO));
}
foreach (Tag tag in metadata.Tags)
{
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO));
}
writer.UpdateDocument(new Term(IdField, movie.Id.ToString()), doc);
}
}
}
private static void UpdateShows(IEnumerable<Show> shows, IndexWriter writer)
{
foreach (Show show in shows)
{
Option<ShowMetadata> maybeMetadata = show.ShowMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
{
ShowMetadata metadata = maybeMetadata.ValueUnsafe();
var doc = new Document
{
new StringField(IdField, show.Id.ToString(), Field.Store.YES),
new StringField(TypeField, ShowType, Field.Store.NO),
new TextField(TitleField, metadata.Title, Field.Store.NO),
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
new TextField(LibraryNameField, show.LibraryPath.Library.Name, Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
};
if (!string.IsNullOrWhiteSpace(metadata.Plot))
{
doc.Add(new TextField(PlotField, metadata.Plot ?? string.Empty, Field.Store.NO));
}
foreach (Genre genre in metadata.Genres)
{
doc.Add(new TextField(GenreField, genre.Name, Field.Store.NO));
}
foreach (Tag tag in metadata.Tags)
{
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO));
}
writer.UpdateDocument(new Term(IdField, show.Id.ToString()), doc);
}
}
}
private SearchItem ProjectToSearchItem(Document doc) => new(Convert.ToInt32(doc.Get(IdField)));
private Query ParseQuery(string searchQuery, QueryParser parser)
{
Query query;
try
{
query = parser.Parse(searchQuery.Trim());
}
catch (ParseException)
{
query = parser.Parse(QueryParserBase.Escape(searchQuery.Trim()));
}
return query;
}
private static string GetTitleAndYear(Metadata metadata) =>
$"{metadata.Title}_{metadata.Year}";
private static string GetJumpLetter(Metadata metadata)
{
char c = metadata.SortTitle.ToLowerInvariant().Head();
return c switch
{
(>= 'a' and <= 'z') => c.ToString(),
_ => "#"
};
}
}
}

2
ErsatzTV/Pages/ChannelEditor.razor

@ -1,11 +1,11 @@
@page "/channels/{Id:int?}" @page "/channels/{Id:int?}"
@page "/channels/add" @page "/channels/add"
@using static LanguageExt.Prelude
@using ErsatzTV.Application.FFmpegProfiles @using ErsatzTV.Application.FFmpegProfiles
@using ErsatzTV.Application.FFmpegProfiles.Queries @using ErsatzTV.Application.FFmpegProfiles.Queries
@using ErsatzTV.Application.Images.Commands @using ErsatzTV.Application.Images.Commands
@using ErsatzTV.Application.Channels @using ErsatzTV.Application.Channels
@using ErsatzTV.Application.Channels.Queries @using ErsatzTV.Application.Channels.Queries
@using static LanguageExt.Prelude
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject ILogger<ChannelEditor> Logger @inject ILogger<ChannelEditor> Logger
@inject ISnackbar Snackbar @inject ISnackbar Snackbar

2
ErsatzTV/Pages/FFmpeg.razor

@ -33,7 +33,7 @@
<MudSwitch T="bool" <MudSwitch T="bool"
Label="Save troubleshooting reports to disk" Label="Save troubleshooting reports to disk"
Color="Color.Primary" Color="Color.Primary"
@bind-Checked="@_ffmpegSettings.SaveReports" /> @bind-Checked="@_ffmpegSettings.SaveReports"/>
</MudElement> </MudElement>
</MudForm> </MudForm>
</MudCardContent> </MudCardContent>

4
ErsatzTV/Pages/Movie.razor

@ -49,7 +49,7 @@
<div class="mb-2"> <div class="mb-2">
@foreach (string genre in _movie.Genres.OrderBy(g => g)) @foreach (string genre in _movie.Genres.OrderBy(g => g))
{ {
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a{genre}")"/> <MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a%22{genre.ToLowerInvariant()}%22")"/>
} }
</div> </div>
} }
@ -59,7 +59,7 @@
<div> <div>
@foreach (string tag in _movie.Tags.OrderBy(t => t)) @foreach (string tag in _movie.Tags.OrderBy(t => t))
{ {
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a{tag}")"/> <MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a%22{tag.ToLowerInvariant()}%22")"/>
} }
</div> </div>
} }

48
ErsatzTV/Pages/MovieList.razor

@ -1,9 +1,12 @@
@page "/media/movies" @page "/media/movies"
@page "/media/movies/page/{PageNumber:int}" @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
@using ErsatzTV.Application.MediaCards.Queries
@using ErsatzTV.Application.MediaCollections @using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands @using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.Search.Queries
@using Unit = LanguageExt.Unit @using Unit = LanguageExt.Unit
@inherits MultiSelectBase<MovieList> @inherits MultiSelectBase<MovieList>
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@ -32,7 +35,8 @@
} }
else else
{ {
<div style="margin-left: auto; margin-right: auto; max-width: 300px;"> <MudText Style="margin-bottom: auto; margin-top: auto; width: 33%">@_query</MudText>
<div style="max-width: 300px; width: 33%;">
<MudPaper Style="align-items: center; display: flex; justify-content: center;"> <MudPaper Style="align-items: center; display: flex; justify-content: center;">
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft" <MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
OnClick="@PrevPage" OnClick="@PrevPage"
@ -63,6 +67,12 @@
} }
</MudContainer> </MudContainer>
</MudContainer> </MudContainer>
@if (_data.PageMap.IsSome)
{
<LetterBar PageMap="@_data.PageMap.ValueUnsafe()"
BaseUri="/media/movies"
Query="@_query"/>
}
@code { @code {
private static int PageSize => 100; private static int PageSize => 100;
@ -71,6 +81,7 @@
public int PageNumber { get; set; } public int PageNumber { get; set; }
private MovieCardResultsViewModel _data; private MovieCardResultsViewModel _data;
private string _query;
protected override Task OnParametersSetAsync() protected override Task OnParametersSetAsync()
{ {
@ -79,15 +90,40 @@
PageNumber = 1; PageNumber = 1;
} }
string query = new Uri(NavigationManager.Uri).Query;
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
{
_query = value;
}
return RefreshData(); return RefreshData();
} }
protected override async Task RefreshData() => protected override async Task RefreshData()
_data = await Mediator.Send(new GetMovieCards(PageNumber, PageSize)); {
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) private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{ {

2
ErsatzTV/Pages/ScheduleItemsEditor.razor

@ -84,7 +84,7 @@
@if (_selectedItem is not null) @if (_selectedItem is not null)
{ {
<div style="display: flex; flex-direction: row;" class="mt-6"> <div style="display: flex; flex-direction: row;" class="mt-6">
<div style="max-width: 400px; flex-grow: 1"> <div style="flex-grow: 1; max-width: 400px;">
<EditForm Model="_selectedItem"> <EditForm Model="_selectedItem">
<FluentValidator/> <FluentValidator/>
<MudCard> <MudCard>

95
ErsatzTV/Pages/Search.razor

@ -1,8 +1,8 @@
@page "/search" @page "/search"
@using ErsatzTV.Application.MediaCards @using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCards.Queries
@using ErsatzTV.Application.MediaCollections @using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands @using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.Search.Queries
@using Microsoft.AspNetCore.WebUtilities @using Microsoft.AspNetCore.WebUtilities
@using Microsoft.Extensions.Primitives @using Microsoft.Extensions.Primitives
@using Unit = LanguageExt.Unit @using Unit = LanguageExt.Unit
@ -34,23 +34,43 @@
else else
{ {
<MudText>@_query</MudText> <MudText>@_query</MudText>
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#movies")">@_data.MovieCards.Count Movies</MudLink> if (_movies.Count > 0)
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#shows")">@_data.ShowCards.Count Shows</MudLink> {
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#movies")">@_movies.Count Movies</MudLink>
}
else
{
<MudText Class="ml-4">0 Movies</MudText>
}
if (_shows.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#shows")">@_shows.Count Shows</MudLink>
}
else
{
<MudText Class="ml-4">0 Shows</MudText>
}
} }
</div> </div>
</MudPaper> </MudPaper>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Style="margin-top: 96px"> <MudContainer MaxWidth="MaxWidth.ExtraLarge" Style="margin-top: 96px">
@if (_data?.MovieCards.Any() == true) @if (_movies.Count > 0)
{ {
<MudText GutterBottom="true" <div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
Typo="Typo.h4" <MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px" Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })"> UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })">
Movies Movies
</MudText> </MudText>
@if (_movies.Count > 50)
{
<MudLink Href="@GetMoviesLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> <MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MovieCardViewModel card in _data.MovieCards.OrderBy(m => m.SortTitle)) @foreach (MovieCardViewModel card in _movies.Cards.OrderBy(m => m.SortTitle))
{ {
<MediaCard Data="@card" <MediaCard Data="@card"
Link="@($"/media/movies/{card.MovieId}")" Link="@($"/media/movies/{card.MovieId}")"
@ -62,17 +82,22 @@
</MudContainer> </MudContainer>
} }
@if (_data?.ShowCards.Any() == true) @if (_shows.Count > 0)
{ {
<MudText GutterBottom="true" <div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
Typo="Typo.h4" <MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px" Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })"> UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })">
Shows Shows
</MudText> </MudText>
@if (_shows.Count > 50)
{
<MudLink Href="@GetShowsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> <MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionShowCardViewModel card in _data.ShowCards.OrderBy(s => s.SortTitle)) @foreach (TelevisionShowCardViewModel card in _shows.Cards.OrderBy(s => s.SortTitle))
{ {
<MediaCard Data="@card" <MediaCard Data="@card"
Link="@($"/media/tv/shows/{card.TelevisionShowId}")" Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
@ -87,7 +112,8 @@
@code { @code {
private string _query; private string _query;
private SearchCardResultsViewModel _data; private MovieCardResultsViewModel _movies;
private TelevisionShowCardResultsViewModel _shows;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@ -96,8 +122,9 @@
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value)) if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
{ {
_query = value; _query = value;
Either<BaseError, SearchCardResultsViewModel> maybeResults = await Mediator.Send(new GetSearchCards(_query));
maybeResults.IfRight(results => _data = results); _movies = await Mediator.Send(new QuerySearchIndexMovies($"type:movie AND ({_query})", 1, 50));
_shows = await Mediator.Send(new QuerySearchIndexShows($"type:show AND ({_query})", 1, 50));
} }
} }
@ -105,8 +132,8 @@
{ {
List<MediaCardViewModel> GetSortedItems() List<MediaCardViewModel> GetSortedItems()
{ {
return _data.MovieCards.OrderBy(m => m.SortTitle) return _movies.Cards.OrderBy(m => m.SortTitle)
.Append<MediaCardViewModel>(_data.ShowCards.OrderBy(s => s.SortTitle)) .Append<MediaCardViewModel>(_shows.Cards.OrderBy(s => s.SortTitle))
.ToList(); .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;
}
} }

4
ErsatzTV/Pages/TelevisionSeasonList.razor

@ -64,7 +64,7 @@
<div class="mb-2"> <div class="mb-2">
@foreach (string genre in _show.Genres.OrderBy(g => g)) @foreach (string genre in _show.Genres.OrderBy(g => g))
{ {
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a{genre}")"/> <MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a%22{genre.ToLowerInvariant()}%22")"/>
} }
</div> </div>
} }
@ -74,7 +74,7 @@
<div> <div>
@foreach (string tag in _show.Tags.OrderBy(t => t)) @foreach (string tag in _show.Tags.OrderBy(t => t))
{ {
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a{tag}")"/> <MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a%22{tag.ToLowerInvariant()}%22")"/>
} }
</div> </div>
} }

48
ErsatzTV/Pages/TelevisionShowList.razor

@ -1,9 +1,12 @@
@page "/media/tv/shows" @page "/media/tv/shows"
@page "/media/tv/shows/page/{PageNumber:int}" @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
@using ErsatzTV.Application.MediaCards.Queries
@using ErsatzTV.Application.MediaCollections @using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands @using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.Search.Queries
@using Unit = LanguageExt.Unit @using Unit = LanguageExt.Unit
@inherits MultiSelectBase<TelevisionShowList> @inherits MultiSelectBase<TelevisionShowList>
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@ -32,7 +35,8 @@
} }
else else
{ {
<div style="margin-left: auto; margin-right: auto; max-width: 300px;"> <MudText Style="margin-bottom: auto; margin-top: auto; width: 33%">@_query</MudText>
<div style="max-width: 300px; width: 33%;">
<MudPaper Style="align-items: center; display: flex; justify-content: center;"> <MudPaper Style="align-items: center; display: flex; justify-content: center;">
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft" <MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
OnClick="@PrevPage" OnClick="@PrevPage"
@ -63,6 +67,12 @@
} }
</MudContainer> </MudContainer>
</MudContainer> </MudContainer>
@if (_data.PageMap.IsSome)
{
<LetterBar PageMap="@_data.PageMap.ValueUnsafe()"
BaseUri="/media/tv/shows"
Query="@_query"/>
}
@code { @code {
private static int PageSize => 100; private static int PageSize => 100;
@ -71,6 +81,7 @@
public int PageNumber { get; set; } public int PageNumber { get; set; }
private TelevisionShowCardResultsViewModel _data; private TelevisionShowCardResultsViewModel _data;
private string _query;
protected override Task OnParametersSetAsync() protected override Task OnParametersSetAsync()
{ {
@ -79,15 +90,40 @@
PageNumber = 1; PageNumber = 1;
} }
string query = new Uri(NavigationManager.Uri).Query;
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
{
_query = value;
}
return RefreshData(); return RefreshData();
} }
protected override async Task RefreshData() => protected override async Task RefreshData()
_data = await Mediator.Send(new GetTelevisionShowCards(PageNumber, PageSize)); {
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) private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{ {

5
ErsatzTV/Services/SchedulerService.cs

@ -8,6 +8,7 @@ using ErsatzTV.Application;
using ErsatzTV.Application.MediaSources.Commands; using ErsatzTV.Application.MediaSources.Commands;
using ErsatzTV.Application.Playouts.Commands; using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Application.Plex.Commands; using ErsatzTV.Application.Plex.Commands;
using ErsatzTV.Application.Search.Commands;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
@ -54,6 +55,7 @@ namespace ErsatzTV.Services
private async Task DoWork(CancellationToken cancellationToken) private async Task DoWork(CancellationToken cancellationToken)
{ {
await RebuildSearchIndex(cancellationToken);
await BuildPlayouts(cancellationToken); await BuildPlayouts(cancellationToken);
await ScanLocalMediaSources(cancellationToken); await ScanLocalMediaSources(cancellationToken);
await ScanPlexMediaSources(cancellationToken); await ScanPlexMediaSources(cancellationToken);
@ -111,5 +113,8 @@ namespace ErsatzTV.Services
} }
} }
} }
private async Task RebuildSearchIndex(CancellationToken cancellationToken) =>
await _channel.WriteAsync(new RebuildSearchIndex(), cancellationToken);
} }
} }

4
ErsatzTV/Services/WorkerService.cs

@ -6,6 +6,7 @@ using ErsatzTV.Application;
using ErsatzTV.Application.MediaSources.Commands; using ErsatzTV.Application.MediaSources.Commands;
using ErsatzTV.Application.Playouts.Commands; using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Application.Plex.Commands; using ErsatzTV.Application.Plex.Commands;
using ErsatzTV.Application.Search.Commands;
using ErsatzTV.Core; using ErsatzTV.Core;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
@ -82,6 +83,9 @@ namespace ErsatzTV.Services
synchronizePlexLibraryById.PlexLibraryId, synchronizePlexLibraryById.PlexLibraryId,
error.Value)); error.Value));
break; break;
case RebuildSearchIndex rebuildSearchIndex:
await mediator.Send(rebuildSearchIndex, cancellationToken);
break;
} }
} }
catch (Exception ex) catch (Exception ex)

56
ErsatzTV/Shared/LetterBar.razor

@ -0,0 +1,56 @@
@using ErsatzTV.Core.Search
@using Microsoft.AspNetCore.WebUtilities
<div class="letter-bar-container">
<div class="letter-bar">
<MudLink Href="@GetLinkForLetter('#')">#</MudLink>
<MudLink Href="@GetLinkForLetter('a')">A</MudLink>
<MudLink Href="@GetLinkForLetter('b')">B</MudLink>
<MudLink Href="@GetLinkForLetter('c')">C</MudLink>
<MudLink Href="@GetLinkForLetter('d')">D</MudLink>
<MudLink Href="@GetLinkForLetter('e')">E</MudLink>
<MudLink Href="@GetLinkForLetter('f')">F</MudLink>
<MudLink Href="@GetLinkForLetter('g')">G</MudLink>
<MudLink Href="@GetLinkForLetter('h')">H</MudLink>
<MudLink Href="@GetLinkForLetter('i')">I</MudLink>
<MudLink Href="@GetLinkForLetter('j')">J</MudLink>
<MudLink Href="@GetLinkForLetter('k')">K</MudLink>
<MudLink Href="@GetLinkForLetter('l')">L</MudLink>
<MudLink Href="@GetLinkForLetter('m')">M</MudLink>
<MudLink Href="@GetLinkForLetter('n')">N</MudLink>
<MudLink Href="@GetLinkForLetter('o')">O</MudLink>
<MudLink Href="@GetLinkForLetter('p')">P</MudLink>
<MudLink Href="@GetLinkForLetter('q')">Q</MudLink>
<MudLink Href="@GetLinkForLetter('r')">R</MudLink>
<MudLink Href="@GetLinkForLetter('s')">S</MudLink>
<MudLink Href="@GetLinkForLetter('t')">T</MudLink>
<MudLink Href="@GetLinkForLetter('u')">U</MudLink>
<MudLink Href="@GetLinkForLetter('v')">V</MudLink>
<MudLink Href="@GetLinkForLetter('w')">W</MudLink>
<MudLink Href="@GetLinkForLetter('x')">X</MudLink>
<MudLink Href="@GetLinkForLetter('y')">Y</MudLink>
<MudLink Href="@GetLinkForLetter('z')">Z</MudLink>
</div>
</div>
@code {
[Parameter]
public SearchPageMap PageMap { get; set; }
[Parameter]
public string BaseUri { get; set; }
[Parameter]
public string Query { get; set; }
private string GetLinkForLetter(char letter)
{
var uri = $"{BaseUri}/page/{PageMap.PageMap[letter]}";
if (!string.IsNullOrWhiteSpace(Query))
{
uri = QueryHelpers.AddQueryString(uri, "query", Query);
}
return uri;
}
}

3
ErsatzTV/Startup.cs

@ -15,6 +15,7 @@ using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex; using ErsatzTV.Core.Plex;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
@ -24,6 +25,7 @@ using ErsatzTV.Infrastructure.Data.Repositories;
using ErsatzTV.Infrastructure.Images; using ErsatzTV.Infrastructure.Images;
using ErsatzTV.Infrastructure.Locking; using ErsatzTV.Infrastructure.Locking;
using ErsatzTV.Infrastructure.Plex; using ErsatzTV.Infrastructure.Plex;
using ErsatzTV.Infrastructure.Search;
using ErsatzTV.Serialization; using ErsatzTV.Serialization;
using ErsatzTV.Services; using ErsatzTV.Services;
using FluentValidation.AspNetCore; using FluentValidation.AspNetCore;
@ -209,6 +211,7 @@ namespace ErsatzTV
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>(); services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>(); services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>();
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>(); services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
services.AddScoped<ISearchIndex, SearchIndex>();
services.AddHostedService<PlexService>(); services.AddHostedService<PlexService>();
services.AddHostedService<FFmpegLocatorService>(); services.AddHostedService<FFmpegLocatorService>();

14
ErsatzTV/wwwroot/css/site.css

@ -106,4 +106,18 @@
.media-item-subtitle { .media-item-subtitle {
color: #fff; color: #fff;
text-shadow: 1px 1px 5px #000; 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;
} }
Loading…
Cancel
Save