Browse Source

starting database redesign

pull/31/head
Jason Dove 6 years ago
parent
commit
8783d5bae1
  1. 2
      ErsatzTV.Application/MediaCards/Mapper.cs
  2. 6
      ErsatzTV.Application/MediaCollections/Commands/AddMovieToSimpleMediaCollectionHandler.cs
  3. 2
      ErsatzTV.Application/MediaCollections/Commands/CreateSimpleMediaCollectionHandler.cs
  4. 27
      ErsatzTV.Application/MediaItems/Mapper.cs
  5. 2
      ErsatzTV.Application/MediaItems/MediaItemViewModel.cs
  6. 20
      ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSource.cs
  7. 154
      ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSourceHandler.cs
  8. 114
      ErsatzTV.Application/MediaSources/Commands/ScanLocalLibraryHandler.cs
  9. 8
      ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs
  10. 111
      ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs
  11. 6
      ErsatzTV.Application/MediaSources/LocalMediaSourceViewModel.cs
  12. 13
      ErsatzTV.Application/MediaSources/Mapper.cs
  13. 6
      ErsatzTV.Application/MediaSources/MediaSourceViewModel.cs
  14. 9
      ErsatzTV.Application/MediaSources/PlexMediaSourceViewModel.cs
  15. 2
      ErsatzTV.Application/Movies/Mapper.cs
  16. 2
      ErsatzTV.Application/Playouts/Mapper.cs
  17. 2
      ErsatzTV.Application/Plex/Commands/StartPlexPinFlow.cs
  18. 2
      ErsatzTV.Application/Plex/Commands/StartPlexPinFlowHandler.cs
  19. 2
      ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraries.cs
  20. 12
      ErsatzTV.Application/Plex/Commands/SynchronizePlexLibrariesHandler.cs
  21. 25
      ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryById.cs
  22. 144
      ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs
  23. 2
      ErsatzTV.Application/Plex/Commands/SynchronizePlexMediaSources.cs
  24. 8
      ErsatzTV.Application/Plex/Commands/SynchronizePlexMediaSourcesHandler.cs
  25. 2
      ErsatzTV.Application/Plex/Commands/TryCompletePlexPinFlow.cs
  26. 2
      ErsatzTV.Application/Plex/Commands/TryCompletePlexPinFlowHandler.cs
  27. 11
      ErsatzTV.Application/Plex/Commands/UpdatePlexLibraryPreferences.cs
  28. 32
      ErsatzTV.Application/Plex/Commands/UpdatePlexLibraryPreferencesHandler.cs
  29. 18
      ErsatzTV.Application/Plex/Mapper.cs
  30. 6
      ErsatzTV.Application/Plex/PlexLibraryViewModel.cs
  31. 6
      ErsatzTV.Application/Plex/PlexMediaSourceViewModel.cs
  32. 2
      ErsatzTV.Application/Plex/Queries/GetAllPlexMediaSources.cs
  33. 4
      ErsatzTV.Application/Plex/Queries/GetAllPlexMediaSourcesHandler.cs
  34. 7
      ErsatzTV.Application/Plex/Queries/GetPlexLibrariesBySourceId.cs
  35. 27
      ErsatzTV.Application/Plex/Queries/GetPlexLibrariesBySourceIdHandler.cs
  36. 7
      ErsatzTV.Application/Plex/Queries/GetPlexMediaSourceById.cs
  37. 23
      ErsatzTV.Application/Plex/Queries/GetPlexMediaSourceByIdHandler.cs
  38. 4
      ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs
  39. 4
      ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs
  40. 2
      ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs
  41. 2
      ErsatzTV.Core.Tests/Metadata/FallbackMetadataProviderTests.cs
  42. 74
      ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs
  43. 34
      ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs
  44. 12
      ErsatzTV.Core/Domain/Collection/Collection.cs
  45. 10
      ErsatzTV.Core/Domain/Collection/CollectionItem.cs
  46. 18
      ErsatzTV.Core/Domain/Library/Library.cs
  47. 8
      ErsatzTV.Core/Domain/Library/LibraryMediaKind.cs
  48. 15
      ErsatzTV.Core/Domain/Library/LibraryPath.cs
  49. 6
      ErsatzTV.Core/Domain/Library/LocalLibrary.cs
  50. 8
      ErsatzTV.Core/Domain/Library/PlexLibrary.cs
  51. 10
      ErsatzTV.Core/Domain/LocalMediaSource.cs
  52. 17
      ErsatzTV.Core/Domain/MediaItem.cs
  53. 13
      ErsatzTV.Core/Domain/MediaItem/Episode.cs
  54. 11
      ErsatzTV.Core/Domain/MediaItem/MediaFile.cs
  55. 27
      ErsatzTV.Core/Domain/MediaItem/MediaItem.cs
  56. 22
      ErsatzTV.Core/Domain/MediaItem/MediaVersion.cs
  57. 18
      ErsatzTV.Core/Domain/MediaItem/Movie.cs
  58. 12
      ErsatzTV.Core/Domain/MediaItem/PlexMediaItemPart.cs
  59. 8
      ErsatzTV.Core/Domain/MediaItem/PlexMovie.cs
  60. 13
      ErsatzTV.Core/Domain/MediaItem/Season.cs
  61. 10
      ErsatzTV.Core/Domain/MediaItem/Show.cs
  62. 12
      ErsatzTV.Core/Domain/MediaSource.cs
  63. 6
      ErsatzTV.Core/Domain/MediaSource/LocalMediaSource.cs
  64. 11
      ErsatzTV.Core/Domain/MediaSource/MediaSource.cs
  65. 4
      ErsatzTV.Core/Domain/MediaSource/PlexConnection.cs
  66. 6
      ErsatzTV.Core/Domain/MediaSource/PlexMediaSource.cs
  67. 10
      ErsatzTV.Core/Domain/MediaSourceType.cs
  68. 9
      ErsatzTV.Core/Domain/MediaType.cs
  69. 11
      ErsatzTV.Core/Domain/Metadata/EpisodeMetadata.cs
  70. 16
      ErsatzTV.Core/Domain/Metadata/Metadata.cs
  71. 8
      ErsatzTV.Core/Domain/Metadata/MetadataKind.cs
  72. 11
      ErsatzTV.Core/Domain/Metadata/NewMovieMetadata.cs
  73. 11
      ErsatzTV.Core/Domain/Metadata/ShowMetadata.cs
  74. 11
      ErsatzTV.Core/Domain/MovieMediaItem.cs
  75. 2
      ErsatzTV.Core/Domain/MovieMetadata.cs
  76. 6
      ErsatzTV.Core/Domain/PlayoutProgramScheduleAnchor.cs
  77. 10
      ErsatzTV.Core/Domain/PlexMediaSourceLibrary.cs
  78. 6
      ErsatzTV.Core/Domain/ProgramScheduleItem.cs
  79. 2
      ErsatzTV.Core/Domain/SimpleMediaCollection.cs
  80. 2
      ErsatzTV.Core/Domain/TelevisionShowMetadata.cs
  81. 2
      ErsatzTV.Core/Domain/TelevisionShowSource.cs
  82. 7
      ErsatzTV.Core/ErsatzTV.Core.csproj.DotSettings
  83. 8
      ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs
  84. 2
      ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs
  85. 2
      ErsatzTV.Core/Interfaces/Metadata/IMovieFolderScanner.cs
  86. 2
      ErsatzTV.Core/Interfaces/Metadata/ITelevisionFolderScanner.cs
  87. 15
      ErsatzTV.Core/Interfaces/Plex/IPlexMovieLibraryScanner.cs
  88. 9
      ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs
  89. 12
      ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs
  90. 5
      ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs
  91. 9
      ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs
  92. 2
      ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs
  93. 6
      ErsatzTV.Core/Iptv/ChannelGuide.cs
  94. 20
      ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs
  95. 4
      ErsatzTV.Core/Metadata/LocalFileSystem.cs
  96. 30
      ErsatzTV.Core/Metadata/LocalMetadataProvider.cs
  97. 20
      ErsatzTV.Core/Metadata/MovieFolderScanner.cs
  98. 35
      ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs
  99. 20
      ErsatzTV.Core/Plex/PlexMediaEntry.cs
  100. 19
      ErsatzTV.Core/Plex/PlexMetadataEntry.cs
  101. Some files were not shown because too many files have changed in this diff Show More

2
ErsatzTV.Application/MediaCards/Mapper.cs

@ -37,7 +37,7 @@ namespace ErsatzTV.Application.MediaCards
televisionEpisode.Poster, televisionEpisode.Poster,
televisionEpisode.Metadata?.Episode.ToString()); televisionEpisode.Metadata?.Episode.ToString());
internal static MovieCardViewModel ProjectToViewModel(MovieMediaItem movie) => internal static MovieCardViewModel ProjectToViewModel(Movie movie) =>
new( new(
movie.Id, movie.Id,
movie.Metadata?.Title, movie.Metadata?.Title,

6
ErsatzTV.Application/MediaCollections/Commands/AddMovieToSimpleMediaCollectionHandler.cs

@ -49,14 +49,14 @@ namespace ErsatzTV.Application.MediaCollections.Commands
_mediaCollectionRepository.GetSimpleMediaCollectionWithItems(updateSimpleMediaCollection.MediaCollectionId) _mediaCollectionRepository.GetSimpleMediaCollectionWithItems(updateSimpleMediaCollection.MediaCollectionId)
.Map(v => v.ToValidation<BaseError>("SimpleMediaCollection does not exist.")); .Map(v => v.ToValidation<BaseError>("SimpleMediaCollection does not exist."));
private Task<Validation<BaseError, MovieMediaItem>> ValidateMovies( private Task<Validation<BaseError, Movie>> ValidateMovies(
AddMovieToSimpleMediaCollection request) => AddMovieToSimpleMediaCollection request) =>
LoadMovie(request) LoadMovie(request)
.Map(v => v.ToValidation<BaseError>("MovieMediaItem does not exist")); .Map(v => v.ToValidation<BaseError>("MovieMediaItem does not exist"));
private Task<Option<MovieMediaItem>> LoadMovie(AddMovieToSimpleMediaCollection request) => private Task<Option<Movie>> LoadMovie(AddMovieToSimpleMediaCollection request) =>
_movieRepository.GetMovie(request.MovieId); _movieRepository.GetMovie(request.MovieId);
private record RequestParameters(SimpleMediaCollection Collection, MovieMediaItem MovieToAdd); private record RequestParameters(SimpleMediaCollection Collection, Movie MovieToAdd);
} }
} }

2
ErsatzTV.Application/MediaCollections/Commands/CreateSimpleMediaCollectionHandler.cs

@ -33,7 +33,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
name => new SimpleMediaCollection name => new SimpleMediaCollection
{ {
Name = name, Name = name,
Movies = new List<MovieMediaItem>(), Movies = new List<Movie>(),
TelevisionShows = new List<TelevisionShow>(), TelevisionShows = new List<TelevisionShow>(),
TelevisionEpisodes = new List<TelevisionEpisodeMediaItem>(), TelevisionEpisodes = new List<TelevisionEpisodeMediaItem>(),
TelevisionSeasons = new List<TelevisionSeason>() TelevisionSeasons = new List<TelevisionSeason>()

27
ErsatzTV.Application/MediaItems/Mapper.cs

@ -9,29 +9,29 @@ namespace ErsatzTV.Application.MediaItems
internal static MediaItemViewModel ProjectToViewModel(MediaItem mediaItem) => internal static MediaItemViewModel ProjectToViewModel(MediaItem mediaItem) =>
new( new(
mediaItem.Id, mediaItem.Id,
mediaItem.MediaSourceId, mediaItem.LibraryPathId,
mediaItem.Path); mediaItem.Path);
internal static MediaItemSearchResultViewModel ProjectToSearchViewModel(MediaItem mediaItem) => internal static MediaItemSearchResultViewModel ProjectToSearchViewModel(MediaItem mediaItem) =>
mediaItem switch mediaItem switch
{ {
TelevisionEpisodeMediaItem e => ProjectToSearchViewModel(e), TelevisionEpisodeMediaItem e => ProjectToSearchViewModel(e),
MovieMediaItem m => ProjectToSearchViewModel(m), Movie m => ProjectToSearchViewModel(m),
_ => throw new ArgumentOutOfRangeException() _ => throw new ArgumentOutOfRangeException()
}; };
private static MediaItemSearchResultViewModel ProjectToSearchViewModel(TelevisionEpisodeMediaItem mediaItem) => private static MediaItemSearchResultViewModel ProjectToSearchViewModel(TelevisionEpisodeMediaItem mediaItem) =>
new( new(
mediaItem.Id, mediaItem.Id,
GetSourceName(mediaItem.Source), GetLibraryName(mediaItem),
"TV Show", "TV Show",
GetDisplayTitle(mediaItem), GetDisplayTitle(mediaItem),
GetDisplayDuration(mediaItem)); GetDisplayDuration(mediaItem));
private static MediaItemSearchResultViewModel ProjectToSearchViewModel(MovieMediaItem mediaItem) => private static MediaItemSearchResultViewModel ProjectToSearchViewModel(Movie mediaItem) =>
new( new(
mediaItem.Id, mediaItem.Id,
GetSourceName(mediaItem.Source), GetLibraryName(mediaItem),
"Movie", "Movie",
GetDisplayTitle(mediaItem), GetDisplayTitle(mediaItem),
GetDisplayDuration(mediaItem)); GetDisplayDuration(mediaItem));
@ -43,7 +43,7 @@ namespace ErsatzTV.Application.MediaItems
TelevisionEpisodeMediaItem e => e.Metadata != null TelevisionEpisodeMediaItem e => e.Metadata != null
? $"{e.Metadata.Title} - s{e.Metadata.Season:00}e{e.Metadata.Episode:00}" ? $"{e.Metadata.Title} - s{e.Metadata.Season:00}e{e.Metadata.Episode:00}"
: Path.GetFileName(e.Path), : Path.GetFileName(e.Path),
MovieMediaItem m => m.Metadata?.Title ?? Path.GetFileName(m.Path), Movie m => m.Metadata?.Title ?? Path.GetFileName(m.Path),
_ => string.Empty _ => string.Empty
}; };
@ -52,11 +52,14 @@ namespace ErsatzTV.Application.MediaItems
mediaItem.Statistics.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}", mediaItem.Statistics.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}",
mediaItem.Statistics.Duration); mediaItem.Statistics.Duration);
private static string GetSourceName(MediaSource source) => // TODO: fix this
source switch private static string GetLibraryName(MediaItem item) =>
{ "Library Name";
LocalMediaSource lms => lms.Folder,
_ => source.Name // source switch
}; // {
// LocalMediaSource lms => "Local Media Source",
// _ => "unknown source"
// };
} }
} }

