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

4
ErsatzTV.Application/MediaCards/MovieCardResultsViewModel.cs

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

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

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

0
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; @@ -4,6 +4,7 @@ using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
namespace ErsatzTV.Application.Plex.Commands
@ -13,16 +14,23 @@ namespace ErsatzTV.Application.Plex.Commands @@ -13,16 +14,23 @@ namespace ErsatzTV.Application.Plex.Commands
Either<BaseError, Unit>>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly ISearchIndex _searchIndex;
public UpdatePlexLibraryPreferencesHandler(IMediaSourceRepository mediaSourceRepository) =>
public UpdatePlexLibraryPreferencesHandler(
IMediaSourceRepository mediaSourceRepository,
ISearchIndex searchIndex)
{
_mediaSourceRepository = mediaSourceRepository;
_searchIndex = searchIndex;
}
public async Task<Either<BaseError, Unit>> Handle(
UpdatePlexLibraryPreferences request,
CancellationToken cancellationToken)
{
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);
await _mediaSourceRepository.EnablePlexLibrarySync(toEnable);

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

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

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

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

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

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

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

@ -3,6 +3,7 @@ using System.Collections.Generic; @@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using LanguageExt;
namespace ErsatzTV.Core.Tests.Fakes
@ -20,6 +21,8 @@ namespace ErsatzTV.Core.Tests.Fakes @@ -20,6 +21,8 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize) =>
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<Season>> GetAllSeasons() => throw new NotSupportedException();
@ -43,7 +46,7 @@ namespace ErsatzTV.Core.Tests.Fakes @@ -43,7 +46,7 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata) =>
throw new NotSupportedException();
public Task<Either<BaseError, Show>>
public Task<Either<BaseError, MediaItemScanResult<Show>>>
AddShow(int libraryPathId, string showFolder, ShowMetadata metadata) =>
throw new NotSupportedException();
@ -59,9 +62,11 @@ namespace ErsatzTV.Core.Tests.Fakes @@ -59,9 +62,11 @@ namespace ErsatzTV.Core.Tests.Fakes
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();
public Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item) =>
@ -70,9 +75,9 @@ namespace ErsatzTV.Core.Tests.Fakes @@ -70,9 +75,9 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item) =>
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();
public Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys) =>

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

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

1
ErsatzTV.Core/Domain/ConfigElementKey.cs

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

1
ErsatzTV.Core/FileSystemLayout.cs

@ -20,6 +20,7 @@ namespace ErsatzTV.Core @@ -20,6 +20,7 @@ namespace ErsatzTV.Core
public static readonly string PlexSecretsPath = Path.Combine(AppDataFolder, "plex-secrets.json");
public static readonly string FFmpegReportsFolder = Path.Combine(AppDataFolder, "ffmpeg-reports");
public static readonly string SearchIndexFolder = Path.Combine(AppDataFolder, "search-index");
public static readonly string ArtworkCacheFolder = Path.Combine(AppDataFolder, "cache", "artwork");

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

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

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

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

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

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

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

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

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

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

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

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

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

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

19
ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Search
{
public interface ISearchIndex
{
public int Version { get; }
Task<bool> Initialize();
Task<Unit> Rebuild(List<MediaItem> items);
Task<Unit> AddItems(List<MediaItem> items);
Task<Unit> UpdateItems(List<MediaItem> items);
Task<Unit> RemoveItems(List<int> ids);
Task<SearchResult> Search(string query, int skip, int limit, string searchField = "");
}
}

10
ErsatzTV.Core/Metadata/LocalFileSystem.cs

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

19
ErsatzTV.Core/Metadata/LocalFolderScanner.cs

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

46
ErsatzTV.Core/Metadata/LocalMetadataProvider.cs

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

11
ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs

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

14
ErsatzTV.Core/Metadata/MediaItemScanResult.cs

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

54
ErsatzTV.Core/Metadata/MovieFolderScanner.cs

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

55
ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs

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

69
ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs

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

49
ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs

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

4
ErsatzTV.Core/Search/SearchItem.cs

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