2
ErsatzTV.Application/MediaItems/MediaItemViewModel.cs

@ -1,4 +1,4 @@
namespace ErsatzTV.Application.MediaItems namespace ErsatzTV.Application.MediaItems
{ {
public record MediaItemViewModel(int Id, int MediaSourceId, string Path); public record MediaItemViewModel(int Id, int LibraryPathId, string Path);
} }

20
ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSource.cs

@ -1,10 +1,12 @@
using ErsatzTV.Core; // using ErsatzTV.Core;
using ErsatzTV.Core.Domain; // using ErsatzTV.Core.Domain;
using LanguageExt; // using LanguageExt;
using MediatR; // using MediatR;
//
// namespace ErsatzTV.Application.MediaSources.Commands
// {
// public record CreateLocalMediaSource
// (string Name, MediaType MediaType, string Folder) : IRequest<Either<BaseError, MediaSourceViewModel>>;
// }
namespace ErsatzTV.Application.MediaSources.Commands
{
public record CreateLocalMediaSource
(string Name, MediaType MediaType, string Folder) : IRequest<Either<BaseError, MediaSourceViewModel>>;
}

154
ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSourceHandler.cs

@ -1,78 +1,80 @@
using System; // using System;
using System.Collections.Generic; // using System.Collections.Generic;
using System.IO; // using System.IO;
using System.Linq; // using System.Linq;
using System.Threading; // using System.Threading;
using System.Threading.Tasks; // using System.Threading.Tasks;
using ErsatzTV.Core; // using ErsatzTV.Core;
using ErsatzTV.Core.Domain; // using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; // using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt; // using LanguageExt;
using MediatR; // using MediatR;
using static ErsatzTV.Application.MediaSources.Mapper; // using static ErsatzTV.Application.MediaSources.Mapper;
using static LanguageExt.Prelude; // using static LanguageExt.Prelude;
//
// namespace ErsatzTV.Application.MediaSources.Commands
// {
// public class CreateLocalMediaSourceHandler : IRequestHandler<CreateLocalMediaSource,
// Either<BaseError, MediaSourceViewModel>>
// {
// private readonly IMediaSourceRepository _mediaSourceRepository;
//
// public CreateLocalMediaSourceHandler(IMediaSourceRepository mediaSourceRepository) =>
// _mediaSourceRepository = mediaSourceRepository;
//
// public Task<Either<BaseError, MediaSourceViewModel>> Handle(
// CreateLocalMediaSource request,
// CancellationToken cancellationToken) =>
// Validate(request).MapT(PersistLocalMediaSource).Bind(v => v.ToEitherAsync());
//
// private Task<MediaSourceViewModel> PersistLocalMediaSource(LocalMediaSource c) =>
// _mediaSourceRepository.Add(c).Map(ProjectToViewModel);
//
// private async Task<Validation<BaseError, LocalMediaSource>> Validate(CreateLocalMediaSource request) =>
// (await ValidateName(request), await ValidateFolder(request))
// .Apply(
// (name, folder) =>
// new LocalMediaSource
// {
// Name = name,
// MediaType = request.MediaType,
// Folder = folder
// });
//
// private async Task<Validation<BaseError, string>> ValidateName(CreateLocalMediaSource request)
// {
// List<string> allNames = await _mediaSourceRepository.GetAll()
// .Map(list => list.Map(c => c.Name).ToList());
//
// Validation<BaseError, string> result1 = request.NotEmpty(c => c.Name)
// .Bind(_ => request.NotLongerThan(50)(c => c.Name));
//
// var result2 = Optional(request.Name)
// .Filter(name => !allNames.Contains(name))
// .ToValidation<BaseError>("Media source name must be unique");
//
// return (result1, result2).Apply((_, _) => request.Name);
// }
//
// private async Task<Validation<BaseError, string>> ValidateFolder(CreateLocalMediaSource request)
// {
// List<string> allFolders = await _mediaSourceRepository.GetAll()
// .Map(list => list.OfType<LocalMediaSource>().Map(c => c.Folder).ToList());
//
//
// return Optional(request.Folder)
// .Filter(folder => allFolders.ForAll(f => !AreSubPaths(f, folder)))
// .ToValidation<BaseError>("Folder must not belong to another media source");
// }
//
// private static bool AreSubPaths(string path1, string path2)
// {
// string one = path1 + Path.DirectorySeparatorChar;
// string two = path2 + Path.DirectorySeparatorChar;
// return one == two || one.StartsWith(two, StringComparison.OrdinalIgnoreCase) ||
// two.StartsWith(one, StringComparison.OrdinalIgnoreCase);
// }
// }
// }
namespace ErsatzTV.Application.MediaSources.Commands
{
public class CreateLocalMediaSourceHandler : IRequestHandler<CreateLocalMediaSource,
Either<BaseError, MediaSourceViewModel>>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
public CreateLocalMediaSourceHandler(IMediaSourceRepository mediaSourceRepository) =>
_mediaSourceRepository = mediaSourceRepository;
public Task<Either<BaseError, MediaSourceViewModel>> Handle(
CreateLocalMediaSource request,
CancellationToken cancellationToken) =>
Validate(request).MapT(PersistLocalMediaSource).Bind(v => v.ToEitherAsync());
private Task<MediaSourceViewModel> PersistLocalMediaSource(LocalMediaSource c) =>
_mediaSourceRepository.Add(c).Map(ProjectToViewModel);
private async Task<Validation<BaseError, LocalMediaSource>> Validate(CreateLocalMediaSource request) =>
(await ValidateName(request), await ValidateFolder(request))
.Apply(
(name, folder) =>
new LocalMediaSource
{
Name = name,
MediaType = request.MediaType,
Folder = folder
});
private async Task<Validation<BaseError, string>> ValidateName(CreateLocalMediaSource request)
{
List<string> allNames = await _mediaSourceRepository.GetAll()
.Map(list => list.Map(c => c.Name).ToList());
Validation<BaseError, string> result1 = request.NotEmpty(c => c.Name)
.Bind(_ => request.NotLongerThan(50)(c => c.Name));
var result2 = Optional(request.Name)
.Filter(name => !allNames.Contains(name))
.ToValidation<BaseError>("Media source name must be unique");
return (result1, result2).Apply((_, _) => request.Name);
}
private async Task<Validation<BaseError, string>> ValidateFolder(CreateLocalMediaSource request)
{
List<string> allFolders = await _mediaSourceRepository.GetAll()
.Map(list => list.OfType<LocalMediaSource>().Map(c => c.Folder).ToList());
return Optional(request.Folder)
.Filter(folder => allFolders.ForAll(f => !AreSubPaths(f, folder)))
.ToValidation<BaseError>("Folder must not belong to another media source");
}
private static bool AreSubPaths(string path1, string path2)
{
string one = path1 + Path.DirectorySeparatorChar;
string two = path2 + Path.DirectorySeparatorChar;
return one == two || one.StartsWith(two, StringComparison.OrdinalIgnoreCase) ||
two.StartsWith(one, StringComparison.OrdinalIgnoreCase);
}
}
}

114
ErsatzTV.Application/MediaSources/Commands/ScanLocalLibraryHandler.cs

@ -0,0 +1,114 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Logging;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.MediaSources.Commands
{
public class ScanLocalLibraryHandler : IRequestHandler<ForceScanLocalLibrary, Either<BaseError, string>>,
IRequestHandler<ScanLocalLibraryIfNeeded, Either<BaseError, string>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IEntityLocker _entityLocker;
private readonly ILibraryRepository _libraryRepository;
private readonly ILogger<ScanLocalLibraryHandler> _logger;
private readonly IMovieFolderScanner _movieFolderScanner;
private readonly ITelevisionFolderScanner _televisionFolderScanner;
public ScanLocalLibraryHandler(
ILibraryRepository libraryRepository,
IConfigElementRepository configElementRepository,
IMovieFolderScanner movieFolderScanner,
ITelevisionFolderScanner televisionFolderScanner,
IEntityLocker entityLocker,
ILogger<ScanLocalLibraryHandler> logger)
{
_libraryRepository = libraryRepository;
_configElementRepository = configElementRepository;
_movieFolderScanner = movieFolderScanner;
_televisionFolderScanner = televisionFolderScanner;
_entityLocker = entityLocker;
_logger = logger;
}
public Task<Either<BaseError, string>> Handle(
ForceScanLocalLibrary request,
CancellationToken cancellationToken) => Handle(request);
public Task<Either<BaseError, string>> Handle(
ScanLocalLibraryIfNeeded request,
CancellationToken cancellationToken) => Handle(request);
private Task<Either<BaseError, string>>
Handle(IScanLocalLibrary request) =>
Validate(request)
.MapT(parameters => PerformScan(parameters).Map(_ => parameters.LocalLibrary.Name))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> PerformScan(RequestParameters parameters)
{
(LocalLibrary localLibrary, string ffprobePath, bool forceScan) = parameters;
DateTimeOffset lastScan = localLibrary.LastScan ?? DateTime.MinValue;
if (forceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
{
foreach (LibraryPath libraryPath in localLibrary.Paths)
{
switch (localLibrary.MediaKind)
{
case LibraryMediaKind.Movies:
await _movieFolderScanner.ScanFolder(libraryPath, ffprobePath);
break;
case LibraryMediaKind.Shows:
await _televisionFolderScanner.ScanFolder(libraryPath, ffprobePath);
break;
}
}
localLibrary.LastScan = DateTimeOffset.Now;
await _libraryRepository.UpdateLastScan(localLibrary);
}
else
{
_logger.LogDebug(
"Skipping unforced scan of library {Name}",
localLibrary.Name);
}
_entityLocker.UnlockLibrary(localLibrary.Id);
return Unit.Default;
}
private async Task<Validation<BaseError, RequestParameters>> Validate(IScanLocalLibrary request) =>
(await LocalLibraryMustExist(request), await ValidateFFprobePath())
.Apply(
(library, ffprobePath) => new RequestParameters(
library,
ffprobePath,
request.ForceScan));
private Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
IScanLocalLibrary request) =>
_libraryRepository.Get(request.LibraryId)
.Map(maybeLibrary => maybeLibrary.Map(ms => ms as LocalLibrary))
.Map(v => v.ToValidation<BaseError>($"Local library {request.LibraryId} does not exist."));
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
.Map(
ffprobePath =>
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private record RequestParameters(LocalLibrary LocalLibrary, string FFprobePath, bool ForceScan);
}
}

8
ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs

@ -4,18 +4,18 @@ using MediatR;
namespace ErsatzTV.Application.MediaSources.Commands namespace ErsatzTV.Application.MediaSources.Commands
{ {
public interface IScanLocalMediaSource : IRequest<Either<BaseError, string>>, IBackgroundServiceRequest public interface IScanLocalLibrary : IRequest<Either<BaseError, string>>, IBackgroundServiceRequest
{ {
int MediaSourceId { get; } int LibraryId { get; }
bool ForceScan { get; } bool ForceScan { get; }
} }
public record ScanLocalMediaSourceIfNeeded(int MediaSourceId) : IScanLocalMediaSource public record ScanLocalLibraryIfNeeded(int LibraryId) : IScanLocalLibrary
{ {
public bool ForceScan => false; public bool ForceScan => false;
} }
public record ForceScanLocalMediaSource(int MediaSourceId) : IScanLocalMediaSource public record ForceScanLocalLibrary(int LibraryId) : IScanLocalLibrary
{ {
public bool ForceScan => true; public bool ForceScan => true;
} }

111
ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs

@ -1,111 +0,0 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Logging;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.MediaSources.Commands
{
public class ScanLocalMediaSourceHandler : IRequestHandler<ForceScanLocalMediaSource, Either<BaseError, string>>,
IRequestHandler<ScanLocalMediaSourceIfNeeded, Either<BaseError, string>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IEntityLocker _entityLocker;
private readonly ILogger<ScanLocalMediaSourceHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMovieFolderScanner _movieFolderScanner;
private readonly ITelevisionFolderScanner _televisionFolderScanner;
public ScanLocalMediaSourceHandler(
IMediaSourceRepository mediaSourceRepository,
IConfigElementRepository configElementRepository,
IMovieFolderScanner movieFolderScanner,
ITelevisionFolderScanner televisionFolderScanner,
IEntityLocker entityLocker,
ILogger<ScanLocalMediaSourceHandler> logger)
{
_mediaSourceRepository = mediaSourceRepository;
_configElementRepository = configElementRepository;
_movieFolderScanner = movieFolderScanner;
_televisionFolderScanner = televisionFolderScanner;
_entityLocker = entityLocker;
_logger = logger;
}
public Task<Either<BaseError, string>> Handle(
ForceScanLocalMediaSource request,
CancellationToken cancellationToken) =>
Handle((IScanLocalMediaSource) request, cancellationToken);
public Task<Either<BaseError, string>> Handle(
ScanLocalMediaSourceIfNeeded request,
CancellationToken cancellationToken) =>
Handle((IScanLocalMediaSource) request, cancellationToken);
private Task<Either<BaseError, string>>
Handle(IScanLocalMediaSource request, CancellationToken cancellationToken) =>
Validate(request)
.MapT(parameters => PerformScan(parameters).Map(_ => parameters.LocalMediaSource.Folder))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> PerformScan(RequestParameters parameters)
{
DateTimeOffset lastScan = parameters.LocalMediaSource.LastScan ?? DateTime.MinValue;
if (parameters.ForceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
{
switch (parameters.LocalMediaSource.MediaType)
{
case MediaType.Movie:
await _movieFolderScanner.ScanFolder(parameters.LocalMediaSource, parameters.FFprobePath);
break;
case MediaType.TvShow:
await _televisionFolderScanner.ScanFolder(parameters.LocalMediaSource, parameters.FFprobePath);
break;
}
parameters.LocalMediaSource.LastScan = DateTimeOffset.Now;
await _mediaSourceRepository.Update(parameters.LocalMediaSource);
}
else
{
_logger.LogDebug(
"Skipping unforced scan of media source {Folder}",
parameters.LocalMediaSource.Folder);
}
_entityLocker.UnlockMediaSource(parameters.LocalMediaSource.Id);
return Unit.Default;
}
private async Task<Validation<BaseError, RequestParameters>> Validate(IScanLocalMediaSource request) =>
(await LocalMediaSourceMustExist(request), await ValidateFFprobePath())
.Apply(
(localMediaSource, ffprobePath) => new RequestParameters(
localMediaSource,
ffprobePath,
request.ForceScan));
private Task<Validation<BaseError, LocalMediaSource>> LocalMediaSourceMustExist(
IScanLocalMediaSource request) =>
_mediaSourceRepository.Get(request.MediaSourceId)
.Map(maybeMediaSource => maybeMediaSource.Map(ms => ms as LocalMediaSource))
.Map(v => v.ToValidation<BaseError>($"Local media source {request.MediaSourceId} does not exist."));
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
.Map(
ffprobePath =>
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private record RequestParameters(LocalMediaSource LocalMediaSource, string FFprobePath, bool ForceScan);
}
}

6
ErsatzTV.Application/MediaSources/LocalMediaSourceViewModel.cs

@ -1,7 +1,5 @@
using ErsatzTV.Core.Domain; namespace ErsatzTV.Application.MediaSources
namespace ErsatzTV.Application.MediaSources
{ {
public record LocalMediaSourceViewModel(int Id, string Name, string Folder) public record LocalMediaSourceViewModel(int Id, string Name, string Folder)
: MediaSourceViewModel(Id, Name, MediaSourceType.Local); : MediaSourceViewModel(Id, Name);
} }

13
ErsatzTV.Application/MediaSources/Mapper.cs

@ -1,7 +1,5 @@
using System; using System;
using System.Linq;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaSources namespace ErsatzTV.Application.MediaSources
{ {
@ -10,15 +8,10 @@ namespace ErsatzTV.Application.MediaSources
internal static MediaSourceViewModel ProjectToViewModel(MediaSource mediaSource) => internal static MediaSourceViewModel ProjectToViewModel(MediaSource mediaSource) =>
mediaSource switch mediaSource switch
{ {
LocalMediaSource lms => new LocalMediaSourceViewModel(lms.Id, lms.Name, lms.Folder), // TODO: address this...
PlexMediaSource pms => ProjectToViewModel(pms), LocalMediaSource lms => new LocalMediaSourceViewModel(lms.Id, "LMS Name", "LMS Folder"),
PlexMediaSource pms => Plex.Mapper.ProjectToViewModel(pms),
_ => throw new NotSupportedException($"Unsupported media source {mediaSource.GetType().Name}") _ => throw new NotSupportedException($"Unsupported media source {mediaSource.GetType().Name}")
}; };
internal static PlexMediaSourceViewModel ProjectToViewModel(PlexMediaSource plexMediaSource) =>
new(
plexMediaSource.Id,
plexMediaSource.Name,
Optional(plexMediaSource.Connections.SingleOrDefault(c => c.IsActive)).Match(c => c.Uri, string.Empty));
} }
} }

6
ErsatzTV.Application/MediaSources/MediaSourceViewModel.cs

@ -1,6 +1,4 @@
using ErsatzTV.Core.Domain; namespace ErsatzTV.Application.MediaSources
namespace ErsatzTV.Application.MediaSources
{ {
public record MediaSourceViewModel(int Id, string Name, MediaSourceType SourceType); public record MediaSourceViewModel(int Id, string Name);
} }

9
ErsatzTV.Application/MediaSources/PlexMediaSourceViewModel.cs

@ -1,9 +0,0 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaSources
{
public record PlexMediaSourceViewModel(int Id, string Name, string Address) : MediaSourceViewModel(
Id,
Name,
MediaSourceType.Plex);
}

2
ErsatzTV.Application/Movies/Mapper.cs

@ -4,7 +4,7 @@ namespace ErsatzTV.Application.Movies
{ {
internal static class Mapper internal static class Mapper
{ {
internal static MovieViewModel ProjectToViewModel(MovieMediaItem movie) => internal static MovieViewModel ProjectToViewModel(Movie movie) =>
new(movie.Metadata.Title, movie.Metadata.Year?.ToString(), movie.Metadata.Plot, movie.Poster); new(movie.Metadata.Title, movie.Metadata.Year?.ToString(), movie.Metadata.Plot, movie.Poster);
} }
} }

2
ErsatzTV.Application/Playouts/Mapper.cs

@ -27,7 +27,7 @@ namespace ErsatzTV.Application.Playouts
TelevisionEpisodeMediaItem e => e.Metadata != null TelevisionEpisodeMediaItem e => e.Metadata != null
? $"{e.Metadata.Title} - s{e.Metadata.Season:00}e{e.Metadata.Episode:00}" ? $"{e.Metadata.Title} - s{e.Metadata.Season:00}e{e.Metadata.Episode:00}"
: Path.GetFileName(e.Path), : Path.GetFileName(e.Path),
MovieMediaItem m => m.Metadata?.Title ?? Path.GetFileName(m.Path), Movie m => m.Metadata?.Title ?? Path.GetFileName(m.Path),
_ => string.Empty _ => string.Empty
}; };

2
ErsatzTV.Application/MediaSources/Commands/StartPlexPinFlow.cs → ErsatzTV.Application/Plex/Commands/StartPlexPinFlow.cs

@ -2,7 +2,7 @@
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
namespace ErsatzTV.Application.MediaSources.Commands namespace ErsatzTV.Application.Plex.Commands
{ {
public record StartPlexPinFlow : IRequest<Either<BaseError, string>>; public record StartPlexPinFlow : IRequest<Either<BaseError, string>>;
} }

2
ErsatzTV.Application/MediaSources/Commands/StartPlexPinFlowHandler.cs → ErsatzTV.Application/Plex/Commands/StartPlexPinFlowHandler.cs