6
ErsatzTV.Core/Search/SearchPageMap.cs

@ -0,0 +1,6 @@ @@ -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 @@ @@ -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 @@ -84,6 +84,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
@"SELECT COUNT(*) FROM MediaItem WHERE 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)
{
await using TvContext context = _dbContextFactory.CreateDbContext();

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

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

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

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

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

@ -21,6 +21,24 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -21,6 +21,24 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
_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)
{
List<int> ids = await _dbConnection.QueryAsync<int>(

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

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

3
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -7,6 +7,9 @@ @@ -7,6 +7,9 @@
<ItemGroup>
<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.Design" Version="5.0.4">
<PrivateAssets>all</PrivateAssets>

6
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -176,7 +176,8 @@ namespace ErsatzTV.Infrastructure.Plex @@ -176,7 +176,8 @@ namespace ErsatzTV.Infrastructure.Plex
Tagline = response.Tagline,
DateAdded = dateAdded,
DateUpdated = lastWriteTime,
Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList()
Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList(),
Tags = new List<Tag>()
};
if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate))
@ -277,7 +278,8 @@ namespace ErsatzTV.Infrastructure.Plex @@ -277,7 +278,8 @@ namespace ErsatzTV.Infrastructure.Plex
Tagline = response.Tagline,
DateAdded = dateAdded,
DateUpdated = lastWriteTime,
Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList()
Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList(),
Tags = new List<Tag>()
};
if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate))

287
ErsatzTV.Infrastructure/Search/SearchIndex.cs

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

2
ErsatzTV/Pages/ChannelEditor.razor

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

2
ErsatzTV/Pages/FFmpeg.razor

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

4
ErsatzTV/Pages/Movie.razor

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

48
ErsatzTV/Pages/MovieList.razor

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

2
ErsatzTV/Pages/ScheduleItemsEditor.razor

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

95
ErsatzTV/Pages/Search.razor

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

48
ErsatzTV/Pages/TelevisionShowList.razor

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

5
ErsatzTV/Services/SchedulerService.cs

@ -8,6 +8,7 @@ using ErsatzTV.Application; @@ -8,6 +8,7 @@ using ErsatzTV.Application;
using ErsatzTV.Application.MediaSources.Commands;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Application.Plex.Commands;
using ErsatzTV.Application.Search.Commands;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Infrastructure.Data;
@ -54,6 +55,7 @@ namespace ErsatzTV.Services @@ -54,6 +55,7 @@ namespace ErsatzTV.Services
private async Task DoWork(CancellationToken cancellationToken)
{
await RebuildSearchIndex(cancellationToken);
await BuildPlayouts(cancellationToken);
await ScanLocalMediaSources(cancellationToken);
await ScanPlexMediaSources(cancellationToken);
@ -111,5 +113,8 @@ namespace ErsatzTV.Services @@ -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; @@ -6,6 +6,7 @@ using ErsatzTV.Application;
using ErsatzTV.Application.MediaSources.Commands;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Application.Plex.Commands;
using ErsatzTV.Application.Search.Commands;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
@ -82,6 +83,9 @@ namespace ErsatzTV.Services @@ -82,6 +83,9 @@ namespace ErsatzTV.Services
synchronizePlexLibraryById.PlexLibraryId,
error.Value));
break;
case RebuildSearchIndex rebuildSearchIndex:
await mediator.Send(rebuildSearchIndex, cancellationToken);
break;
}
}
catch (Exception ex)

56
ErsatzTV/Shared/LetterBar.razor

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

3
ErsatzTV/Startup.cs

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

14
ErsatzTV/wwwroot/css/site.css

@ -106,4 +106,18 @@ @@ -106,4 +106,18 @@
.media-item-subtitle {
color: #fff;
text-shadow: 1px 1px 5px #000;
}
.letter-bar-container {
height: calc(100vh - 128px);
position: fixed;
right: 1em;
top: 128px;
}
.letter-bar {
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-around;
}
Loading…
Cancel
Save