@ -7,7 +7,7 @@ using LanguageExt;
using MediatR; using MediatR;
using static LanguageExt.Prelude; using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaSources.Commands namespace ErsatzTV.Application.Plex.Commands
{ {
public class StartPlexPinFlowHandler : IRequestHandler<StartPlexPinFlow, Either<BaseError, string>> public class StartPlexPinFlowHandler : IRequestHandler<StartPlexPinFlow, Either<BaseError, string>>
{ {

2
ErsatzTV.Application/MediaSources/Commands/SynchronizePlexLibraries.cs → ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraries.cs

@ -1,7 +1,7 @@
using ErsatzTV.Core; using ErsatzTV.Core;
using LanguageExt; using LanguageExt;
namespace ErsatzTV.Application.MediaSources.Commands namespace ErsatzTV.Application.Plex.Commands
{ {
public record SynchronizePlexLibraries(int PlexMediaSourceId) : MediatR.IRequest<Either<BaseError, Unit>>; public record SynchronizePlexLibraries(int PlexMediaSourceId) : MediatR.IRequest<Either<BaseError, Unit>>;
} }

12
ErsatzTV.Application/MediaSources/Commands/SynchronizePlexLibrariesHandler.cs → ErsatzTV.Application/Plex/Commands/SynchronizePlexLibrariesHandler.cs

@ -10,7 +10,7 @@ using ErsatzTV.Core.Plex;
using LanguageExt; using LanguageExt;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.MediaSources.Commands namespace ErsatzTV.Application.Plex.Commands
{ {
public class public class
SynchronizePlexLibrariesHandler : MediatR.IRequestHandler<SynchronizePlexLibraries, Either<BaseError, Unit>> SynchronizePlexLibrariesHandler : MediatR.IRequestHandler<SynchronizePlexLibraries, Either<BaseError, Unit>>
@ -51,7 +51,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection( private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
PlexMediaSource plexMediaSource) PlexMediaSource plexMediaSource)
{ {
Option<PlexMediaSourceConnection> maybeConnection = Option<PlexConnection> maybeConnection =
plexMediaSource.Connections.SingleOrDefault(c => c.IsActive); plexMediaSource.Connections.SingleOrDefault(c => c.IsActive);
return maybeConnection.Map(connection => new ConnectionParameters(plexMediaSource, connection)) return maybeConnection.Map(connection => new ConnectionParameters(plexMediaSource, connection))
.ToValidation<BaseError>("Plex media source requires an active connection"); .ToValidation<BaseError>("Plex media source requires an active connection");
@ -68,14 +68,14 @@ namespace ErsatzTV.Application.MediaSources.Commands
private async Task<Unit> SynchronizeLibraries(ConnectionParameters connectionParameters) private async Task<Unit> SynchronizeLibraries(ConnectionParameters connectionParameters)
{ {
Either<BaseError, List<PlexMediaSourceLibrary>> maybeLibraries = await _plexServerApiClient.GetLibraries( Either<BaseError, List<PlexLibrary>> maybeLibraries = await _plexServerApiClient.GetLibraries(
connectionParameters.ActiveConnection, connectionParameters.ActiveConnection,
connectionParameters.PlexServerAuthToken); connectionParameters.PlexServerAuthToken);
await maybeLibraries.Match( await maybeLibraries.Match(
libraries => libraries =>
{ {
List<PlexMediaSourceLibrary> existing = connectionParameters.PlexMediaSource.Libraries; var existing = connectionParameters.PlexMediaSource.Libraries.OfType<PlexLibrary>().ToList();
var toAdd = libraries.Filter(library => existing.All(l => l.Key != library.Key)).ToList(); var toAdd = libraries.Filter(library => existing.All(l => l.Key != library.Key)).ToList();
var toRemove = existing.Filter(library => libraries.All(l => l.Key != library.Key)).ToList(); var toRemove = existing.Filter(library => libraries.All(l => l.Key != library.Key)).ToList();
existing.AddRange(toAdd); existing.AddRange(toAdd);
@ -87,7 +87,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
{ {
_logger.LogWarning( _logger.LogWarning(
"Unable to synchronize libraries from plex server {PlexServer}: {Error}", "Unable to synchronize libraries from plex server {PlexServer}: {Error}",
connectionParameters.PlexMediaSource.Name, connectionParameters.PlexMediaSource.ServerName,
error.Value); error.Value);
return Task.CompletedTask; return Task.CompletedTask;
@ -98,7 +98,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
private record ConnectionParameters( private record ConnectionParameters(
PlexMediaSource PlexMediaSource, PlexMediaSource PlexMediaSource,
PlexMediaSourceConnection ActiveConnection) PlexConnection ActiveConnection)
{ {
public PlexServerAuthToken PlexServerAuthToken { get; set; } public PlexServerAuthToken PlexServerAuthToken { get; set; }
} }

25
ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryById.cs

@ -0,0 +1,25 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Plex.Commands
{
public interface ISynchronizePlexLibraryById : IRequest<Either<BaseError, string>>, IBackgroundServiceRequest
{
int PlexMediaSourceId { get; }
int PlexLibraryId { get; }
bool ForceScan { get; }
}
public record SynchronizePlexLibraryByIdIfNeeded
(int PlexMediaSourceId, int PlexLibraryId) : ISynchronizePlexLibraryById
{
public bool ForceScan => false;
}
public record ForceSynchronizePlexLibraryById
(int PlexMediaSourceId, int PlexLibraryId) : ISynchronizePlexLibraryById
{
public bool ForceScan => true;
}
}

144
ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs

@ -0,0 +1,144 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Plex;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Logging;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Plex.Commands
{
public class
SynchronizePlexLibraryByIdHandler : IRequestHandler<ForceSynchronizePlexLibraryById, Either<BaseError, string>>,
IRequestHandler<SynchronizePlexLibraryByIdIfNeeded, Either<BaseError, string>>
{
private readonly IEntityLocker _entityLocker;
private readonly ILogger<SynchronizePlexLibraryByIdHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IPlexMovieLibraryScanner _plexMovieLibraryScanner;
private readonly IPlexSecretStore _plexSecretStore;
public SynchronizePlexLibraryByIdHandler(
IMediaSourceRepository mediaSourceRepository,
IPlexSecretStore plexSecretStore,
IPlexMovieLibraryScanner plexMovieLibraryScanner,
IEntityLocker entityLocker,
ILogger<SynchronizePlexLibraryByIdHandler> logger)
{
_mediaSourceRepository = mediaSourceRepository;
_plexSecretStore = plexSecretStore;
_plexMovieLibraryScanner = plexMovieLibraryScanner;
_entityLocker = entityLocker;
_logger = logger;
}
public Task<Either<BaseError, string>> Handle(
ForceSynchronizePlexLibraryById request,
CancellationToken cancellationToken) => Handle(request);
public Task<Either<BaseError, string>> Handle(
SynchronizePlexLibraryByIdIfNeeded request,
CancellationToken cancellationToken) => Handle(request);
private Task<Either<BaseError, string>>
Handle(ISynchronizePlexLibraryById request) =>
Validate(request)
.MapT(parameters => Synchronize(parameters).Map(_ => parameters.Library.Name))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> Synchronize(RequestParameters parameters)
{
DateTimeOffset lastScan = parameters.Library.LastScan ?? DateTime.MinValue;
if (parameters.ForceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
{
switch (parameters.Library.MediaKind)
{
case LibraryMediaKind.Movies:
await _plexMovieLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection,
parameters.ConnectionParameters.PlexServerAuthToken,
parameters.Library);
break;
case LibraryMediaKind.Shows:
// TODO: plex tv scanner
// await _televisionFolderScanner.ScanFolder(parameters.LocalMediaSource, parameters.FFprobePath);
break;
}
parameters.Library.LastScan = DateTimeOffset.Now;
await _mediaSourceRepository.Update(parameters.Library);
}
else
{
_logger.LogDebug(
"Skipping unforced scan of plex media library {Name}",
parameters.Library.Name);
}
// _entityLocker.UnlockMediaSource(parameters.MediaSource.Id);
return Unit.Default;
}
private async Task<Validation<BaseError, RequestParameters>> Validate(ISynchronizePlexLibraryById request) =>
(await ValidateConnection(request), await PlexLibraryMustExist(request))
.Apply(
(connectionParameters, plexLibrary) => new RequestParameters(
connectionParameters,
plexLibrary,
request.ForceScan
));
private Task<Validation<BaseError, ConnectionParameters>> ValidateConnection(
ISynchronizePlexLibraryById request) =>
PlexMediaSourceMustExist(request)
.BindT(MediaSourceMustHaveActiveConnection)
.BindT(MediaSourceMustHaveToken);
private Task<Validation<BaseError, PlexMediaSource>> PlexMediaSourceMustExist(
ISynchronizePlexLibraryById request) =>
_mediaSourceRepository.GetPlex(request.PlexMediaSourceId)
.Map(v => v.ToValidation<BaseError>($"Plex media source {request.PlexMediaSourceId} does not exist."));
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
PlexMediaSource plexMediaSource)
{
Option<PlexConnection> maybeConnection =
plexMediaSource.Connections.SingleOrDefault(c => c.IsActive);
return maybeConnection.Map(connection => new ConnectionParameters(plexMediaSource, connection))
.ToValidation<BaseError>("Plex media source requires an active connection");
}
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveToken(
ConnectionParameters connectionParameters)
{
Option<PlexServerAuthToken> maybeToken = await
_plexSecretStore.GetServerAuthToken(connectionParameters.PlexMediaSource.ClientIdentifier);
return maybeToken.Map(token => connectionParameters with { PlexServerAuthToken = token })
.ToValidation<BaseError>("Plex media source requires a token");
}
private Task<Validation<BaseError, PlexLibrary>> PlexLibraryMustExist(
ISynchronizePlexLibraryById request) =>
_mediaSourceRepository.GetPlexLibrary(request.PlexLibraryId)
.Map(v => v.ToValidation<BaseError>($"Plex library {request.PlexLibraryId} does not exist."));
private record RequestParameters(
ConnectionParameters ConnectionParameters,
PlexLibrary Library,
bool ForceScan);
private record ConnectionParameters(
PlexMediaSource PlexMediaSource,
PlexConnection ActiveConnection)
{
public PlexServerAuthToken PlexServerAuthToken { get; set; }
}
}
}

2
ErsatzTV.Application/MediaSources/Commands/SynchronizePlexMediaSources.cs → ErsatzTV.Application/Plex/Commands/SynchronizePlexMediaSources.cs

@ -4,7 +4,7 @@ using ErsatzTV.Core.Domain;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
namespace ErsatzTV.Application.MediaSources.Commands namespace ErsatzTV.Application.Plex.Commands
{ {
public record public record
SynchronizePlexMediaSources : IRequest<Either<BaseError, List<PlexMediaSource>>>, IPlexBackgroundServiceRequest; SynchronizePlexMediaSources : IRequest<Either<BaseError, List<PlexMediaSource>>>, IPlexBackgroundServiceRequest;

8
ErsatzTV.Application/MediaSources/Commands/SynchronizePlexMediaSourcesHandler.cs → ErsatzTV.Application/Plex/Commands/SynchronizePlexMediaSourcesHandler.cs

@ -9,7 +9,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
namespace ErsatzTV.Application.MediaSources.Commands namespace ErsatzTV.Application.Plex.Commands
{ {
public class public class
SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePlexMediaSources, SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePlexMediaSources,
@ -50,7 +50,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
existing => existing =>
{ {
existing.ProductVersion = server.ProductVersion; existing.ProductVersion = server.ProductVersion;
existing.Name = server.Name; existing.ServerName = server.ServerName;
MergeConnections(existing.Connections, server.Connections); MergeConnections(existing.Connections, server.Connections);
if (existing.Connections.Any() && existing.Connections.All(c => !c.IsActive)) if (existing.Connections.Any() && existing.Connections.All(c => !c.IsActive))
{ {
@ -72,8 +72,8 @@ namespace ErsatzTV.Application.MediaSources.Commands
} }
private void MergeConnections( private void MergeConnections(
List<PlexMediaSourceConnection> existing, List<PlexConnection> existing,
List<PlexMediaSourceConnection> incoming) List<PlexConnection> incoming)
{ {
var toAdd = incoming.Filter(connection => existing.All(c => c.Uri != connection.Uri)).ToList(); var toAdd = incoming.Filter(connection => existing.All(c => c.Uri != connection.Uri)).ToList();
var toRemove = existing.Filter(connection => incoming.All(c => c.Uri != connection.Uri)).ToList(); var toRemove = existing.Filter(connection => incoming.All(c => c.Uri != connection.Uri)).ToList();

2
ErsatzTV.Application/MediaSources/Commands/TryCompletePlexPinFlow.cs → ErsatzTV.Application/Plex/Commands/TryCompletePlexPinFlow.cs

@ -3,7 +3,7 @@ using ErsatzTV.Core.Plex;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
namespace ErsatzTV.Application.MediaSources.Commands namespace ErsatzTV.Application.Plex.Commands
{ {
public record TryCompletePlexPinFlow(PlexAuthPin AuthPin) : IRequest<Either<BaseError, bool>>, public record TryCompletePlexPinFlow(PlexAuthPin AuthPin) : IRequest<Either<BaseError, bool>>,
IPlexBackgroundServiceRequest; IPlexBackgroundServiceRequest;

2
ErsatzTV.Application/MediaSources/Commands/TryCompletePlexPinFlowHandler.cs → ErsatzTV.Application/Plex/Commands/TryCompletePlexPinFlowHandler.cs

@ -7,7 +7,7 @@ using ErsatzTV.Core.Interfaces.Plex;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
namespace ErsatzTV.Application.MediaSources.Commands namespace ErsatzTV.Application.Plex.Commands
{ {
public class TryCompletePlexPinFlowHandler : IRequestHandler<TryCompletePlexPinFlow, Either<BaseError, bool>> public class TryCompletePlexPinFlowHandler : IRequestHandler<TryCompletePlexPinFlow, Either<BaseError, bool>>
{ {

11
ErsatzTV.Application/Plex/Commands/UpdatePlexLibraryPreferences.cs

@ -0,0 +1,11 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.Plex.Commands
{
public record UpdatePlexLibraryPreferences
(List<PlexLibraryPreference> Preferences) : MediatR.IRequest<Either<BaseError, Unit>>;
public record PlexLibraryPreference(int Id, bool ShouldSyncItems);
}

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

@ -0,0 +1,32 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
namespace ErsatzTV.Application.Plex.Commands
{
public class
UpdatePlexLibraryPreferencesHandler : MediatR.IRequestHandler<UpdatePlexLibraryPreferences,
Either<BaseError, Unit>>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
public UpdatePlexLibraryPreferencesHandler(IMediaSourceRepository mediaSourceRepository) =>
_mediaSourceRepository = mediaSourceRepository;
public async Task<Either<BaseError, Unit>> Handle(
UpdatePlexLibraryPreferences request,
CancellationToken cancellationToken)
{
IEnumerable<int> toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id);
await _mediaSourceRepository.DisablePlexLibrarySync(toDisable);
IEnumerable<int> toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id);
await _mediaSourceRepository.EnablePlexLibrarySync(toEnable);
return Unit.Default;
}
}
}

18
ErsatzTV.Application/Plex/Mapper.cs

@ -0,0 +1,18 @@
using System.Linq;
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Plex
{
internal static class Mapper
{
internal static PlexMediaSourceViewModel ProjectToViewModel(PlexMediaSource plexMediaSource) =>
new(
plexMediaSource.Id,
plexMediaSource.ServerName,
Optional(plexMediaSource.Connections.SingleOrDefault(c => c.IsActive)).Match(c => c.Uri, string.Empty));
internal static PlexLibraryViewModel ProjectToViewModel(PlexLibrary library) =>
new(library.Id, library.Name, library.MediaKind, library.ShouldSyncItems);
}
}

6
ErsatzTV.Application/Plex/PlexLibraryViewModel.cs

@ -0,0 +1,6 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Plex
{
public record PlexLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind, bool ShouldSyncItems);
}

6
ErsatzTV.Application/Plex/PlexMediaSourceViewModel.cs

@ -0,0 +1,6 @@
using ErsatzTV.Application.MediaSources;
namespace ErsatzTV.Application.Plex
{
public record PlexMediaSourceViewModel(int Id, string Name, string Address) : MediaSourceViewModel(Id, Name);
}

2
ErsatzTV.Application/MediaSources/Queries/GetAllPlexMediaSources.cs → ErsatzTV.Application/Plex/Queries/GetAllPlexMediaSources.cs

@ -1,7 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using MediatR; using MediatR;
namespace ErsatzTV.Application.MediaSources.Queries namespace ErsatzTV.Application.Plex.Queries
{ {
public record GetAllPlexMediaSources : IRequest<List<PlexMediaSourceViewModel>>; public record GetAllPlexMediaSources : IRequest<List<PlexMediaSourceViewModel>>;
} }

4
ErsatzTV.Application/MediaSources/Queries/GetAllPlexMediaSourcesHandler.cs → ErsatzTV.Application/Plex/Queries/GetAllPlexMediaSourcesHandler.cs

@ -5,9 +5,9 @@ using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
using static ErsatzTV.Application.MediaSources.Mapper; using static ErsatzTV.Application.Plex.Mapper;
namespace ErsatzTV.Application.MediaSources.Queries namespace ErsatzTV.Application.Plex.Queries
{ {
public class GetAllPlexMediaSourcesHandler : IRequestHandler<GetAllPlexMediaSources, List<PlexMediaSourceViewModel>> public class GetAllPlexMediaSourcesHandler : IRequestHandler<GetAllPlexMediaSources, List<PlexMediaSourceViewModel>>
{ {

7
ErsatzTV.Application/Plex/Queries/GetPlexLibrariesBySourceId.cs

@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.Plex.Queries
{
public record GetPlexLibrariesBySourceId(int PlexMediaSourceId) : IRequest<List<PlexLibraryViewModel>>;
}

27
ErsatzTV.Application/Plex/Queries/GetPlexLibrariesBySourceIdHandler.cs

@ -0,0 +1,27 @@
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.Plex.Mapper;
namespace ErsatzTV.Application.Plex.Queries
{
public class
GetPlexLibrariesBySourceIdHandler : IRequestHandler<GetPlexLibrariesBySourceId,
List<PlexLibraryViewModel>>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
public GetPlexLibrariesBySourceIdHandler(IMediaSourceRepository mediaSourceRepository) =>
_mediaSourceRepository = mediaSourceRepository;
public Task<List<PlexLibraryViewModel>> Handle(
GetPlexLibrariesBySourceId request,
CancellationToken cancellationToken) =>
_mediaSourceRepository.GetPlexLibraries(request.PlexMediaSourceId)
.Map(list => list.Map(ProjectToViewModel).ToList());
}
}

7
ErsatzTV.Application/Plex/Queries/GetPlexMediaSourceById.cs

@ -0,0 +1,7 @@
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Plex.Queries
{
public record GetPlexMediaSourceById(int PlexMediaSourceId) : IRequest<Option<PlexMediaSourceViewModel>>;
}

23
ErsatzTV.Application/Plex/Queries/GetPlexMediaSourceByIdHandler.cs

@ -0,0 +1,23 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.Plex.Mapper;
namespace ErsatzTV.Application.Plex.Queries
{
public class
GetPlexMediaSourceByIdHandler : IRequestHandler<GetPlexMediaSourceById, Option<PlexMediaSourceViewModel>>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
public GetPlexMediaSourceByIdHandler(IMediaSourceRepository mediaSourceRepository) =>
_mediaSourceRepository = mediaSourceRepository;
public Task<Option<PlexMediaSourceViewModel>> Handle(
GetPlexMediaSourceById request,
CancellationToken cancellationToken) =>
_mediaSourceRepository.GetPlex(request.PlexMediaSourceId).MapT(ProjectToViewModel);
}
}

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

@ -41,8 +41,8 @@ namespace ErsatzTV.Core.Tests.Fakes
.Map(f => f.LastWriteTime) .Map(f => f.LastWriteTime)
.IfNone(DateTime.MinValue); .IfNone(DateTime.MinValue);
public bool IsMediaSourceAccessible(LocalMediaSource localMediaSource) => public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
_files.Any(f => f.Path.StartsWith(localMediaSource.Folder)); _files.Any(f => f.Path.StartsWith(libraryPath.Path + Path.DirectorySeparatorChar));
public IEnumerable<string> ListSubdirectories(string folder) => public IEnumerable<string> ListSubdirectories(string folder) =>
_folders.Map(f => f.Path).Filter(f => f.StartsWith(folder) && Directory.GetParent(f)?.FullName == folder); _folders.Map(f => f.Path).Filter(f => f.StartsWith(folder) && Directory.GetParent(f)?.FullName == folder);

4
ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs

@ -11,9 +11,9 @@ namespace ErsatzTV.Core.Tests.Fakes
{ {
public class FakeMediaCollectionRepository : IMediaCollectionRepository public class FakeMediaCollectionRepository : IMediaCollectionRepository
{ {
private readonly Map<int, List<MovieMediaItem>> _data; private readonly Map<int, List<Movie>> _data;
public FakeMediaCollectionRepository(Map<int, List<MovieMediaItem>> data) => _data = data; public FakeMediaCollectionRepository(Map<int, List<Movie>> data) => _data = data;
public Task<SimpleMediaCollection> Add(SimpleMediaCollection collection) => throw new NotSupportedException(); public Task<SimpleMediaCollection> Add(SimpleMediaCollection collection) => throw new NotSupportedException();

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

@ -67,7 +67,7 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<Either<BaseError, TelevisionEpisodeMediaItem>> GetOrAddEpisode( public Task<Either<BaseError, TelevisionEpisodeMediaItem>> GetOrAddEpisode(
TelevisionSeason season, TelevisionSeason season,
int mediaSourceId, LibraryPath libraryPath,
string path) => throw new NotSupportedException(); string path) => throw new NotSupportedException();
public Task<Unit> DeleteMissingSources(int localMediaSourceId, List<string> allFolders) => public Task<Unit> DeleteMissingSources(int localMediaSourceId, List<string> allFolders) =>

2
ErsatzTV.Core.Tests/Metadata/FallbackMetadataProviderTests.cs

@ -39,7 +39,7 @@ namespace ErsatzTV.Core.Tests.Metadata
{ {
TelevisionEpisodeMetadata metadata = FallbackMetadataProvider.GetFallbackMetadata( TelevisionEpisodeMetadata metadata = FallbackMetadataProvider.GetFallbackMetadata(
new TelevisionEpisodeMediaItem new TelevisionEpisodeMediaItem
{ Path = path, Source = new LocalMediaSource { MediaType = MediaType.TvShow } }); { Path = path, LibraryPath = new LibraryPath() });
metadata.Title.Should().Be(title); metadata.Title.Should().Be(title);
metadata.Season.Should().Be(season); metadata.Season.Should().Be(season);

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

@ -40,10 +40,10 @@ namespace ErsatzTV.Core.Tests.Metadata
public void SetUp() public void SetUp()
{ {
_movieRepository = new Mock<IMovieRepository>(); _movieRepository = new Mock<IMovieRepository>();
_movieRepository.Setup(x => x.GetOrAdd(It.IsAny<int>(), It.IsAny<string>())) _movieRepository.Setup(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>()))
.Returns( .Returns(
(int _, string path) => (LibraryPath _, string path) =>
Right<BaseError, MovieMediaItem>(new MovieMediaItem { Path = path }).AsTask()); Right<BaseError, Movie>(new Movie { Path = path }).AsTask());
_localStatisticsProvider = new Mock<ILocalStatisticsProvider>(); _localStatisticsProvider = new Mock<ILocalStatisticsProvider>();
_localMetadataProvider = new Mock<ILocalMetadataProvider>(); _localMetadataProvider = new Mock<ILocalMetadataProvider>();
@ -65,9 +65,9 @@ namespace ErsatzTV.Core.Tests.Metadata
MovieFolderScanner service = GetService( MovieFolderScanner service = GetService(
new FakeFileEntry(Path.Combine(FakeRoot, Path.Combine("Movie (2020)", "Movie (2020).mkv"))) new FakeFileEntry(Path.Combine(FakeRoot, Path.Combine("Movie (2020)", "Movie (2020).mkv")))
); );
var source = new LocalMediaSource { Folder = BadFakeRoot }; var libraryPath = new LibraryPath { Path = BadFakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(source, FFprobePath); Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
result.IsLeft.Should().BeTrue(); result.IsLeft.Should().BeTrue();
result.IfLeft(error => error.Should().BeOfType<MediaSourceInaccessible>()); result.IfLeft(error => error.Should().BeOfType<MediaSourceInaccessible>());
@ -85,14 +85,14 @@ namespace ErsatzTV.Core.Tests.Metadata
MovieFolderScanner service = GetService( MovieFolderScanner service = GetService(
new FakeFileEntry(moviePath) new FakeFileEntry(moviePath)
); );
var source = new LocalMediaSource { Id = 1, Folder = FakeRoot }; var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(source, FFprobePath); Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.GetOrAdd(It.IsAny<int>(), It.IsAny<string>()), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
_movieRepository.Verify(x => x.GetOrAdd(1, moviePath), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify( _localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<MediaItem>(i => i.Path == moviePath)), x => x.RefreshStatistics(FFprobePath, It.Is<MediaItem>(i => i.Path == moviePath)),
@ -118,14 +118,14 @@ namespace ErsatzTV.Core.Tests.Metadata
new FakeFileEntry(moviePath), new FakeFileEntry(moviePath),
new FakeFileEntry(metadataPath) new FakeFileEntry(metadataPath)
); );
var source = new LocalMediaSource { Id = 1, Folder = FakeRoot }; var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(source, FFprobePath); Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.GetOrAdd(It.IsAny<int>(), It.IsAny<string>()), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
_movieRepository.Verify(x => x.GetOrAdd(1, moviePath), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify( _localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<MediaItem>(i => i.Path == moviePath)), x => x.RefreshStatistics(FFprobePath, It.Is<MediaItem>(i => i.Path == moviePath)),
@ -151,14 +151,14 @@ namespace ErsatzTV.Core.Tests.Metadata
new FakeFileEntry(moviePath), new FakeFileEntry(moviePath),
new FakeFileEntry(metadataPath) new FakeFileEntry(metadataPath)
); );
var source = new LocalMediaSource { Id = 1, Folder = FakeRoot }; var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(source, FFprobePath); Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.GetOrAdd(It.IsAny<int>(), It.IsAny<string>()), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
_movieRepository.Verify(x => x.GetOrAdd(1, moviePath), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify( _localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<MediaItem>(i => i.Path == moviePath)), x => x.RefreshStatistics(FFprobePath, It.Is<MediaItem>(i => i.Path == moviePath)),
@ -188,14 +188,14 @@ namespace ErsatzTV.Core.Tests.Metadata
new FakeFileEntry(moviePath), new FakeFileEntry(moviePath),
new FakeFileEntry(posterPath) new FakeFileEntry(posterPath)
); );
var source = new LocalMediaSource { Id = 1, Folder = FakeRoot }; var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(source, FFprobePath); Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.GetOrAdd(It.IsAny<int>(), It.IsAny<string>()), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
_movieRepository.Verify(x => x.GetOrAdd(1, moviePath), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify( _localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<MediaItem>(i => i.Path == moviePath)), x => x.RefreshStatistics(FFprobePath, It.Is<MediaItem>(i => i.Path == moviePath)),
@ -229,14 +229,14 @@ namespace ErsatzTV.Core.Tests.Metadata
new FakeFileEntry(moviePath), new FakeFileEntry(moviePath),
new FakeFileEntry(posterPath) new FakeFileEntry(posterPath)
); );
var source = new LocalMediaSource { Id = 1, Folder = FakeRoot }; var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(source, FFprobePath); Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.GetOrAdd(It.IsAny<int>(), It.IsAny<string>()), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
_movieRepository.Verify(x => x.GetOrAdd(1, moviePath), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify( _localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<MediaItem>(i => i.Path == moviePath)), x => x.RefreshStatistics(FFprobePath, It.Is<MediaItem>(i => i.Path == moviePath)),
@ -269,14 +269,14 @@ namespace ErsatzTV.Core.Tests.Metadata
Path.GetDirectoryName(moviePath) ?? string.Empty, Path.GetDirectoryName(moviePath) ?? string.Empty,
$"Movie (2020)-{extraFile}{videoExtension}")) $"Movie (2020)-{extraFile}{videoExtension}"))
); );
var source = new LocalMediaSource { Id = 1, Folder = FakeRoot }; var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(source, FFprobePath); Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.GetOrAdd(It.IsAny<int>(), It.IsAny<string>()), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
_movieRepository.Verify(x => x.GetOrAdd(1, moviePath), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify( _localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<MediaItem>(i => i.Path == moviePath)), x => x.RefreshStatistics(FFprobePath, It.Is<MediaItem>(i => i.Path == moviePath)),
@ -305,14 +305,14 @@ namespace ErsatzTV.Core.Tests.Metadata
Path.GetDirectoryName(moviePath) ?? string.Empty, Path.GetDirectoryName(moviePath) ?? string.Empty,
Path.Combine(extraFolder, $"Movie (2020){videoExtension}"))) Path.Combine(extraFolder, $"Movie (2020){videoExtension}")))
); );
var source = new LocalMediaSource { Id = 1, Folder = FakeRoot }; var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(source, FFprobePath); Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.GetOrAdd(It.IsAny<int>(), It.IsAny<string>()), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
_movieRepository.Verify(x => x.GetOrAdd(1, moviePath), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify( _localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<MediaItem>(i => i.Path == moviePath)), x => x.RefreshStatistics(FFprobePath, It.Is<MediaItem>(i => i.Path == moviePath)),
@ -335,14 +335,14 @@ namespace ErsatzTV.Core.Tests.Metadata
MovieFolderScanner service = GetService( MovieFolderScanner service = GetService(
new FakeFileEntry(moviePath) new FakeFileEntry(moviePath)
); );
var source = new LocalMediaSource { Id = 1, Folder = FakeRoot }; var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(source, FFprobePath); Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.GetOrAdd(It.IsAny<int>(), It.IsAny<string>()), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
_movieRepository.Verify(x => x.GetOrAdd(1, moviePath), Times.Once); _movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify( _localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<MediaItem>(i => i.Path == moviePath)), x => x.RefreshStatistics(FFprobePath, It.Is<MediaItem>(i => i.Path == moviePath)),

34
ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs

@ -40,7 +40,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
[Test] [Test]
public async Task InitialFlood_Should_StartAtMidnight() public async Task InitialFlood_Should_StartAtMidnight()
{ {
var mediaItems = new List<MovieMediaItem> var mediaItems = new List<Movie>
{ {
TestMovie(1, TimeSpan.FromHours(6), DateTime.Today) TestMovie(1, TimeSpan.FromHours(6), DateTime.Today)
}; };
@ -59,7 +59,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
[Test] [Test]
public async Task InitialFlood_Should_StartAtMidnight_With_LateStart() public async Task InitialFlood_Should_StartAtMidnight_With_LateStart()
{ {
var mediaItems = new List<MovieMediaItem> var mediaItems = new List<Movie>
{ {
TestMovie(1, TimeSpan.FromHours(6), DateTime.Today) TestMovie(1, TimeSpan.FromHours(6), DateTime.Today)
}; };
@ -79,7 +79,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
[Test] [Test]
public async Task ChronologicalContent_Should_CreateChronologicalItems() public async Task ChronologicalContent_Should_CreateChronologicalItems()
{ {
var mediaItems = new List<MovieMediaItem> var mediaItems = new List<Movie>
{ {
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)), TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1)) TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1))
@ -105,7 +105,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
[Test] [Test]
public async Task ChronologicalFlood_Should_AnchorAndMaintainExistingPlayout() public async Task ChronologicalFlood_Should_AnchorAndMaintainExistingPlayout()
{ {
var mediaItems = new List<MovieMediaItem> var mediaItems = new List<Movie>
{ {
TestMovie(1, TimeSpan.FromHours(6), DateTime.Today), TestMovie(1, TimeSpan.FromHours(6), DateTime.Today),
TestMovie(2, TimeSpan.FromHours(6), DateTime.Today.AddHours(1)) TestMovie(2, TimeSpan.FromHours(6), DateTime.Today.AddHours(1))
@ -142,7 +142,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
[Test] [Test]
public async Task ChronologicalFlood_Should_AnchorAndReturnNewPlayoutItems() public async Task ChronologicalFlood_Should_AnchorAndReturnNewPlayoutItems()
{ {
var mediaItems = new List<MovieMediaItem> var mediaItems = new List<Movie>
{ {
TestMovie(1, TimeSpan.FromHours(6), DateTime.Today), TestMovie(1, TimeSpan.FromHours(6), DateTime.Today),
TestMovie(2, TimeSpan.FromHours(6), DateTime.Today.AddHours(1)) TestMovie(2, TimeSpan.FromHours(6), DateTime.Today.AddHours(1))
@ -180,7 +180,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
[Test] [Test]
public async Task ShuffleFloodRebuild_Should_IgnoreAnchors() public async Task ShuffleFloodRebuild_Should_IgnoreAnchors()
{ {
var mediaItems = new List<MovieMediaItem> var mediaItems = new List<Movie>
{ {
TestMovie(1, TimeSpan.FromHours(1), DateTime.Today), TestMovie(1, TimeSpan.FromHours(1), DateTime.Today),
TestMovie(2, TimeSpan.FromHours(1), DateTime.Today.AddHours(1)), TestMovie(2, TimeSpan.FromHours(1), DateTime.Today.AddHours(1)),
@ -223,7 +223,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
[Test] [Test]
public async Task ShuffleFlood_Should_MaintainRandomSeed() public async Task ShuffleFlood_Should_MaintainRandomSeed()
{ {
var mediaItems = new List<MovieMediaItem> var mediaItems = new List<Movie>
{ {
TestMovie(1, TimeSpan.FromHours(1), DateTime.Today), TestMovie(1, TimeSpan.FromHours(1), DateTime.Today),
TestMovie(2, TimeSpan.FromHours(1), DateTime.Today.AddHours(1)), TestMovie(2, TimeSpan.FromHours(1), DateTime.Today.AddHours(1)),
@ -259,7 +259,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
{ {
Id = 1, Id = 1,
Name = "Flood Items", Name = "Flood Items",
Movies = new List<MovieMediaItem> Movies = new List<Movie>
{ {
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)), TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1)) TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1))
@ -270,7 +270,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
{ {
Id = 2, Id = 2,
Name = "Fixed Items", Name = "Fixed Items",
Movies = new List<MovieMediaItem> Movies = new List<Movie>
{ {
TestMovie(3, TimeSpan.FromHours(2), new DateTime(2020, 1, 1)) TestMovie(3, TimeSpan.FromHours(2), new DateTime(2020, 1, 1))
} }
@ -337,7 +337,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
{ {
Id = 1, Id = 1,
Name = "Flood Items", Name = "Flood Items",
Movies = new List<MovieMediaItem> Movies = new List<Movie>
{ {
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)), TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1)) TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1))
@ -348,7 +348,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
{ {
Id = 2, Id = 2,
Name = "Fixed Items", Name = "Fixed Items",
Movies = new List<MovieMediaItem> Movies = new List<Movie>
{ {
TestMovie(3, TimeSpan.FromHours(2), new DateTime(2020, 1, 1)), TestMovie(3, TimeSpan.FromHours(2), new DateTime(2020, 1, 1)),
TestMovie(4, TimeSpan.FromHours(1), new DateTime(2020, 1, 2)) TestMovie(4, TimeSpan.FromHours(1), new DateTime(2020, 1, 2))
@ -422,7 +422,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
{ {
Id = 1, Id = 1,
Name = "Flood Items", Name = "Flood Items",
Movies = new List<MovieMediaItem> Movies = new List<Movie>
{ {
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)), TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1)) TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1))
@ -433,7 +433,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
{ {
Id = 2, Id = 2,
Name = "Fixed Items", Name = "Fixed Items",
Movies = new List<MovieMediaItem> Movies = new List<Movie>
{ {
TestMovie(3, TimeSpan.FromHours(0.75), new DateTime(2020, 1, 1)), TestMovie(3, TimeSpan.FromHours(0.75), new DateTime(2020, 1, 1)),
TestMovie(4, TimeSpan.FromHours(1.5), new DateTime(2020, 1, 2)) TestMovie(4, TimeSpan.FromHours(1.5), new DateTime(2020, 1, 2))
@ -511,7 +511,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
{ {
Id = 1, Id = 1,
Name = "Multiple Items", Name = "Multiple Items",
Movies = new List<MovieMediaItem> Movies = new List<Movie>
{ {
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)), TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1)) TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1))
@ -522,7 +522,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
{ {
Id = 2, Id = 2,
Name = "Dynamic Items", Name = "Dynamic Items",
Movies = new List<MovieMediaItem> Movies = new List<Movie>
{ {
TestMovie(3, TimeSpan.FromHours(0.75), new DateTime(2020, 1, 1)), TestMovie(3, TimeSpan.FromHours(0.75), new DateTime(2020, 1, 1)),
TestMovie(4, TimeSpan.FromHours(1.5), new DateTime(2020, 1, 2)) TestMovie(4, TimeSpan.FromHours(1.5), new DateTime(2020, 1, 2))
@ -607,7 +607,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
StartTime = null StartTime = null
}; };
private static MovieMediaItem TestMovie(int id, TimeSpan duration, DateTime aired) => private static Movie TestMovie(int id, TimeSpan duration, DateTime aired) =>
new() new()
{ {
Id = id, Id = id,
@ -615,7 +615,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
Statistics = new MediaItemStatistics { Duration = duration } Statistics = new MediaItemStatistics { Duration = duration }
}; };
private TestData TestDataFloodForItems(List<MovieMediaItem> mediaItems, PlaybackOrder playbackOrder) private TestData TestDataFloodForItems(List<Movie> mediaItems, PlaybackOrder playbackOrder)
{ {
var mediaCollection = new SimpleMediaCollection var mediaCollection = new SimpleMediaCollection
{ {

12
ErsatzTV.Core/Domain/Collection/Collection.cs

@ -0,0 +1,12 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class Collection
{
public int Id { get; set; }
public string Name { get; set; }
public List<MediaItem> MediaItems { get; set; }
public List<CollectionItem> CollectionItems { get; set; }
}
}

10
ErsatzTV.Core/Domain/Collection/CollectionItem.cs

@ -0,0 +1,10 @@
namespace ErsatzTV.Core.Domain
{
public class CollectionItem
{
public int CollectionId { get; set; }
public Collection Collection { get; set; }
public int MediaItemId { get; set; }
public MediaItem MediaItem { get; set; }
}
}

18
ErsatzTV.Core/Domain/Library/Library.cs

@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public abstract class Library
{
public int Id { get; set; }
public string Name { get; set; }
public LibraryMediaKind MediaKind { get; set; }
public DateTimeOffset? LastScan { get; set; }
public int MediaSourceId { get; set; }
public MediaSource MediaSource { get; set; }
public List<LibraryPath> Paths { get; set; }
}
}

8
ErsatzTV.Core/Domain/Library/LibraryMediaKind.cs

@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public enum LibraryMediaKind
{
Movies = 1,
Shows = 2
}
}

15
ErsatzTV.Core/Domain/Library/LibraryPath.cs

@ -0,0 +1,15 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class LibraryPath
{
public int Id { get; set; }
public string Path { get; set; }
public int LibraryId { get; set; }
public Library Library { get; set; }
public List<MediaItem> MediaItems { get; set; }
}
}

6
ErsatzTV.Core/Domain/Library/LocalLibrary.cs

@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Domain
{
public class LocalLibrary : Library
{
}
}

8
ErsatzTV.Core/Domain/Library/PlexLibrary.cs

@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public class PlexLibrary : Library
{
public string Key { get; set; }
public bool ShouldSyncItems { get; set; }
}
}

10
ErsatzTV.Core/Domain/LocalMediaSource.cs

@ -1,10 +0,0 @@
namespace ErsatzTV.Core.Domain
{
public class LocalMediaSource : MediaSource
{
public LocalMediaSource() => SourceType = MediaSourceType.Local;
public MediaType MediaType { get; set; }
public string Folder { get; set; }
}
}

17
ErsatzTV.Core/Domain/MediaItem.cs

@ -1,17 +0,0 @@
using System;
using ErsatzTV.Core.Interfaces.Domain;
namespace ErsatzTV.Core.Domain
{
public class MediaItem : IHasAPoster
{
public int Id { get; set; }
public int MediaSourceId { get; set; }
public MediaSource Source { get; set; }
public MediaItemStatistics Statistics { get; set; }
public DateTime? LastWriteTime { get; set; }
public string Path { get; set; }
public string Poster { get; set; }
public DateTime? PosterLastWriteTime { get; set; }
}
}

13
ErsatzTV.Core/Domain/MediaItem/Episode.cs

@ -0,0 +1,13 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class Episode : MediaItem
{
public int EpisodeNumber { get; set; }
public int SeasonId { get; set; }
public Season Season { get; set; }
public List<EpisodeMetadata> EpisodeMetadata { get; set; }
public List<MediaVersion> MediaVersions { get; set; }
}
}

11
ErsatzTV.Core/Domain/MediaItem/MediaFile.cs

@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Domain
{
public class MediaFile
{
public int Id { get; set; }
public string Path { get; set; }
public int MediaVersionId { get; set; }
public MediaVersion MediaVersion { get; set; }
}
}

27
ErsatzTV.Core/Domain/MediaItem/MediaItem.cs

@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
using ErsatzTV.Core.Interfaces.Domain;
namespace ErsatzTV.Core.Domain
{
public class MediaItem : IHasAPoster
{
public int Id { get; set; }
public MediaItemStatistics Statistics { get; set; }
public DateTime? LastWriteTime { get; set; }
public int LibraryPathId { get; set; }
public LibraryPath LibraryPath { get; set; }
public string Path { get; set; }
public string Poster { get; set; }
public DateTime? PosterLastWriteTime { get; set; }
// temporary fields to help migrations...
public int TelevisionShowId { get; set; }
public int TelevisionSeasonId { get; set; }
public int TelevisionEpisodeId { get; set; }
public List<Collection> Collections { get; set; }
public List<CollectionItem> CollectionItems { get; set; }
}
}

22
ErsatzTV.Core/Domain/MediaItem/MediaVersion.cs

@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class MediaVersion
{
public int Id { get; set; }
public string Name { get; set; }
public List<MediaFile> MediaFiles { get; set; }
public TimeSpan Duration { get; set; }
public string SampleAspectRatio { get; set; }
public string DisplayAspectRatio { get; set; }
public string VideoCodec { get; set; }
public string AudioCodec { get; set; }
public bool IsInterlaced { get; set; }
public int Width { get; set; }
public int Height { get; set; }
}
}

18
ErsatzTV.Core/Domain/MediaItem/Movie.cs

@ -0,0 +1,18 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class Movie : MediaItem
{
// TODO: remove MetadataId
public int MetadataId { get; set; }
// TODO: remove Metadata
public MovieMetadata Metadata { get; set; }
public List<NewMovieMetadata> MovieMetadata { get; set; }
public List<MediaVersion> MediaVersions { get; set; }
// TODO: remove SimpleMediaCollections
public List<SimpleMediaCollection> SimpleMediaCollections { get; set; }
}
}

12
ErsatzTV.Core/Domain/MediaItem/PlexMediaItemPart.cs

@ -0,0 +1,12 @@
namespace ErsatzTV.Core.Domain
{
public class PlexMediaItemPart
{
public int Id { get; set; }
public int PlexId { get; set; }
public string Key { get; set; }
public int Duration { get; set; }
public string File { get; set; }
public int Size { get; set; }
}
}

8
ErsatzTV.Core/Domain/MediaItem/PlexMovie.cs

@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public class PlexMovie : Movie
{
public string Key { get; set; }
public PlexMediaItemPart Part { get; set; }
}
}

13
ErsatzTV.Core/Domain/MediaItem/Season.cs

@ -0,0 +1,13 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class Season : MediaItem
{
public int SeasonNumber { get; set; }
public int ShowId { get; set; }
public Show Show { get; set; }
public List<Episode> Episodes { get; set; }
}
}

10
ErsatzTV.Core/Domain/MediaItem/Show.cs

@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class Show : MediaItem
{
public List<Season> Seasons { get; set; }
public List<ShowMetadata> ShowMetadata { get; set; }
}
}

12
ErsatzTV.Core/Domain/MediaSource.cs

@ -1,12 +0,0 @@
using System;
namespace ErsatzTV.Core.Domain
{
public abstract class MediaSource
{
public int Id { get; set; }
public MediaSourceType SourceType { get; set; }
public string Name { get; set; }
public DateTimeOffset? LastScan { get; set; }
}
}

6
ErsatzTV.Core/Domain/MediaSource/LocalMediaSource.cs

@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Domain
{
public class LocalMediaSource : MediaSource
{
}
}

11
ErsatzTV.Core/Domain/MediaSource/MediaSource.cs

@ -0,0 +1,11 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public abstract class MediaSource
{
public int Id { get; set; }
public List<Library> Libraries { get; set; }
}
}

4
ErsatzTV.Core/Domain/PlexMediaSourceConnection.cs → ErsatzTV.Core/Domain/MediaSource/PlexConnection.cs

@ -1,9 +1,11 @@
namespace ErsatzTV.Core.Domain namespace ErsatzTV.Core.Domain
{ {
public class PlexMediaSourceConnection public class PlexConnection
{ {
public int Id { get; set; } public int Id { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public string Uri { get; set; } public string Uri { get; set; }
public int PlexMediaSourceId { get; set; }
public PlexMediaSource PlexMediaSource { get; set; }
} }
} }

6
ErsatzTV.Core/Domain/PlexMediaSource.cs → ErsatzTV.Core/Domain/MediaSource/PlexMediaSource.cs

@ -4,13 +4,11 @@ namespace ErsatzTV.Core.Domain
{ {
public class PlexMediaSource : MediaSource public class PlexMediaSource : MediaSource
{ {
public PlexMediaSource() => SourceType = MediaSourceType.Plex; public string ServerName { get; set; }
public string ProductVersion { get; set; } public string ProductVersion { get; set; }
public string ClientIdentifier { get; set; } public string ClientIdentifier { get; set; }
// public bool IsOwned { get; set; } // public bool IsOwned { get; set; }
public List<PlexMediaSourceConnection> Connections { get; set; } public List<PlexConnection> Connections { get; set; }
public List<PlexMediaSourceLibrary> Libraries { get; set; }
} }
} }

10
ErsatzTV.Core/Domain/MediaSourceType.cs

@ -1,10 +0,0 @@
namespace ErsatzTV.Core.Domain
{
public enum MediaSourceType
{
None = 0,
Local = 1,
Plex = 2,
Jellyfin = 3
}
}

9
ErsatzTV.Core/Domain/MediaType.cs

@ -1,9 +0,0 @@
namespace ErsatzTV.Core.Domain
{
public enum MediaType
{
Other = 0,
TvShow = 1,
Movie = 2
}
}

11
ErsatzTV.Core/Domain/Metadata/EpisodeMetadata.cs

@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Domain
{
public class EpisodeMetadata : Metadata
{
public string Outline { get; set; }
public string Plot { get; set; }
public string Tagline { get; set; }
public int EpisodeId { get; set; }
public Episode Episode { get; set; }
}
}

16
ErsatzTV.Core/Domain/Metadata/Metadata.cs

@ -0,0 +1,16 @@
using System;
namespace ErsatzTV.Core.Domain
{
public class Metadata
{
public int Id { get; set; }
public MetadataKind MetadataKind { get; set; }
public string Title { get; set; }
public string OriginalTitle { get; set; }
public string SortTitle { get; set; }
public DateTimeOffset? ReleaseDate { get; set; }
public DateTimeOffset DateAdded { get; set; }
public DateTimeOffset DateUpdated { get; set; }
}
}

8
ErsatzTV.Core/Domain/Metadata/MetadataKind.cs

@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public enum MetadataKind
{
Fallback,
Sidecar
}
}

11
ErsatzTV.Core/Domain/Metadata/NewMovieMetadata.cs

@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Domain
{
public class NewMovieMetadata : Metadata
{
public string Outline { get; set; }
public string Plot { get; set; }
public string Tagline { get; set; }
public int MovieId { get; set; }
public Movie Movie { get; set; }
}
}

11
ErsatzTV.Core/Domain/Metadata/ShowMetadata.cs

@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Domain
{
public class ShowMetadata : Metadata
{
public string Outline { get; set; }
public string Plot { get; set; }
public string Tagline { get; set; }
public int ShowId { get; set; }
public Show Show { get; set; }
}
}

11
ErsatzTV.Core/Domain/MovieMediaItem.cs

@ -1,11 +0,0 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class MovieMediaItem : MediaItem
{
public int MetadataId { get; set; }
public MovieMetadata Metadata { get; set; }
public List<SimpleMediaCollection> SimpleMediaCollections { get; set; }
}
}

2
ErsatzTV.Core/Domain/MovieMetadata.cs

@ -6,7 +6,7 @@ namespace ErsatzTV.Core.Domain
{ {
public int Id { get; set; } public int Id { get; set; }
public int MovieId { get; set; } public int MovieId { get; set; }
public MovieMediaItem Movie { get; set; } public Movie Movie { get; set; }
public int? Year { get; set; } public int? Year { get; set; }
public DateTime? Premiered { get; set; } public DateTime? Premiered { get; set; }
public string Plot { get; set; } public string Plot { get; set; }

6
ErsatzTV.Core/Domain/PlayoutProgramScheduleAnchor.cs

@ -8,7 +8,13 @@
public int ProgramScheduleId { get; set; } public int ProgramScheduleId { get; set; }
public ProgramSchedule ProgramSchedule { get; set; } public ProgramSchedule ProgramSchedule { get; set; }
public ProgramScheduleItemCollectionType CollectionType { get; set; } public ProgramScheduleItemCollectionType CollectionType { get; set; }
// TODO: remove this
public int CollectionId { get; set; } public int CollectionId { get; set; }
// TODO: rename this
public int? NewCollectionId { get; set; }
public Collection Collection { get; set; }
public int? MediaItemId { get; set; }
public MediaItem MediaItem { get; set; }
public MediaCollectionEnumeratorState EnumeratorState { get; set; } public MediaCollectionEnumeratorState EnumeratorState { get; set; }
} }
} }

10
ErsatzTV.Core/Domain/PlexMediaSourceLibrary.cs

@ -1,10 +0,0 @@
namespace ErsatzTV.Core.Domain
{
public class PlexMediaSourceLibrary
{
public int Id { get; set; }
public string Key { get; init; }
public string Name { get; init; }
public MediaType MediaType { get; init; }
}
}

6
ErsatzTV.Core/Domain/ProgramScheduleItem.cs

@ -9,6 +9,7 @@ namespace ErsatzTV.Core.Domain
public StartType StartType => StartTime.HasValue ? StartType.Fixed : StartType.Dynamic; public StartType StartType => StartTime.HasValue ? StartType.Fixed : StartType.Dynamic;
public TimeSpan? StartTime { get; set; } public TimeSpan? StartTime { get; set; }
public ProgramScheduleItemCollectionType CollectionType { get; set; } public ProgramScheduleItemCollectionType CollectionType { get; set; }
// TODO: remove these
public int? MediaCollectionId { get; set; } public int? MediaCollectionId { get; set; }
public MediaCollection MediaCollection { get; set; } public MediaCollection MediaCollection { get; set; }
public int? TelevisionShowId { get; set; } public int? TelevisionShowId { get; set; }
@ -17,5 +18,10 @@ namespace ErsatzTV.Core.Domain
public TelevisionSeason TelevisionSeason { get; set; } public TelevisionSeason TelevisionSeason { get; set; }
public int ProgramScheduleId { get; set; } public int ProgramScheduleId { get; set; }
public ProgramSchedule ProgramSchedule { get; set; } public ProgramSchedule ProgramSchedule { get; set; }
public int? CollectionId { get; set; }
public Collection Collection { get; set; }
public int? MediaItemId { get; set; }
public MediaItem MediaItem { get; set; }
} }
} }

2
ErsatzTV.Core/Domain/SimpleMediaCollection.cs

@ -4,7 +4,7 @@ namespace ErsatzTV.Core.Domain
{ {
public class SimpleMediaCollection : MediaCollection public class SimpleMediaCollection : MediaCollection
{ {
public List<MovieMediaItem> Movies { get; set; } public List<Movie> Movies { get; set; }
public List<TelevisionShow> TelevisionShows { get; set; } public List<TelevisionShow> TelevisionShows { get; set; }
public List<TelevisionSeason> TelevisionSeasons { get; set; } public List<TelevisionSeason> TelevisionSeasons { get; set; }
public List<TelevisionEpisodeMediaItem> TelevisionEpisodes { get; set; } public List<TelevisionEpisodeMediaItem> TelevisionEpisodes { get; set; }

2
ErsatzTV.Core/Domain/TelevisionShowMetadata.cs

@ -7,6 +7,8 @@ namespace ErsatzTV.Core.Domain
public int Id { get; set; } public int Id { get; set; }
public int TelevisionShowId { get; set; } public int TelevisionShowId { get; set; }
public TelevisionShow TelevisionShow { get; set; } public TelevisionShow TelevisionShow { get; set; }
// public int ShowId { get; set; }
// public Show Show { get; set; }
public MetadataSource Source { get; set; } public MetadataSource Source { get; set; }
public DateTime? LastWriteTime { get; set; } public DateTime? LastWriteTime { get; set; }
public string Title { get; set; } public string Title { get; set; }

2
ErsatzTV.Core/Domain/TelevisionShowSource.cs

@ -1,5 +1,7 @@
namespace ErsatzTV.Core.Domain namespace ErsatzTV.Core.Domain
{ {
// used for shows split across sources (folders)
// won't need to convert, since shows will be linked via metadata
public class TelevisionShowSource public class TelevisionShowSource
{ {
public int Id { get; set; } public int Id { get; set; }

7
ErsatzTV.Core/ErsatzTV.Core.csproj.DotSettings

@ -0,0 +1,7 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=domain_005Ccollection/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=domain_005Clibrary/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=domain_005Cmediaitem/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=domain_005Cmediasource/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=domain_005Cmediasources/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=domain_005Cmetadata/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

8
ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs

@ -4,9 +4,9 @@ namespace ErsatzTV.Core.Interfaces.Locking
{ {
public interface IEntityLocker public interface IEntityLocker
{ {
event EventHandler OnMediaSourceChanged; event EventHandler OnLibraryChanged;
bool LockMediaSource(int mediaSourceId); bool LockLibrary(int libraryId);
bool UnlockMediaSource(int mediaSourceId); bool UnlockLibrary(int libraryId);
bool IsMediaSourceLocked(int mediaSourceId); bool IsLibraryLocked(int libraryId);
} }
} }

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

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

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

@ -6,6 +6,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
{ {
public interface IMovieFolderScanner public interface IMovieFolderScanner
{ {
Task<Either<BaseError, Unit>> ScanFolder(LocalMediaSource localMediaSource, string ffprobePath); Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath);
} }
} }

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

@ -6,6 +6,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
{ {
public interface ITelevisionFolderScanner public interface ITelevisionFolderScanner
{ {
Task<Unit> ScanFolder(LocalMediaSource localMediaSource, string ffprobePath); Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath);
} }
} }

15
ErsatzTV.Core/Interfaces/Plex/IPlexMovieLibraryScanner.cs

@ -0,0 +1,15 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Plex;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Plex
{
public interface IPlexMovieLibraryScanner
{
Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary plexMediaSourceLibrary);
}
}

9
ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs

@ -8,8 +8,13 @@ namespace ErsatzTV.Core.Interfaces.Plex
{ {
public interface IPlexServerApiClient public interface IPlexServerApiClient
{ {
Task<Either<BaseError, List<PlexMediaSourceLibrary>>> GetLibraries( Task<Either<BaseError, List<PlexLibrary>>> GetLibraries(
PlexMediaSourceConnection connection, PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, List<PlexMovie>>> GetLibraryContents(
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token); PlexServerAuthToken token);
} }
} }

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

@ -0,0 +1,12 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface ILibraryRepository
{
Task<Option<Library>> Get(int libraryId);
Task<Unit> UpdateLastScan(Library library);
}
}

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

@ -11,11 +11,16 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<PlexMediaSource> Add(PlexMediaSource plexMediaSource); Task<PlexMediaSource> Add(PlexMediaSource plexMediaSource);
Task<List<MediaSource>> GetAll(); Task<List<MediaSource>> GetAll();
Task<List<PlexMediaSource>> GetAllPlex(); Task<List<PlexMediaSource>> GetAllPlex();
Task<List<PlexLibrary>> GetPlexLibraries(int plexMediaSourceId);
Task<Option<PlexLibrary>> GetPlexLibrary(int plexLibraryId);
Task<Option<MediaSource>> Get(int id); Task<Option<MediaSource>> Get(int id);
Task<Option<PlexMediaSource>> GetPlex(int id); Task<Option<PlexMediaSource>> GetPlex(int id);
Task<int> CountMediaItems(int id); Task<int> CountMediaItems(int id);
Task Update(LocalMediaSource localMediaSource); Task Update(LocalMediaSource localMediaSource);
Task Update(PlexMediaSource plexMediaSource); Task Update(PlexMediaSource plexMediaSource);
Task Update(PlexLibrary plexMediaSourceLibrary);
Task Delete(int id); Task Delete(int id);
Task DisablePlexLibrarySync(IEnumerable<int> libraryIds);
Task EnablePlexLibrarySync(IEnumerable<int> libraryIds);
} }
} }

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

@ -7,10 +7,11 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{ {
public interface IMovieRepository public interface IMovieRepository
{ {
Task<Option<MovieMediaItem>> GetMovie(int movieId); Task<Option<Movie>> GetMovie(int movieId);
Task<Either<BaseError, MovieMediaItem>> GetOrAdd(int mediaSourceId, string path); Task<Either<BaseError, Movie>> GetOrAdd(LibraryPath libraryPath, string path);
Task<bool> Update(MovieMediaItem movie); Task<Either<BaseError, PlexMovie>> GetOrAdd(PlexLibrary library, PlexMovie item);
Task<bool> Update(Movie movie);
Task<int> GetMovieCount(); Task<int> GetMovieCount();
Task<List<MovieMediaItem>> GetPagedMovies(int pageNumber, int pageSize); Task<List<Movie>> GetPagedMovies(int pageNumber, int pageSize);
} }
} }

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

@ -38,7 +38,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Either<BaseError, TelevisionEpisodeMediaItem>> GetOrAddEpisode( Task<Either<BaseError, TelevisionEpisodeMediaItem>> GetOrAddEpisode(
TelevisionSeason season, TelevisionSeason season,
int mediaSourceId, LibraryPath libraryPath,
string path); string path);
Task<Unit> DeleteMissingSources(int localMediaSourceId, List<string> allFolders); Task<Unit> DeleteMissingSources(int localMediaSourceId, List<string> allFolders);

6
ErsatzTV.Core/Iptv/ChannelGuide.cs

@ -60,21 +60,21 @@ namespace ErsatzTV.Core.Iptv
string title = playoutItem.MediaItem switch string title = playoutItem.MediaItem switch
{ {
MovieMediaItem m => m.Metadata?.Title ?? m.Path, Movie m => m.Metadata?.Title ?? m.Path,
TelevisionEpisodeMediaItem e => e.Metadata?.Title ?? e.Path, TelevisionEpisodeMediaItem e => e.Metadata?.Title ?? e.Path,
_ => "[unknown]" _ => "[unknown]"
}; };
string description = playoutItem.MediaItem switch string description = playoutItem.MediaItem switch
{ {
MovieMediaItem m => m.Metadata?.Plot, Movie m => m.Metadata?.Plot,
TelevisionEpisodeMediaItem e => e.Metadata?.Plot, TelevisionEpisodeMediaItem e => e.Metadata?.Plot,
_ => string.Empty _ => string.Empty
}; };
string contentRating = playoutItem.MediaItem switch string contentRating = playoutItem.MediaItem switch
{ {
MovieMediaItem m => m.Metadata?.ContentRating, Movie m => m.Metadata?.ContentRating,
_ => string.Empty _ => string.Empty
}; };

20
ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs

@ -24,10 +24,11 @@ namespace ErsatzTV.Core.Metadata
if (fileName != null) if (fileName != null)
{ {
if (!(mediaItem.Source is LocalMediaSource)) // TODO: ensure local?
{ // if (!(mediaItem.LibraryPath is LocalMediaSource))
return metadata; // {
} // return metadata;
// }
return GetEpisodeMetadata(fileName, metadata); return GetEpisodeMetadata(fileName, metadata);
} }
@ -35,17 +36,18 @@ namespace ErsatzTV.Core.Metadata
return metadata; return metadata;
} }
public static MovieMetadata GetFallbackMetadata(MovieMediaItem mediaItem) public static MovieMetadata GetFallbackMetadata(Movie mediaItem)
{ {
string fileName = Path.GetFileName(mediaItem.Path); string fileName = Path.GetFileName(mediaItem.Path);
var metadata = new MovieMetadata { Source = MetadataSource.Fallback, Title = fileName ?? mediaItem.Path }; var metadata = new MovieMetadata { Source = MetadataSource.Fallback, Title = fileName ?? mediaItem.Path };
if (fileName != null) if (fileName != null)
{ {
if (!(mediaItem.Source is LocalMediaSource)) // TODO: ensure local?
{ // if (!(mediaItem.Source is LocalMediaSource))
return metadata; // {
} // return metadata;
// }
return GetMovieMetadata(fileName, metadata); return GetMovieMetadata(fileName, metadata);
} }

4
ErsatzTV.Core/Metadata/LocalFileSystem.cs

@ -13,8 +13,8 @@ namespace ErsatzTV.Core.Metadata
public DateTime GetLastWriteTime(string path) => public DateTime GetLastWriteTime(string path) =>
Try(File.GetLastWriteTimeUtc(path)).IfFail(() => DateTime.MinValue); Try(File.GetLastWriteTimeUtc(path)).IfFail(() => DateTime.MinValue);
public bool IsMediaSourceAccessible(LocalMediaSource localMediaSource) => public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
Directory.Exists(localMediaSource.Folder); Directory.Exists(libraryPath.Path);
public IEnumerable<string> ListSubdirectories(string folder) => public IEnumerable<string> ListSubdirectories(string folder) =>
Try(Directory.EnumerateDirectories(folder)).IfFail(new List<string>()); Try(Directory.EnumerateDirectories(folder)).IfFail(new List<string>());

30
ErsatzTV.Core/Metadata/LocalMetadataProvider.cs

@ -57,7 +57,7 @@ namespace ErsatzTV.Core.Metadata
{ {
TelevisionEpisodeMediaItem e => LoadMetadata(e, path) TelevisionEpisodeMediaItem e => LoadMetadata(e, path)
.Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(e, metadata))), .Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(e, metadata))),
MovieMediaItem m => LoadMetadata(m, path) Movie m => LoadMetadata(m, path)
.Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(m, metadata))), .Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(m, metadata))),
_ => Task.FromResult(Unit.Default) _ => Task.FromResult(Unit.Default)
}; };
@ -71,7 +71,7 @@ namespace ErsatzTV.Core.Metadata
{ {
TelevisionEpisodeMediaItem e => ApplyMetadataUpdate(e, FallbackMetadataProvider.GetFallbackMetadata(e)) TelevisionEpisodeMediaItem e => ApplyMetadataUpdate(e, FallbackMetadataProvider.GetFallbackMetadata(e))
.ToUnit(), .ToUnit(),
MovieMediaItem m => ApplyMetadataUpdate(m, FallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(), Movie m => ApplyMetadataUpdate(m, FallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(),
_ => Task.FromResult(Unit.Default) _ => Task.FromResult(Unit.Default)
}; };
@ -94,7 +94,7 @@ namespace ErsatzTV.Core.Metadata
await _televisionRepository.Update(mediaItem); await _televisionRepository.Update(mediaItem);
} }
private async Task ApplyMetadataUpdate(MovieMediaItem mediaItem, MovieMetadata metadata) private async Task ApplyMetadataUpdate(Movie mediaItem, MovieMetadata metadata)
{ {
mediaItem.Metadata ??= new MovieMetadata(); mediaItem.Metadata ??= new MovieMetadata();
mediaItem.Metadata.Source = metadata.Source; mediaItem.Metadata.Source = metadata.Source;
@ -124,7 +124,7 @@ namespace ErsatzTV.Core.Metadata
await _televisionRepository.Update(televisionShow); await _televisionRepository.Update(televisionShow);
} }
private async Task<Option<MovieMetadata>> LoadMetadata(MovieMediaItem mediaItem, string nfoFileName) private async Task<Option<MovieMetadata>> LoadMetadata(Movie mediaItem, string nfoFileName)
{ {
if (nfoFileName == null || !File.Exists(nfoFileName)) if (nfoFileName == null || !File.Exists(nfoFileName))
{ {
@ -132,11 +132,11 @@ namespace ErsatzTV.Core.Metadata
return None; return None;
} }
if (!(mediaItem.Source is LocalMediaSource)) // if (!(mediaItem.Source is LocalMediaSource))
{ // {
_logger.LogDebug("Media source {Name} is not a local media source", mediaItem.Source.Name); // _logger.LogDebug("Media source {Name} is not a local media source", mediaItem.Source.Name);
return None; // return None;
} // }
return await LoadMovieMetadata(mediaItem, nfoFileName); return await LoadMovieMetadata(mediaItem, nfoFileName);
} }
@ -151,11 +151,11 @@ namespace ErsatzTV.Core.Metadata
return None; return None;
} }
if (!(mediaItem.Source is LocalMediaSource)) // if (!(mediaItem.Source is LocalMediaSource))
{ // {
_logger.LogDebug("Media source {Name} is not a local media source", mediaItem.Source.Name); // _logger.LogDebug("Media source {Name} is not a local media source", mediaItem.Source.Name);
return None; // return None;
} // }
return await LoadEpisodeMetadata(mediaItem, nfoFileName); return await LoadEpisodeMetadata(mediaItem, nfoFileName);
} }
@ -225,7 +225,7 @@ namespace ErsatzTV.Core.Metadata
} }
} }
private async Task<Option<MovieMetadata>> LoadMovieMetadata(MovieMediaItem mediaItem, string nfoFileName) private async Task<Option<MovieMetadata>> LoadMovieMetadata(Movie mediaItem, string nfoFileName)
{ {
try try
{ {

20
ErsatzTV.Core/Metadata/MovieFolderScanner.cs

@ -37,15 +37,15 @@ namespace ErsatzTV.Core.Metadata
_logger = logger; _logger = logger;
} }
public async Task<Either<BaseError, Unit>> ScanFolder(LocalMediaSource localMediaSource, string ffprobePath) public async Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath)
{ {
if (!_localFileSystem.IsMediaSourceAccessible(localMediaSource)) if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
{ {
return new MediaSourceInaccessible(); return new MediaSourceInaccessible();
} }
var folderQueue = new Queue<string>(); var folderQueue = new Queue<string>();
foreach (string folder in _localFileSystem.ListSubdirectories(localMediaSource.Folder).OrderBy(identity)) foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path).OrderBy(identity))
{ {
folderQueue.Enqueue(folder); folderQueue.Enqueue(folder);
} }
@ -73,10 +73,10 @@ namespace ErsatzTV.Core.Metadata
foreach (string file in allFiles.OrderBy(identity)) foreach (string file in allFiles.OrderBy(identity))
{ {
// TODO: optimize dbcontext use here, do we need tracking? can we make partial updates with dapper?
// TODO: figure out how to rebuild playlists // TODO: figure out how to rebuild playlists
Either<BaseError, MovieMediaItem> x = await _movieRepository.GetOrAdd(localMediaSource.Id, file); Either<BaseError, Movie> maybeMovie = await _movieRepository
.GetOrAdd(libraryPath, file)
Either<BaseError, MovieMediaItem> maybeMovie = await x.AsTask()
.BindT(movie => UpdateStatistics(movie, ffprobePath).MapT(_ => movie)) .BindT(movie => UpdateStatistics(movie, ffprobePath).MapT(_ => movie))
.BindT(UpdateMetadata) .BindT(UpdateMetadata)
.BindT(UpdatePoster); .BindT(UpdatePoster);
@ -89,7 +89,7 @@ namespace ErsatzTV.Core.Metadata
return Unit.Default; return Unit.Default;
} }
private async Task<Either<BaseError, MovieMediaItem>> UpdateMetadata(MovieMediaItem movie) private async Task<Either<BaseError, Movie>> UpdateMetadata(Movie movie)
{ {
try try
{ {
@ -121,7 +121,7 @@ namespace ErsatzTV.Core.Metadata
} }
} }
private async Task<Either<BaseError, MovieMediaItem>> UpdatePoster(MovieMediaItem movie) private async Task<Either<BaseError, Movie>> UpdatePoster(Movie movie)
{ {
try try
{ {
@ -145,7 +145,7 @@ namespace ErsatzTV.Core.Metadata
} }
} }
private Option<string> LocateNfoFile(MovieMediaItem movie) private Option<string> LocateNfoFile(Movie movie)
{ {
string movieAsNfo = Path.ChangeExtension(movie.Path, "nfo"); string movieAsNfo = Path.ChangeExtension(movie.Path, "nfo");
string movieNfo = Path.Combine(Path.GetDirectoryName(movie.Path) ?? string.Empty, "movie.nfo"); string movieNfo = Path.Combine(Path.GetDirectoryName(movie.Path) ?? string.Empty, "movie.nfo");
@ -154,7 +154,7 @@ namespace ErsatzTV.Core.Metadata
.HeadOrNone(); .HeadOrNone();
} }
private Option<string> LocatePoster(MovieMediaItem movie) private Option<string> LocatePoster(Movie movie)
{ {
string folder = Path.GetDirectoryName(movie.Path) ?? string.Empty; string folder = Path.GetDirectoryName(movie.Path) ?? string.Empty;
IEnumerable<string> possibleMoviePosters = ImageFileExtensions.Collect( IEnumerable<string> possibleMoviePosters = ImageFileExtensions.Collect(

35
ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs

@ -3,6 +3,7 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
@ -37,17 +38,14 @@ namespace ErsatzTV.Core.Metadata
_logger = logger; _logger = logger;
} }
public async Task<Unit> ScanFolder(LocalMediaSource localMediaSource, string ffprobePath) public async Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath)
{ {
if (!_localFileSystem.IsMediaSourceAccessible(localMediaSource)) if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
{ {
_logger.LogWarning( return new MediaSourceInaccessible();
"Media source is not accessible or missing; skipping scan of {Folder}",
localMediaSource.Folder);
return Unit.Default;
} }
var allShowFolders = _localFileSystem.ListSubdirectories(localMediaSource.Folder) var allShowFolders = _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder) .Filter(ShouldIncludeFolder)
.OrderBy(identity) .OrderBy(identity)
.ToList(); .ToList();
@ -56,27 +54,28 @@ namespace ErsatzTV.Core.Metadata
{ {
// TODO: check all sources for latest metadata? // TODO: check all sources for latest metadata?
Either<BaseError, TelevisionShow> maybeShow = Either<BaseError, TelevisionShow> maybeShow =
await FindOrCreateShow(localMediaSource.Id, showFolder) await FindOrCreateShow(libraryPath.Id, showFolder)
.BindT(show => UpdateMetadataForShow(show, showFolder)) .BindT(show => UpdateMetadataForShow(show, showFolder))
.BindT(show => UpdatePosterForShow(show, showFolder)); .BindT(show => UpdatePosterForShow(show, showFolder));
await maybeShow.Match( await maybeShow.Match(
show => ScanSeasons(localMediaSource, ffprobePath, show, showFolder), show => ScanSeasons(libraryPath, ffprobePath, show, showFolder),
_ => Task.FromResult(Unit.Default)); _ => Task.FromResult(Unit.Default));
} }
await _televisionRepository.DeleteMissingSources(localMediaSource.Id, allShowFolders); // TODO: remove this?
// await _televisionRepository.DeleteMissingSources(libraryPath.Id, allShowFolders);
await _televisionRepository.DeleteEmptyShows(); await _televisionRepository.DeleteEmptyShows();
return Unit.Default; return Unit.Default;
} }
private async Task<Either<BaseError, TelevisionShow>> FindOrCreateShow( private async Task<Either<BaseError, TelevisionShow>> FindOrCreateShow(
int localMediaSourceId, int libraryPathId,
string showFolder) string showFolder)
{ {
Option<TelevisionShow> maybeShowByPath = Option<TelevisionShow> maybeShowByPath =
await _televisionRepository.GetShowByPath(localMediaSourceId, showFolder); await _televisionRepository.GetShowByPath(libraryPathId, showFolder);
return await maybeShowByPath.Match( return await maybeShowByPath.Match(
show => Right<BaseError, TelevisionShow>(show).AsTask(), show => Right<BaseError, TelevisionShow>(show).AsTask(),
async () => async () =>
@ -89,19 +88,19 @@ namespace ErsatzTV.Core.Metadata
show.Sources.Add( show.Sources.Add(
new LocalTelevisionShowSource new LocalTelevisionShowSource
{ {
MediaSourceId = localMediaSourceId, MediaSourceId = libraryPathId,
Path = showFolder, Path = showFolder,
TelevisionShow = show TelevisionShow = show
}); });
await _televisionRepository.Update(show); await _televisionRepository.Update(show);
return Right<BaseError, TelevisionShow>(show); return Right<BaseError, TelevisionShow>(show);
}, },
async () => await _televisionRepository.AddShow(localMediaSourceId, showFolder, metadata)); async () => await _televisionRepository.AddShow(libraryPathId, showFolder, metadata));
}); });
} }
private async Task<Unit> ScanSeasons( private async Task<Unit> ScanSeasons(
LocalMediaSource localMediaSource, LibraryPath libraryPath,
string ffprobePath, string ffprobePath,
TelevisionShow show, TelevisionShow show,
string showPath) string showPath)
@ -118,7 +117,7 @@ namespace ErsatzTV.Core.Metadata
.BindT(UpdatePoster); .BindT(UpdatePoster);
await maybeSeason.Match( await maybeSeason.Match(
season => ScanEpisodes(localMediaSource, ffprobePath, season), season => ScanEpisodes(libraryPath, ffprobePath, season),
_ => Task.FromResult(Unit.Default)); _ => Task.FromResult(Unit.Default));
}); });
} }
@ -127,7 +126,7 @@ namespace ErsatzTV.Core.Metadata
} }
private async Task<Unit> ScanEpisodes( private async Task<Unit> ScanEpisodes(
LocalMediaSource localMediaSource, LibraryPath libraryPath,
string ffprobePath, string ffprobePath,
TelevisionSeason season) TelevisionSeason season)
{ {
@ -136,7 +135,7 @@ namespace ErsatzTV.Core.Metadata
{ {
// TODO: figure out how to rebuild playlists // TODO: figure out how to rebuild playlists
Either<BaseError, TelevisionEpisodeMediaItem> maybeEpisode = await _televisionRepository Either<BaseError, TelevisionEpisodeMediaItem> maybeEpisode = await _televisionRepository
.GetOrAddEpisode(season, localMediaSource.Id, file) .GetOrAddEpisode(season, libraryPath, file)
.BindT(episode => UpdateStatistics(episode, ffprobePath).MapT(_ => episode)) .BindT(episode => UpdateStatistics(episode, ffprobePath).MapT(_ => episode))
.BindT(UpdateMetadata) .BindT(UpdateMetadata)
.BindT(UpdateThumbnail); .BindT(UpdateThumbnail);

20
ErsatzTV.Core/Plex/PlexMediaEntry.cs

@ -0,0 +1,20 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Plex
{
public class PlexMediaEntry
{
public int Id { get; set; }
public int Duration { get; set; }
public int Bitrate { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public double AspectRatio { get; set; }
public int AudioChannels { get; set; }
public string AudioCodec { get; set; }
public string VideoCodec { get; set; }
public string Container { get; set; }
public string VideoFrameRate { get; set; }
public List<PlexPartEntry> Part { get; set; }
}
}

19
ErsatzTV.Core/Plex/PlexMetadataEntry.cs

@ -0,0 +1,19 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Plex
{
public class PlexMetadataEntry
{
public string Key { get; set; }
public string Title { get; set; }
public string Summary { get; set; }
public int Year { get; set; }
public string Tagline { get; set; }
public string Thumb { get; set; }
public string Art { get; set; }
public string OriginallyAvailableAt { get; set; }
public int AddedAt { get; set; }
public int UpdatedAt { get; set; }
public List<PlexMediaEntry> Media { get; set; }
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save