diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 785d5211..358ab1a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,24 +39,24 @@ jobs: # Define some variables for things we need tag=$(git describe --tags --abbrev=0) release_name="ErsatzTV-$tag-${{ matrix.target }}" - release_name_cli="ErsatzTV.CommandLine-$tag-${{ matrix.target }}" + #release_name_cli="ErsatzTV.CommandLine-$tag-${{ matrix.target }}" # Build everything dotnet publish ErsatzTV/ErsatzTV.csproj --framework net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" /property:InformationalVersion="${tag:1}-${{ matrix.target }}" - dotnet publish ErsatzTV.CommandLine/ErsatzTV.CommandLine.csproj --framework net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name_cli" /property:InformationalVersion="${tag:1}-${{ matrix.target }}" + #dotnet publish ErsatzTV.CommandLine/ErsatzTV.CommandLine.csproj --framework net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name_cli" /property:InformationalVersion="${tag:1}-${{ matrix.target }}" # Pack files if [ "${{ matrix.target }}" == "win-x64" ]; then 7z a -tzip "${release_name}.zip" "./${release_name}/*" - 7z a -tzip "${release_name_cli}.zip" "./${release_name_cli}/*" + #7z a -tzip "${release_name_cli}.zip" "./${release_name_cli}/*" else tar czvf "${release_name}.tar.gz" "$release_name" - tar czvf "${release_name_cli}.tar.gz" "$release_name_cli" + #tar czvf "${release_name_cli}.tar.gz" "$release_name_cli" fi # Delete output directory rm -r "$release_name" - rm -r "$release_name_cli" + #rm -r "$release_name_cli" - name: Publish uses: softprops/action-gh-release@v1 diff --git a/ErsatzTV.Application/IMediaCard.cs b/ErsatzTV.Application/IMediaCard.cs deleted file mode 100644 index a50da975..00000000 --- a/ErsatzTV.Application/IMediaCard.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ErsatzTV.Application -{ - public interface IMediaCard - { - string Title { get; } - string SortTitle { get; } - string Subtitle { get; } - } -} diff --git a/ErsatzTV.Application/MediaCards/Mapper.cs b/ErsatzTV.Application/MediaCards/Mapper.cs new file mode 100644 index 00000000..d087d3a4 --- /dev/null +++ b/ErsatzTV.Application/MediaCards/Mapper.cs @@ -0,0 +1,60 @@ +using System; +using System.Linq; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.MediaCards +{ + internal static class Mapper + { + internal static TelevisionShowCardViewModel ProjectToViewModel(TelevisionShow televisionShow) => + new( + televisionShow.Id, + televisionShow.Metadata?.Title, + televisionShow.Metadata?.Year.ToString(), + televisionShow.Metadata?.SortTitle, + televisionShow.Poster); + + internal static TelevisionSeasonCardViewModel ProjectToViewModel(TelevisionSeason televisionSeason) => + new( + televisionSeason.TelevisionShow.Metadata?.Title, + televisionSeason.Id, + televisionSeason.Number, + GetSeasonName(televisionSeason.Number), + string.Empty, + GetSeasonName(televisionSeason.Number), + televisionSeason.Poster, + televisionSeason.Number == 0 ? "S" : televisionSeason.Number.ToString()); + + internal static TelevisionEpisodeCardViewModel ProjectToViewModel( + TelevisionEpisodeMediaItem televisionEpisode) => + new( + televisionEpisode.Id, + televisionEpisode.Metadata?.Aired ?? DateTime.MinValue, + televisionEpisode.Season.TelevisionShow.Metadata.Title, + televisionEpisode.Metadata?.Title, + $"Episode {televisionEpisode.Metadata?.Episode}", + televisionEpisode.Metadata?.Episode.ToString(), + televisionEpisode.Poster, + televisionEpisode.Metadata?.Episode.ToString()); + + internal static MovieCardViewModel ProjectToViewModel(MovieMediaItem movie) => + new( + movie.Id, + movie.Metadata?.Title, + movie.Metadata?.Year?.ToString(), + movie.Metadata?.SortTitle, + movie.Poster); + + internal static SimpleMediaCollectionCardResultsViewModel + ProjectToViewModel(SimpleMediaCollection collection) => + new( + collection.Name, + collection.Movies.Map(ProjectToViewModel).ToList(), + collection.TelevisionShows.Map(ProjectToViewModel).ToList(), + collection.TelevisionSeasons.Map(ProjectToViewModel).ToList(), + collection.TelevisionEpisodes.Map(ProjectToViewModel).ToList()); + + private static string GetSeasonName(int number) => + number == 0 ? "Specials" : $"Season {number}"; + } +} diff --git a/ErsatzTV.Application/MediaCards/MediaCardViewModel.cs b/ErsatzTV.Application/MediaCards/MediaCardViewModel.cs new file mode 100644 index 00000000..eeb87c6a --- /dev/null +++ b/ErsatzTV.Application/MediaCards/MediaCardViewModel.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Application.MediaCards +{ + public record MediaCardViewModel(string Title, string Subtitle, string SortTitle, string Poster); +} diff --git a/ErsatzTV.Application/MediaCards/MovieCardResultsViewModel.cs b/ErsatzTV.Application/MediaCards/MovieCardResultsViewModel.cs new file mode 100644 index 00000000..f4062bba --- /dev/null +++ b/ErsatzTV.Application/MediaCards/MovieCardResultsViewModel.cs @@ -0,0 +1,6 @@ +using System.Collections.Generic; + +namespace ErsatzTV.Application.MediaCards +{ + public record MovieCardResultsViewModel(int Count, List Cards); +} diff --git a/ErsatzTV.Application/MediaCards/MovieCardViewModel.cs b/ErsatzTV.Application/MediaCards/MovieCardViewModel.cs new file mode 100644 index 00000000..a1e97a8d --- /dev/null +++ b/ErsatzTV.Application/MediaCards/MovieCardViewModel.cs @@ -0,0 +1,11 @@ +namespace ErsatzTV.Application.MediaCards +{ + public record MovieCardViewModel + (int MovieId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel( + Title, + Subtitle, + SortTitle, + Poster) + { + } +} diff --git a/ErsatzTV.Application/MediaCards/Queries/GetMovieCards.cs b/ErsatzTV.Application/MediaCards/Queries/GetMovieCards.cs new file mode 100644 index 00000000..67efe3c9 --- /dev/null +++ b/ErsatzTV.Application/MediaCards/Queries/GetMovieCards.cs @@ -0,0 +1,6 @@ +using MediatR; + +namespace ErsatzTV.Application.MediaCards.Queries +{ + public record GetMovieCards(int PageNumber, int PageSize) : IRequest; +} diff --git a/ErsatzTV.Application/MediaCards/Queries/GetMovieCardsHandler.cs b/ErsatzTV.Application/MediaCards/Queries/GetMovieCardsHandler.cs new file mode 100644 index 00000000..1a55618c --- /dev/null +++ b/ErsatzTV.Application/MediaCards/Queries/GetMovieCardsHandler.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.MediaCards.Mapper; + +namespace ErsatzTV.Application.MediaCards.Queries +{ + public class + GetMovieCardsHandler : IRequestHandler + { + private readonly IMovieRepository _movieRepository; + + public GetMovieCardsHandler(IMovieRepository movieRepository) => _movieRepository = movieRepository; + + public async Task Handle( + GetMovieCards request, + CancellationToken cancellationToken) + { + int count = await _movieRepository.GetMovieCount(); + + List results = await _movieRepository + .GetPagedMovies(request.PageNumber, request.PageSize) + .Map(list => list.Map(ProjectToViewModel).ToList()); + + return new MovieCardResultsViewModel(count, results); + } + } +} diff --git a/ErsatzTV.Application/MediaCards/Queries/GetSimpleMediaCollectionCards.cs b/ErsatzTV.Application/MediaCards/Queries/GetSimpleMediaCollectionCards.cs new file mode 100644 index 00000000..d99fc0ea --- /dev/null +++ b/ErsatzTV.Application/MediaCards/Queries/GetSimpleMediaCollectionCards.cs @@ -0,0 +1,9 @@ +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.MediaCards.Queries +{ + public record GetSimpleMediaCollectionCards + (int Id) : IRequest>; +} diff --git a/ErsatzTV.Application/MediaCards/Queries/GetSimpleMediaCollectionCardsHandler.cs b/ErsatzTV.Application/MediaCards/Queries/GetSimpleMediaCollectionCardsHandler.cs new file mode 100644 index 00000000..07393be8 --- /dev/null +++ b/ErsatzTV.Application/MediaCards/Queries/GetSimpleMediaCollectionCardsHandler.cs @@ -0,0 +1,26 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.MediaCards.Mapper; + +namespace ErsatzTV.Application.MediaCards.Queries +{ + public class GetSimpleMediaCollectionCardsHandler : IRequestHandler> + { + private readonly IMediaCollectionRepository _mediaCollectionRepository; + + public GetSimpleMediaCollectionCardsHandler(IMediaCollectionRepository mediaCollectionRepository) => + _mediaCollectionRepository = mediaCollectionRepository; + + public async Task> Handle( + GetSimpleMediaCollectionCards request, + CancellationToken cancellationToken) => + (await _mediaCollectionRepository.GetSimpleMediaCollectionWithItemsUntracked(request.Id)) + .ToEither(BaseError.New("Unable to load collection")) + .Map(ProjectToViewModel); + } +} diff --git a/ErsatzTV.Application/MediaCards/Queries/GetTelevisionEpisodeCards.cs b/ErsatzTV.Application/MediaCards/Queries/GetTelevisionEpisodeCards.cs new file mode 100644 index 00000000..b6aa3cc5 --- /dev/null +++ b/ErsatzTV.Application/MediaCards/Queries/GetTelevisionEpisodeCards.cs @@ -0,0 +1,7 @@ +using MediatR; + +namespace ErsatzTV.Application.MediaCards.Queries +{ + public record GetTelevisionEpisodeCards + (int TelevisionSeasonId, int PageNumber, int PageSize) : IRequest; +} diff --git a/ErsatzTV.Application/MediaCards/Queries/GetTelevisionEpisodeCardsHandler.cs b/ErsatzTV.Application/MediaCards/Queries/GetTelevisionEpisodeCardsHandler.cs new file mode 100644 index 00000000..942a1541 --- /dev/null +++ b/ErsatzTV.Application/MediaCards/Queries/GetTelevisionEpisodeCardsHandler.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.MediaCards.Mapper; + +namespace ErsatzTV.Application.MediaCards.Queries +{ + public class + GetTelevisionEpisodeCardsHandler : IRequestHandler + { + private readonly ITelevisionRepository _televisionRepository; + + public GetTelevisionEpisodeCardsHandler(ITelevisionRepository televisionRepository) => + _televisionRepository = televisionRepository; + + public async Task Handle( + GetTelevisionEpisodeCards request, + CancellationToken cancellationToken) + { + int count = await _televisionRepository.GetEpisodeCount(request.TelevisionSeasonId); + + List results = await _televisionRepository + .GetPagedEpisodes(request.TelevisionSeasonId, request.PageNumber, request.PageSize) + .Map(list => list.Map(ProjectToViewModel).ToList()); + + return new TelevisionEpisodeCardResultsViewModel(count, results); + } + } +} diff --git a/ErsatzTV.Application/MediaCards/Queries/GetTelevisionSeasonCards.cs b/ErsatzTV.Application/MediaCards/Queries/GetTelevisionSeasonCards.cs new file mode 100644 index 00000000..85f7546f --- /dev/null +++ b/ErsatzTV.Application/MediaCards/Queries/GetTelevisionSeasonCards.cs @@ -0,0 +1,7 @@ +using MediatR; + +namespace ErsatzTV.Application.MediaCards.Queries +{ + public record GetTelevisionSeasonCards + (int TelevisionShowId, int PageNumber, int PageSize) : IRequest; +} diff --git a/ErsatzTV.Application/MediaCards/Queries/GetTelevisionSeasonCardsHandler.cs b/ErsatzTV.Application/MediaCards/Queries/GetTelevisionSeasonCardsHandler.cs new file mode 100644 index 00000000..1a339a96 --- /dev/null +++ b/ErsatzTV.Application/MediaCards/Queries/GetTelevisionSeasonCardsHandler.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.MediaCards.Mapper; + +namespace ErsatzTV.Application.MediaCards.Queries +{ + public class + GetTelevisionSeasonCardsHandler : IRequestHandler + { + private readonly ITelevisionRepository _televisionRepository; + + public GetTelevisionSeasonCardsHandler(ITelevisionRepository televisionRepository) => + _televisionRepository = televisionRepository; + + public async Task Handle( + GetTelevisionSeasonCards request, + CancellationToken cancellationToken) + { + int count = await _televisionRepository.GetSeasonCount(request.TelevisionShowId); + + List results = await _televisionRepository + .GetPagedSeasons(request.TelevisionShowId, request.PageNumber, request.PageSize) + .Map(list => list.Map(ProjectToViewModel).ToList()); + + return new TelevisionSeasonCardResultsViewModel(count, results); + } + } +} diff --git a/ErsatzTV.Application/MediaCards/Queries/GetTelevisionShowCards.cs b/ErsatzTV.Application/MediaCards/Queries/GetTelevisionShowCards.cs new file mode 100644 index 00000000..1e790324 --- /dev/null +++ b/ErsatzTV.Application/MediaCards/Queries/GetTelevisionShowCards.cs @@ -0,0 +1,6 @@ +using MediatR; + +namespace ErsatzTV.Application.MediaCards.Queries +{ + public record GetTelevisionShowCards(int PageNumber, int PageSize) : IRequest; +} diff --git a/ErsatzTV.Application/MediaCards/Queries/GetTelevisionShowCardsHandler.cs b/ErsatzTV.Application/MediaCards/Queries/GetTelevisionShowCardsHandler.cs new file mode 100644 index 00000000..ca8465d3 --- /dev/null +++ b/ErsatzTV.Application/MediaCards/Queries/GetTelevisionShowCardsHandler.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.MediaCards.Mapper; + +namespace ErsatzTV.Application.MediaCards.Queries +{ + public class + GetTelevisionShowCardsHandler : IRequestHandler + { + private readonly ITelevisionRepository _televisionRepository; + + public GetTelevisionShowCardsHandler(ITelevisionRepository televisionRepository) => + _televisionRepository = televisionRepository; + + public async Task Handle( + GetTelevisionShowCards request, + CancellationToken cancellationToken) + { + int count = await _televisionRepository.GetShowCount(); + + List results = await _televisionRepository + .GetPagedShows(request.PageNumber, request.PageSize) + .Map(list => list.Map(ProjectToViewModel).ToList()); + + return new TelevisionShowCardResultsViewModel(count, results); + } + } +} diff --git a/ErsatzTV.Application/MediaCards/SimpleMediaCollectionCardResultsViewModel.cs b/ErsatzTV.Application/MediaCards/SimpleMediaCollectionCardResultsViewModel.cs new file mode 100644 index 00000000..0ac4bebb --- /dev/null +++ b/ErsatzTV.Application/MediaCards/SimpleMediaCollectionCardResultsViewModel.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace ErsatzTV.Application.MediaCards +{ + public record SimpleMediaCollectionCardResultsViewModel( + string Name, + List MovieCards, + List ShowCards, + List SeasonCards, + List EpisodeCards); +} diff --git a/ErsatzTV.Application/MediaCards/TelevisionEpisodeCardResultsViewModel.cs b/ErsatzTV.Application/MediaCards/TelevisionEpisodeCardResultsViewModel.cs new file mode 100644 index 00000000..348b7f9e --- /dev/null +++ b/ErsatzTV.Application/MediaCards/TelevisionEpisodeCardResultsViewModel.cs @@ -0,0 +1,6 @@ +using System.Collections.Generic; + +namespace ErsatzTV.Application.MediaCards +{ + public record TelevisionEpisodeCardResultsViewModel(int Count, List Cards); +} diff --git a/ErsatzTV.Application/MediaCards/TelevisionEpisodeCardViewModel.cs b/ErsatzTV.Application/MediaCards/TelevisionEpisodeCardViewModel.cs new file mode 100644 index 00000000..c14521e4 --- /dev/null +++ b/ErsatzTV.Application/MediaCards/TelevisionEpisodeCardViewModel.cs @@ -0,0 +1,21 @@ +using System; + +namespace ErsatzTV.Application.MediaCards +{ + public record TelevisionEpisodeCardViewModel + ( + int EpisodeId, + DateTime Aired, + string ShowTitle, + string Title, + string Subtitle, + string SortTitle, + string Poster, + string Placeholder) : MediaCardViewModel( + Title, + Subtitle, + SortTitle, + Poster) + { + } +} diff --git a/ErsatzTV.Application/MediaCards/TelevisionSeasonCardResultsViewModel.cs b/ErsatzTV.Application/MediaCards/TelevisionSeasonCardResultsViewModel.cs new file mode 100644 index 00000000..9df90f1a --- /dev/null +++ b/ErsatzTV.Application/MediaCards/TelevisionSeasonCardResultsViewModel.cs @@ -0,0 +1,6 @@ +using System.Collections.Generic; + +namespace ErsatzTV.Application.MediaCards +{ + public record TelevisionSeasonCardResultsViewModel(int Count, List Cards); +} diff --git a/ErsatzTV.Application/MediaCards/TelevisionSeasonCardViewModel.cs b/ErsatzTV.Application/MediaCards/TelevisionSeasonCardViewModel.cs new file mode 100644 index 00000000..0cc0b4d4 --- /dev/null +++ b/ErsatzTV.Application/MediaCards/TelevisionSeasonCardViewModel.cs @@ -0,0 +1,19 @@ +namespace ErsatzTV.Application.MediaCards +{ + public record TelevisionSeasonCardViewModel + ( + string ShowTitle, + int TelevisionSeasonId, + int TelevisionSeasonNumber, + string Title, + string Subtitle, + string SortTitle, + string Poster, + string Placeholder) : MediaCardViewModel( + Title, + Subtitle, + SortTitle, + Poster) + { + } +} diff --git a/ErsatzTV.Application/MediaCards/TelevisionShowCardResultsViewModel.cs b/ErsatzTV.Application/MediaCards/TelevisionShowCardResultsViewModel.cs new file mode 100644 index 00000000..228085f1 --- /dev/null +++ b/ErsatzTV.Application/MediaCards/TelevisionShowCardResultsViewModel.cs @@ -0,0 +1,6 @@ +using System.Collections.Generic; + +namespace ErsatzTV.Application.MediaCards +{ + public record TelevisionShowCardResultsViewModel(int Count, List Cards); +} diff --git a/ErsatzTV.Application/MediaCards/TelevisionShowCardViewModel.cs b/ErsatzTV.Application/MediaCards/TelevisionShowCardViewModel.cs new file mode 100644 index 00000000..975781c2 --- /dev/null +++ b/ErsatzTV.Application/MediaCards/TelevisionShowCardViewModel.cs @@ -0,0 +1,11 @@ +namespace ErsatzTV.Application.MediaCards +{ + public record TelevisionShowCardViewModel + (int TelevisionShowId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel( + Title, + Subtitle, + SortTitle, + Poster) + { + } +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddItemsToSimpleMediaCollection.cs b/ErsatzTV.Application/MediaCollections/Commands/AddItemsToSimpleMediaCollection.cs deleted file mode 100644 index a3d1a2d7..00000000 --- a/ErsatzTV.Application/MediaCollections/Commands/AddItemsToSimpleMediaCollection.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Collections.Generic; -using ErsatzTV.Core; -using LanguageExt; - -namespace ErsatzTV.Application.MediaCollections.Commands -{ - public record AddItemsToSimpleMediaCollection - (int MediaCollectionId, List ItemIds) : MediatR.IRequest>; -} diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddItemsToSimpleMediaCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/AddItemsToSimpleMediaCollectionHandler.cs deleted file mode 100644 index a94211d8..00000000 --- a/ErsatzTV.Application/MediaCollections/Commands/AddItemsToSimpleMediaCollectionHandler.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using ErsatzTV.Core; -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Interfaces.Repositories; -using LanguageExt; -using static LanguageExt.Prelude; - -namespace ErsatzTV.Application.MediaCollections.Commands -{ - public class - AddItemsToSimpleMediaCollectionHandler : MediatR.IRequestHandler> - { - private readonly IMediaCollectionRepository _mediaCollectionRepository; - private readonly IMediaItemRepository _mediaItemRepository; - - public AddItemsToSimpleMediaCollectionHandler( - IMediaCollectionRepository mediaCollectionRepository, - IMediaItemRepository mediaItemRepository) - { - _mediaCollectionRepository = mediaCollectionRepository; - _mediaItemRepository = mediaItemRepository; - } - - public Task> Handle( - AddItemsToSimpleMediaCollection request, - CancellationToken cancellationToken) => - Validate(request) - .MapT(ApplyAddItemsRequest) - .Bind(v => v.ToEitherAsync()); - - private async Task ApplyAddItemsRequest(RequestParameters parameters) - { - foreach (MediaItem item in parameters.ItemsToAdd.Where( - item => parameters.Collection.Items.All(i => i.Id != item.Id))) - { - parameters.Collection.Items.Add(item); - } - - await _mediaCollectionRepository.Update(parameters.Collection); - - return Unit.Default; - } - - private async Task> - Validate(AddItemsToSimpleMediaCollection request) => - (await SimpleMediaCollectionMustExist(request), await ValidateItems(request)) - .Apply( - (simpleMediaCollectionToUpdate, itemsToAdd) => - new RequestParameters(simpleMediaCollectionToUpdate, itemsToAdd)); - - private Task> SimpleMediaCollectionMustExist( - AddItemsToSimpleMediaCollection updateSimpleMediaCollection) => - _mediaCollectionRepository.GetSimpleMediaCollection(updateSimpleMediaCollection.MediaCollectionId) - .Map(v => v.ToValidation("SimpleMediaCollection does not exist.")); - - private Task>> ValidateItems( - AddItemsToSimpleMediaCollection request) => - LoadAllMediaItems(request) - .Map(v => v.ToValidation("MediaItem does not exist")); - - private async Task>> LoadAllMediaItems(AddItemsToSimpleMediaCollection request) - { - var items = (await request.ItemIds.Map(async id => await _mediaItemRepository.Get(id)).Sequence()) - .ToList(); - if (items.Any(i => i.IsNone)) - { - return None; - } - - return items.Somes().ToList(); - } - - private record RequestParameters(SimpleMediaCollection Collection, List ItemsToAdd); - } -} diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddMovieToSimpleMediaCollection.cs b/ErsatzTV.Application/MediaCollections/Commands/AddMovieToSimpleMediaCollection.cs new file mode 100644 index 00000000..cacb5f4b --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/AddMovieToSimpleMediaCollection.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Core; +using LanguageExt; + +namespace ErsatzTV.Application.MediaCollections.Commands +{ + public record AddMovieToSimpleMediaCollection + (int MediaCollectionId, int MovieId) : MediatR.IRequest>; +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddMovieToSimpleMediaCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/AddMovieToSimpleMediaCollectionHandler.cs new file mode 100644 index 00000000..a42304b0 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/AddMovieToSimpleMediaCollectionHandler.cs @@ -0,0 +1,62 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; + +namespace ErsatzTV.Application.MediaCollections.Commands +{ + public class + AddMovieToSimpleMediaCollectionHandler : MediatR.IRequestHandler> + { + private readonly IMediaCollectionRepository _mediaCollectionRepository; + private readonly IMovieRepository _movieRepository; + + public AddMovieToSimpleMediaCollectionHandler( + IMediaCollectionRepository mediaCollectionRepository, + IMovieRepository movieRepository) + { + _mediaCollectionRepository = mediaCollectionRepository; + _movieRepository = movieRepository; + } + + public Task> Handle( + AddMovieToSimpleMediaCollection request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(ApplyAddMoviesRequest) + .Bind(v => v.ToEitherAsync()); + + private async Task ApplyAddMoviesRequest(RequestParameters parameters) + { + parameters.Collection.Movies.Add(parameters.MovieToAdd); + await _mediaCollectionRepository.Update(parameters.Collection); + + return Unit.Default; + } + + private async Task> + Validate(AddMovieToSimpleMediaCollection request) => + (await SimpleMediaCollectionMustExist(request), await ValidateMovies(request)) + .Apply( + (simpleMediaCollectionToUpdate, movieToAdd) => + new RequestParameters(simpleMediaCollectionToUpdate, movieToAdd)); + + private Task> SimpleMediaCollectionMustExist( + AddMovieToSimpleMediaCollection updateSimpleMediaCollection) => + _mediaCollectionRepository.GetSimpleMediaCollectionWithItems(updateSimpleMediaCollection.MediaCollectionId) + .Map(v => v.ToValidation("SimpleMediaCollection does not exist.")); + + private Task> ValidateMovies( + AddMovieToSimpleMediaCollection request) => + LoadMovie(request) + .Map(v => v.ToValidation("MovieMediaItem does not exist")); + + private Task> LoadMovie(AddMovieToSimpleMediaCollection request) => + _movieRepository.GetMovie(request.MovieId); + + private record RequestParameters(SimpleMediaCollection Collection, MovieMediaItem MovieToAdd); + } +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddTelevisionEpisodeToSimpleMediaCollection.cs b/ErsatzTV.Application/MediaCollections/Commands/AddTelevisionEpisodeToSimpleMediaCollection.cs new file mode 100644 index 00000000..290d8d17 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/AddTelevisionEpisodeToSimpleMediaCollection.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Core; +using LanguageExt; + +namespace ErsatzTV.Application.MediaCollections.Commands +{ + public record AddTelevisionEpisodeToSimpleMediaCollection + (int MediaCollectionId, int TelevisionEpisodeId) : MediatR.IRequest>; +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddTelevisionEpisodeToSimpleMediaCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/AddTelevisionEpisodeToSimpleMediaCollectionHandler.cs new file mode 100644 index 00000000..9604b9ba --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/AddTelevisionEpisodeToSimpleMediaCollectionHandler.cs @@ -0,0 +1,68 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; + +namespace ErsatzTV.Application.MediaCollections.Commands +{ + public class + AddTelevisionEpisodeToSimpleMediaCollectionHandler : MediatR.IRequestHandler< + AddTelevisionEpisodeToSimpleMediaCollection, + Either> + { + private readonly IMediaCollectionRepository _mediaCollectionRepository; + private readonly ITelevisionRepository _televisionRepository; + + public AddTelevisionEpisodeToSimpleMediaCollectionHandler( + IMediaCollectionRepository mediaCollectionRepository, + ITelevisionRepository televisionRepository) + { + _mediaCollectionRepository = mediaCollectionRepository; + _televisionRepository = televisionRepository; + } + + public Task> Handle( + AddTelevisionEpisodeToSimpleMediaCollection request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(ApplyAddTelevisionEpisodeRequest) + .Bind(v => v.ToEitherAsync()); + + private async Task ApplyAddTelevisionEpisodeRequest(RequestParameters parameters) + { + if (parameters.Collection.TelevisionEpisodes.All(s => s.Id != parameters.EpisodeToAdd.Id)) + { + parameters.Collection.TelevisionEpisodes.Add(parameters.EpisodeToAdd); + await _mediaCollectionRepository.Update(parameters.Collection); + } + + return Unit.Default; + } + + private async Task> + Validate(AddTelevisionEpisodeToSimpleMediaCollection request) => + (await SimpleMediaCollectionMustExist(request), await ValidateEpisode(request)) + .Apply( + (simpleMediaCollectionToUpdate, episode) => + new RequestParameters(simpleMediaCollectionToUpdate, episode)); + + private Task> SimpleMediaCollectionMustExist( + AddTelevisionEpisodeToSimpleMediaCollection updateSimpleMediaCollection) => + _mediaCollectionRepository.GetSimpleMediaCollectionWithItems(updateSimpleMediaCollection.MediaCollectionId) + .Map(v => v.ToValidation("SimpleMediaCollection does not exist.")); + + private Task> ValidateEpisode( + AddTelevisionEpisodeToSimpleMediaCollection request) => + LoadTelevisionEpisode(request) + .Map(v => v.ToValidation("TelevisionEpisode does not exist")); + + private Task> LoadTelevisionEpisode( + AddTelevisionEpisodeToSimpleMediaCollection request) => + _televisionRepository.GetEpisode(request.TelevisionEpisodeId); + + private record RequestParameters(SimpleMediaCollection Collection, TelevisionEpisodeMediaItem EpisodeToAdd); + } +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddTelevisionSeasonToSimpleMediaCollection.cs b/ErsatzTV.Application/MediaCollections/Commands/AddTelevisionSeasonToSimpleMediaCollection.cs new file mode 100644 index 00000000..54cde04a --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/AddTelevisionSeasonToSimpleMediaCollection.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Core; +using LanguageExt; + +namespace ErsatzTV.Application.MediaCollections.Commands +{ + public record AddTelevisionSeasonToSimpleMediaCollection + (int MediaCollectionId, int TelevisionSeasonId) : MediatR.IRequest>; +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddTelevisionSeasonToSimpleMediaCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/AddTelevisionSeasonToSimpleMediaCollectionHandler.cs new file mode 100644 index 00000000..fb4ada45 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/AddTelevisionSeasonToSimpleMediaCollectionHandler.cs @@ -0,0 +1,68 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; + +namespace ErsatzTV.Application.MediaCollections.Commands +{ + public class + AddTelevisionSeasonToSimpleMediaCollectionHandler : MediatR.IRequestHandler< + AddTelevisionSeasonToSimpleMediaCollection, + Either> + { + private readonly IMediaCollectionRepository _mediaCollectionRepository; + private readonly ITelevisionRepository _televisionRepository; + + public AddTelevisionSeasonToSimpleMediaCollectionHandler( + IMediaCollectionRepository mediaCollectionRepository, + ITelevisionRepository televisionRepository) + { + _mediaCollectionRepository = mediaCollectionRepository; + _televisionRepository = televisionRepository; + } + + public Task> Handle( + AddTelevisionSeasonToSimpleMediaCollection request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(ApplyAddTelevisionSeasonRequest) + .Bind(v => v.ToEitherAsync()); + + private async Task ApplyAddTelevisionSeasonRequest(RequestParameters parameters) + { + if (parameters.Collection.TelevisionSeasons.All(s => s.Id != parameters.SeasonToAdd.Id)) + { + parameters.Collection.TelevisionSeasons.Add(parameters.SeasonToAdd); + await _mediaCollectionRepository.Update(parameters.Collection); + } + + return Unit.Default; + } + + private async Task> + Validate(AddTelevisionSeasonToSimpleMediaCollection request) => + (await SimpleMediaCollectionMustExist(request), await ValidateSeason(request)) + .Apply( + (simpleMediaCollectionToUpdate, season) => + new RequestParameters(simpleMediaCollectionToUpdate, season)); + + private Task> SimpleMediaCollectionMustExist( + AddTelevisionSeasonToSimpleMediaCollection updateSimpleMediaCollection) => + _mediaCollectionRepository.GetSimpleMediaCollectionWithItems(updateSimpleMediaCollection.MediaCollectionId) + .Map(v => v.ToValidation("SimpleMediaCollection does not exist.")); + + private Task> ValidateSeason( + AddTelevisionSeasonToSimpleMediaCollection request) => + LoadTelevisionSeason(request) + .Map(v => v.ToValidation("TelevisionSeason does not exist")); + + private Task> LoadTelevisionSeason( + AddTelevisionSeasonToSimpleMediaCollection request) => + _televisionRepository.GetSeason(request.TelevisionSeasonId); + + private record RequestParameters(SimpleMediaCollection Collection, TelevisionSeason SeasonToAdd); + } +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddTelevisionShowToSimpleMediaCollection.cs b/ErsatzTV.Application/MediaCollections/Commands/AddTelevisionShowToSimpleMediaCollection.cs new file mode 100644 index 00000000..faf5eb3f --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/AddTelevisionShowToSimpleMediaCollection.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Core; +using LanguageExt; + +namespace ErsatzTV.Application.MediaCollections.Commands +{ + public record AddTelevisionShowToSimpleMediaCollection + (int MediaCollectionId, int TelevisionShowId) : MediatR.IRequest>; +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddTelevisionShowToSimpleMediaCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/AddTelevisionShowToSimpleMediaCollectionHandler.cs new file mode 100644 index 00000000..77197f62 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/AddTelevisionShowToSimpleMediaCollectionHandler.cs @@ -0,0 +1,67 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; + +namespace ErsatzTV.Application.MediaCollections.Commands +{ + public class + AddTelevisionShowToSimpleMediaCollectionHandler : MediatR.IRequestHandler< + AddTelevisionShowToSimpleMediaCollection, + Either> + { + private readonly IMediaCollectionRepository _mediaCollectionRepository; + private readonly ITelevisionRepository _televisionRepository; + + public AddTelevisionShowToSimpleMediaCollectionHandler( + IMediaCollectionRepository mediaCollectionRepository, + ITelevisionRepository televisionRepository) + { + _mediaCollectionRepository = mediaCollectionRepository; + _televisionRepository = televisionRepository; + } + + public Task> Handle( + AddTelevisionShowToSimpleMediaCollection request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(ApplyAddTelevisionShowRequest) + .Bind(v => v.ToEitherAsync()); + + private async Task ApplyAddTelevisionShowRequest(RequestParameters parameters) + { + if (parameters.Collection.TelevisionShows.All(s => s.Id != parameters.ShowToAdd.Id)) + { + parameters.Collection.TelevisionShows.Add(parameters.ShowToAdd); + await _mediaCollectionRepository.Update(parameters.Collection); + } + + return Unit.Default; + } + + private async Task> + Validate(AddTelevisionShowToSimpleMediaCollection request) => + (await SimpleMediaCollectionMustExist(request), await ValidateShow(request)) + .Apply( + (simpleMediaCollectionToUpdate, show) => + new RequestParameters(simpleMediaCollectionToUpdate, show)); + + private Task> SimpleMediaCollectionMustExist( + AddTelevisionShowToSimpleMediaCollection updateSimpleMediaCollection) => + _mediaCollectionRepository.GetSimpleMediaCollectionWithItems(updateSimpleMediaCollection.MediaCollectionId) + .Map(v => v.ToValidation("SimpleMediaCollection does not exist.")); + + private Task> ValidateShow( + AddTelevisionShowToSimpleMediaCollection request) => + LoadTelevisionShow(request) + .Map(v => v.ToValidation("TelevisionShow does not exist")); + + private Task> LoadTelevisionShow(AddTelevisionShowToSimpleMediaCollection request) => + _televisionRepository.GetShow(request.TelevisionShowId); + + private record RequestParameters(SimpleMediaCollection Collection, TelevisionShow ShowToAdd); + } +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/CreateSimpleMediaCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/CreateSimpleMediaCollectionHandler.cs index 8940f710..362fab63 100644 --- a/ErsatzTV.Application/MediaCollections/Commands/CreateSimpleMediaCollectionHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Commands/CreateSimpleMediaCollectionHandler.cs @@ -29,7 +29,15 @@ namespace ErsatzTV.Application.MediaCollections.Commands _mediaCollectionRepository.Add(c).Map(ProjectToViewModel); private Task> Validate(CreateSimpleMediaCollection request) => - ValidateName(request).MapT(name => new SimpleMediaCollection { Name = name }); + ValidateName(request).MapT( + name => new SimpleMediaCollection + { + Name = name, + Movies = new List(), + TelevisionShows = new List(), + TelevisionEpisodes = new List(), + TelevisionSeasons = new List() + }); private async Task> ValidateName(CreateSimpleMediaCollection createCollection) { diff --git a/ErsatzTV.Application/MediaCollections/Commands/RemoveItemsFromSimpleMediaCollection.cs b/ErsatzTV.Application/MediaCollections/Commands/RemoveItemsFromSimpleMediaCollection.cs new file mode 100644 index 00000000..a3ea0249 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/RemoveItemsFromSimpleMediaCollection.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using ErsatzTV.Core; +using LanguageExt; + +namespace ErsatzTV.Application.MediaCollections.Commands +{ + public record RemoveItemsFromSimpleMediaCollection + (int MediaCollectionId) : MediatR.IRequest> + { + public List MovieIds { get; set; } = new(); + public List TelevisionShowIds { get; set; } = new(); + public List TelevisionSeasonIds { get; set; } = new(); + public List TelevisionEpisodeIds { get; set; } = new(); + } +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/RemoveItemsFromSimpleMediaCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/RemoveItemsFromSimpleMediaCollectionHandler.cs new file mode 100644 index 00000000..d7342eba --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/RemoveItemsFromSimpleMediaCollectionHandler.cs @@ -0,0 +1,74 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; + +namespace ErsatzTV.Application.MediaCollections.Commands +{ + public class + RemoveItemsFromSimpleMediaCollectionHandler : MediatR.IRequestHandler< + RemoveItemsFromSimpleMediaCollection, + Either> + { + private readonly IMediaCollectionRepository _mediaCollectionRepository; + + public RemoveItemsFromSimpleMediaCollectionHandler( + IMediaCollectionRepository mediaCollectionRepository) => + _mediaCollectionRepository = mediaCollectionRepository; + + public Task> Handle( + RemoveItemsFromSimpleMediaCollection request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(collection => ApplyAddTelevisionEpisodeRequest(request, collection)) + .Bind(v => v.ToEitherAsync()); + + private Task ApplyAddTelevisionEpisodeRequest( + RemoveItemsFromSimpleMediaCollection request, + SimpleMediaCollection collection) + { + var moviesToRemove = collection.Movies + .Filter(m => request.MovieIds.Contains(m.Id)) + .ToList(); + + moviesToRemove.ForEach(m => collection.Movies.Remove(m)); + + var showsToRemove = collection.TelevisionShows + .Filter(s => request.TelevisionShowIds.Contains(s.Id)) + .ToList(); + + showsToRemove.ForEach(s => collection.TelevisionShows.Remove(s)); + + var seasonsToRemove = collection.TelevisionSeasons + .Filter(s => request.TelevisionSeasonIds.Contains(s.Id)) + .ToList(); + + seasonsToRemove.ForEach(s => collection.TelevisionSeasons.Remove(s)); + + var episodesToRemove = collection.TelevisionEpisodes + .Filter(e => request.TelevisionEpisodeIds.Contains(e.Id)) + .ToList(); + + episodesToRemove.ForEach(e => collection.TelevisionEpisodes.Remove(e)); + + if (moviesToRemove.Any() || showsToRemove.Any() || seasonsToRemove.Any() || episodesToRemove.Any()) + { + return _mediaCollectionRepository.Update(collection).ToUnit(); + } + + return Task.FromResult(Unit.Default); + } + + private Task> Validate( + RemoveItemsFromSimpleMediaCollection request) => + SimpleMediaCollectionMustExist(request); + + private Task> SimpleMediaCollectionMustExist( + RemoveItemsFromSimpleMediaCollection updateSimpleMediaCollection) => + _mediaCollectionRepository.GetSimpleMediaCollectionWithItems(updateSimpleMediaCollection.MediaCollectionId) + .Map(v => v.ToValidation("SimpleMediaCollection does not exist.")); + } +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/ReplaceSimpleMediaCollectionItems.cs b/ErsatzTV.Application/MediaCollections/Commands/ReplaceSimpleMediaCollectionItems.cs deleted file mode 100644 index 84c4c14c..00000000 --- a/ErsatzTV.Application/MediaCollections/Commands/ReplaceSimpleMediaCollectionItems.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Collections.Generic; -using ErsatzTV.Application.MediaItems; -using ErsatzTV.Core; -using LanguageExt; -using MediatR; - -namespace ErsatzTV.Application.MediaCollections.Commands -{ - public record ReplaceSimpleMediaCollectionItems - (int MediaCollectionId, List MediaItemIds) : IRequest>>; -} diff --git a/ErsatzTV.Application/MediaCollections/Commands/ReplaceSimpleMediaCollectionItemsHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/ReplaceSimpleMediaCollectionItemsHandler.cs deleted file mode 100644 index 5c7c7996..00000000 --- a/ErsatzTV.Application/MediaCollections/Commands/ReplaceSimpleMediaCollectionItemsHandler.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using ErsatzTV.Application.MediaItems; -using ErsatzTV.Core; -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Interfaces.Repositories; -using LanguageExt; -using LanguageExt.UnsafeValueAccess; -using MediatR; - -namespace ErsatzTV.Application.MediaCollections.Commands -{ - public class ReplaceSimpleMediaCollectionItemsHandler : IRequestHandler>> - { - private readonly IMediaCollectionRepository _mediaCollectionRepository; - private readonly IMediaItemRepository _mediaItemRepository; - - public ReplaceSimpleMediaCollectionItemsHandler( - IMediaCollectionRepository mediaCollectionRepository, - IMediaItemRepository mediaItemRepository) - { - _mediaCollectionRepository = mediaCollectionRepository; - _mediaItemRepository = mediaItemRepository; - } - - public Task>> Handle( - ReplaceSimpleMediaCollectionItems request, - CancellationToken cancellationToken) => - Validate(request) - .MapT(mediaItems => PersistItems(request, mediaItems)) - .Bind(v => v.ToEitherAsync()); - - private async Task> PersistItems( - ReplaceSimpleMediaCollectionItems request, - List mediaItems) - { - await _mediaCollectionRepository.ReplaceItems(request.MediaCollectionId, mediaItems); - return mediaItems.Map(MediaItems.Mapper.ProjectToViewModel).ToList(); - } - - private Task>> Validate(ReplaceSimpleMediaCollectionItems request) => - MediaCollectionMustExist(request).BindT(_ => MediaItemsMustExist(request)); - - private async Task> MediaCollectionMustExist( - ReplaceSimpleMediaCollectionItems request) => - (await _mediaCollectionRepository.GetSimpleMediaCollection(request.MediaCollectionId)) - .ToValidation("[MediaCollectionId] does not exist."); - - private async Task>> MediaItemsMustExist( - ReplaceSimpleMediaCollectionItems replaceItems) - { - var allMediaItems = (await replaceItems.MediaItemIds.Map(i => _mediaItemRepository.Get(i)).Sequence()) - .ToList(); - if (allMediaItems.Any(x => x.IsNone)) - { - return BaseError.New("[MediaItemId] does not exist"); - } - - return allMediaItems.Sequence().ValueUnsafe().ToList(); - } - } -} diff --git a/ErsatzTV.Application/MediaCollections/Mapper.cs b/ErsatzTV.Application/MediaCollections/Mapper.cs index 021ae7fa..59217405 100644 --- a/ErsatzTV.Application/MediaCollections/Mapper.cs +++ b/ErsatzTV.Application/MediaCollections/Mapper.cs @@ -1,5 +1,4 @@ -using ErsatzTV.Core.AggregateModels; -using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain; namespace ErsatzTV.Application.MediaCollections { @@ -7,13 +6,5 @@ namespace ErsatzTV.Application.MediaCollections { internal static MediaCollectionViewModel ProjectToViewModel(MediaCollection mediaCollection) => new(mediaCollection.Id, mediaCollection.Name); - - internal static MediaCollectionSummaryViewModel ProjectToViewModel( - MediaCollectionSummary mediaCollectionSummary) => - new( - mediaCollectionSummary.Id, - mediaCollectionSummary.Name, - mediaCollectionSummary.ItemCount, - mediaCollectionSummary.IsSimple); } } diff --git a/ErsatzTV.Application/MediaCollections/MediaCollectionViewModel.cs b/ErsatzTV.Application/MediaCollections/MediaCollectionViewModel.cs index add855bf..7bfe6e0c 100644 --- a/ErsatzTV.Application/MediaCollections/MediaCollectionViewModel.cs +++ b/ErsatzTV.Application/MediaCollections/MediaCollectionViewModel.cs @@ -1,4 +1,10 @@ -namespace ErsatzTV.Application.MediaCollections +using ErsatzTV.Application.MediaCards; + +namespace ErsatzTV.Application.MediaCollections { - public record MediaCollectionViewModel(int Id, string Name); + public record MediaCollectionViewModel(int Id, string Name) : MediaCardViewModel( + Name, + string.Empty, + Name, + string.Empty); } diff --git a/ErsatzTV.Application/MediaCollections/Queries/GetMediaCollectionSummaries.cs b/ErsatzTV.Application/MediaCollections/Queries/GetMediaCollectionSummaries.cs deleted file mode 100644 index d48c716d..00000000 --- a/ErsatzTV.Application/MediaCollections/Queries/GetMediaCollectionSummaries.cs +++ /dev/null @@ -1,7 +0,0 @@ -using System.Collections.Generic; -using MediatR; - -namespace ErsatzTV.Application.MediaCollections.Queries -{ - public record GetMediaCollectionSummaries(string SearchString) : IRequest>; -} diff --git a/ErsatzTV.Application/MediaCollections/Queries/GetMediaCollectionSummariesHandler.cs b/ErsatzTV.Application/MediaCollections/Queries/GetMediaCollectionSummariesHandler.cs deleted file mode 100644 index b08800ae..00000000 --- a/ErsatzTV.Application/MediaCollections/Queries/GetMediaCollectionSummariesHandler.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using ErsatzTV.Core.Interfaces.Repositories; -using LanguageExt; -using MediatR; -using static ErsatzTV.Application.MediaCollections.Mapper; - -namespace ErsatzTV.Application.MediaCollections.Queries -{ - public class - GetMediaCollectionSummariesHandler : IRequestHandler> - { - private readonly IMediaCollectionRepository _mediaCollectionRepository; - - public GetMediaCollectionSummariesHandler(IMediaCollectionRepository mediaCollectionRepository) => - _mediaCollectionRepository = mediaCollectionRepository; - - public Task> Handle( - GetMediaCollectionSummaries request, - CancellationToken cancellationToken) => - _mediaCollectionRepository.GetSummaries(request.SearchString) - .Map(list => list.Map(ProjectToViewModel).ToList()); - } -} diff --git a/ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionWithItemsById.cs b/ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionWithItemsById.cs deleted file mode 100644 index 470ff51a..00000000 --- a/ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionWithItemsById.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; -using System.Collections.Generic; -using ErsatzTV.Application.MediaItems; -using LanguageExt; -using MediatR; - -namespace ErsatzTV.Application.MediaCollections.Queries -{ - public record GetSimpleMediaCollectionWithItemsById - (int Id) : IRequest>>>; -} diff --git a/ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionWithItemsByIdHandler.cs b/ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionWithItemsByIdHandler.cs deleted file mode 100644 index 11f9f79b..00000000 --- a/ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionWithItemsByIdHandler.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using ErsatzTV.Application.MediaItems; -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Interfaces.Repositories; -using LanguageExt; -using MediatR; -using static LanguageExt.Prelude; -using static ErsatzTV.Application.MediaCollections.Mapper; -using static ErsatzTV.Application.MediaItems.Mapper; - -namespace ErsatzTV.Application.MediaCollections.Queries -{ - public class GetSimpleMediaCollectionWithItemsByIdHandler : IRequestHandler>>> - { - private readonly IMediaCollectionRepository _mediaCollectionRepository; - - public GetSimpleMediaCollectionWithItemsByIdHandler(IMediaCollectionRepository mediaCollectionRepository) => - _mediaCollectionRepository = mediaCollectionRepository; - - public async Task>>> Handle( - GetSimpleMediaCollectionWithItemsById request, - CancellationToken cancellationToken) - { - Option maybeCollection = - await _mediaCollectionRepository.GetSimpleMediaCollectionWithItems(request.Id); - - return maybeCollection.Match>>>( - c => Tuple(ProjectToViewModel(c), c.Items.Map(ProjectToSearchViewModel).ToList()), - None); - } - } -} diff --git a/ErsatzTV.Application/MediaItems/AggregateMediaItemResults.cs b/ErsatzTV.Application/MediaItems/AggregateMediaItemResults.cs deleted file mode 100644 index 9c38e3ea..00000000 --- a/ErsatzTV.Application/MediaItems/AggregateMediaItemResults.cs +++ /dev/null @@ -1,6 +0,0 @@ -using System.Collections.Generic; - -namespace ErsatzTV.Application.MediaItems -{ - public record AggregateMediaItemResults(int Count, List DataPage); -} diff --git a/ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs b/ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs deleted file mode 100644 index 8fed6051..00000000 --- a/ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ErsatzTV.Application.MediaItems -{ - public record AggregateMediaItemViewModel( - int MediaItemId, - string Title, - string Subtitle, - string SortTitle, - string Poster); -} diff --git a/ErsatzTV.Application/MediaItems/Commands/CreateMediaItem.cs b/ErsatzTV.Application/MediaItems/Commands/CreateMediaItem.cs deleted file mode 100644 index 01252d9f..00000000 --- a/ErsatzTV.Application/MediaItems/Commands/CreateMediaItem.cs +++ /dev/null @@ -1,8 +0,0 @@ -using ErsatzTV.Core; -using LanguageExt; -using MediatR; - -namespace ErsatzTV.Application.MediaItems.Commands -{ - public record CreateMediaItem(int MediaSourceId, string Path) : IRequest>; -} diff --git a/ErsatzTV.Application/MediaItems/Commands/CreateMediaItemHandler.cs b/ErsatzTV.Application/MediaItems/Commands/CreateMediaItemHandler.cs deleted file mode 100644 index f478f58c..00000000 --- a/ErsatzTV.Application/MediaItems/Commands/CreateMediaItemHandler.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using ErsatzTV.Core; -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Interfaces.Metadata; -using ErsatzTV.Core.Interfaces.Repositories; -using LanguageExt; -using MediatR; -using static LanguageExt.Prelude; -using static ErsatzTV.Application.MediaItems.Mapper; - -namespace ErsatzTV.Application.MediaItems.Commands -{ - public class CreateMediaItemHandler : IRequestHandler> - { - private readonly IConfigElementRepository _configElementRepository; - private readonly ILocalMetadataProvider _localMetadataProvider; - private readonly ILocalPosterProvider _localPosterProvider; - private readonly ILocalStatisticsProvider _localStatisticsProvider; - private readonly IMediaItemRepository _mediaItemRepository; - private readonly IMediaSourceRepository _mediaSourceRepository; - private readonly ISmartCollectionBuilder _smartCollectionBuilder; - - public CreateMediaItemHandler( - IMediaItemRepository mediaItemRepository, - IMediaSourceRepository mediaSourceRepository, - IConfigElementRepository configElementRepository, - ISmartCollectionBuilder smartCollectionBuilder, - ILocalMetadataProvider localMetadataProvider, - ILocalStatisticsProvider localStatisticsProvider, - ILocalPosterProvider localPosterProvider) - { - _mediaItemRepository = mediaItemRepository; - _mediaSourceRepository = mediaSourceRepository; - _configElementRepository = configElementRepository; - _smartCollectionBuilder = smartCollectionBuilder; - _localMetadataProvider = localMetadataProvider; - _localStatisticsProvider = localStatisticsProvider; - _localPosterProvider = localPosterProvider; - } - - public Task> Handle( - CreateMediaItem request, - CancellationToken cancellationToken) => - Validate(request) - .MapT(PersistMediaItem) - .Bind(v => v.ToEitherAsync()); - - private async Task PersistMediaItem(RequestParameters parameters) - { - await _mediaItemRepository.Add(parameters.MediaItem); - - await _localStatisticsProvider.RefreshStatistics(parameters.FFprobePath, parameters.MediaItem); - // TODO: reimplement this - // await _localMetadataProvider.RefreshMetadata(parameters.MediaItem); - // await _localPosterProvider.RefreshPoster(parameters.MediaItem); - // await _smartCollectionBuilder.RefreshSmartCollections(parameters.MediaItem); - - return ProjectToViewModel(parameters.MediaItem); - } - - private async Task> Validate(CreateMediaItem request) => - (await ValidateMediaSource(request), PathMustExist(request), await ValidateFFprobePath()) - .Apply( - (mediaSourceId, path, ffprobePath) => new RequestParameters( - ffprobePath, - new MediaItem - { - MediaSourceId = mediaSourceId, - Path = path - })); - - private async Task> ValidateMediaSource(CreateMediaItem createMediaItem) => - (await MediaSourceMustExist(createMediaItem)).Bind(MediaSourceMustBeLocal); - - private async Task> MediaSourceMustExist(CreateMediaItem createMediaItem) => - (await _mediaSourceRepository.Get(createMediaItem.MediaSourceId)) - .ToValidation($"[MediaSource] {createMediaItem.MediaSourceId} does not exist."); - - private Validation MediaSourceMustBeLocal(MediaSource mediaSource) => - Some(mediaSource) - .Filter(ms => ms is LocalMediaSource) - .ToValidation($"[MediaSource] {mediaSource.Id} must be a local media source") - .Map(ms => ms.Id); - - private Validation PathMustExist(CreateMediaItem createMediaItem) => - Some(createMediaItem.Path) - .Filter(File.Exists) - .ToValidation("[Path] does not exist on the file system"); - - private Task> ValidateFFprobePath() => - _configElementRepository.GetValue(ConfigElementKey.FFprobePath) - .FilterT(File.Exists) - .Map( - ffprobePath => - ffprobePath.ToValidation("FFprobe path does not exist on the file system")); - - private record RequestParameters(string FFprobePath, MediaItem MediaItem); - } -} diff --git a/ErsatzTV.Application/MediaItems/Commands/DeleteMediaItem.cs b/ErsatzTV.Application/MediaItems/Commands/DeleteMediaItem.cs deleted file mode 100644 index f1a1abd6..00000000 --- a/ErsatzTV.Application/MediaItems/Commands/DeleteMediaItem.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Threading.Tasks; -using ErsatzTV.Core; -using LanguageExt; -using MediatR; - -namespace ErsatzTV.Application.MediaItems.Commands -{ - public record DeleteMediaItem(int MediaItemId) : IRequest>; -} diff --git a/ErsatzTV.Application/MediaItems/Commands/DeleteMediaItemHandler.cs b/ErsatzTV.Application/MediaItems/Commands/DeleteMediaItemHandler.cs deleted file mode 100644 index 59357d6f..00000000 --- a/ErsatzTV.Application/MediaItems/Commands/DeleteMediaItemHandler.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using ErsatzTV.Core; -using ErsatzTV.Core.Interfaces.Repositories; -using LanguageExt; -using MediatR; - -namespace ErsatzTV.Application.MediaItems.Commands -{ - public class DeleteMediaItemHandler : IRequestHandler> - { - private readonly IMediaItemRepository _mediaItemRepository; - - public DeleteMediaItemHandler(IMediaItemRepository mediaItemRepository) => - _mediaItemRepository = mediaItemRepository; - - public async Task> Handle( - DeleteMediaItem request, - CancellationToken cancellationToken) => - (await MediaItemMustExist(request)) - .Map(DoDeletion) - .ToEither(); - - private Task DoDeletion(int mediaItemId) => _mediaItemRepository.Delete(mediaItemId); - - private async Task> MediaItemMustExist(DeleteMediaItem deleteMediaItem) => - (await _mediaItemRepository.Get(deleteMediaItem.MediaItemId)) - .ToValidation($"MediaItem {deleteMediaItem.MediaItemId} does not exist.") - .Map(c => c.Id); - } -} diff --git a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItem.cs b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItem.cs deleted file mode 100644 index 608fe29a..00000000 --- a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItem.cs +++ /dev/null @@ -1,8 +0,0 @@ -using ErsatzTV.Core; -using LanguageExt; - -namespace ErsatzTV.Application.MediaItems.Commands -{ - public record RefreshMediaItem(int MediaItemId) : MediatR.IRequest>, - IBackgroundServiceRequest; -} diff --git a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemCollections.cs b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemCollections.cs deleted file mode 100644 index 49ab14ef..00000000 --- a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemCollections.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ErsatzTV.Application.MediaItems.Commands -{ - public record RefreshMediaItemCollections : RefreshMediaItem - { - public RefreshMediaItemCollections(int mediaItemId) : base(mediaItemId) - { - } - } -} diff --git a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemCollectionsHandler.cs b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemCollectionsHandler.cs deleted file mode 100644 index b7d78bb9..00000000 --- a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemCollectionsHandler.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using ErsatzTV.Core; -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Interfaces.Metadata; -using ErsatzTV.Core.Interfaces.Repositories; -using LanguageExt; - -namespace ErsatzTV.Application.MediaItems.Commands -{ - public class - RefreshMediaItemCollectionsHandler : MediatR.IRequestHandler> - { - private readonly IMediaItemRepository _mediaItemRepository; - private readonly ISmartCollectionBuilder _smartCollectionBuilder; - - public RefreshMediaItemCollectionsHandler( - IMediaItemRepository mediaItemRepository, - ISmartCollectionBuilder smartCollectionBuilder) - { - _mediaItemRepository = mediaItemRepository; - _smartCollectionBuilder = smartCollectionBuilder; - } - - public Task> Handle( - RefreshMediaItemCollections request, - CancellationToken cancellationToken) => - Validate(request) - .MapT(RefreshCollections) - .Bind(v => v.ToEitherAsync()); - - private Task> Validate(RefreshMediaItemCollections request) => - MediaItemMustExist(request); - - private Task> MediaItemMustExist( - RefreshMediaItemCollections refreshMediaItemCollections) => - _mediaItemRepository.Get(refreshMediaItemCollections.MediaItemId) - .Map( - maybeItem => maybeItem.ToValidation( - $"[MediaItem] {refreshMediaItemCollections.MediaItemId} does not exist.")); - - private Task RefreshCollections(MediaItem mediaItem) => - _smartCollectionBuilder.RefreshSmartCollections(mediaItem).ToUnit(); - } -} diff --git a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadata.cs b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadata.cs deleted file mode 100644 index 4c3c1bd9..00000000 --- a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadata.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ErsatzTV.Application.MediaItems.Commands -{ - public record RefreshMediaItemMetadata : RefreshMediaItem - { - public RefreshMediaItemMetadata(int mediaItemId) : base(mediaItemId) - { - } - } -} diff --git a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadataHandler.cs b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadataHandler.cs deleted file mode 100644 index 7bc3b695..00000000 --- a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadataHandler.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using ErsatzTV.Core; -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Interfaces.Metadata; -using ErsatzTV.Core.Interfaces.Repositories; -using LanguageExt; -using static LanguageExt.Prelude; - -namespace ErsatzTV.Application.MediaItems.Commands -{ - public class - RefreshMediaItemMetadataHandler : MediatR.IRequestHandler> - { - private readonly ILocalMetadataProvider _localMetadataProvider; - private readonly IMediaItemRepository _mediaItemRepository; - - public RefreshMediaItemMetadataHandler( - IMediaItemRepository mediaItemRepository, - ILocalMetadataProvider localMetadataProvider) - { - _mediaItemRepository = mediaItemRepository; - _localMetadataProvider = localMetadataProvider; - } - - public Task> Handle( - RefreshMediaItemMetadata request, - CancellationToken cancellationToken) => - Validate(request) - .MapT(RefreshMetadata) - .Bind(v => v.ToEitherAsync()); - - private Task> Validate(RefreshMediaItemMetadata request) => - MediaItemMustExist(request).BindT(PathMustExist); - - private Task> MediaItemMustExist( - RefreshMediaItemMetadata refreshMediaItemMetadata) => - _mediaItemRepository.Get(refreshMediaItemMetadata.MediaItemId) - .Map( - maybeItem => maybeItem.ToValidation( - $"[MediaItem] {refreshMediaItemMetadata.MediaItemId} does not exist.")); - - private Validation PathMustExist(MediaItem mediaItem) => - Some(mediaItem) - .Filter(item => File.Exists(item.Path)) - .ToValidation($"[Path] '{mediaItem.Path}' does not exist on the file system"); - - private Task RefreshMetadata(MediaItem mediaItem) => Task.CompletedTask.ToUnit(); - // TODO: reimplement this - // _localMetadataProvider.RefreshMetadata(mediaItem).ToUnit(); - } -} diff --git a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemPoster.cs b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemPoster.cs deleted file mode 100644 index c6d7ddb8..00000000 --- a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemPoster.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ErsatzTV.Application.MediaItems.Commands -{ - public record RefreshMediaItemPoster : RefreshMediaItem - { - public RefreshMediaItemPoster(int mediaItemId) : base(mediaItemId) - { - } - } -} diff --git a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemPosterHandler.cs b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemPosterHandler.cs deleted file mode 100644 index c0a6f3ac..00000000 --- a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemPosterHandler.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using ErsatzTV.Core; -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Interfaces.Metadata; -using ErsatzTV.Core.Interfaces.Repositories; -using LanguageExt; - -namespace ErsatzTV.Application.MediaItems.Commands -{ - public class - RefreshMediaItemPosterHandler : MediatR.IRequestHandler> - { - private readonly ILocalPosterProvider _localPosterProvider; - private readonly IMediaItemRepository _mediaItemRepository; - - public RefreshMediaItemPosterHandler( - IMediaItemRepository mediaItemRepository, - ILocalPosterProvider localPosterProvider) - { - _mediaItemRepository = mediaItemRepository; - _localPosterProvider = localPosterProvider; - } - - public Task> Handle( - RefreshMediaItemPoster request, - CancellationToken cancellationToken) => - Validate(request) - .MapT(RefreshPoster) - .Bind(v => v.ToEitherAsync()); - - private Task> Validate(RefreshMediaItemPoster request) => - MediaItemMustExist(request); - - private Task> MediaItemMustExist(RefreshMediaItemPoster request) => - _mediaItemRepository.Get(request.MediaItemId) - .Map( - maybeItem => maybeItem.ToValidation( - $"[MediaItem] {request.MediaItemId} does not exist.")); - - private Task RefreshPoster(MediaItem mediaItem) => - _localPosterProvider.RefreshPoster(mediaItem).ToUnit(); - } -} diff --git a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemStatistics.cs b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemStatistics.cs deleted file mode 100644 index f25ecec4..00000000 --- a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemStatistics.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ErsatzTV.Application.MediaItems.Commands -{ - public record RefreshMediaItemStatistics : RefreshMediaItem - { - public RefreshMediaItemStatistics(int mediaItemId) : base(mediaItemId) - { - } - } -} diff --git a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemStatisticsHandler.cs b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemStatisticsHandler.cs deleted file mode 100644 index 7d9b1c75..00000000 --- a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemStatisticsHandler.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using ErsatzTV.Core; -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Interfaces.Metadata; -using ErsatzTV.Core.Interfaces.Repositories; -using LanguageExt; -using static LanguageExt.Prelude; - -namespace ErsatzTV.Application.MediaItems.Commands -{ - public class - RefreshMediaItemStatisticsHandler : MediatR.IRequestHandler> - { - private readonly IConfigElementRepository _configElementRepository; - private readonly ILocalStatisticsProvider _localStatisticsProvider; - private readonly IMediaItemRepository _mediaItemRepository; - - public RefreshMediaItemStatisticsHandler( - IMediaItemRepository mediaItemRepository, - IConfigElementRepository configElementRepository, - ILocalStatisticsProvider localStatisticsProvider) - { - _mediaItemRepository = mediaItemRepository; - _configElementRepository = configElementRepository; - _localStatisticsProvider = localStatisticsProvider; - } - - public Task> Handle( - RefreshMediaItemStatistics request, - CancellationToken cancellationToken) => - Validate(request) - .MapT(RefreshStatistics) - .Bind(v => v.ToEitherAsync()); - - private async Task> Validate(RefreshMediaItemStatistics request) => - (await MediaItemMustExist(request).BindT(PathMustExist), await ValidateFFprobePath()) - .Apply((mediaItem, ffprobePath) => new RefreshParameters(mediaItem, ffprobePath)); - - private Task> MediaItemMustExist( - RefreshMediaItemStatistics refreshMediaItemStatistics) => - _mediaItemRepository.Get(refreshMediaItemStatistics.MediaItemId) - .Map( - maybeItem => maybeItem.ToValidation( - $"[MediaItem] {refreshMediaItemStatistics.MediaItemId} does not exist.")); - - private Validation PathMustExist(MediaItem mediaItem) => - Some(mediaItem) - .Filter(item => File.Exists(item.Path)) - .ToValidation($"[Path] '{mediaItem.Path}' does not exist on the file system"); - - private Task> ValidateFFprobePath() => - _configElementRepository.GetValue(ConfigElementKey.FFprobePath) - .FilterT(File.Exists) - .Map( - ffprobePath => - ffprobePath.ToValidation("FFprobe path does not exist on the file system")); - - private Task RefreshStatistics(RefreshParameters parameters) => - _localStatisticsProvider.RefreshStatistics(parameters.FFprobePath, parameters.MediaItem).ToUnit(); - - private record RefreshParameters(MediaItem MediaItem, string FFprobePath); - } -} diff --git a/ErsatzTV.Application/MediaItems/Mapper.cs b/ErsatzTV.Application/MediaItems/Mapper.cs index 6680e7dc..59fb7c5f 100644 --- a/ErsatzTV.Application/MediaItems/Mapper.cs +++ b/ErsatzTV.Application/MediaItems/Mapper.cs @@ -1,5 +1,6 @@ -using ErsatzTV.Core.Domain; -using static LanguageExt.Prelude; +using System; +using System.IO; +using ErsatzTV.Core.Domain; namespace ErsatzTV.Application.MediaItems { @@ -12,25 +13,44 @@ namespace ErsatzTV.Application.MediaItems mediaItem.Path); internal static MediaItemSearchResultViewModel ProjectToSearchViewModel(MediaItem mediaItem) => + mediaItem switch + { + TelevisionEpisodeMediaItem e => ProjectToSearchViewModel(e), + MovieMediaItem m => ProjectToSearchViewModel(m), + _ => throw new ArgumentOutOfRangeException() + }; + + private static MediaItemSearchResultViewModel ProjectToSearchViewModel(TelevisionEpisodeMediaItem mediaItem) => new( mediaItem.Id, GetSourceName(mediaItem.Source), - mediaItem.Metadata.MediaType.ToString(), + "TV Show", + GetDisplayTitle(mediaItem), + GetDisplayDuration(mediaItem)); + + private static MediaItemSearchResultViewModel ProjectToSearchViewModel(MovieMediaItem mediaItem) => + new( + mediaItem.Id, + GetSourceName(mediaItem.Source), + "Movie", GetDisplayTitle(mediaItem), GetDisplayDuration(mediaItem)); - private static string GetDisplayTitle(this MediaItem mediaItem) => - mediaItem.Metadata.MediaType == MediaType.TvShow && - Optional(mediaItem.Metadata.SeasonNumber).IsSome && - Optional(mediaItem.Metadata.EpisodeNumber).IsSome - ? $"{mediaItem.Metadata.Title} s{mediaItem.Metadata.SeasonNumber:00}e{mediaItem.Metadata.EpisodeNumber:00}" - : mediaItem.Metadata.Title; + private static string GetDisplayTitle(MediaItem mediaItem) => + mediaItem switch + { + TelevisionEpisodeMediaItem e => e.Metadata != null + ? $"{e.Metadata.Title} - s{e.Metadata.Season:00}e{e.Metadata.Episode:00}" + : Path.GetFileName(e.Path), + MovieMediaItem m => m.Metadata?.Title ?? Path.GetFileName(m.Path), + _ => string.Empty + }; private static string GetDisplayDuration(MediaItem mediaItem) => string.Format( - mediaItem.Metadata.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}", - mediaItem.Metadata.Duration); + mediaItem.Statistics.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}", + mediaItem.Statistics.Duration); private static string GetSourceName(MediaSource source) => source switch diff --git a/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItems.cs b/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItems.cs deleted file mode 100644 index c552bf0c..00000000 --- a/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItems.cs +++ /dev/null @@ -1,8 +0,0 @@ -using ErsatzTV.Core.Domain; -using MediatR; - -namespace ErsatzTV.Application.MediaItems.Queries -{ - public record GetAggregateMediaItems - (MediaType MediaType, int PageNumber, int PageSize) : IRequest; -} diff --git a/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs b/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs deleted file mode 100644 index 67dcfd79..00000000 --- a/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using ErsatzTV.Core.AggregateModels; -using ErsatzTV.Core.Interfaces.Repositories; -using MediatR; - -namespace ErsatzTV.Application.MediaItems.Queries -{ - public class - GetAggregateMediaItemsHandler : IRequestHandler - { - private readonly IMediaItemRepository _mediaItemRepository; - - public GetAggregateMediaItemsHandler(IMediaItemRepository mediaItemRepository) => - _mediaItemRepository = mediaItemRepository; - - public async Task Handle( - GetAggregateMediaItems request, - CancellationToken cancellationToken) - { - int count = await _mediaItemRepository.GetCountByType(request.MediaType); - - IEnumerable allItems = await _mediaItemRepository.GetPageByType( - request.MediaType, - request.PageNumber, - request.PageSize); - - var results = allItems - .Map( - s => new AggregateMediaItemViewModel( - s.MediaItemId, - s.Title, - s.Subtitle, - s.SortTitle, - s.Poster)) - .ToList(); - - return new AggregateMediaItemResults(count, results); - } - } -} diff --git a/ErsatzTV.Application/MediaSources/Commands/DeleteLocalMediaSourceHandler.cs b/ErsatzTV.Application/MediaSources/Commands/DeleteLocalMediaSourceHandler.cs index 8e9fd604..26eff7b3 100644 --- a/ErsatzTV.Application/MediaSources/Commands/DeleteLocalMediaSourceHandler.cs +++ b/ErsatzTV.Application/MediaSources/Commands/DeleteLocalMediaSourceHandler.cs @@ -12,16 +12,10 @@ namespace ErsatzTV.Application.MediaSources.Commands public class DeleteLocalMediaSourceHandler : IRequestHandler> { - private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly IMediaSourceRepository _mediaSourceRepository; - public DeleteLocalMediaSourceHandler( - IMediaSourceRepository mediaSourceRepository, - IMediaCollectionRepository mediaCollectionRepository) - { + public DeleteLocalMediaSourceHandler(IMediaSourceRepository mediaSourceRepository) => _mediaSourceRepository = mediaSourceRepository; - _mediaCollectionRepository = mediaCollectionRepository; - } public async Task> Handle( DeleteLocalMediaSource request, @@ -30,14 +24,8 @@ namespace ErsatzTV.Application.MediaSources.Commands .Map(DoDeletion) .ToEither(); - private async Task DoDeletion(LocalMediaSource mediaSource) - { + private async Task DoDeletion(LocalMediaSource mediaSource) => await _mediaSourceRepository.Delete(mediaSource.Id); - if (mediaSource.MediaType == MediaType.TvShow) - { - await _mediaCollectionRepository.DeleteEmptyTelevisionCollections(); - } - } private async Task> MediaSourceMustExist( DeleteLocalMediaSource deleteMediaSource) => diff --git a/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs b/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs index 818949a2..0e08d61d 100644 --- a/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs +++ b/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs @@ -1,11 +1,9 @@ using ErsatzTV.Core; -using ErsatzTV.Core.Metadata; using LanguageExt; using MediatR; namespace ErsatzTV.Application.MediaSources.Commands { - public record ScanLocalMediaSource(int MediaSourceId, ScanningMode ScanningMode) : - IRequest>, + public record ScanLocalMediaSource(int MediaSourceId) : IRequest>, IBackgroundServiceRequest; } diff --git a/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs b/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs index 655f5df9..1f98dc31 100644 --- a/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs +++ b/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs @@ -16,33 +16,41 @@ namespace ErsatzTV.Application.MediaSources.Commands { private readonly IConfigElementRepository _configElementRepository; private readonly IEntityLocker _entityLocker; - private readonly ILocalMediaScanner _localMediaScanner; private readonly IMediaSourceRepository _mediaSourceRepository; + private readonly IMovieFolderScanner _movieFolderScanner; + private readonly ITelevisionFolderScanner _televisionFolderScanner; public ScanLocalMediaSourceHandler( IMediaSourceRepository mediaSourceRepository, IConfigElementRepository configElementRepository, - ILocalMediaScanner localMediaScanner, + IMovieFolderScanner movieFolderScanner, + ITelevisionFolderScanner televisionFolderScanner, IEntityLocker entityLocker) { _mediaSourceRepository = mediaSourceRepository; _configElementRepository = configElementRepository; - _localMediaScanner = localMediaScanner; + _movieFolderScanner = movieFolderScanner; + _televisionFolderScanner = televisionFolderScanner; _entityLocker = entityLocker; } public Task> Handle(ScanLocalMediaSource request, CancellationToken cancellationToken) => Validate(request) - .MapT(parameters => PerformScan(request, parameters).Map(_ => parameters.LocalMediaSource.Folder)) + .MapT(parameters => PerformScan(parameters).Map(_ => parameters.LocalMediaSource.Folder)) .Bind(v => v.ToEitherAsync()); - private async Task PerformScan(ScanLocalMediaSource request, RequestParameters parameters) + private async Task PerformScan(RequestParameters parameters) { - await _localMediaScanner.ScanLocalMediaSource( - parameters.LocalMediaSource, - parameters.FFprobePath, - request.ScanningMode); + 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; + } _entityLocker.UnlockMediaSource(parameters.LocalMediaSource.Id); diff --git a/ErsatzTV.Application/Movies/Mapper.cs b/ErsatzTV.Application/Movies/Mapper.cs new file mode 100644 index 00000000..4656151f --- /dev/null +++ b/ErsatzTV.Application/Movies/Mapper.cs @@ -0,0 +1,10 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.Movies +{ + internal static class Mapper + { + internal static MovieViewModel ProjectToViewModel(MovieMediaItem movie) => + new(movie.Metadata.Title, movie.Metadata.Year?.ToString(), movie.Metadata.Plot, movie.Poster); + } +} diff --git a/ErsatzTV.Application/Movies/MovieViewModel.cs b/ErsatzTV.Application/Movies/MovieViewModel.cs new file mode 100644 index 00000000..a854506e --- /dev/null +++ b/ErsatzTV.Application/Movies/MovieViewModel.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Application.Movies +{ + public record MovieViewModel(string Title, string Year, string Plot, string Poster); +} diff --git a/ErsatzTV.Application/Movies/Queries/GetMovieById.cs b/ErsatzTV.Application/Movies/Queries/GetMovieById.cs new file mode 100644 index 00000000..7a264190 --- /dev/null +++ b/ErsatzTV.Application/Movies/Queries/GetMovieById.cs @@ -0,0 +1,7 @@ +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Movies.Queries +{ + public record GetMovieById(int Id) : IRequest>; +} diff --git a/ErsatzTV.Application/Movies/Queries/GetMovieByIdHandler.cs b/ErsatzTV.Application/Movies/Queries/GetMovieByIdHandler.cs new file mode 100644 index 00000000..ed5fb72e --- /dev/null +++ b/ErsatzTV.Application/Movies/Queries/GetMovieByIdHandler.cs @@ -0,0 +1,22 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.Movies.Mapper; + +namespace ErsatzTV.Application.Movies.Queries +{ + public class GetMovieByIdHandler : IRequestHandler> + { + private readonly IMovieRepository _movieRepository; + + public GetMovieByIdHandler(IMovieRepository movieRepository) => + _movieRepository = movieRepository; + + public Task> Handle( + GetMovieById request, + CancellationToken cancellationToken) => + _movieRepository.GetMovie(request.Id).MapT(ProjectToViewModel); + } +} diff --git a/ErsatzTV.Application/Playouts/Mapper.cs b/ErsatzTV.Application/Playouts/Mapper.cs index 93793982..f2167f72 100644 --- a/ErsatzTV.Application/Playouts/Mapper.cs +++ b/ErsatzTV.Application/Playouts/Mapper.cs @@ -1,5 +1,5 @@ -using ErsatzTV.Core.Domain; -using static LanguageExt.Prelude; +using System.IO; +using ErsatzTV.Core.Domain; namespace ErsatzTV.Application.Playouts { @@ -22,15 +22,18 @@ namespace ErsatzTV.Application.Playouts new(programSchedule.Id, programSchedule.Name); private static string GetDisplayTitle(MediaItem mediaItem) => - mediaItem.Metadata.MediaType == MediaType.TvShow && - Optional(mediaItem.Metadata.SeasonNumber).IsSome && - Optional(mediaItem.Metadata.EpisodeNumber).IsSome - ? $"{mediaItem.Metadata.Title} s{mediaItem.Metadata.SeasonNumber:00}e{mediaItem.Metadata.EpisodeNumber:00}" - : mediaItem.Metadata.Title; + mediaItem switch + { + TelevisionEpisodeMediaItem e => e.Metadata != null + ? $"{e.Metadata.Title} - s{e.Metadata.Season:00}e{e.Metadata.Episode:00}" + : Path.GetFileName(e.Path), + MovieMediaItem m => m.Metadata?.Title ?? Path.GetFileName(m.Path), + _ => string.Empty + }; private static string GetDisplayDuration(MediaItem mediaItem) => string.Format( - mediaItem.Metadata.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}", - mediaItem.Metadata.Duration); + mediaItem.Statistics.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}", + mediaItem.Statistics.Duration); } } diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItem.cs b/ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItem.cs index ebd5a337..bb7265c4 100644 --- a/ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItem.cs +++ b/ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItem.cs @@ -11,7 +11,10 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands StartType StartType, TimeSpan? StartTime, PlayoutMode PlayoutMode, - int MediaCollectionId, + ProgramScheduleItemCollectionType CollectionType, + int? MediaCollectionId, + int? TelevisionShowId, + int? TelevisionSeasonId, int? MultipleCount, TimeSpan? PlayoutDuration, bool? OfflineTail) : IRequest>, IProgramScheduleItemRequest; diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/IProgramScheduleItemRequest.cs b/ErsatzTV.Application/ProgramSchedules/Commands/IProgramScheduleItemRequest.cs index 717e2897..be9def8d 100644 --- a/ErsatzTV.Application/ProgramSchedules/Commands/IProgramScheduleItemRequest.cs +++ b/ErsatzTV.Application/ProgramSchedules/Commands/IProgramScheduleItemRequest.cs @@ -6,7 +6,10 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands public interface IProgramScheduleItemRequest { TimeSpan? StartTime { get; } - int MediaCollectionId { get; } + ProgramScheduleItemCollectionType CollectionType { get; } + int? MediaCollectionId { get; } + int? TelevisionShowId { get; } + int? TelevisionSeasonId { get; } PlayoutMode PlayoutMode { get; } int? MultipleCount { get; } TimeSpan? PlayoutDuration { get; } diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs b/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs index 696d4d90..f205279d 100644 --- a/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs +++ b/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs @@ -53,6 +53,40 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands return programSchedule; } + protected Validation CollectionTypeMustBeValid( + IProgramScheduleItemRequest item, + ProgramSchedule programSchedule) + { + switch (item.CollectionType) + { + case ProgramScheduleItemCollectionType.Collection: + if (item.MediaCollectionId is null) + { + return BaseError.New("[MediaCollection] is required for collection type 'Collection'"); + } + + break; + case ProgramScheduleItemCollectionType.TelevisionShow: + if (item.TelevisionShowId is null) + { + return BaseError.New("[TelevisionShow] is required for collection type 'TelevisionShow'"); + } + + break; + case ProgramScheduleItemCollectionType.TelevisionSeason: + if (item.TelevisionSeasonId is null) + { + return BaseError.New("[TelevisionSeason] is required for collection type 'TelevisionSeason'"); + } + + break; + default: + return BaseError.New("[CollectionType] is invalid"); + } + + return programSchedule; + } + protected ProgramScheduleItem BuildItem( ProgramSchedule programSchedule, int index, @@ -64,21 +98,30 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands ProgramScheduleId = programSchedule.Id, Index = index, StartTime = item.StartTime, - MediaCollectionId = item.MediaCollectionId + CollectionType = item.CollectionType, + MediaCollectionId = item.MediaCollectionId, + TelevisionShowId = item.TelevisionShowId, + TelevisionSeasonId = item.TelevisionSeasonId }, PlayoutMode.One => new ProgramScheduleItemOne { ProgramScheduleId = programSchedule.Id, Index = index, StartTime = item.StartTime, - MediaCollectionId = item.MediaCollectionId + CollectionType = item.CollectionType, + MediaCollectionId = item.MediaCollectionId, + TelevisionShowId = item.TelevisionShowId, + TelevisionSeasonId = item.TelevisionSeasonId }, PlayoutMode.Multiple => new ProgramScheduleItemMultiple { ProgramScheduleId = programSchedule.Id, Index = index, StartTime = item.StartTime, + CollectionType = item.CollectionType, MediaCollectionId = item.MediaCollectionId, + TelevisionShowId = item.TelevisionShowId, + TelevisionSeasonId = item.TelevisionSeasonId, Count = item.MultipleCount.GetValueOrDefault() }, PlayoutMode.Duration => new ProgramScheduleItemDuration @@ -86,7 +129,10 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands ProgramScheduleId = programSchedule.Id, Index = index, StartTime = item.StartTime, + CollectionType = item.CollectionType, MediaCollectionId = item.MediaCollectionId, + TelevisionShowId = item.TelevisionShowId, + TelevisionSeasonId = item.TelevisionSeasonId, PlayoutDuration = item.PlayoutDuration.GetValueOrDefault(), OfflineTail = item.OfflineTail.GetValueOrDefault() }, diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItems.cs b/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItems.cs index 0a1b051b..50c7a18e 100644 --- a/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItems.cs +++ b/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItems.cs @@ -12,7 +12,10 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands StartType StartType, TimeSpan? StartTime, PlayoutMode PlayoutMode, - int MediaCollectionId, + ProgramScheduleItemCollectionType CollectionType, + int? MediaCollectionId, + int? TelevisionShowId, + int? TelevisionSeasonId, int? MultipleCount, TimeSpan? PlayoutDuration, bool? OfflineTail) : IProgramScheduleItemRequest; diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs b/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs index 54e1587a..fcd8a7d9 100644 --- a/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs +++ b/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs @@ -55,12 +55,19 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands private Task> Validate(ReplaceProgramScheduleItems request) => ProgramScheduleMustExist(request.ProgramScheduleId) - .BindT(programSchedule => PlayoutModesMustBeValid(request, programSchedule)); + .BindT(programSchedule => PlayoutModesMustBeValid(request, programSchedule)) + .BindT(programSchedule => CollectionTypesMustBeValid(request, programSchedule)); private Validation PlayoutModesMustBeValid( ReplaceProgramScheduleItems request, ProgramSchedule programSchedule) => request.Items.Map(item => PlayoutModeMustBeValid(item, programSchedule)).Sequence() .Map(_ => programSchedule); + + private Validation CollectionTypesMustBeValid( + ReplaceProgramScheduleItems request, + ProgramSchedule programSchedule) => + request.Items.Map(item => CollectionTypeMustBeValid(item, programSchedule)).Sequence() + .Map(_ => programSchedule); } } diff --git a/ErsatzTV.Application/ProgramSchedules/Mapper.cs b/ErsatzTV.Application/ProgramSchedules/Mapper.cs index 1b705688..9d8ea1e6 100644 --- a/ErsatzTV.Application/ProgramSchedules/Mapper.cs +++ b/ErsatzTV.Application/ProgramSchedules/Mapper.cs @@ -17,7 +17,16 @@ namespace ErsatzTV.Application.ProgramSchedules duration.Index, duration.StartType, duration.StartTime, - MediaCollections.Mapper.ProjectToViewModel(duration.MediaCollection), + duration.CollectionType, + duration.MediaCollection != null + ? MediaCollections.Mapper.ProjectToViewModel(duration.MediaCollection) + : null, + duration.TelevisionShow != null + ? Television.Mapper.ProjectToViewModel(duration.TelevisionShow) + : null, + duration.TelevisionSeason != null + ? Television.Mapper.ProjectToViewModel(duration.TelevisionSeason) + : null, duration.PlayoutDuration, duration.OfflineTail), ProgramScheduleItemFlood flood => @@ -26,14 +35,32 @@ namespace ErsatzTV.Application.ProgramSchedules flood.Index, flood.StartType, flood.StartTime, - MediaCollections.Mapper.ProjectToViewModel(flood.MediaCollection)), + flood.CollectionType, + flood.MediaCollection != null + ? MediaCollections.Mapper.ProjectToViewModel(flood.MediaCollection) + : null, + flood.TelevisionShow != null + ? Television.Mapper.ProjectToViewModel(flood.TelevisionShow) + : null, + flood.TelevisionSeason != null + ? Television.Mapper.ProjectToViewModel(flood.TelevisionSeason) + : null), ProgramScheduleItemMultiple multiple => new ProgramScheduleItemMultipleViewModel( multiple.Id, multiple.Index, multiple.StartType, multiple.StartTime, - MediaCollections.Mapper.ProjectToViewModel(multiple.MediaCollection), + multiple.CollectionType, + multiple.MediaCollection != null + ? MediaCollections.Mapper.ProjectToViewModel(multiple.MediaCollection) + : null, + multiple.TelevisionShow != null + ? Television.Mapper.ProjectToViewModel(multiple.TelevisionShow) + : null, + multiple.TelevisionSeason != null + ? Television.Mapper.ProjectToViewModel(multiple.TelevisionSeason) + : null, multiple.Count), ProgramScheduleItemOne one => new ProgramScheduleItemOneViewModel( @@ -41,7 +68,14 @@ namespace ErsatzTV.Application.ProgramSchedules one.Index, one.StartType, one.StartTime, - MediaCollections.Mapper.ProjectToViewModel(one.MediaCollection)), + one.CollectionType, + one.MediaCollection != null + ? MediaCollections.Mapper.ProjectToViewModel(one.MediaCollection) + : null, + one.TelevisionShow != null ? Television.Mapper.ProjectToViewModel(one.TelevisionShow) : null, + one.TelevisionSeason != null + ? Television.Mapper.ProjectToViewModel(one.TelevisionSeason) + : null), _ => throw new NotSupportedException( $"Unsupported program schedule item type {programScheduleItem.GetType().Name}") }; diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemDurationViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemDurationViewModel.cs index 76fc0ab3..6071ffc9 100644 --- a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemDurationViewModel.cs +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemDurationViewModel.cs @@ -1,5 +1,6 @@ using System; using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Application.Television; using ErsatzTV.Core.Domain; namespace ErsatzTV.Application.ProgramSchedules @@ -11,7 +12,10 @@ namespace ErsatzTV.Application.ProgramSchedules int index, StartType startType, TimeSpan? startTime, + ProgramScheduleItemCollectionType collectionType, MediaCollectionViewModel mediaCollection, + TelevisionShowViewModel televisionShow, + TelevisionSeasonViewModel televisionSeason, TimeSpan playoutDuration, bool offlineTail) : base( id, @@ -19,7 +23,10 @@ namespace ErsatzTV.Application.ProgramSchedules startType, startTime, PlayoutMode.Duration, - mediaCollection) + collectionType, + mediaCollection, + televisionShow, + televisionSeason) { PlayoutDuration = playoutDuration; OfflineTail = offlineTail; diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemFloodViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemFloodViewModel.cs index 13105513..e1f13b32 100644 --- a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemFloodViewModel.cs +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemFloodViewModel.cs @@ -1,5 +1,6 @@ using System; using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Application.Television; using ErsatzTV.Core.Domain; namespace ErsatzTV.Application.ProgramSchedules @@ -11,13 +12,19 @@ namespace ErsatzTV.Application.ProgramSchedules int index, StartType startType, TimeSpan? startTime, - MediaCollectionViewModel mediaCollection) : base( + ProgramScheduleItemCollectionType collectionType, + MediaCollectionViewModel mediaCollection, + TelevisionShowViewModel televisionShow, + TelevisionSeasonViewModel televisionSeason) : base( id, index, startType, startTime, PlayoutMode.Flood, - mediaCollection) + collectionType, + mediaCollection, + televisionShow, + televisionSeason) { } } diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemMultipleViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemMultipleViewModel.cs index b298d63c..4106836f 100644 --- a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemMultipleViewModel.cs +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemMultipleViewModel.cs @@ -1,5 +1,6 @@ using System; using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Application.Television; using ErsatzTV.Core.Domain; namespace ErsatzTV.Application.ProgramSchedules @@ -11,14 +12,20 @@ namespace ErsatzTV.Application.ProgramSchedules int index, StartType startType, TimeSpan? startTime, + ProgramScheduleItemCollectionType collectionType, MediaCollectionViewModel mediaCollection, + TelevisionShowViewModel televisionShow, + TelevisionSeasonViewModel televisionSeason, int count) : base( id, index, startType, startTime, PlayoutMode.Multiple, - mediaCollection) => + collectionType, + mediaCollection, + televisionShow, + televisionSeason) => Count = count; public int Count { get; } diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemOneViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemOneViewModel.cs index 62f55f10..e77e5566 100644 --- a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemOneViewModel.cs +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemOneViewModel.cs @@ -1,5 +1,6 @@ using System; using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Application.Television; using ErsatzTV.Core.Domain; namespace ErsatzTV.Application.ProgramSchedules @@ -11,13 +12,19 @@ namespace ErsatzTV.Application.ProgramSchedules int index, StartType startType, TimeSpan? startTime, - MediaCollectionViewModel mediaCollection) : base( + ProgramScheduleItemCollectionType collectionType, + MediaCollectionViewModel mediaCollection, + TelevisionShowViewModel televisionShow, + TelevisionSeasonViewModel televisionSeason) : base( id, index, startType, startTime, PlayoutMode.One, - mediaCollection) + collectionType, + mediaCollection, + televisionShow, + televisionSeason) { } } diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs index 7a0ed5e8..dc7392ab 100644 --- a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs @@ -1,5 +1,6 @@ using System; using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Application.Television; using ErsatzTV.Core.Domain; namespace ErsatzTV.Application.ProgramSchedules @@ -10,5 +11,18 @@ namespace ErsatzTV.Application.ProgramSchedules StartType StartType, TimeSpan? StartTime, PlayoutMode PlayoutMode, - MediaCollectionViewModel MediaCollection); + ProgramScheduleItemCollectionType CollectionType, + MediaCollectionViewModel MediaCollection, + TelevisionShowViewModel TelevisionShow, + TelevisionSeasonViewModel TelevisionSeason) + { + public string Name => CollectionType switch + { + ProgramScheduleItemCollectionType.Collection => MediaCollection?.Name, + ProgramScheduleItemCollectionType.TelevisionShow => $"{TelevisionShow?.Title} ({TelevisionShow?.Year})", + ProgramScheduleItemCollectionType.TelevisionSeason => + $"{TelevisionSeason?.Title} ({TelevisionSeason?.Plot})", + _ => string.Empty + }; + } } diff --git a/ErsatzTV.Application/Television/Mapper.cs b/ErsatzTV.Application/Television/Mapper.cs new file mode 100644 index 00000000..983e973d --- /dev/null +++ b/ErsatzTV.Application/Television/Mapper.cs @@ -0,0 +1,28 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.Television +{ + internal static class Mapper + { + internal static TelevisionShowViewModel ProjectToViewModel(TelevisionShow show) => + new(show.Id, show.Metadata.Title, show.Metadata.Year?.ToString(), show.Metadata.Plot, show.Poster); + + internal static TelevisionSeasonViewModel ProjectToViewModel(TelevisionSeason season) => + new( + season.Id, + season.TelevisionShowId, + season.TelevisionShow.Metadata.Title, + season.TelevisionShow.Metadata.Year?.ToString(), + season.Number == 0 ? "Specials" : $"Season {season.Number}", + season.Poster); + + internal static TelevisionEpisodeViewModel ProjectToViewModel(TelevisionEpisodeMediaItem episode) => + new( + episode.Season.TelevisionShowId, + episode.SeasonId, + episode.Metadata.Episode, + episode.Metadata.Title, + episode.Metadata.Plot, + episode.Poster); + } +} diff --git a/ErsatzTV.Application/Television/Queries/GetAllTelevisionSeasons.cs b/ErsatzTV.Application/Television/Queries/GetAllTelevisionSeasons.cs new file mode 100644 index 00000000..303c8aac --- /dev/null +++ b/ErsatzTV.Application/Television/Queries/GetAllTelevisionSeasons.cs @@ -0,0 +1,7 @@ +using System.Collections.Generic; +using MediatR; + +namespace ErsatzTV.Application.Television.Queries +{ + public record GetAllTelevisionSeasons : IRequest>; +} diff --git a/ErsatzTV.Application/Television/Queries/GetAllTelevisionSeasonsHandler.cs b/ErsatzTV.Application/Television/Queries/GetAllTelevisionSeasonsHandler.cs new file mode 100644 index 00000000..5c0668b3 --- /dev/null +++ b/ErsatzTV.Application/Television/Queries/GetAllTelevisionSeasonsHandler.cs @@ -0,0 +1,25 @@ +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.Television.Mapper; + +namespace ErsatzTV.Application.Television.Queries +{ + public class + GetAllTelevisionSeasonsHandler : IRequestHandler> + { + private readonly ITelevisionRepository _televisionRepository; + + public GetAllTelevisionSeasonsHandler(ITelevisionRepository televisionRepository) => + _televisionRepository = televisionRepository; + + public Task> Handle( + GetAllTelevisionSeasons request, + CancellationToken cancellationToken) => + _televisionRepository.GetAllSeasons().Map(list => list.Map(ProjectToViewModel).ToList()); + } +} diff --git a/ErsatzTV.Application/Television/Queries/GetAllTelevisionShows.cs b/ErsatzTV.Application/Television/Queries/GetAllTelevisionShows.cs new file mode 100644 index 00000000..87f91eb1 --- /dev/null +++ b/ErsatzTV.Application/Television/Queries/GetAllTelevisionShows.cs @@ -0,0 +1,7 @@ +using System.Collections.Generic; +using MediatR; + +namespace ErsatzTV.Application.Television.Queries +{ + public record GetAllTelevisionShows : IRequest>; +} diff --git a/ErsatzTV.Application/Television/Queries/GetAllTelevisionShowsHandler.cs b/ErsatzTV.Application/Television/Queries/GetAllTelevisionShowsHandler.cs new file mode 100644 index 00000000..09c73e8a --- /dev/null +++ b/ErsatzTV.Application/Television/Queries/GetAllTelevisionShowsHandler.cs @@ -0,0 +1,24 @@ +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.Television.Mapper; + +namespace ErsatzTV.Application.Television.Queries +{ + public class GetAllTelevisionShowsHandler : IRequestHandler> + { + private readonly ITelevisionRepository _televisionRepository; + + public GetAllTelevisionShowsHandler(ITelevisionRepository televisionRepository) => + _televisionRepository = televisionRepository; + + public Task> Handle( + GetAllTelevisionShows request, + CancellationToken cancellationToken) => + _televisionRepository.GetAllShows().Map(list => list.Map(ProjectToViewModel).ToList()); + } +} diff --git a/ErsatzTV.Application/Television/Queries/GetTelevisionEpisodeById.cs b/ErsatzTV.Application/Television/Queries/GetTelevisionEpisodeById.cs new file mode 100644 index 00000000..2e6cc033 --- /dev/null +++ b/ErsatzTV.Application/Television/Queries/GetTelevisionEpisodeById.cs @@ -0,0 +1,7 @@ +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Television.Queries +{ + public record GetTelevisionEpisodeById(int EpisodeId) : IRequest>; +} diff --git a/ErsatzTV.Application/Television/Queries/GetTelevisionEpisodeByIdHandler.cs b/ErsatzTV.Application/Television/Queries/GetTelevisionEpisodeByIdHandler.cs new file mode 100644 index 00000000..69a0fc33 --- /dev/null +++ b/ErsatzTV.Application/Television/Queries/GetTelevisionEpisodeByIdHandler.cs @@ -0,0 +1,24 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.Television.Mapper; + +namespace ErsatzTV.Application.Television.Queries +{ + public class + GetTelevisionEpisodeByIdHandler : IRequestHandler> + { + private readonly ITelevisionRepository _televisionRepository; + + public GetTelevisionEpisodeByIdHandler(ITelevisionRepository televisionRepository) => + _televisionRepository = televisionRepository; + + public Task> Handle( + GetTelevisionEpisodeById request, + CancellationToken cancellationToken) => + _televisionRepository.GetEpisode(request.EpisodeId) + .MapT(ProjectToViewModel); + } +} diff --git a/ErsatzTV.Application/Television/Queries/GetTelevisionSeasonById.cs b/ErsatzTV.Application/Television/Queries/GetTelevisionSeasonById.cs new file mode 100644 index 00000000..e551e216 --- /dev/null +++ b/ErsatzTV.Application/Television/Queries/GetTelevisionSeasonById.cs @@ -0,0 +1,7 @@ +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Television.Queries +{ + public record GetTelevisionSeasonById(int SeasonId) : IRequest>; +} diff --git a/ErsatzTV.Application/Television/Queries/GetTelevisionSeasonByIdHandler.cs b/ErsatzTV.Application/Television/Queries/GetTelevisionSeasonByIdHandler.cs new file mode 100644 index 00000000..6adea60a --- /dev/null +++ b/ErsatzTV.Application/Television/Queries/GetTelevisionSeasonByIdHandler.cs @@ -0,0 +1,24 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.Television.Mapper; + +namespace ErsatzTV.Application.Television.Queries +{ + public class + GetTelevisionSeasonByIdHandler : IRequestHandler> + { + private readonly ITelevisionRepository _televisionRepository; + + public GetTelevisionSeasonByIdHandler(ITelevisionRepository televisionRepository) => + _televisionRepository = televisionRepository; + + public Task> Handle( + GetTelevisionSeasonById request, + CancellationToken cancellationToken) => + _televisionRepository.GetSeason(request.SeasonId) + .MapT(ProjectToViewModel); + } +} diff --git a/ErsatzTV.Application/Television/Queries/GetTelevisionShowById.cs b/ErsatzTV.Application/Television/Queries/GetTelevisionShowById.cs new file mode 100644 index 00000000..5d640f39 --- /dev/null +++ b/ErsatzTV.Application/Television/Queries/GetTelevisionShowById.cs @@ -0,0 +1,7 @@ +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Television.Queries +{ + public record GetTelevisionShowById(int Id) : IRequest>; +} diff --git a/ErsatzTV.Application/Television/Queries/GetTelevisionShowByIdHandler.cs b/ErsatzTV.Application/Television/Queries/GetTelevisionShowByIdHandler.cs new file mode 100644 index 00000000..04ba479b --- /dev/null +++ b/ErsatzTV.Application/Television/Queries/GetTelevisionShowByIdHandler.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.Television.Mapper; + +namespace ErsatzTV.Application.Television.Queries +{ + public class GetTelevisionShowByIdHandler : IRequestHandler> + { + private readonly ITelevisionRepository _televisionRepository; + + public GetTelevisionShowByIdHandler(ITelevisionRepository televisionRepository) => + _televisionRepository = televisionRepository; + + public Task> Handle( + GetTelevisionShowById request, + CancellationToken cancellationToken) => + _televisionRepository.GetShow(request.Id) + .MapT(ProjectToViewModel); + } +} diff --git a/ErsatzTV.Application/Television/TelevisionEpisodeViewModel.cs b/ErsatzTV.Application/Television/TelevisionEpisodeViewModel.cs new file mode 100644 index 00000000..937a943f --- /dev/null +++ b/ErsatzTV.Application/Television/TelevisionEpisodeViewModel.cs @@ -0,0 +1,10 @@ +namespace ErsatzTV.Application.Television +{ + public record TelevisionEpisodeViewModel( + int ShowId, + int SeasonId, + int Episode, + string Title, + string Plot, + string Poster); +} diff --git a/ErsatzTV.Application/Television/TelevisionSeasonViewModel.cs b/ErsatzTV.Application/Television/TelevisionSeasonViewModel.cs new file mode 100644 index 00000000..d5781734 --- /dev/null +++ b/ErsatzTV.Application/Television/TelevisionSeasonViewModel.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Application.Television +{ + public record TelevisionSeasonViewModel(int Id, int ShowId, string Title, string Year, string Plot, string Poster); +} diff --git a/ErsatzTV.Application/Television/TelevisionShowViewModel.cs b/ErsatzTV.Application/Television/TelevisionShowViewModel.cs new file mode 100644 index 00000000..885187f6 --- /dev/null +++ b/ErsatzTV.Application/Television/TelevisionShowViewModel.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Application.Television +{ + public record TelevisionShowViewModel(int Id, string Title, string Year, string Plot, string Poster); +} diff --git a/ErsatzTV.CommandLine/Commands/MediaCollections/MediaCollectionMediaItemsCommand.cs b/ErsatzTV.CommandLine/Commands/MediaCollections/MediaCollectionMediaItemsCommand.cs deleted file mode 100644 index 259be438..00000000 --- a/ErsatzTV.CommandLine/Commands/MediaCollections/MediaCollectionMediaItemsCommand.cs +++ /dev/null @@ -1,143 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using CliFx; -using CliFx.Attributes; -using ErsatzTV.Api.Sdk.Api; -using ErsatzTV.Api.Sdk.Model; -using LanguageExt; -using LanguageExt.Common; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using static LanguageExt.Prelude; - -namespace ErsatzTV.CommandLine.Commands.MediaCollections -{ - [Command("collection add-items", Description = "Ensure media collection exists and contains requested media items")] - public class MediaCollectionMediaItemsCommand : MediaItemCommandBase - { - private readonly ILogger _logger; - private readonly string _serverUrl; - - public MediaCollectionMediaItemsCommand( - IConfiguration configuration, - ILogger logger) - { - _logger = logger; - _serverUrl = configuration["ServerUrl"]; - } - - [CommandParameter(0, Name = "collection-name", Description = "The name of the media collection")] - public string Name { get; set; } - - public override async ValueTask ExecuteAsync(IConsole console) - { - try - { - CancellationToken cancellationToken = console.GetCancellationToken(); - - Either> maybeFileNames = await GetFileNames(); - await maybeFileNames.Match( - allFiles => SynchronizeMediaItemsToCollection(cancellationToken, allFiles), - error => - { - _logger.LogError("{Error}", error.Message); - return Task.CompletedTask; - }); - } - catch (Exception ex) - { - _logger.LogError("Unable to synchronize media items to media collection: {Error}", ex.Message); - } - } - - private async Task SynchronizeMediaItemsToCollection(CancellationToken cancellationToken, List allFiles) - { - Either result = await GetMediaSourceIdAsync(cancellationToken) - .BindAsync(mediaSourceId => SynchronizeMediaItemsAsync(mediaSourceId, allFiles, cancellationToken)) - .BindAsync(mediaItemIds => SynchronizeMediaItemsToCollectionAsync(mediaItemIds, cancellationToken)); - - result.Match( - _ => _logger.LogInformation( - "Successfully synchronized {Count} media items to media collection {MediaCollection}", - allFiles.Count, - Name), - error => _logger.LogError( - "Unable to synchronize media items to media collection: {Error}", - error.Message)); - } - - private async Task> GetMediaSourceIdAsync(CancellationToken cancellationToken) - { - var mediaSourcesApi = new MediaSourcesApi(_serverUrl); - List allMediaSources = - await mediaSourcesApi.ApiMediaSourcesGetAsync(cancellationToken); - Option maybeLocalMediaSource = - allMediaSources.SingleOrDefault(cs => cs.SourceType == MediaSourceType.Local); - return maybeLocalMediaSource.Match>( - mediaSource => mediaSource.Id, - () => Error.New("Unable to find local media source")); - } - - private async Task>> SynchronizeMediaItemsAsync( - int mediaSourceId, - ICollection fileNames, - CancellationToken cancellationToken) - { - var mediaItemsApi = new MediaItemsApi(_serverUrl); - List allMediaItems = await mediaItemsApi.ApiMediaItemsGetAsync(cancellationToken); - var missingMediaItems = fileNames.Where(f => allMediaItems.All(c => c.Path != f)) - .Map(f => new CreateMediaItem(mediaSourceId, f)) - .ToList(); - - var addedIds = new List(); - foreach (CreateMediaItem mediaItem in missingMediaItems) - { - _logger.LogInformation("Adding media item {Path}", mediaItem.Path); - addedIds.Add(await mediaItemsApi.ApiMediaItemsPostAsync(mediaItem, cancellationToken).Map(vm => vm.Id)); - } - - IEnumerable knownIds = allMediaItems.Where(c => fileNames.Contains(c.Path)).Map(c => c.Id); - - return knownIds.Concat(addedIds).ToList(); - } - - private async Task> SynchronizeMediaItemsToCollectionAsync( - List mediaItemIds, - CancellationToken cancellationToken) => - await EnsureMediaCollectionExistsAsync(cancellationToken) - .BindAsync( - mediaSourceId => SynchronizeMediaCollectionAsync(mediaSourceId, mediaItemIds, cancellationToken)); - - private async Task> EnsureMediaCollectionExistsAsync(CancellationToken cancellationToken) - { - var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl); - Option maybeExisting = await mediaCollectionsApi - .ApiMediaCollectionsGetAsync(cancellationToken) - .Map(list => list.SingleOrDefault(mc => mc.Name == Name)); - return await maybeExisting.Match( - existing => Task.FromResult(existing.Id), - async () => - { - var data = new CreateSimpleMediaCollection(Name); - return await mediaCollectionsApi.ApiMediaCollectionsPostAsync(data, cancellationToken) - .Map(vm => vm.Id); - }); - } - - private async Task> SynchronizeMediaCollectionAsync( - int mediaCollectionId, - List mediaItemIds, - CancellationToken cancellationToken) - { - var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl); - await mediaCollectionsApi.ApiMediaCollectionsIdItemsPutAsync( - mediaCollectionId, - mediaItemIds, - cancellationToken); - return unit; - } - } -} diff --git a/ErsatzTV.CommandLine/Commands/MediaItemCommandBase.cs b/ErsatzTV.CommandLine/Commands/MediaItemCommandBase.cs deleted file mode 100644 index 850f73dc..00000000 --- a/ErsatzTV.CommandLine/Commands/MediaItemCommandBase.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using CliFx; -using CliFx.Attributes; -using LanguageExt; -using LanguageExt.Common; - -namespace ErsatzTV.CommandLine.Commands -{ - public abstract class MediaItemCommandBase : ICommand - { - [CommandOption("folder", 'f', Description = "Folder to search for media items")] - public string Folder { get; set; } - - [CommandOption("pattern", 'p', Description = "File search pattern")] - public string SearchPattern { get; set; } - - public abstract ValueTask ExecuteAsync(IConsole console); - - protected async Task>> GetFileNames() - { - if (Console.IsInputRedirected) - { - await using Stream standardInput = Console.OpenStandardInput(); - using var streamReader = new StreamReader(standardInput); - string input = await streamReader.ReadToEndAsync(); - return input.Trim().Split("\n").Map(s => s.Trim()).ToList(); - } - - if (string.IsNullOrWhiteSpace(Folder) || string.IsNullOrWhiteSpace(SearchPattern)) - { - return Error.New( - "--folder and --pattern are required when file names are not passed on standard input"); - } - - return Directory.GetFiles(Folder, SearchPattern, SearchOption.AllDirectories).ToList(); - } - } -} diff --git a/ErsatzTV.CommandLine/Commands/MediaItemsCommand.cs b/ErsatzTV.CommandLine/Commands/MediaItemsCommand.cs deleted file mode 100644 index 10576a1b..00000000 --- a/ErsatzTV.CommandLine/Commands/MediaItemsCommand.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using CliFx; -using CliFx.Attributes; -using ErsatzTV.Api.Sdk.Api; -using ErsatzTV.Api.Sdk.Model; -using LanguageExt; -using LanguageExt.Common; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using static LanguageExt.Prelude; - -namespace ErsatzTV.CommandLine.Commands -{ - [Command("items", Description = "Ensure media items exist")] - public class MediaItemsCommand : MediaItemCommandBase - { - private readonly ILogger _logger; - private readonly string _serverUrl; - - public MediaItemsCommand(IConfiguration configuration, ILogger logger) - { - _logger = logger; - _serverUrl = configuration["ServerUrl"]; - } - - public override async ValueTask ExecuteAsync(IConsole console) - { - try - { - CancellationToken cancellationToken = console.GetCancellationToken(); - - Either> maybeFileNames = await GetFileNames(); - await maybeFileNames.Match( - allFiles => SynchronizeMediaItems(cancellationToken, allFiles), - error => - { - _logger.LogError("{Error}", error.Message); - return Task.CompletedTask; - }); - } - catch (Exception ex) - { - _logger.LogError("Unable to synchronize media items: {Error}", ex.Message); - } - } - - private async Task SynchronizeMediaItems(CancellationToken cancellationToken, List allFiles) - { - Either result = await GetMediaSourceId(cancellationToken) - .BindAsync( - contentSourceId => PostMediaItems( - contentSourceId, - allFiles, - cancellationToken)); - - result.Match( - _ => _logger.LogInformation( - "Successfully synchronized {Count} media items", - allFiles.Count), - error => _logger.LogError("Unable to synchronize media items: {Error}", error.Message)); - } - - private async Task> GetMediaSourceId(CancellationToken cancellationToken) - { - var mediaSourcesApi = new MediaSourcesApi(_serverUrl); - List allMediaSources = - await mediaSourcesApi.ApiMediaSourcesGetAsync(cancellationToken); - Option maybeLocalMediaSource = - allMediaSources.SingleOrDefault(cs => cs.SourceType == MediaSourceType.Local); - return maybeLocalMediaSource.Match>( - mediaSource => mediaSource.Id, - () => Error.New("Unable to find local media source")); - } - - private async Task> PostMediaItems( - int mediaSourceId, - ICollection fileNames, - CancellationToken cancellationToken) - { - var mediaItemsApi = new MediaItemsApi(_serverUrl); - List allContent = await mediaItemsApi.ApiMediaItemsGetAsync(cancellationToken); - var missingMediaItems = fileNames.Where(f => allContent.All(c => c.Path != f)) - .Map(f => new CreateMediaItem(mediaSourceId, f)) - .ToList(); - - foreach (CreateMediaItem mediaItem in missingMediaItems) - { - _logger.LogInformation("Adding media item {Path}", mediaItem.Path); - await mediaItemsApi.ApiMediaItemsPostAsync(mediaItem, cancellationToken); - } - - return unit; - } - } -} diff --git a/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsServiceTests.cs b/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsServiceTests.cs index 6497ebb1..b4c4ae7f 100644 --- a/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsServiceTests.cs +++ b/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsServiceTests.cs @@ -20,7 +20,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg { MediaItem = new MediaItem { - Metadata = new MediaMetadata() + Statistics = new MediaItemStatistics() } }; @@ -175,9 +175,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.Width = 1920; - playoutItem.MediaItem.Metadata.Height = 1080; - playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic + playoutItem.MediaItem.Statistics.Width = 1920; + playoutItem.MediaItem.Statistics.Height = 1080; + playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.TransportStream, @@ -198,9 +198,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.Width = 1918; - playoutItem.MediaItem.Metadata.Height = 1080; - playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic + playoutItem.MediaItem.Statistics.Width = 1918; + playoutItem.MediaItem.Statistics.Height = 1080; + playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.TransportStream, @@ -221,9 +221,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.Width = 1920; - playoutItem.MediaItem.Metadata.Height = 1080; - playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic + playoutItem.MediaItem.Statistics.Width = 1920; + playoutItem.MediaItem.Statistics.Height = 1080; + playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.TransportStream, @@ -245,9 +245,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.Width = 1918; - playoutItem.MediaItem.Metadata.Height = 1080; - playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic + playoutItem.MediaItem.Statistics.Width = 1918; + playoutItem.MediaItem.Statistics.Height = 1080; + playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.TransportStream, @@ -269,9 +269,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.Width = 1918; - playoutItem.MediaItem.Metadata.Height = 1080; - playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic + playoutItem.MediaItem.Statistics.Width = 1918; + playoutItem.MediaItem.Statistics.Height = 1080; + playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.HttpLiveStreaming, @@ -295,9 +295,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.Width = 1918; - playoutItem.MediaItem.Metadata.Height = 1080; - playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic + playoutItem.MediaItem.Statistics.Width = 1918; + playoutItem.MediaItem.Statistics.Height = 1080; + playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.TransportStream, @@ -323,10 +323,10 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.Width = 1920; - playoutItem.MediaItem.Metadata.Height = 1080; - playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic - playoutItem.MediaItem.Metadata.VideoCodec = "mpeg2video"; + playoutItem.MediaItem.Statistics.Width = 1920; + playoutItem.MediaItem.Statistics.Height = 1080; + playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic + playoutItem.MediaItem.Statistics.VideoCodec = "mpeg2video"; FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.TransportStream, @@ -352,10 +352,10 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.Width = 1920; - playoutItem.MediaItem.Metadata.Height = 1080; - playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic - playoutItem.MediaItem.Metadata.VideoCodec = "mpeg2video"; + playoutItem.MediaItem.Statistics.Width = 1920; + playoutItem.MediaItem.Statistics.Height = 1080; + playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic + playoutItem.MediaItem.Statistics.VideoCodec = "mpeg2video"; FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.HttpLiveStreaming, @@ -380,10 +380,10 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.Width = 1920; - playoutItem.MediaItem.Metadata.Height = 1080; - playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic - playoutItem.MediaItem.Metadata.VideoCodec = "libx264"; + playoutItem.MediaItem.Statistics.Width = 1920; + playoutItem.MediaItem.Statistics.Height = 1080; + playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic + playoutItem.MediaItem.Statistics.VideoCodec = "libx264"; FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.TransportStream, @@ -409,10 +409,10 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.Width = 1920; - playoutItem.MediaItem.Metadata.Height = 1080; - playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic - playoutItem.MediaItem.Metadata.VideoCodec = "mpeg2video"; + playoutItem.MediaItem.Statistics.Width = 1920; + playoutItem.MediaItem.Statistics.Height = 1080; + playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic + playoutItem.MediaItem.Statistics.VideoCodec = "mpeg2video"; FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.TransportStream, @@ -437,9 +437,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.Width = 1918; - playoutItem.MediaItem.Metadata.Height = 1080; - playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic + playoutItem.MediaItem.Statistics.Width = 1918; + playoutItem.MediaItem.Statistics.Height = 1080; + playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.TransportStream, @@ -464,10 +464,10 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.Width = 1920; - playoutItem.MediaItem.Metadata.Height = 1080; - playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic - playoutItem.MediaItem.Metadata.VideoCodec = "mpeg2video"; + playoutItem.MediaItem.Statistics.Width = 1920; + playoutItem.MediaItem.Statistics.Height = 1080; + playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic + playoutItem.MediaItem.Statistics.VideoCodec = "mpeg2video"; FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.TransportStream, @@ -492,9 +492,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.Width = 1918; - playoutItem.MediaItem.Metadata.Height = 1080; - playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic + playoutItem.MediaItem.Statistics.Width = 1918; + playoutItem.MediaItem.Statistics.Height = 1080; + playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.TransportStream, @@ -520,10 +520,10 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.Width = 1920; - playoutItem.MediaItem.Metadata.Height = 1080; - playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic - playoutItem.MediaItem.Metadata.VideoCodec = "mpeg2video"; + playoutItem.MediaItem.Statistics.Width = 1920; + playoutItem.MediaItem.Statistics.Height = 1080; + playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic + playoutItem.MediaItem.Statistics.VideoCodec = "mpeg2video"; FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.TransportStream, @@ -546,7 +546,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.AudioCodec = "aac"; + playoutItem.MediaItem.Statistics.AudioCodec = "aac"; FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.TransportStream, @@ -567,7 +567,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.AudioCodec = "ac3"; + playoutItem.MediaItem.Statistics.AudioCodec = "ac3"; FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.TransportStream, @@ -588,7 +588,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.AudioCodec = "ac3"; + playoutItem.MediaItem.Statistics.AudioCodec = "ac3"; FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.TransportStream, @@ -609,7 +609,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.AudioCodec = "ac3"; + playoutItem.MediaItem.Statistics.AudioCodec = "ac3"; FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.HttpLiveStreaming, @@ -630,7 +630,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.AudioCodec = "ac3"; + playoutItem.MediaItem.Statistics.AudioCodec = "ac3"; FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.TransportStream, @@ -651,7 +651,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.AudioCodec = "ac3"; + playoutItem.MediaItem.Statistics.AudioCodec = "ac3"; FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.TransportStream, @@ -674,7 +674,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.AudioCodec = "ac3"; + playoutItem.MediaItem.Statistics.AudioCodec = "ac3"; FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.TransportStream, @@ -697,7 +697,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.AudioCodec = "ac3"; + playoutItem.MediaItem.Statistics.AudioCodec = "ac3"; FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.TransportStream, @@ -719,7 +719,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.AudioCodec = "ac3"; + playoutItem.MediaItem.Statistics.AudioCodec = "ac3"; FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.TransportStream, @@ -741,7 +741,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg }; PlayoutItem playoutItem = EmptyPlayoutItem(); - playoutItem.MediaItem.Metadata.AudioCodec = "ac3"; + playoutItem.MediaItem.Statistics.AudioCodec = "ac3"; FFmpegPlaybackSettings actual = _calculator.CalculateSettings( StreamingMode.TransportStream, diff --git a/ErsatzTV.Core.Tests/Fakes/FakeFileEntry.cs b/ErsatzTV.Core.Tests/Fakes/FakeFileEntry.cs new file mode 100644 index 00000000..fb824bf6 --- /dev/null +++ b/ErsatzTV.Core.Tests/Fakes/FakeFileEntry.cs @@ -0,0 +1,9 @@ +using System; + +namespace ErsatzTV.Core.Tests.Fakes +{ + public record FakeFileEntry(string Path) + { + public DateTime LastWriteTime { get; set; } = DateTime.MinValue; + } +} diff --git a/ErsatzTV.Core.Tests/Fakes/FakeFolderEntry.cs b/ErsatzTV.Core.Tests/Fakes/FakeFolderEntry.cs new file mode 100644 index 00000000..3fbe5da0 --- /dev/null +++ b/ErsatzTV.Core.Tests/Fakes/FakeFolderEntry.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Core.Tests.Fakes +{ + public record FakeFolderEntry(string Path); +} diff --git a/ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs b/ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs new file mode 100644 index 00000000..0e28632c --- /dev/null +++ b/ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Metadata; +using LanguageExt; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.Tests.Fakes +{ + public class FakeLocalFileSystem : ILocalFileSystem + { + public static readonly byte[] TestBytes = { 1, 2, 3, 4, 5 }; + + private readonly List _files; + private readonly List _folders; + + public FakeLocalFileSystem(List files) : this(files, new List()) + { + } + + public FakeLocalFileSystem(List files, List folders) + { + _files = files; + + var allFolders = new List(folders.Map(f => f.Path)); + foreach (FakeFileEntry file in _files) + { + List moreFolders = + Split(new DirectoryInfo(Path.GetDirectoryName(file.Path) ?? string.Empty)); + allFolders.AddRange(moreFolders.Map(i => i.FullName)); + } + + _folders = allFolders.Distinct().Map(f => new FakeFolderEntry(f)).ToList(); + } + + public DateTime GetLastWriteTime(string path) => + Optional(_files.SingleOrDefault(f => f.Path == path)) + .Map(f => f.LastWriteTime) + .IfNone(DateTime.MinValue); + + public bool IsMediaSourceAccessible(LocalMediaSource localMediaSource) => + _files.Any(f => f.Path.StartsWith(localMediaSource.Folder)); + + public IEnumerable ListSubdirectories(string folder) => + _folders.Map(f => f.Path).Filter(f => f.StartsWith(folder) && Directory.GetParent(f)?.FullName == folder); + + public IEnumerable ListFiles(string folder) => + _files.Map(f => f.Path).Filter(f => Path.GetDirectoryName(f) == folder); + + public bool FileExists(string path) => _files.Any(f => f.Path == path); + + public Task ReadAllBytes(string path) => TestBytes.AsTask(); + + private static List Split(DirectoryInfo path) + { + var result = new List(); + if (path == null || string.IsNullOrWhiteSpace(path.FullName)) + { + return result; + } + + if (path.Parent != null) + { + result.AddRange(Split(path.Parent)); + } + + result.Add(path); + + return result; + } + } +} diff --git a/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs b/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs index 45f90a96..62142b4b 100644 --- a/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs +++ b/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; -using ErsatzTV.Core.AggregateModels; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; using LanguageExt; @@ -11,9 +11,9 @@ namespace ErsatzTV.Core.Tests.Fakes { public class FakeMediaCollectionRepository : IMediaCollectionRepository { - private readonly Map> _data; + private readonly Map> _data; - public FakeMediaCollectionRepository(Map> data) => _data = data; + public FakeMediaCollectionRepository(Map> data) => _data = data; public Task Add(SimpleMediaCollection collection) => throw new NotSupportedException(); @@ -25,32 +25,20 @@ namespace ErsatzTV.Core.Tests.Fakes public Task> GetSimpleMediaCollectionWithItems(int id) => throw new NotSupportedException(); - public Task> GetTelevisionMediaCollection(int id) => + public Task> GetSimpleMediaCollectionWithItemsUntracked(int id) => throw new NotSupportedException(); public Task> GetSimpleMediaCollections() => throw new NotSupportedException(); public Task> GetAll() => throw new NotSupportedException(); - public Task> GetSummaries(string searchString) => - throw new NotSupportedException(); - - public Task>> GetItems(int id) => Some(_data[id]).AsTask(); + public Task>> GetItems(int id) => Some(_data[id].OfType().ToList()).AsTask(); public Task>> GetSimpleMediaCollectionItems(int id) => throw new NotSupportedException(); - public Task>> GetTelevisionMediaCollectionItems(int id) => - throw new NotSupportedException(); - public Task Update(SimpleMediaCollection collection) => throw new NotSupportedException(); - public Task InsertOrIgnore(TelevisionMediaCollection collection) => throw new NotSupportedException(); - - public Task ReplaceItems(int collectionId, List mediaItems) => - throw new NotSupportedException(); - public Task Delete(int mediaCollectionId) => throw new NotSupportedException(); - public Task DeleteEmptyTelevisionCollections() => throw new NotSupportedException(); } } diff --git a/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs b/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs new file mode 100644 index 00000000..002c86c7 --- /dev/null +++ b/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; + +namespace ErsatzTV.Core.Tests.Fakes +{ + public class FakeTelevisionRepository : ITelevisionRepository + { + public Task Update(TelevisionShow show) => throw new NotSupportedException(); + + public Task Update(TelevisionSeason season) => throw new NotSupportedException(); + + public Task Update(TelevisionEpisodeMediaItem episode) => throw new NotSupportedException(); + + public Task> GetAllShows() => throw new NotSupportedException(); + + public Task> GetShow(int televisionShowId) => throw new NotSupportedException(); + + public Task GetShowCount() => throw new NotSupportedException(); + + public Task> GetPagedShows(int pageNumber, int pageSize) => + throw new NotSupportedException(); + + public Task> GetShowItems(int televisionShowId) => + throw new NotSupportedException(); + + public Task> GetAllSeasons() => throw new NotSupportedException(); + + public Task> GetSeason(int televisionSeasonId) => throw new NotSupportedException(); + + public Task GetSeasonCount(int televisionShowId) => throw new NotSupportedException(); + + public Task> GetPagedSeasons(int televisionShowId, int pageNumber, int pageSize) => + throw new NotSupportedException(); + + public Task> GetSeasonItems(int televisionSeasonId) => + throw new NotSupportedException(); + + public Task> GetEpisode(int televisionEpisodeId) => + throw new NotSupportedException(); + + public Task GetEpisodeCount(int televisionSeasonId) => throw new NotSupportedException(); + + public Task> GetPagedEpisodes( + int televisionSeasonId, + int pageNumber, + int pageSize) => throw new NotSupportedException(); + + public Task> GetShowByPath(int mediaSourceId, string path) => + throw new NotSupportedException(); + + public Task> GetShowByMetadata(TelevisionShowMetadata metadata) => + throw new NotSupportedException(); + + public Task> AddShow( + int localMediaSourceId, + string showFolder, + TelevisionShowMetadata metadata) => throw new NotSupportedException(); + + public Task> GetOrAddSeason( + TelevisionShow show, + string path, + int seasonNumber) => throw new NotSupportedException(); + + public Task> GetOrAddEpisode( + TelevisionSeason season, + int mediaSourceId, + string path) => throw new NotSupportedException(); + + public Task DeleteMissingSources(int localMediaSourceId, List allFolders) => + throw new NotSupportedException(); + + public Task DeleteEmptyShows() => throw new NotSupportedException(); + } +} diff --git a/ErsatzTV.Core.Tests/Metadata/FallbackMetadataProviderTests.cs b/ErsatzTV.Core.Tests/Metadata/FallbackMetadataProviderTests.cs index 85957559..6d1a5ad6 100644 --- a/ErsatzTV.Core.Tests/Metadata/FallbackMetadataProviderTests.cs +++ b/ErsatzTV.Core.Tests/Metadata/FallbackMetadataProviderTests.cs @@ -37,13 +37,13 @@ namespace ErsatzTV.Core.Tests.Metadata 2)] public void GetFallbackMetadata_ShouldHandleVariousFormats(string path, string title, int season, int episode) { - MediaMetadata metadata = FallbackMetadataProvider.GetFallbackMetadata( - new MediaItem { Path = path, Source = new LocalMediaSource { MediaType = MediaType.TvShow } }); + TelevisionEpisodeMetadata metadata = FallbackMetadataProvider.GetFallbackMetadata( + new TelevisionEpisodeMediaItem + { Path = path, Source = new LocalMediaSource { MediaType = MediaType.TvShow } }); - metadata.MediaType.Should().Be(MediaType.TvShow); metadata.Title.Should().Be(title); - metadata.SeasonNumber.Should().Be(season); - metadata.EpisodeNumber.Should().Be(episode); + metadata.Season.Should().Be(season); + metadata.Episode.Should().Be(episode); } } } diff --git a/ErsatzTV.Core.Tests/Metadata/LocalMediaSourcePlannerTests.cs b/ErsatzTV.Core.Tests/Metadata/LocalMediaSourcePlannerTests.cs deleted file mode 100644 index 24b21e45..00000000 --- a/ErsatzTV.Core.Tests/Metadata/LocalMediaSourcePlannerTests.cs +++ /dev/null @@ -1,1002 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Runtime.InteropServices; -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Interfaces.Metadata; -using ErsatzTV.Core.Metadata; -using FluentAssertions; -using LanguageExt; -using NUnit.Framework; - -namespace ErsatzTV.Core.Tests.Metadata -{ - [TestFixture] - public class LocalMediaSourcePlannerTests - { - private static readonly List VideoFileExtensions = new() - { - "mpg", "mp2", "mpeg", "mpe", "mpv", "ogg", "mp4", - "m4p", "m4v", "avi", "wmv", "mov", "mkv", "ts" - }; - - private static IEnumerable OldEntriesFor(params string[] fileNames) => - fileNames.Map(f => new FakeFileSystemEntry(f, DateTime.MinValue)); - - private static IEnumerable NewEntriesFor(params string[] fileNames) => - fileNames.Map(f => new FakeFileSystemEntry(f, DateTime.MaxValue)); - - private static LocalMediaSourcePlanner ScannerForOldFiles(params string[] fileNames) - => new(new FakeLocalFileSystem(OldEntriesFor(fileNames))); - - private static LocalMediaSourcePlanner ScannerForNewFiles(params string[] fileNames) - => new(new FakeLocalFileSystem(NewEntriesFor(fileNames))); - - private static LocalMediaSourcePlanner ScannerFor(IEnumerable entries) - => new(new FakeLocalFileSystem(entries)); - - private static readonly string FakeRoot = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? "C:\\" - : "/"; - - private static string MovieNameWithExtension(string extension, int year = 2021) => Path.Combine( - FakeRoot, - Path.Combine( - "movies", - Path.Combine($"test ({year})"), - Path.Combine($"test ({year}).{extension}"))); - - private static string MovieNfoName(string nfoFileName) => - Path.Combine(FakeRoot, Path.Combine("movies", Path.Combine("test (2021)"), nfoFileName)); - - private static string MoviePosterNameWithExtension(string basePosterName, string extension) => Path.Combine( - FakeRoot, - Path.Combine( - "movies", - Path.Combine("test (2021)"), - $"{basePosterName}poster.{extension}")); - - private static string EpisodeNameWithExtension(string extension, int episodeNumber = 3) => Path.Combine( - FakeRoot, - Path.Combine( - "tv", - Path.Combine( - "test (2021)", - Path.Combine("season 01", $"test (2021) - s01e{episodeNumber:00}.{extension}")))); - - private static string EpisodeNfoName(int episodeNumber = 3) => - Path.Combine( - FakeRoot, - Path.Combine( - "tv", - Path.Combine( - "test (2021)", - Path.Combine("season 01", $"test (2021) - s01e{episodeNumber:00}.nfo")))); - - private static string SeriesPosterNameWithExtension(string extension) => - Path.Combine(FakeRoot, Path.Combine("tv", Path.Combine("test (2021)", $"poster.{extension}"))); - - [TestFixture] - public class NewMovieTests - { - [Test] - public void WithoutNfo_WithoutPoster( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension) - { - string movieFileName = MovieNameWithExtension(extension); - string[] fileNames = { movieFileName }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.Movie, - Seq.Empty, - fileNames.ToSeq()); - - result.Count.Should().Be(1); - (Either source, List itemScanningPlans) = result.Head(); - source.IsLeft.Should().BeTrue(); - source.LeftToSeq().Should().BeEquivalentTo(movieFileName); - itemScanningPlans.Should().BeEquivalentTo( - new ActionPlan(movieFileName, ScanningAction.Add), - new ActionPlan(movieFileName, ScanningAction.Statistics), - new ActionPlan(movieFileName, ScanningAction.FallbackMetadata), - new ActionPlan(movieFileName, ScanningAction.Collections)); - } - - [Test] - public void WithNfo_WithoutPoster( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension, - [Values("test (2021).nfo", "movie.nfo")] - string nfoFile) - { - string movieFileName = MovieNameWithExtension(extension); - string nfoFileName = MovieNfoName(nfoFile); - string[] fileNames = { movieFileName, nfoFileName }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.Movie, - Seq.Empty, - fileNames.ToSeq()); - - result.Count.Should().Be(1); - (Either source, List itemScanningPlans) = result.Head(); - source.IsLeft.Should().BeTrue(); - source.LeftToSeq().Should().BeEquivalentTo(movieFileName); - itemScanningPlans.Should().BeEquivalentTo( - new ActionPlan(movieFileName, ScanningAction.Add), - new ActionPlan(movieFileName, ScanningAction.Statistics), - new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata), - new ActionPlan(nfoFileName, ScanningAction.Collections)); - } - - [Test] - public void WithoutNfo_WithPoster( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension, - [Values("", "test (2021)-")] - string basePosterName, - [Values("jpg", "jpeg", "png", "gif", "tbn")] - string posterExtension) - { - string movieFileName = MovieNameWithExtension(extension); - string posterFileName = MoviePosterNameWithExtension(basePosterName, posterExtension); - - string[] fileNames = { movieFileName, posterFileName }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.Movie, - Seq.Empty, - fileNames.ToSeq()); - - result.Count.Should().Be(1); - (Either source, List itemScanningPlans) = result.Head(); - source.IsLeft.Should().BeTrue(); - source.LeftToSeq().Should().BeEquivalentTo(movieFileName); - itemScanningPlans.Should().BeEquivalentTo( - new ActionPlan(movieFileName, ScanningAction.Add), - new ActionPlan(movieFileName, ScanningAction.Statistics), - new ActionPlan(movieFileName, ScanningAction.FallbackMetadata), - new ActionPlan(posterFileName, ScanningAction.Poster), - new ActionPlan(movieFileName, ScanningAction.Collections)); - } - - [Test] - public void WithNfo_WithPoster( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension, - [Values("test (2021).nfo", "movie.nfo")] - string nfoFile, - [Values("", "test (2021)-")] - string basePosterName, - [Values("jpg", "jpeg", "png", "gif", "tbn")] - string posterExtension) - { - string movieFileName = MovieNameWithExtension(extension); - string nfoFileName = MovieNfoName(nfoFile); - string posterFileName = MoviePosterNameWithExtension(basePosterName, posterExtension); - - string[] fileNames = { movieFileName, nfoFileName, posterFileName }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.Movie, - Seq.Empty, - fileNames.ToSeq()); - - result.Count.Should().Be(1); - (Either source, List itemScanningPlans) = result.Head(); - source.IsLeft.Should().BeTrue(); - source.LeftToSeq().Should().BeEquivalentTo(movieFileName); - itemScanningPlans.Should().BeEquivalentTo( - new ActionPlan(movieFileName, ScanningAction.Add), - new ActionPlan(movieFileName, ScanningAction.Statistics), - new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata), - new ActionPlan(posterFileName, ScanningAction.Poster), - new ActionPlan(nfoFileName, ScanningAction.Collections)); - } - } - - [TestFixture] - public class ExistingMovieTests - { - [Test] - public void Old_File_Should_Do_Nothing( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension) - { - var movieMediaItem = new MediaItem - { - Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, - Path = MovieNameWithExtension(extension) - }; - - string[] fileNames = { movieMediaItem.Path }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.Movie, - Seq.create(movieMediaItem), - fileNames.ToSeq()); - - result.Count.Should().Be(0); - } - - [Test] - public void Updated_File_Should_Refresh_Statistics( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension) - { - var movieMediaItem = new MediaItem - { - Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, - Path = MovieNameWithExtension(extension) - }; - - string[] fileNames = { movieMediaItem.Path }; - - Seq result = ScannerForNewFiles(fileNames).DetermineActions( - MediaType.Movie, - Seq.create(movieMediaItem), - fileNames.ToSeq()); - - result.Count.Should().Be(1); - (Either source, List itemScanningPlans) = result.Head(); - source.IsRight.Should().BeTrue(); - source.RightToSeq().Should().BeEquivalentTo(movieMediaItem); - itemScanningPlans.Should().BeEquivalentTo( - new ActionPlan(movieMediaItem.Path, ScanningAction.Statistics)); - } - - [Test] - public void Fallback_WithNewNfo_WithoutPoster( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension, - [Values("test (2021).nfo", "movie.nfo")] - string nfoFile) - { - var movieMediaItem = new MediaItem - { - Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, - Path = MovieNameWithExtension(extension) - }; - - string nfoFileName = MovieNfoName(nfoFile); - string[] fileNames = { movieMediaItem.Path, nfoFileName }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.Movie, - Seq.create(movieMediaItem), - fileNames.ToSeq()); - - result.Count.Should().Be(1); - (Either source, List itemScanningPlans) = result.Head(); - source.IsRight.Should().BeTrue(); - source.RightToSeq().Should().BeEquivalentTo(movieMediaItem); - itemScanningPlans.Should().BeEquivalentTo( - new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata), - new ActionPlan(nfoFileName, ScanningAction.Collections)); - } - - [Test] - public void Sidecar_WithOldNfo_WithoutPoster( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension, - [Values("test (2021).nfo", "movie.nfo")] - string nfoFile) - { - var movieMediaItem = new MediaItem - { - Metadata = new MediaMetadata { Source = MetadataSource.Sidecar }, - Path = MovieNameWithExtension(extension) - }; - - string nfoFileName = MovieNfoName(nfoFile); - string[] fileNames = { movieMediaItem.Path, nfoFileName }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.Movie, - Seq.create(movieMediaItem), - fileNames.ToSeq()); - - result.Count.Should().Be(0); - } - - [Test] - public void Sidecar_WithUpdatedNfo_WithoutPoster( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension, - [Values("test (2021).nfo", "movie.nfo")] - string nfoFile) - { - var movieMediaItem = new MediaItem - { - Metadata = new MediaMetadata { Source = MetadataSource.Sidecar }, - Path = MovieNameWithExtension(extension) - }; - - string nfoFileName = MovieNfoName(nfoFile); - string[] fileNames = { movieMediaItem.Path, nfoFileName }; - - Seq result = - ScannerFor(OldEntriesFor(movieMediaItem.Path).Concat(NewEntriesFor(nfoFileName))) - .DetermineActions( - MediaType.Movie, - Seq.create(movieMediaItem), - fileNames.ToSeq()); - - result.Count.Should().Be(1); - (Either source, List itemScanningPlans) = result.Head(); - source.IsRight.Should().BeTrue(); - source.RightToSeq().Should().BeEquivalentTo(movieMediaItem); - itemScanningPlans.Should().BeEquivalentTo( - new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata), - new ActionPlan(nfoFileName, ScanningAction.Collections)); - } - - [Test] - public void WithoutNfo_WithNewPoster( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension, - [Values("", "test (2021)-")] - string basePosterName, - [Values("jpg", "jpeg", "png", "gif", "tbn")] - string posterExtension) - { - var movieMediaItem = new MediaItem - { - Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, - Path = MovieNameWithExtension(extension) - }; - - string posterFileName = MoviePosterNameWithExtension(basePosterName, posterExtension); - string[] fileNames = { movieMediaItem.Path, posterFileName }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.Movie, - Seq.create(movieMediaItem), - fileNames.ToSeq()); - - result.Count.Should().Be(1); - (Either source, List itemScanningPlans) = result.Head(); - source.IsRight.Should().BeTrue(); - source.RightToSeq().Should().BeEquivalentTo(movieMediaItem); - itemScanningPlans.Should() - .BeEquivalentTo(new ActionPlan(posterFileName, ScanningAction.Poster)); - } - - [Test] - public void WithoutNfo_WithOldPoster( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension, - [Values("", "test (2021)-")] - string basePosterName, - [Values("jpg", "jpeg", "png", "gif", "tbn")] - string posterExtension) - { - var movieMediaItem = new MediaItem - { - Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, - Path = MovieNameWithExtension(extension), - Poster = "anything", - PosterLastWriteTime = DateTime.UtcNow - }; - - string posterFileName = MoviePosterNameWithExtension(basePosterName, posterExtension); - string[] fileNames = { movieMediaItem.Path, posterFileName }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.Movie, - Seq.create(movieMediaItem), - fileNames.ToSeq()); - - result.Count.Should().Be(0); - } - - [Test] - public void WithoutNfo_WithUpdatedPoster( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension, - [Values("", "test (2021)-")] - string basePosterName, - [Values("jpg", "jpeg", "png", "gif", "tbn")] - string posterExtension) - { - var movieMediaItem = new MediaItem - { - Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, - Path = MovieNameWithExtension(extension), - Poster = "anything", - PosterLastWriteTime = DateTime.UtcNow - }; - - string posterFileName = MoviePosterNameWithExtension(basePosterName, posterExtension); - string[] fileNames = { movieMediaItem.Path, posterFileName }; - - Seq result = - ScannerFor(OldEntriesFor(movieMediaItem.Path).Concat(NewEntriesFor(posterFileName))) - .DetermineActions( - MediaType.Movie, - Seq.create(movieMediaItem), - fileNames.ToSeq()); - - result.Count.Should().Be(1); - (Either source, List itemScanningPlans) = result.Head(); - source.IsRight.Should().BeTrue(); - source.RightToSeq().Should().BeEquivalentTo(movieMediaItem); - itemScanningPlans.Should() - .BeEquivalentTo(new ActionPlan(posterFileName, ScanningAction.Poster)); - } - - [Test] - public void WithNewNfo_WithNewPoster( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension, - [Values("test (2021).nfo", "movie.nfo")] - string nfoFile, - [Values("", "test (2021)-")] - string basePosterName, - [Values("jpg", "jpeg", "png", "gif", "tbn")] - string posterExtension) - { - var movieMediaItem = new MediaItem - { - Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, - Path = MovieNameWithExtension(extension) - }; - - string nfoFileName = MovieNfoName(nfoFile); - string posterFileName = MoviePosterNameWithExtension(basePosterName, posterExtension); - string[] fileNames = { movieMediaItem.Path, nfoFileName, posterFileName }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.Movie, - Seq.create(movieMediaItem), - fileNames.ToSeq()); - - result.Count.Should().Be(1); - (Either source, List itemScanningPlans) = result.Head(); - source.IsRight.Should().BeTrue(); - source.RightToSeq().Should().BeEquivalentTo(movieMediaItem); - itemScanningPlans.Should().BeEquivalentTo( - new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata), - new ActionPlan(posterFileName, ScanningAction.Poster), - new ActionPlan(nfoFileName, ScanningAction.Collections)); - } - } - - [TestFixture] - public class NewEpisodeTests - { - [Test] - public void WithoutNfo_WithoutPoster( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension) - { - string episodeFileName = EpisodeNameWithExtension(extension); - string[] fileNames = { episodeFileName }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.TvShow, - Seq.Empty, - fileNames.ToSeq()); - - result.Count.Should().Be(1); - (Either source, List itemScanningPlans) = result.Head(); - source.IsLeft.Should().BeTrue(); - source.LeftToSeq().Should().BeEquivalentTo(episodeFileName); - itemScanningPlans.Should().BeEquivalentTo( - new ActionPlan(episodeFileName, ScanningAction.Add), - new ActionPlan(episodeFileName, ScanningAction.Statistics), - new ActionPlan(episodeFileName, ScanningAction.FallbackMetadata), - new ActionPlan(episodeFileName, ScanningAction.Collections)); - } - - [Test] - public void WithNfo_WithoutPoster( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension) - { - string episodeFileName = EpisodeNameWithExtension(extension); - string nfoFileName = EpisodeNfoName(); - string[] fileNames = { episodeFileName, nfoFileName }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.TvShow, - Seq.Empty, - fileNames.ToSeq()); - - result.Count.Should().Be(1); - (Either source, List itemScanningPlans) = result.Head(); - source.IsLeft.Should().BeTrue(); - source.LeftToSeq().Should().BeEquivalentTo(episodeFileName); - itemScanningPlans.Should().BeEquivalentTo( - new ActionPlan(episodeFileName, ScanningAction.Add), - new ActionPlan(episodeFileName, ScanningAction.Statistics), - new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata), - new ActionPlan(nfoFileName, ScanningAction.Collections)); - } - - [Test] - public void WithoutNfo_WithPoster( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension, - [Values("jpg", "jpeg", "png", "gif", "tbn")] - string posterExtension) - { - string episodeFileName = EpisodeNameWithExtension(extension); - string posterFileName = SeriesPosterNameWithExtension(posterExtension); - - string[] fileNames = { episodeFileName, posterFileName }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.TvShow, - Seq.Empty, - fileNames.ToSeq()); - - result.Count.Should().Be(1); - (Either source, List itemScanningPlans) = result.Head(); - source.IsLeft.Should().BeTrue(); - source.LeftToSeq().Should().BeEquivalentTo(episodeFileName); - itemScanningPlans.Should().BeEquivalentTo( - new ActionPlan(episodeFileName, ScanningAction.Add), - new ActionPlan(episodeFileName, ScanningAction.Statistics), - new ActionPlan(episodeFileName, ScanningAction.FallbackMetadata), - new ActionPlan(posterFileName, ScanningAction.Poster), - new ActionPlan(episodeFileName, ScanningAction.Collections)); - } - - [Test] - public void WithNfo_WithPoster( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension, - [Values("jpg", "jpeg", "png", "gif", "tbn")] - string posterExtension) - { - string episodeFileName = EpisodeNameWithExtension(extension); - string nfoFileName = EpisodeNfoName(); - string posterFileName = SeriesPosterNameWithExtension(posterExtension); - - string[] fileNames = { episodeFileName, nfoFileName, posterFileName }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.TvShow, - Seq.Empty, - fileNames.ToSeq()); - - result.Count.Should().Be(1); - (Either source, List itemScanningPlans) = result.Head(); - source.IsLeft.Should().BeTrue(); - source.LeftToSeq().Should().BeEquivalentTo(episodeFileName); - itemScanningPlans.Should().BeEquivalentTo( - new ActionPlan(episodeFileName, ScanningAction.Add), - new ActionPlan(episodeFileName, ScanningAction.Statistics), - new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata), - new ActionPlan(posterFileName, ScanningAction.Poster), - new ActionPlan(nfoFileName, ScanningAction.Collections)); - } - } - - [TestFixture] - public class ExistingEpisodeTests - { - [Test] - public void Old_File_Should_Do_Nothing( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension) - { - var episodeMediaItem = new MediaItem - { - Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, - Path = EpisodeNameWithExtension(extension) - }; - - string[] fileNames = { episodeMediaItem.Path }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.TvShow, - Seq.create(episodeMediaItem), - fileNames.ToSeq()); - - result.Count.Should().Be(0); - } - - [Test] - public void Updated_File_Should_Refresh_Statistics( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension) - { - var episodeMediaItem = new MediaItem - { - Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, - Path = EpisodeNameWithExtension(extension) - }; - - string[] fileNames = { episodeMediaItem.Path }; - - Seq result = ScannerForNewFiles(fileNames).DetermineActions( - MediaType.TvShow, - Seq.create(episodeMediaItem), - fileNames.ToSeq()); - - result.Count.Should().Be(1); - (Either source, List itemScanningPlans) = result.Head(); - source.IsRight.Should().BeTrue(); - source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem); - itemScanningPlans.Should().BeEquivalentTo( - new ActionPlan(episodeMediaItem.Path, ScanningAction.Statistics)); - } - - [Test] - public void Fallback_WithNewNfo_WithoutPoster( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension) - { - var episodeMediaItem = new MediaItem - { - Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, - Path = EpisodeNameWithExtension(extension) - }; - - string nfoFileName = EpisodeNfoName(); - string[] fileNames = { episodeMediaItem.Path, nfoFileName }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.TvShow, - Seq.create(episodeMediaItem), - fileNames.ToSeq()); - - result.Count.Should().Be(1); - (Either source, List itemScanningPlans) = result.Head(); - source.IsRight.Should().BeTrue(); - source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem); - itemScanningPlans.Should().BeEquivalentTo( - new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata), - new ActionPlan(nfoFileName, ScanningAction.Collections)); - } - - [Test] - public void Sidecar_WithOldNfo_WithoutPoster( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension) - { - var episodeMediaItem = new MediaItem - { - Metadata = new MediaMetadata { Source = MetadataSource.Sidecar }, - Path = EpisodeNameWithExtension(extension) - }; - - string nfoFileName = EpisodeNfoName(); - string[] fileNames = { episodeMediaItem.Path, nfoFileName }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.TvShow, - Seq.create(episodeMediaItem), - fileNames.ToSeq()); - - result.Count.Should().Be(0); - } - - [Test] - public void Sidecar_WithUpdatedNfo_WithoutPoster( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension) - { - var episodeMediaItem = new MediaItem - { - Metadata = new MediaMetadata { Source = MetadataSource.Sidecar }, - Path = EpisodeNameWithExtension(extension) - }; - - string nfoFileName = EpisodeNfoName(); - string[] fileNames = { episodeMediaItem.Path, nfoFileName }; - - Seq result = - ScannerFor(OldEntriesFor(episodeMediaItem.Path).Concat(NewEntriesFor(nfoFileName))) - .DetermineActions( - MediaType.TvShow, - Seq.create(episodeMediaItem), - fileNames.ToSeq()); - - result.Count.Should().Be(1); - (Either source, List itemScanningPlans) = result.Head(); - source.IsRight.Should().BeTrue(); - source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem); - itemScanningPlans.Should().BeEquivalentTo( - new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata), - new ActionPlan(nfoFileName, ScanningAction.Collections)); - } - - [Test] - public void WithoutNfo_WithNewPoster( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension, - [Values("jpg", "jpeg", "png", "gif", "tbn")] - string posterExtension) - { - var episodeMediaItem = new MediaItem - { - Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, - Path = EpisodeNameWithExtension(extension) - }; - - string posterFileName = SeriesPosterNameWithExtension(posterExtension); - string[] fileNames = { episodeMediaItem.Path, posterFileName }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.TvShow, - Seq.create(episodeMediaItem), - fileNames.ToSeq()); - - result.Count.Should().Be(1); - (Either source, List itemScanningPlans) = result.Head(); - source.IsRight.Should().BeTrue(); - source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem); - itemScanningPlans.Should() - .BeEquivalentTo(new ActionPlan(posterFileName, ScanningAction.Poster)); - } - - [Test] - public void WithoutNfo_WithOldPoster( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension, - [Values("jpg", "jpeg", "png", "gif", "tbn")] - string posterExtension) - { - var episodeMediaItem = new MediaItem - { - Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, - Path = EpisodeNameWithExtension(extension), - Poster = "anything", - PosterLastWriteTime = DateTime.UtcNow - }; - - string posterFileName = SeriesPosterNameWithExtension(posterExtension); - string[] fileNames = { episodeMediaItem.Path, posterFileName }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.TvShow, - Seq.create(episodeMediaItem), - fileNames.ToSeq()); - - result.Count.Should().Be(0); - } - - [Test] - public void WithoutNfo_WithUpdatedPoster( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension, - [Values("jpg", "jpeg", "png", "gif", "tbn")] - string posterExtension) - { - var episodeMediaItem = new MediaItem - { - Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, - Path = EpisodeNameWithExtension(extension), - Poster = "anything", - PosterLastWriteTime = DateTime.UtcNow - }; - - string posterFileName = SeriesPosterNameWithExtension(posterExtension); - string[] fileNames = { episodeMediaItem.Path, posterFileName }; - - Seq result = - ScannerFor(OldEntriesFor(episodeMediaItem.Path).Concat(NewEntriesFor(posterFileName))) - .DetermineActions( - MediaType.TvShow, - Seq.create(episodeMediaItem), - fileNames.ToSeq()); - - result.Count.Should().Be(1); - (Either source, List itemScanningPlans) = result.Head(); - source.IsRight.Should().BeTrue(); - source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem); - itemScanningPlans.Should() - .BeEquivalentTo(new ActionPlan(posterFileName, ScanningAction.Poster)); - } - - [Test] - public void WithNewNfo_WithNewPoster( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension, - [Values("jpg", "jpeg", "png", "gif", "tbn")] - string posterExtension) - { - var episodeMediaItem = new MediaItem - { - Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, - Path = EpisodeNameWithExtension(extension) - }; - - string nfoFileName = EpisodeNfoName(); - string posterFileName = SeriesPosterNameWithExtension(posterExtension); - string[] fileNames = { episodeMediaItem.Path, nfoFileName, posterFileName }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.TvShow, - Seq.create(episodeMediaItem), - fileNames.ToSeq()); - - result.Count.Should().Be(1); - (Either source, List itemScanningPlans) = result.Head(); - source.IsRight.Should().BeTrue(); - source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem); - itemScanningPlans.Should().BeEquivalentTo( - new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata), - new ActionPlan(posterFileName, ScanningAction.Poster), - new ActionPlan(nfoFileName, ScanningAction.Collections)); - } - } - - [Test] - public void Movies_Should_Ignore_ExtraFolders( - [Values( - "Behind The Scenes", - "Deleted Scenes", - "Featurettes", - "Interviews", - "Scenes", - "Shorts", - "Trailers", - "Other", - "Extras", - "Specials")] - string folder, - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension) - { - string[] fileNames = - { - Path.Combine( - FakeRoot, - Path.Combine( - "movies", - Path.Combine("test (2021)", Path.Combine(folder, $"test (2021).{extension}")))) - }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.Movie, - Seq.Empty, - fileNames.ToSeq()); - - result.Count.Should().Be(0); - } - - [Test] - public void Movies_Should_Ignore_ExtraFiles( - [Values( - "behindthescenes", - "deleted", - "featurette", - "interview", - "scene", - "short", - "trailer", - "other")] - string extra, - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension) - { - string[] fileNames = - { - Path.Combine( - FakeRoot, - Path.Combine( - "movies", - Path.Combine("test (2021)", $"test (2021)-{extra}.{extension}"))) - }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.Movie, - Seq.Empty, - fileNames.ToSeq()); - - result.Count.Should().Be(0); - } - - [Test] - public void Movies_Should_Remove_Missing_MediaItems( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension) - { - var movieMediaItem = new MediaItem - { - Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, - Path = MovieNameWithExtension(extension) - }; - - var movieMediaItem2 = new MediaItem - { - Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, - Path = MovieNameWithExtension(extension, 2022) - }; - - string[] fileNames = { "anything" }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.Movie, - Seq.create(movieMediaItem, movieMediaItem2), - fileNames.ToSeq()); - - result.Count.Should().Be(2); - - (Either source1, List itemScanningPlans1) = result.Head(); - source1.IsRight.Should().BeTrue(); - source1.RightToSeq().Should().BeEquivalentTo(movieMediaItem); - itemScanningPlans1.Should().BeEquivalentTo( - new ActionPlan(movieMediaItem.Path, ScanningAction.Remove)); - - (Either source2, List itemScanningPlans2) = result.Last(); - source2.IsRight.Should().BeTrue(); - source2.RightToSeq().Should().BeEquivalentTo(movieMediaItem2); - itemScanningPlans2.Should().BeEquivalentTo( - new ActionPlan(movieMediaItem2.Path, ScanningAction.Remove)); - } - - [Test] - public void Episodes_Should_Remove_Missing_MediaItems( - [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] - string extension) - { - var episodeMediaItem = new MediaItem - { - Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, - Path = EpisodeNameWithExtension(extension) - }; - - var episodeMediaItem2 = new MediaItem - { - Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, - Path = EpisodeNameWithExtension(extension, 4) - }; - - string[] fileNames = { "anything" }; - - Seq result = ScannerForOldFiles(fileNames).DetermineActions( - MediaType.TvShow, - Seq.create(episodeMediaItem, episodeMediaItem2), - fileNames.ToSeq()); - - result.Count.Should().Be(2); - - (Either source1, List itemScanningPlans1) = result.Head(); - source1.IsRight.Should().BeTrue(); - source1.RightToSeq().Should().BeEquivalentTo(episodeMediaItem); - itemScanningPlans1.Should().BeEquivalentTo( - new ActionPlan(episodeMediaItem.Path, ScanningAction.Remove)); - - (Either source2, List itemScanningPlans2) = result.Last(); - source2.IsRight.Should().BeTrue(); - source2.RightToSeq().Should().BeEquivalentTo(episodeMediaItem2); - itemScanningPlans2.Should().BeEquivalentTo( - new ActionPlan(episodeMediaItem2.Path, ScanningAction.Remove)); - } - - private class FakeLocalFileSystem : ILocalFileSystem - { - private readonly Dictionary _files = new(); - - public FakeLocalFileSystem(IEnumerable entries) - { - foreach ((string path, DateTime modifyTime) in entries) - { - _files.Add(path, modifyTime); - } - } - - public DateTime GetLastWriteTime(string path) => - _files.ContainsKey(path) ? _files[path] : DateTime.MinValue; - - public bool IsMediaSourceAccessible(LocalMediaSource localMediaSource) => throw new NotSupportedException(); - - public Seq FindRelevantVideos(LocalMediaSource localMediaSource) => - throw new NotSupportedException(); - - public bool ShouldRefreshMetadata(LocalMediaSource localMediaSource, MediaItem mediaItem) => - throw new NotSupportedException(); - - public bool ShouldRefreshPoster(MediaItem mediaItem) => throw new NotSupportedException(); - } - - private record FakeFileSystemEntry(string Path, DateTime ModifyTime); - } -} diff --git a/ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs b/ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs new file mode 100644 index 00000000..8f0e75df --- /dev/null +++ b/ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs @@ -0,0 +1,370 @@ +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Interfaces.Images; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Metadata; +using ErsatzTV.Core.Tests.Fakes; +using FluentAssertions; +using LanguageExt; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.Tests.Metadata +{ + [TestFixture] + public class MovieFolderScannerTests + { + private static readonly string BadFakeRoot = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? @"C:\Movies-That-Dont-Exist" + : @"/movies-that-dont-exist"; + + private static readonly string FakeRoot = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? @"C:\Movies" + : "/movies"; + + private static readonly string FFprobePath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? @"C:\bin\ffprobe.exe" + : "/bin/ffprobe"; + + [TestFixture] + public class ScanFolder + { + [SetUp] + public void SetUp() + { + _movieRepository = new Mock(); + _movieRepository.Setup(x => x.GetOrAdd(It.IsAny(), It.IsAny())) + .Returns( + (int _, string path) => + Right(new MovieMediaItem { Path = path }).AsTask()); + + _localStatisticsProvider = new Mock(); + _localMetadataProvider = new Mock(); + + _imageCache = new Mock(); + _imageCache.Setup( + x => x.ResizeAndSaveImage(FakeLocalFileSystem.TestBytes, It.IsAny(), It.IsAny())) + .Returns(Right("poster").AsTask()); + } + + private Mock _movieRepository; + private Mock _localStatisticsProvider; + private Mock _localMetadataProvider; + private Mock _imageCache; + + [Test] + public async Task Missing_Folder() + { + MovieFolderScanner service = GetService( + new FakeFileEntry(Path.Combine(FakeRoot, Path.Combine("Movie (2020)", "Movie (2020).mkv"))) + ); + var source = new LocalMediaSource { Folder = BadFakeRoot }; + + Either result = await service.ScanFolder(source, FFprobePath); + + result.IsLeft.Should().BeTrue(); + result.IfLeft(error => error.Should().BeOfType()); + } + + [Test] + public async Task NewMovie_Statistics_And_FallbackMetadata( + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))] + string videoExtension) + { + string moviePath = Path.Combine( + FakeRoot, + Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}")); + + MovieFolderScanner service = GetService( + new FakeFileEntry(moviePath) + ); + var source = new LocalMediaSource { Id = 1, Folder = FakeRoot }; + + Either result = await service.ScanFolder(source, FFprobePath); + + result.IsRight.Should().BeTrue(); + + _movieRepository.Verify(x => x.GetOrAdd(It.IsAny(), It.IsAny()), Times.Once); + _movieRepository.Verify(x => x.GetOrAdd(1, moviePath), Times.Once); + + _localStatisticsProvider.Verify( + x => x.RefreshStatistics(FFprobePath, It.Is(i => i.Path == moviePath)), + Times.Once); + + _localMetadataProvider.Verify( + x => x.RefreshFallbackMetadata(It.Is(i => i.Path == moviePath)), + Times.Once); + } + + [Test] + public async Task NewMovie_Statistics_And_SidecarMetadata_MovieNameNfo( + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))] + string videoExtension) + { + string moviePath = Path.Combine( + FakeRoot, + Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}")); + + string metadataPath = Path.ChangeExtension(moviePath, "nfo"); + + MovieFolderScanner service = GetService( + new FakeFileEntry(moviePath), + new FakeFileEntry(metadataPath) + ); + var source = new LocalMediaSource { Id = 1, Folder = FakeRoot }; + + Either result = await service.ScanFolder(source, FFprobePath); + + result.IsRight.Should().BeTrue(); + + _movieRepository.Verify(x => x.GetOrAdd(It.IsAny(), It.IsAny()), Times.Once); + _movieRepository.Verify(x => x.GetOrAdd(1, moviePath), Times.Once); + + _localStatisticsProvider.Verify( + x => x.RefreshStatistics(FFprobePath, It.Is(i => i.Path == moviePath)), + Times.Once); + + _localMetadataProvider.Verify( + x => x.RefreshSidecarMetadata(It.Is(i => i.Path == moviePath), metadataPath), + Times.Once); + } + + [Test] + public async Task NewMovie_Statistics_And_SidecarMetadata_MovieNfo( + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))] + string videoExtension) + { + string moviePath = Path.Combine( + FakeRoot, + Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}")); + + string metadataPath = Path.Combine(Path.GetDirectoryName(moviePath) ?? string.Empty, "movie.nfo"); + + MovieFolderScanner service = GetService( + new FakeFileEntry(moviePath), + new FakeFileEntry(metadataPath) + ); + var source = new LocalMediaSource { Id = 1, Folder = FakeRoot }; + + Either result = await service.ScanFolder(source, FFprobePath); + + result.IsRight.Should().BeTrue(); + + _movieRepository.Verify(x => x.GetOrAdd(It.IsAny(), It.IsAny()), Times.Once); + _movieRepository.Verify(x => x.GetOrAdd(1, moviePath), Times.Once); + + _localStatisticsProvider.Verify( + x => x.RefreshStatistics(FFprobePath, It.Is(i => i.Path == moviePath)), + Times.Once); + + _localMetadataProvider.Verify( + x => x.RefreshSidecarMetadata(It.Is(i => i.Path == moviePath), metadataPath), + Times.Once); + } + + [Test] + public async Task NewMovie_Statistics_And_FallbackMetadata_And_Poster( + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))] + string videoExtension, + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.ImageFileExtensions))] + string imageExtension) + { + string moviePath = Path.Combine( + FakeRoot, + Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}")); + + string posterPath = Path.Combine( + Path.GetDirectoryName(moviePath) ?? string.Empty, + $"poster.{imageExtension}"); + + MovieFolderScanner service = GetService( + new FakeFileEntry(moviePath), + new FakeFileEntry(posterPath) + ); + var source = new LocalMediaSource { Id = 1, Folder = FakeRoot }; + + Either result = await service.ScanFolder(source, FFprobePath); + + result.IsRight.Should().BeTrue(); + + _movieRepository.Verify(x => x.GetOrAdd(It.IsAny(), It.IsAny()), Times.Once); + _movieRepository.Verify(x => x.GetOrAdd(1, moviePath), Times.Once); + + _localStatisticsProvider.Verify( + x => x.RefreshStatistics(FFprobePath, It.Is(i => i.Path == moviePath)), + Times.Once); + + _localMetadataProvider.Verify( + x => x.RefreshFallbackMetadata(It.Is(i => i.Path == moviePath)), + Times.Once); + + _imageCache.Verify( + x => x.ResizeAndSaveImage(FakeLocalFileSystem.TestBytes, It.IsAny(), It.IsAny()), + Times.Once); + } + + [Test] + public async Task NewMovie_Statistics_And_FallbackMetadata_And_MovieNamePoster( + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))] + string videoExtension, + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.ImageFileExtensions))] + string imageExtension) + { + string moviePath = Path.Combine( + FakeRoot, + Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}")); + + string posterPath = Path.Combine( + Path.GetDirectoryName(moviePath) ?? string.Empty, + $"Movie (2020)-poster.{imageExtension}"); + + MovieFolderScanner service = GetService( + new FakeFileEntry(moviePath), + new FakeFileEntry(posterPath) + ); + var source = new LocalMediaSource { Id = 1, Folder = FakeRoot }; + + Either result = await service.ScanFolder(source, FFprobePath); + + result.IsRight.Should().BeTrue(); + + _movieRepository.Verify(x => x.GetOrAdd(It.IsAny(), It.IsAny()), Times.Once); + _movieRepository.Verify(x => x.GetOrAdd(1, moviePath), Times.Once); + + _localStatisticsProvider.Verify( + x => x.RefreshStatistics(FFprobePath, It.Is(i => i.Path == moviePath)), + Times.Once); + + _localMetadataProvider.Verify( + x => x.RefreshFallbackMetadata(It.Is(i => i.Path == moviePath)), + Times.Once); + + _imageCache.Verify( + x => x.ResizeAndSaveImage(FakeLocalFileSystem.TestBytes, It.IsAny(), It.IsAny()), + Times.Once); + } + + [Test] + public async Task Should_Ignore_Extra_Files( + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))] + string videoExtension, + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.ExtraFiles))] + string extraFile) + { + string moviePath = Path.Combine( + FakeRoot, + Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}")); + + MovieFolderScanner service = GetService( + new FakeFileEntry(moviePath), + new FakeFileEntry( + Path.Combine( + Path.GetDirectoryName(moviePath) ?? string.Empty, + $"Movie (2020)-{extraFile}{videoExtension}")) + ); + var source = new LocalMediaSource { Id = 1, Folder = FakeRoot }; + + Either result = await service.ScanFolder(source, FFprobePath); + + result.IsRight.Should().BeTrue(); + + _movieRepository.Verify(x => x.GetOrAdd(It.IsAny(), It.IsAny()), Times.Once); + _movieRepository.Verify(x => x.GetOrAdd(1, moviePath), Times.Once); + + _localStatisticsProvider.Verify( + x => x.RefreshStatistics(FFprobePath, It.Is(i => i.Path == moviePath)), + Times.Once); + + _localMetadataProvider.Verify( + x => x.RefreshFallbackMetadata(It.Is(i => i.Path == moviePath)), + Times.Once); + } + + [Test] + public async Task Should_Ignore_Extra_Folders( + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))] + string videoExtension, + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.ExtraDirectories))] + string extraFolder) + { + string moviePath = Path.Combine( + FakeRoot, + Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}")); + + MovieFolderScanner service = GetService( + new FakeFileEntry(moviePath), + new FakeFileEntry( + Path.Combine( + Path.GetDirectoryName(moviePath) ?? string.Empty, + Path.Combine(extraFolder, $"Movie (2020){videoExtension}"))) + ); + var source = new LocalMediaSource { Id = 1, Folder = FakeRoot }; + + Either result = await service.ScanFolder(source, FFprobePath); + + result.IsRight.Should().BeTrue(); + + _movieRepository.Verify(x => x.GetOrAdd(It.IsAny(), It.IsAny()), Times.Once); + _movieRepository.Verify(x => x.GetOrAdd(1, moviePath), Times.Once); + + _localStatisticsProvider.Verify( + x => x.RefreshStatistics(FFprobePath, It.Is(i => i.Path == moviePath)), + Times.Once); + + _localMetadataProvider.Verify( + x => x.RefreshFallbackMetadata(It.Is(i => i.Path == moviePath)), + Times.Once); + } + + [Test] + public async Task Should_Work_With_Nested_Folders( + [ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))] + string videoExtension) + { + string moviePath = Path.Combine( + Path.Combine(FakeRoot, "L-P"), + Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}")); + + MovieFolderScanner service = GetService( + new FakeFileEntry(moviePath) + ); + var source = new LocalMediaSource { Id = 1, Folder = FakeRoot }; + + Either result = await service.ScanFolder(source, FFprobePath); + + result.IsRight.Should().BeTrue(); + + _movieRepository.Verify(x => x.GetOrAdd(It.IsAny(), It.IsAny()), Times.Once); + _movieRepository.Verify(x => x.GetOrAdd(1, moviePath), Times.Once); + + _localStatisticsProvider.Verify( + x => x.RefreshStatistics(FFprobePath, It.Is(i => i.Path == moviePath)), + Times.Once); + + _localMetadataProvider.Verify( + x => x.RefreshFallbackMetadata(It.Is(i => i.Path == moviePath)), + Times.Once); + } + + private MovieFolderScanner GetService(params FakeFileEntry[] files) => + // var mockImageCache = new Mock(); + // mockImageCache.Setup(i => i.ResizeAndSaveImage(It.IsAny(), It.IsAny(), It.IsAny())) + // .Returns(Right("image").AsTask()); + new( + new FakeLocalFileSystem(new List(files)), + _movieRepository.Object, + _localStatisticsProvider.Object, + _localMetadataProvider.Object, + _imageCache.Object, + new Mock>().Object + ); + } + } +} diff --git a/ErsatzTV.Core.Tests/Scheduling/ChronologicalContentTests.cs b/ErsatzTV.Core.Tests/Scheduling/ChronologicalContentTests.cs index 9a9e35b7..c0022aa6 100644 --- a/ErsatzTV.Core.Tests/Scheduling/ChronologicalContentTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/ChronologicalContentTests.cs @@ -62,12 +62,12 @@ namespace ErsatzTV.Core.Tests.Scheduling private static List Episodes(int count) => Range(1, count).Map( - i => new MediaItem + i => (MediaItem) new TelevisionEpisodeMediaItem { Id = i, - Metadata = new MediaMetadata + Metadata = new TelevisionEpisodeMetadata { - MediaType = MediaType.TvShow, Aired = new DateTime(2020, 1, i) + Aired = new DateTime(2020, 1, i) } }) .Reverse() diff --git a/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs b/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs index d67f4d55..acee730a 100644 --- a/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs @@ -40,7 +40,7 @@ namespace ErsatzTV.Core.Tests.Scheduling [Test] public async Task InitialFlood_Should_StartAtMidnight() { - var mediaItems = new List + var mediaItems = new List { TestMovie(1, TimeSpan.FromHours(6), DateTime.Today) }; @@ -59,7 +59,7 @@ namespace ErsatzTV.Core.Tests.Scheduling [Test] public async Task InitialFlood_Should_StartAtMidnight_With_LateStart() { - var mediaItems = new List + var mediaItems = new List { TestMovie(1, TimeSpan.FromHours(6), DateTime.Today) }; @@ -79,7 +79,7 @@ namespace ErsatzTV.Core.Tests.Scheduling [Test] public async Task ChronologicalContent_Should_CreateChronologicalItems() { - var mediaItems = new List + var mediaItems = new List { TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)), TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1)) @@ -105,7 +105,7 @@ namespace ErsatzTV.Core.Tests.Scheduling [Test] public async Task ChronologicalFlood_Should_AnchorAndMaintainExistingPlayout() { - var mediaItems = new List + var mediaItems = new List { TestMovie(1, TimeSpan.FromHours(6), DateTime.Today), TestMovie(2, TimeSpan.FromHours(6), DateTime.Today.AddHours(1)) @@ -142,7 +142,7 @@ namespace ErsatzTV.Core.Tests.Scheduling [Test] public async Task ChronologicalFlood_Should_AnchorAndReturnNewPlayoutItems() { - var mediaItems = new List + var mediaItems = new List { TestMovie(1, TimeSpan.FromHours(6), DateTime.Today), TestMovie(2, TimeSpan.FromHours(6), DateTime.Today.AddHours(1)) @@ -180,7 +180,7 @@ namespace ErsatzTV.Core.Tests.Scheduling [Test] public async Task ShuffleFloodRebuild_Should_IgnoreAnchors() { - var mediaItems = new List + var mediaItems = new List { TestMovie(1, TimeSpan.FromHours(1), DateTime.Today), TestMovie(2, TimeSpan.FromHours(1), DateTime.Today.AddHours(1)), @@ -223,7 +223,7 @@ namespace ErsatzTV.Core.Tests.Scheduling [Test] public async Task ShuffleFlood_Should_MaintainRandomSeed() { - var mediaItems = new List + var mediaItems = new List { TestMovie(1, TimeSpan.FromHours(1), DateTime.Today), TestMovie(2, TimeSpan.FromHours(1), DateTime.Today.AddHours(1)), @@ -259,7 +259,7 @@ namespace ErsatzTV.Core.Tests.Scheduling { Id = 1, Name = "Flood Items", - Items = new List + Movies = new List { TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)), TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1)) @@ -270,7 +270,7 @@ namespace ErsatzTV.Core.Tests.Scheduling { Id = 2, Name = "Fixed Items", - Items = new List + Movies = new List { TestMovie(3, TimeSpan.FromHours(2), new DateTime(2020, 1, 1)) } @@ -278,8 +278,8 @@ namespace ErsatzTV.Core.Tests.Scheduling var fakeRepository = new FakeMediaCollectionRepository( Map( - (floodCollection.Id, floodCollection.Items.ToList()), - (fixedCollection.Id, fixedCollection.Items.ToList()))); + (floodCollection.Id, floodCollection.Movies.ToList()), + (fixedCollection.Id, fixedCollection.Movies.ToList()))); var items = new List { @@ -309,7 +309,8 @@ namespace ErsatzTV.Core.Tests.Scheduling Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" } }; - var builder = new PlayoutBuilder(fakeRepository, _logger); + var televisionRepo = new FakeTelevisionRepository(); + var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger); DateTimeOffset start = HoursAfterMidnight(0); DateTimeOffset finish = start + TimeSpan.FromHours(6); @@ -336,7 +337,7 @@ namespace ErsatzTV.Core.Tests.Scheduling { Id = 1, Name = "Flood Items", - Items = new List + Movies = new List { TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)), TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1)) @@ -347,7 +348,7 @@ namespace ErsatzTV.Core.Tests.Scheduling { Id = 2, Name = "Fixed Items", - Items = new List + Movies = new List { TestMovie(3, TimeSpan.FromHours(2), new DateTime(2020, 1, 1)), TestMovie(4, TimeSpan.FromHours(1), new DateTime(2020, 1, 2)) @@ -356,8 +357,8 @@ namespace ErsatzTV.Core.Tests.Scheduling var fakeRepository = new FakeMediaCollectionRepository( Map( - (floodCollection.Id, floodCollection.Items.ToList()), - (fixedCollection.Id, fixedCollection.Items.ToList()))); + (floodCollection.Id, floodCollection.Movies.ToList()), + (fixedCollection.Id, fixedCollection.Movies.ToList()))); var items = new List { @@ -388,7 +389,8 @@ namespace ErsatzTV.Core.Tests.Scheduling Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" } }; - var builder = new PlayoutBuilder(fakeRepository, _logger); + var televisionRepo = new FakeTelevisionRepository(); + var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger); DateTimeOffset start = HoursAfterMidnight(0); DateTimeOffset finish = start + TimeSpan.FromHours(7); @@ -420,7 +422,7 @@ namespace ErsatzTV.Core.Tests.Scheduling { Id = 1, Name = "Flood Items", - Items = new List + Movies = new List { TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)), TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1)) @@ -431,7 +433,7 @@ namespace ErsatzTV.Core.Tests.Scheduling { Id = 2, Name = "Fixed Items", - Items = new List + Movies = new List { TestMovie(3, TimeSpan.FromHours(0.75), new DateTime(2020, 1, 1)), TestMovie(4, TimeSpan.FromHours(1.5), new DateTime(2020, 1, 2)) @@ -440,8 +442,8 @@ namespace ErsatzTV.Core.Tests.Scheduling var fakeRepository = new FakeMediaCollectionRepository( Map( - (floodCollection.Id, floodCollection.Items.ToList()), - (fixedCollection.Id, fixedCollection.Items.ToList()))); + (floodCollection.Id, floodCollection.Movies.ToList()), + (fixedCollection.Id, fixedCollection.Movies.ToList()))); var items = new List { @@ -473,7 +475,8 @@ namespace ErsatzTV.Core.Tests.Scheduling Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" } }; - var builder = new PlayoutBuilder(fakeRepository, _logger); + var televisionRepo = new FakeTelevisionRepository(); + var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger); DateTimeOffset start = HoursAfterMidnight(0); DateTimeOffset finish = start + TimeSpan.FromHours(6); @@ -508,7 +511,7 @@ namespace ErsatzTV.Core.Tests.Scheduling { Id = 1, Name = "Multiple Items", - Items = new List + Movies = new List { TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)), TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1)) @@ -519,7 +522,7 @@ namespace ErsatzTV.Core.Tests.Scheduling { Id = 2, Name = "Dynamic Items", - Items = new List + Movies = new List { TestMovie(3, TimeSpan.FromHours(0.75), new DateTime(2020, 1, 1)), TestMovie(4, TimeSpan.FromHours(1.5), new DateTime(2020, 1, 2)) @@ -528,8 +531,8 @@ namespace ErsatzTV.Core.Tests.Scheduling var fakeRepository = new FakeMediaCollectionRepository( Map( - (multipleCollection.Id, multipleCollection.Items.ToList()), - (dynamicCollection.Id, dynamicCollection.Items.ToList()))); + (multipleCollection.Id, multipleCollection.Movies.ToList()), + (dynamicCollection.Id, dynamicCollection.Movies.ToList()))); var items = new List { @@ -562,7 +565,8 @@ namespace ErsatzTV.Core.Tests.Scheduling Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" } }; - var builder = new PlayoutBuilder(fakeRepository, _logger); + var televisionRepo = new FakeTelevisionRepository(); + var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger); DateTimeOffset start = HoursAfterMidnight(0); DateTimeOffset finish = start + TimeSpan.FromHours(6); @@ -603,23 +607,25 @@ namespace ErsatzTV.Core.Tests.Scheduling StartTime = null }; - private static MediaItem TestMovie(int id, TimeSpan duration, DateTime aired) => + private static MovieMediaItem TestMovie(int id, TimeSpan duration, DateTime aired) => new() { Id = id, - Metadata = new MediaMetadata { Duration = duration, MediaType = MediaType.Movie, Aired = aired } + Metadata = new MovieMetadata { Premiered = aired }, + Statistics = new MediaItemStatistics { Duration = duration } }; - private TestData TestDataFloodForItems(List mediaItems, PlaybackOrder playbackOrder) + private TestData TestDataFloodForItems(List mediaItems, PlaybackOrder playbackOrder) { var mediaCollection = new SimpleMediaCollection { Id = 1, - Items = mediaItems + Movies = mediaItems }; var collectionRepo = new FakeMediaCollectionRepository(Map((mediaCollection.Id, mediaItems))); - var builder = new PlayoutBuilder(collectionRepo, _logger); + var televisionRepo = new FakeTelevisionRepository(); + var builder = new PlayoutBuilder(collectionRepo, televisionRepo, _logger); var items = new List { Flood(mediaCollection) }; diff --git a/ErsatzTV.Core.Tests/Scheduling/RandomizedContentTests.cs b/ErsatzTV.Core.Tests/Scheduling/RandomizedContentTests.cs index 8992c6ed..13f3d8f0 100644 --- a/ErsatzTV.Core.Tests/Scheduling/RandomizedContentTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/RandomizedContentTests.cs @@ -80,12 +80,12 @@ namespace ErsatzTV.Core.Tests.Scheduling private static List Episodes(int count) => Range(1, count).Map( - i => new MediaItem + i => (MediaItem) new TelevisionEpisodeMediaItem { Id = i, - Metadata = new MediaMetadata + Metadata = new TelevisionEpisodeMetadata { - MediaType = MediaType.TvShow, Aired = new DateTime(2020, 1, i) + Aired = new DateTime(2020, 1, i) } }) .Reverse() diff --git a/ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs b/ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs index f767f3f1..eeeaef98 100644 --- a/ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs @@ -70,12 +70,12 @@ namespace ErsatzTV.Core.Tests.Scheduling private static List Episodes(int count) => Range(1, count).Map( - i => new MediaItem + i => (MediaItem) new TelevisionEpisodeMediaItem { Id = i, - Metadata = new MediaMetadata + Metadata = new TelevisionEpisodeMetadata { - MediaType = MediaType.TvShow, Aired = new DateTime(2020, 1, i) + Aired = new DateTime(2020, 1, i) } }) .Reverse() diff --git a/ErsatzTV.Core/Domain/FFmpegProfile.cs b/ErsatzTV.Core/Domain/FFmpegProfile.cs index c12aa460..e1931513 100644 --- a/ErsatzTV.Core/Domain/FFmpegProfile.cs +++ b/ErsatzTV.Core/Domain/FFmpegProfile.cs @@ -33,9 +33,9 @@ VideoCodec = "libx264", AudioCodec = "ac3", VideoBitrate = 2000, - VideoBufferSize = 2000, + VideoBufferSize = 4000, AudioBitrate = 192, - AudioBufferSize = 50, + AudioBufferSize = 384, AudioVolume = 100, AudioChannels = 2, AudioSampleRate = 48, diff --git a/ErsatzTV.Core/Domain/LocalTelevisionShowSource.cs b/ErsatzTV.Core/Domain/LocalTelevisionShowSource.cs new file mode 100644 index 00000000..05515809 --- /dev/null +++ b/ErsatzTV.Core/Domain/LocalTelevisionShowSource.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Core.Domain +{ + public class LocalTelevisionShowSource : TelevisionShowSource + { + public int MediaSourceId { get; set; } + public LocalMediaSource MediaSource { get; set; } + public string Path { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/MediaItem.cs b/ErsatzTV.Core/Domain/MediaItem.cs index 5136d3b8..a17b305e 100644 --- a/ErsatzTV.Core/Domain/MediaItem.cs +++ b/ErsatzTV.Core/Domain/MediaItem.cs @@ -1,18 +1,17 @@ using System; -using System.Collections.Generic; +using ErsatzTV.Core.Interfaces.Domain; namespace ErsatzTV.Core.Domain { - public class MediaItem + 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; } - public MediaMetadata Metadata { get; set; } - public DateTime? LastWriteTime { get; set; } - public IList SimpleMediaCollections { get; set; } } } diff --git a/ErsatzTV.Core/Domain/MediaItemMetadata.cs b/ErsatzTV.Core/Domain/MediaItemMetadata.cs new file mode 100644 index 00000000..4d46e894 --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaItemMetadata.cs @@ -0,0 +1,12 @@ +using System; + +namespace ErsatzTV.Core.Domain +{ + public class MediaItemMetadata + { + public MetadataSource Source { get; set; } + public DateTime? LastWriteTime { get; set; } + public string Title { get; set; } + public string SortTitle { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/MediaMetadata.cs b/ErsatzTV.Core/Domain/MediaItemStatistics.cs similarity index 51% rename from ErsatzTV.Core/Domain/MediaMetadata.cs rename to ErsatzTV.Core/Domain/MediaItemStatistics.cs index 7e725d0b..56b0f59c 100644 --- a/ErsatzTV.Core/Domain/MediaMetadata.cs +++ b/ErsatzTV.Core/Domain/MediaItemStatistics.cs @@ -3,24 +3,14 @@ using ErsatzTV.Core.Interfaces.FFmpeg; namespace ErsatzTV.Core.Domain { - public record MediaMetadata : IDisplaySize + public record MediaItemStatistics : IDisplaySize { - public MetadataSource Source { get; set; } public DateTime? LastWriteTime { 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 MediaType MediaType { get; set; } - public string Title { get; set; } - public string SortTitle { get; set; } - public string Subtitle { get; set; } - public string Description { get; set; } - public int? SeasonNumber { get; set; } - public int? EpisodeNumber { get; set; } - public string ContentRating { get; set; } - public DateTime? Aired { get; set; } public VideoScanType VideoScanType { get; set; } public int Width { get; set; } public int Height { get; set; } diff --git a/ErsatzTV.Core/Domain/MovieMediaItem.cs b/ErsatzTV.Core/Domain/MovieMediaItem.cs new file mode 100644 index 00000000..9b2d44fe --- /dev/null +++ b/ErsatzTV.Core/Domain/MovieMediaItem.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace ErsatzTV.Core.Domain +{ + public class MovieMediaItem : MediaItem + { + public int MetadataId { get; set; } + public MovieMetadata Metadata { get; set; } + public List SimpleMediaCollections { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/MovieMetadata.cs b/ErsatzTV.Core/Domain/MovieMetadata.cs new file mode 100644 index 00000000..3c4009ee --- /dev/null +++ b/ErsatzTV.Core/Domain/MovieMetadata.cs @@ -0,0 +1,17 @@ +using System; + +namespace ErsatzTV.Core.Domain +{ + public class MovieMetadata : MediaItemMetadata + { + public int Id { get; set; } + public int MovieId { get; set; } + public MovieMediaItem Movie { get; set; } + public int? Year { get; set; } + public DateTime? Premiered { get; set; } + public string Plot { get; set; } + public string Outline { get; set; } + public string Tagline { get; set; } + public string ContentRating { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/PlayoutProgramScheduleAnchor.cs b/ErsatzTV.Core/Domain/PlayoutProgramScheduleAnchor.cs index 17405561..d7cea705 100644 --- a/ErsatzTV.Core/Domain/PlayoutProgramScheduleAnchor.cs +++ b/ErsatzTV.Core/Domain/PlayoutProgramScheduleAnchor.cs @@ -2,12 +2,13 @@ { public class PlayoutProgramScheduleAnchor { + public int Id { get; set; } public int PlayoutId { get; set; } public Playout Playout { get; set; } public int ProgramScheduleId { get; set; } public ProgramSchedule ProgramSchedule { get; set; } - public int MediaCollectionId { get; set; } - public MediaCollection MediaCollection { get; set; } + public ProgramScheduleItemCollectionType CollectionType { get; set; } + public int CollectionId { get; set; } public MediaCollectionEnumeratorState EnumeratorState { get; set; } } } diff --git a/ErsatzTV.Core/Domain/ProgramScheduleItem.cs b/ErsatzTV.Core/Domain/ProgramScheduleItem.cs index 25363406..ecc12d02 100644 --- a/ErsatzTV.Core/Domain/ProgramScheduleItem.cs +++ b/ErsatzTV.Core/Domain/ProgramScheduleItem.cs @@ -8,8 +8,13 @@ namespace ErsatzTV.Core.Domain public int Index { get; set; } public StartType StartType => StartTime.HasValue ? StartType.Fixed : StartType.Dynamic; public TimeSpan? StartTime { get; set; } - public int MediaCollectionId { get; set; } + public ProgramScheduleItemCollectionType CollectionType { get; set; } + public int? MediaCollectionId { get; set; } public MediaCollection MediaCollection { get; set; } + public int? TelevisionShowId { get; set; } + public TelevisionShow TelevisionShow { get; set; } + public int? TelevisionSeasonId { get; set; } + public TelevisionSeason TelevisionSeason { get; set; } public int ProgramScheduleId { get; set; } public ProgramSchedule ProgramSchedule { get; set; } } diff --git a/ErsatzTV.Core/Domain/ProgramScheduleItemCollectionType.cs b/ErsatzTV.Core/Domain/ProgramScheduleItemCollectionType.cs new file mode 100644 index 00000000..e0dcd6cf --- /dev/null +++ b/ErsatzTV.Core/Domain/ProgramScheduleItemCollectionType.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Core.Domain +{ + public enum ProgramScheduleItemCollectionType + { + Collection = 0, + TelevisionShow = 1, + TelevisionSeason = 2 + } +} diff --git a/ErsatzTV.Core/Domain/ResolutionKey.cs b/ErsatzTV.Core/Domain/ResolutionKey.cs deleted file mode 100644 index 65d3d737..00000000 --- a/ErsatzTV.Core/Domain/ResolutionKey.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace ErsatzTV.Core.Domain -{ - public enum ResolutionKey - { - None = 0, - W720H480 = 1, - W1280H720 = 2, - W1920H1080 = 3, - W3840H2160 = 4 - } -} diff --git a/ErsatzTV.Core/Domain/SimpleMediaCollection.cs b/ErsatzTV.Core/Domain/SimpleMediaCollection.cs index 6f7a1b13..f475a4ad 100644 --- a/ErsatzTV.Core/Domain/SimpleMediaCollection.cs +++ b/ErsatzTV.Core/Domain/SimpleMediaCollection.cs @@ -4,6 +4,9 @@ namespace ErsatzTV.Core.Domain { public class SimpleMediaCollection : MediaCollection { - public IList Items { get; set; } + public List Movies { get; set; } + public List TelevisionShows { get; set; } + public List TelevisionSeasons { get; set; } + public List TelevisionEpisodes { get; set; } } } diff --git a/ErsatzTV.Core/Domain/TelevisionEpisodeMediaItem.cs b/ErsatzTV.Core/Domain/TelevisionEpisodeMediaItem.cs new file mode 100644 index 00000000..bd2d66bc --- /dev/null +++ b/ErsatzTV.Core/Domain/TelevisionEpisodeMediaItem.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace ErsatzTV.Core.Domain +{ + public class TelevisionEpisodeMediaItem : MediaItem + { + public int SeasonId { get; set; } + public TelevisionSeason Season { get; set; } + public TelevisionEpisodeMetadata Metadata { get; set; } + public List SimpleMediaCollections { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/TelevisionEpisodeMetadata.cs b/ErsatzTV.Core/Domain/TelevisionEpisodeMetadata.cs new file mode 100644 index 00000000..9b210e4e --- /dev/null +++ b/ErsatzTV.Core/Domain/TelevisionEpisodeMetadata.cs @@ -0,0 +1,15 @@ +using System; + +namespace ErsatzTV.Core.Domain +{ + public class TelevisionEpisodeMetadata : MediaItemMetadata + { + public int Id { get; set; } + public int TelevisionEpisodeId { get; set; } + public TelevisionEpisodeMediaItem TelevisionEpisode { get; set; } + public int Season { get; set; } + public int Episode { get; set; } + public string Plot { get; set; } + public DateTime? Aired { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/TelevisionMediaCollection.cs b/ErsatzTV.Core/Domain/TelevisionMediaCollection.cs deleted file mode 100644 index 3d872c9b..00000000 --- a/ErsatzTV.Core/Domain/TelevisionMediaCollection.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace ErsatzTV.Core.Domain -{ - public class TelevisionMediaCollection : MediaCollection - { - public string ShowTitle { get; set; } - public int? SeasonNumber { get; set; } - } -} diff --git a/ErsatzTV.Core/Domain/TelevisionSeason.cs b/ErsatzTV.Core/Domain/TelevisionSeason.cs new file mode 100644 index 00000000..009bbf3c --- /dev/null +++ b/ErsatzTV.Core/Domain/TelevisionSeason.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using ErsatzTV.Core.Interfaces.Domain; + +namespace ErsatzTV.Core.Domain +{ + public class TelevisionSeason : IHasAPoster + { + public int Id { get; set; } + public int TelevisionShowId { get; set; } + public TelevisionShow TelevisionShow { get; set; } + public int Number { get; set; } + public List Episodes { get; set; } + public List SimpleMediaCollections { get; set; } + public string Path { get; set; } + public string Poster { get; set; } + public DateTime? PosterLastWriteTime { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/TelevisionShow.cs b/ErsatzTV.Core/Domain/TelevisionShow.cs new file mode 100644 index 00000000..e50793d2 --- /dev/null +++ b/ErsatzTV.Core/Domain/TelevisionShow.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; + +namespace ErsatzTV.Core.Domain +{ + public class TelevisionShow + { + public int Id { get; set; } + public List Sources { get; set; } + public TelevisionShowMetadata Metadata { get; set; } + public List Seasons { get; set; } + public string Poster { get; set; } + public DateTime? PosterLastWriteTime { get; set; } + public List SimpleMediaCollections { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/TelevisionShowMetadata.cs b/ErsatzTV.Core/Domain/TelevisionShowMetadata.cs new file mode 100644 index 00000000..914de83d --- /dev/null +++ b/ErsatzTV.Core/Domain/TelevisionShowMetadata.cs @@ -0,0 +1,17 @@ +using System; + +namespace ErsatzTV.Core.Domain +{ + public class TelevisionShowMetadata + { + public int Id { get; set; } + public int TelevisionShowId { get; set; } + public TelevisionShow TelevisionShow { get; set; } + public MetadataSource Source { get; set; } + public DateTime? LastWriteTime { get; set; } + public string Title { get; set; } + public string SortTitle { get; set; } + public int? Year { get; set; } + public string Plot { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/TelevisionShowSource.cs b/ErsatzTV.Core/Domain/TelevisionShowSource.cs new file mode 100644 index 00000000..43f7890a --- /dev/null +++ b/ErsatzTV.Core/Domain/TelevisionShowSource.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Core.Domain +{ + public class TelevisionShowSource + { + public int Id { get; set; } + public int TelevisionShowId { get; set; } + public TelevisionShow TelevisionShow { get; set; } + } +} diff --git a/ErsatzTV.Core/Errors/MediaSourceInaccessible.cs b/ErsatzTV.Core/Errors/MediaSourceInaccessible.cs new file mode 100644 index 00000000..7ce775a3 --- /dev/null +++ b/ErsatzTV.Core/Errors/MediaSourceInaccessible.cs @@ -0,0 +1,10 @@ +namespace ErsatzTV.Core.Errors +{ + public class MediaSourceInaccessible : BaseError + { + public MediaSourceInaccessible() + : base("Media source is not accessible or missing") + { + } + } +} diff --git a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs index f06e755d..479b906f 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs @@ -66,24 +66,24 @@ namespace ErsatzTV.Core.FFmpeg result.Deinterlace = false; break; case StreamingMode.TransportStream: - if (NeedToScale(ffmpegProfile, playoutItem.MediaItem.Metadata)) + if (NeedToScale(ffmpegProfile, playoutItem.MediaItem.Statistics)) { - IDisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, playoutItem.MediaItem.Metadata); - if (!scaledSize.IsSameSizeAs(playoutItem.MediaItem.Metadata)) + IDisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, playoutItem.MediaItem.Statistics); + if (!scaledSize.IsSameSizeAs(playoutItem.MediaItem.Statistics)) { result.ScaledSize = Some( - CalculateScaledSize(ffmpegProfile, playoutItem.MediaItem.Metadata)); + CalculateScaledSize(ffmpegProfile, playoutItem.MediaItem.Statistics)); } } - IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(playoutItem.MediaItem.Metadata); + IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(playoutItem.MediaItem.Statistics); if (!sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution)) { result.PadToDesiredResolution = true; } if (result.ScaledSize.IsSome || result.PadToDesiredResolution || - NeedToNormalizeVideoCodec(ffmpegProfile, playoutItem.MediaItem.Metadata)) + NeedToNormalizeVideoCodec(ffmpegProfile, playoutItem.MediaItem.Statistics)) { result.VideoCodec = ffmpegProfile.VideoCodec; result.VideoBitrate = ffmpegProfile.VideoBitrate; @@ -94,7 +94,7 @@ namespace ErsatzTV.Core.FFmpeg result.VideoCodec = "copy"; } - if (NeedToNormalizeAudioCodec(ffmpegProfile, playoutItem.MediaItem.Metadata)) + if (NeedToNormalizeAudioCodec(ffmpegProfile, playoutItem.MediaItem.Statistics)) { result.AudioCodec = ffmpegProfile.AudioCodec; result.AudioBitrate = ffmpegProfile.AudioBitrate; @@ -104,7 +104,7 @@ namespace ErsatzTV.Core.FFmpeg { result.AudioChannels = ffmpegProfile.AudioChannels; result.AudioSampleRate = ffmpegProfile.AudioSampleRate; - result.AudioDuration = playoutItem.MediaItem.Metadata.Duration; + result.AudioDuration = playoutItem.MediaItem.Statistics.Duration; } } else @@ -112,7 +112,7 @@ namespace ErsatzTV.Core.FFmpeg result.AudioCodec = "copy"; } - if (playoutItem.MediaItem.Metadata.VideoScanType == VideoScanType.Interlaced) + if (playoutItem.MediaItem.Statistics.VideoScanType == VideoScanType.Interlaced) { result.Deinterlace = true; } @@ -132,16 +132,16 @@ namespace ErsatzTV.Core.FFmpeg AudioCodec = ffmpegProfile.AudioCodec }; - private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaMetadata mediaMetadata) => + private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaItemStatistics statistics) => ffmpegProfile.NormalizeResolution && - IsIncorrectSize(ffmpegProfile.Resolution, mediaMetadata) || - IsTooLarge(ffmpegProfile.Resolution, mediaMetadata) || - IsOddSize(mediaMetadata); + IsIncorrectSize(ffmpegProfile.Resolution, statistics) || + IsTooLarge(ffmpegProfile.Resolution, statistics) || + IsOddSize(statistics); - private static bool IsIncorrectSize(IDisplaySize desiredResolution, MediaMetadata mediaMetadata) => - IsAnamorphic(mediaMetadata) || - mediaMetadata.Width != desiredResolution.Width || - mediaMetadata.Height != desiredResolution.Height; + private static bool IsIncorrectSize(IDisplaySize desiredResolution, MediaItemStatistics statistics) => + IsAnamorphic(statistics) || + statistics.Width != desiredResolution.Width || + statistics.Height != desiredResolution.Height; private static bool IsTooLarge(IDisplaySize desiredResolution, IDisplaySize mediaSize) => mediaSize.Height > desiredResolution.Height || @@ -150,17 +150,17 @@ namespace ErsatzTV.Core.FFmpeg private static bool IsOddSize(IDisplaySize displaySize) => displaySize.Height % 2 == 1 || displaySize.Width % 2 == 1; - private static bool NeedToNormalizeVideoCodec(FFmpegProfile ffmpegProfile, MediaMetadata mediaMetadata) => - ffmpegProfile.NormalizeVideoCodec && ffmpegProfile.VideoCodec != mediaMetadata.VideoCodec; + private static bool NeedToNormalizeVideoCodec(FFmpegProfile ffmpegProfile, MediaItemStatistics statistics) => + ffmpegProfile.NormalizeVideoCodec && ffmpegProfile.VideoCodec != statistics.VideoCodec; - private static bool NeedToNormalizeAudioCodec(FFmpegProfile ffmpegProfile, MediaMetadata mediaMetadata) => - ffmpegProfile.NormalizeAudioCodec && ffmpegProfile.AudioCodec != mediaMetadata.AudioCodec; + private static bool NeedToNormalizeAudioCodec(FFmpegProfile ffmpegProfile, MediaItemStatistics statistics) => + ffmpegProfile.NormalizeAudioCodec && ffmpegProfile.AudioCodec != statistics.AudioCodec; - private static IDisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaMetadata mediaMetadata) + private static IDisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaItemStatistics statistics) { - IDisplaySize sarSize = SARSize(mediaMetadata); - int p = mediaMetadata.Width * sarSize.Width; - int q = mediaMetadata.Height * sarSize.Height; + IDisplaySize sarSize = SARSize(statistics); + int p = statistics.Width * sarSize.Width; + int q = statistics.Height * sarSize.Height; int g = Gcd(q, p); p = p / g; q = q / g; @@ -194,29 +194,29 @@ namespace ErsatzTV.Core.FFmpeg return a | b; } - private static bool IsAnamorphic(MediaMetadata mediaMetadata) + private static bool IsAnamorphic(MediaItemStatistics statistics) { - if (mediaMetadata.SampleAspectRatio == "1:1") + if (statistics.SampleAspectRatio == "1:1") { return false; } - if (mediaMetadata.SampleAspectRatio != "0:1") + if (statistics.SampleAspectRatio != "0:1") { return true; } - if (mediaMetadata.DisplayAspectRatio == "0:1") + if (statistics.DisplayAspectRatio == "0:1") { return false; } - return mediaMetadata.DisplayAspectRatio != $"{mediaMetadata.Width}:{mediaMetadata.Height}"; + return statistics.DisplayAspectRatio != $"{statistics.Width}:{statistics.Height}"; } - private static IDisplaySize SARSize(MediaMetadata mediaMetadata) + private static IDisplaySize SARSize(MediaItemStatistics statistics) { - string[] split = mediaMetadata.SampleAspectRatio.Split(":"); + string[] split = statistics.SampleAspectRatio.Split(":"); return new DisplaySize(int.Parse(split[0]), int.Parse(split[1])); } } diff --git a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs index cfb13473..99fd9ad8 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs @@ -76,7 +76,7 @@ namespace ErsatzTV.Core.FFmpeg return builder.WithPlaybackArgs(playbackSettings) .WithMetadata(channel) .WithFormat("mpegts") - .WithDuration(item.Start + item.MediaItem.Metadata.Duration - now) + .WithDuration(item.Start + item.MediaItem.Statistics.Duration - now) .WithPipe() .Build(); } diff --git a/ErsatzTV.Core/Interfaces/Domain/IHasAPoster.cs b/ErsatzTV.Core/Interfaces/Domain/IHasAPoster.cs new file mode 100644 index 00000000..72007f6e --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Domain/IHasAPoster.cs @@ -0,0 +1,11 @@ +using System; + +namespace ErsatzTV.Core.Interfaces.Domain +{ + public interface IHasAPoster + { + string Path { get; set; } + string Poster { get; set; } + DateTime? PosterLastWriteTime { get; set; } + } +} diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IDisplaySize.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IDisplaySize.cs index 7b1562fb..36b12cc4 100644 --- a/ErsatzTV.Core/Interfaces/FFmpeg/IDisplaySize.cs +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IDisplaySize.cs @@ -2,7 +2,7 @@ { public interface IDisplaySize { - public int Width { get; } - public int Height { get; } + int Width { get; } + int Height { get; } } } diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegLocator.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegLocator.cs index 04a18a22..37262349 100644 --- a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegLocator.cs +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegLocator.cs @@ -6,6 +6,6 @@ namespace ErsatzTV.Core.Interfaces.FFmpeg { public interface IFFmpegLocator { - public Task> ValidatePath(string executableBase, ConfigElementKey key); + Task> ValidatePath(string executableBase, ConfigElementKey key); } } diff --git a/ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs b/ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs index f750db1b..e51ccf70 100644 --- a/ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs +++ b/ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs @@ -4,9 +4,9 @@ namespace ErsatzTV.Core.Interfaces.Locking { public interface IEntityLocker { - public event EventHandler OnMediaSourceChanged; - public bool LockMediaSource(int mediaSourceId); - public bool UnlockMediaSource(int mediaSourceId); - public bool IsMediaSourceLocked(int mediaSourceId); + event EventHandler OnMediaSourceChanged; + bool LockMediaSource(int mediaSourceId); + bool UnlockMediaSource(int mediaSourceId); + bool IsMediaSourceLocked(int mediaSourceId); } } diff --git a/ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs b/ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs new file mode 100644 index 00000000..4325b1fd --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs @@ -0,0 +1,9 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Interfaces.Metadata +{ + public interface IFallbackMetadataProvider + { + TelevisionShowMetadata GetFallbackMetadataForShow(string showFolder); + } +} diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs index d2d0bb57..4a698e0d 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs @@ -1,15 +1,17 @@ using System; +using System.Collections.Generic; +using System.Threading.Tasks; using ErsatzTV.Core.Domain; -using LanguageExt; namespace ErsatzTV.Core.Interfaces.Metadata { public interface ILocalFileSystem { - public DateTime GetLastWriteTime(string path); - public bool IsMediaSourceAccessible(LocalMediaSource localMediaSource); - public Seq FindRelevantVideos(LocalMediaSource localMediaSource); - public bool ShouldRefreshMetadata(LocalMediaSource localMediaSource, MediaItem mediaItem); - public bool ShouldRefreshPoster(MediaItem mediaItem); + DateTime GetLastWriteTime(string path); + bool IsMediaSourceAccessible(LocalMediaSource localMediaSource); + IEnumerable ListSubdirectories(string folder); + IEnumerable ListFiles(string folder); + bool FileExists(string path); + Task ReadAllBytes(string path); } } diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalMediaScanner.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalMediaScanner.cs deleted file mode 100644 index 4b8ed759..00000000 --- a/ErsatzTV.Core/Interfaces/Metadata/ILocalMediaScanner.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Threading.Tasks; -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Metadata; -using LanguageExt; - -namespace ErsatzTV.Core.Interfaces.Metadata -{ - public interface ILocalMediaScanner - { - Task ScanLocalMediaSource( - LocalMediaSource localMediaSource, - string ffprobePath, - ScanningMode scanningMode); - } -} diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalMediaSourcePlanner.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalMediaSourcePlanner.cs deleted file mode 100644 index 555463b5..00000000 --- a/ErsatzTV.Core/Interfaces/Metadata/ILocalMediaSourcePlanner.cs +++ /dev/null @@ -1,14 +0,0 @@ -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Metadata; -using LanguageExt; - -namespace ErsatzTV.Core.Interfaces.Metadata -{ - public interface ILocalMediaSourcePlanner - { - public Seq DetermineActions( - MediaType mediaType, - Seq mediaItems, - Seq files); - } -} diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs index 8e413e1a..317e3588 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs @@ -1,11 +1,15 @@ using System.Threading.Tasks; using ErsatzTV.Core.Domain; +using LanguageExt; namespace ErsatzTV.Core.Interfaces.Metadata { public interface ILocalMetadataProvider { - Task RefreshSidecarMetadata(MediaItem mediaItem, string path); - Task RefreshFallbackMetadata(MediaItem mediaItem); + Task GetMetadataForShow(string showFolder); + Task RefreshSidecarMetadata(MediaItem mediaItem, string path); + Task RefreshSidecarMetadata(TelevisionShow televisionShow, string showFolder); + Task RefreshFallbackMetadata(MediaItem mediaItem); + Task RefreshFallbackMetadata(TelevisionShow televisionShow, string showFolder); } } diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalPosterProvider.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalPosterProvider.cs deleted file mode 100644 index ab47e5da..00000000 --- a/ErsatzTV.Core/Interfaces/Metadata/ILocalPosterProvider.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Threading.Tasks; -using ErsatzTV.Core.Domain; - -namespace ErsatzTV.Core.Interfaces.Metadata -{ - public interface ILocalPosterProvider - { - Task RefreshPoster(MediaItem mediaItem); - } -} diff --git a/ErsatzTV.Core/Interfaces/Metadata/IMovieFolderScanner.cs b/ErsatzTV.Core/Interfaces/Metadata/IMovieFolderScanner.cs new file mode 100644 index 00000000..cf43a87d --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Metadata/IMovieFolderScanner.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Metadata +{ + public interface IMovieFolderScanner + { + Task> ScanFolder(LocalMediaSource localMediaSource, string ffprobePath); + } +} diff --git a/ErsatzTV.Core/Interfaces/Metadata/ISmartCollectionBuilder.cs b/ErsatzTV.Core/Interfaces/Metadata/ISmartCollectionBuilder.cs deleted file mode 100644 index 0983b5fd..00000000 --- a/ErsatzTV.Core/Interfaces/Metadata/ISmartCollectionBuilder.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Threading.Tasks; -using ErsatzTV.Core.Domain; - -namespace ErsatzTV.Core.Interfaces.Metadata -{ - public interface ISmartCollectionBuilder - { - Task RefreshSmartCollections(MediaItem mediaItem); - } -} diff --git a/ErsatzTV.Core/Interfaces/Metadata/ITelevisionFolderScanner.cs b/ErsatzTV.Core/Interfaces/Metadata/ITelevisionFolderScanner.cs new file mode 100644 index 00000000..b000216b --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Metadata/ITelevisionFolderScanner.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Metadata +{ + public interface ITelevisionFolderScanner + { + Task ScanFolder(LocalMediaSource localMediaSource, string ffprobePath); + } +} diff --git a/ErsatzTV.Core/Interfaces/Plex/IPlexSecretStore.cs b/ErsatzTV.Core/Interfaces/Plex/IPlexSecretStore.cs index f3a3bdf2..cf6973b8 100644 --- a/ErsatzTV.Core/Interfaces/Plex/IPlexSecretStore.cs +++ b/ErsatzTV.Core/Interfaces/Plex/IPlexSecretStore.cs @@ -7,11 +7,11 @@ namespace ErsatzTV.Core.Interfaces.Plex { public interface IPlexSecretStore { - public Task GetClientIdentifier(); - public Task> GetUserAuthTokens(); - public Task UpsertUserAuthToken(PlexUserAuthToken userAuthToken); - public Task> GetServerAuthTokens(); - public Task> GetServerAuthToken(string clientIdentifier); - public Task UpsertServerAuthToken(PlexServerAuthToken serverAuthToken); + Task GetClientIdentifier(); + Task> GetUserAuthTokens(); + Task UpsertUserAuthToken(PlexUserAuthToken userAuthToken); + Task> GetServerAuthTokens(); + Task> GetServerAuthToken(string clientIdentifier); + Task UpsertServerAuthToken(PlexServerAuthToken serverAuthToken); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs index 14b3954c..fda5b1cd 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs @@ -7,12 +7,12 @@ namespace ErsatzTV.Core.Interfaces.Repositories { public interface IChannelRepository { - public Task Add(Channel channel); - public Task> Get(int id); - public Task> GetByNumber(int number); - public Task> GetAll(); - public Task> GetAllForGuide(); - public Task Update(Channel channel); - public Task Delete(int channelId); + Task Add(Channel channel); + Task> Get(int id); + Task> GetByNumber(int number); + Task> GetAll(); + Task> GetAllForGuide(); + Task Update(Channel channel); + Task Delete(int channelId); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IConfigElementRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IConfigElementRepository.cs index 087e62cc..bd83fc4e 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IConfigElementRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IConfigElementRepository.cs @@ -6,10 +6,10 @@ namespace ErsatzTV.Core.Interfaces.Repositories { public interface IConfigElementRepository { - public Task Add(ConfigElement configElement); - public Task> Get(ConfigElementKey key); - public Task> GetValue(ConfigElementKey key); - public Task Update(ConfigElement configElement); - public Task Delete(ConfigElement configElement); + Task Add(ConfigElement configElement); + Task> Get(ConfigElementKey key); + Task> GetValue(ConfigElementKey key); + Task Update(ConfigElement configElement); + Task Delete(ConfigElement configElement); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IFFmpegProfileRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IFFmpegProfileRepository.cs index ac4962ee..c08ef846 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IFFmpegProfileRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IFFmpegProfileRepository.cs @@ -7,10 +7,10 @@ namespace ErsatzTV.Core.Interfaces.Repositories { public interface IFFmpegProfileRepository { - public Task Add(FFmpegProfile ffmpegProfile); - public Task> Get(int id); - public Task> GetAll(); - public Task Update(FFmpegProfile ffmpegProfile); - public Task Delete(int ffmpegProfileId); + Task Add(FFmpegProfile ffmpegProfile); + Task> Get(int id); + Task> GetAll(); + Task Update(FFmpegProfile ffmpegProfile); + Task Delete(int ffmpegProfileId); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/ILogRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/ILogRepository.cs index 096d24be..a285f45f 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/ILogRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/ILogRepository.cs @@ -6,6 +6,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories { public interface ILogRepository { - public Task> GetRecentLogEntries(); + Task> GetRecentLogEntries(); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs index 67e2ccc9..4dd81dfb 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.Threading.Tasks; -using ErsatzTV.Core.AggregateModels; using ErsatzTV.Core.Domain; using LanguageExt; @@ -8,21 +7,16 @@ namespace ErsatzTV.Core.Interfaces.Repositories { public interface IMediaCollectionRepository { - public Task Add(SimpleMediaCollection collection); - public Task> Get(int id); - public Task> GetSimpleMediaCollection(int id); - public Task> GetSimpleMediaCollectionWithItems(int id); - public Task> GetTelevisionMediaCollection(int id); - public Task> GetSimpleMediaCollections(); - public Task> GetAll(); - public Task> GetSummaries(string searchString); - public Task>> GetItems(int id); - public Task>> GetSimpleMediaCollectionItems(int id); - public Task>> GetTelevisionMediaCollectionItems(int id); - public Task Update(SimpleMediaCollection collection); - public Task InsertOrIgnore(TelevisionMediaCollection collection); - public Task ReplaceItems(int collectionId, List mediaItems); - public Task Delete(int mediaCollectionId); - public Task DeleteEmptyTelevisionCollections(); + Task Add(SimpleMediaCollection collection); + Task> Get(int id); + Task> GetSimpleMediaCollection(int id); + Task> GetSimpleMediaCollectionWithItems(int id); + Task> GetSimpleMediaCollectionWithItemsUntracked(int id); + Task> GetSimpleMediaCollections(); + Task> GetAll(); + Task>> GetItems(int id); + Task>> GetSimpleMediaCollectionItems(int id); + Task Update(SimpleMediaCollection collection); + Task Delete(int mediaCollectionId); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs index 513ef76e..44345271 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.Threading.Tasks; -using ErsatzTV.Core.AggregateModels; using ErsatzTV.Core.Domain; using LanguageExt; @@ -8,14 +7,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories { public interface IMediaItemRepository { - public Task Add(MediaItem mediaItem); - public Task> Get(int id); - public Task> GetAll(); - public Task> Search(string searchString); - public Task> GetPageByType(MediaType mediaType, int pageNumber, int pageSize); - public Task GetCountByType(MediaType mediaType); - public Task> GetAllByMediaSourceId(int mediaSourceId); - public Task Update(MediaItem mediaItem); - public Task Delete(int mediaItemId); + Task> Get(int id); + Task> GetAll(); + Task> Search(string searchString); + Task Update(MediaItem mediaItem); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs index e5246b90..4469f45d 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs @@ -7,14 +7,14 @@ namespace ErsatzTV.Core.Interfaces.Repositories { public interface IMediaSourceRepository { - public Task Add(LocalMediaSource localMediaSource); - public Task Add(PlexMediaSource plexMediaSource); - public Task> GetAll(); - public Task> GetAllPlex(); - public Task> Get(int id); - public Task> GetPlex(int id); - public Task CountMediaItems(int id); - public Task Update(PlexMediaSource plexMediaSource); - public Task Delete(int id); + Task Add(LocalMediaSource localMediaSource); + Task Add(PlexMediaSource plexMediaSource); + Task> GetAll(); + Task> GetAllPlex(); + Task> Get(int id); + Task> GetPlex(int id); + Task CountMediaItems(int id); + Task Update(PlexMediaSource plexMediaSource); + Task Delete(int id); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs new file mode 100644 index 00000000..08371123 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Repositories +{ + public interface IMovieRepository + { + Task> GetMovie(int movieId); + Task> GetOrAdd(int mediaSourceId, string path); + Task Update(MovieMediaItem movie); + Task GetMovieCount(); + Task> GetPagedMovies(int pageNumber, int pageSize); + } +} diff --git a/ErsatzTV.Core/Interfaces/Repositories/IPlayoutRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IPlayoutRepository.cs index 357fb208..354b1733 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IPlayoutRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IPlayoutRepository.cs @@ -8,14 +8,14 @@ namespace ErsatzTV.Core.Interfaces.Repositories { public interface IPlayoutRepository { - public Task Add(Playout playout); - public Task> Get(int id); - public Task> GetFull(int id); - public Task> GetPlayoutItem(int channelId, DateTimeOffset now); - public Task> GetPlayoutItems(int playoutId); - public Task> GetPlayoutIdsForMediaItems(Seq mediaItems); - public Task> GetAll(); - public Task Update(Playout playout); - public Task Delete(int playoutId); + Task Add(Playout playout); + Task> Get(int id); + Task> GetFull(int id); + Task> GetPlayoutItem(int channelId, DateTimeOffset now); + Task> GetPlayoutItems(int playoutId); + Task> GetPlayoutIdsForMediaItems(Seq mediaItems); + Task> GetAll(); + Task Update(Playout playout); + Task Delete(int playoutId); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IProgramScheduleRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IProgramScheduleRepository.cs index 098538fc..bb2bf429 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IProgramScheduleRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IProgramScheduleRepository.cs @@ -7,12 +7,12 @@ namespace ErsatzTV.Core.Interfaces.Repositories { public interface IProgramScheduleRepository { - public Task Add(ProgramSchedule programSchedule); - public Task> Get(int id); - public Task> GetWithPlayouts(int id); - public Task> GetAll(); - public Task Update(ProgramSchedule programSchedule); - public Task Delete(int programScheduleId); - public Task>> GetItems(int programScheduleId); + Task Add(ProgramSchedule programSchedule); + Task> Get(int id); + Task> GetWithPlayouts(int id); + Task> GetAll(); + Task Update(ProgramSchedule programSchedule); + Task Delete(int programScheduleId); + Task>> GetItems(int programScheduleId); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IResolutionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IResolutionRepository.cs index c9f2fd6c..fc0abc19 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IResolutionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IResolutionRepository.cs @@ -7,7 +7,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories { public interface IResolutionRepository { - public Task> Get(int id); - public Task> GetAll(); + Task> Get(int id); + Task> GetAll(); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs new file mode 100644 index 00000000..88d7ebca --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Repositories +{ + public interface ITelevisionRepository + { + Task Update(TelevisionShow show); + Task Update(TelevisionSeason season); + Task Update(TelevisionEpisodeMediaItem episode); + Task> GetAllShows(); + Task> GetShow(int televisionShowId); + Task GetShowCount(); + Task> GetPagedShows(int pageNumber, int pageSize); + Task> GetShowItems(int televisionShowId); + Task> GetAllSeasons(); + Task> GetSeason(int televisionSeasonId); + Task GetSeasonCount(int televisionShowId); + Task> GetPagedSeasons(int televisionShowId, int pageNumber, int pageSize); + Task> GetSeasonItems(int televisionSeasonId); + Task> GetEpisode(int televisionEpisodeId); + Task GetEpisodeCount(int televisionSeasonId); + Task> GetPagedEpisodes(int televisionSeasonId, int pageNumber, int pageSize); + Task> GetShowByPath(int mediaSourceId, string path); + Task> GetShowByMetadata(TelevisionShowMetadata metadata); + + Task> AddShow( + int localMediaSourceId, + string showFolder, + TelevisionShowMetadata metadata); + + Task> GetOrAddSeason( + TelevisionShow show, + string path, + int seasonNumber); + + Task> GetOrAddEpisode( + TelevisionSeason season, + int mediaSourceId, + string path); + + Task DeleteMissingSources(int localMediaSourceId, List allFolders); + Task DeleteEmptyShows(); + } +} diff --git a/ErsatzTV.Core/Interfaces/Scheduling/IMediaCollectionEnumerator.cs b/ErsatzTV.Core/Interfaces/Scheduling/IMediaCollectionEnumerator.cs index 684badcf..7d36941a 100644 --- a/ErsatzTV.Core/Interfaces/Scheduling/IMediaCollectionEnumerator.cs +++ b/ErsatzTV.Core/Interfaces/Scheduling/IMediaCollectionEnumerator.cs @@ -6,7 +6,7 @@ namespace ErsatzTV.Core.Interfaces.Scheduling public interface IMediaCollectionEnumerator { MediaCollectionEnumeratorState State { get; } - public Option Current { get; } - public void MoveNext(); + Option Current { get; } + void MoveNext(); } } diff --git a/ErsatzTV.Core/Interfaces/Scheduling/IPlayoutBuilder.cs b/ErsatzTV.Core/Interfaces/Scheduling/IPlayoutBuilder.cs index 6492dc83..6e8224bb 100644 --- a/ErsatzTV.Core/Interfaces/Scheduling/IPlayoutBuilder.cs +++ b/ErsatzTV.Core/Interfaces/Scheduling/IPlayoutBuilder.cs @@ -6,9 +6,9 @@ namespace ErsatzTV.Core.Interfaces.Scheduling { public interface IPlayoutBuilder { - public Task BuildPlayoutItems(Playout playout, bool rebuild = false); + Task BuildPlayoutItems(Playout playout, bool rebuild = false); - public Task BuildPlayoutItems( + Task BuildPlayoutItems( Playout playout, DateTimeOffset playoutStart, DateTimeOffset playoutFinish, diff --git a/ErsatzTV.Core/Iptv/ChannelGuide.cs b/ErsatzTV.Core/Iptv/ChannelGuide.cs index f4f71b88..55d37171 100644 --- a/ErsatzTV.Core/Iptv/ChannelGuide.cs +++ b/ErsatzTV.Core/Iptv/ChannelGuide.cs @@ -57,11 +57,26 @@ namespace ErsatzTV.Core.Iptv { string start = playoutItem.Start.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty); string stop = playoutItem.Finish.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty); - MediaMetadata metadata = Optional(playoutItem.MediaItem.Metadata).IfNone( - new MediaMetadata - { - Title = Path.GetFileName(playoutItem.MediaItem.Path) - }); + + string title = playoutItem.MediaItem switch + { + MovieMediaItem m => m.Metadata?.Title ?? m.Path, + TelevisionEpisodeMediaItem e => e.Metadata?.Title ?? e.Path, + _ => "[unknown]" + }; + + string description = playoutItem.MediaItem switch + { + MovieMediaItem m => m.Metadata?.Plot, + TelevisionEpisodeMediaItem e => e.Metadata?.Plot, + _ => string.Empty + }; + + string contentRating = playoutItem.MediaItem switch + { + MovieMediaItem m => m.Metadata?.ContentRating, + _ => string.Empty + }; xml.WriteStartElement("programme"); xml.WriteAttributeString("start", start); @@ -70,7 +85,7 @@ namespace ErsatzTV.Core.Iptv xml.WriteStartElement("title"); xml.WriteAttributeString("lang", "en"); - xml.WriteString(metadata.Title); + xml.WriteString(title); xml.WriteEndElement(); // title xml.WriteStartElement("previously-shown"); @@ -80,28 +95,35 @@ namespace ErsatzTV.Core.Iptv xml.WriteAttributeString("lang", "en"); xml.WriteEndElement(); // sub-title - int season = Optional(metadata.SeasonNumber).IfNone(0); - int episode = Optional(metadata.EpisodeNumber).IfNone(0); - if (season > 0 && episode > 0) + if (playoutItem.MediaItem is TelevisionEpisodeMediaItem episode) { - xml.WriteStartElement("episode-num"); - xml.WriteAttributeString("system", "xmltv_ns"); - xml.WriteString($"{season - 1}.{episode - 1}.0/1"); - xml.WriteEndElement(); // episode-num + int s = Optional(episode.Metadata?.Season).IfNone(0); + int e = Optional(episode.Metadata?.Episode).IfNone(0); + if (s > 0 && e > 0) + { + xml.WriteStartElement("episode-num"); + xml.WriteAttributeString("system", "xmltv_ns"); + xml.WriteString($"{s - 1}.{e - 1}.0/1"); + xml.WriteEndElement(); // episode-num + } } // sb.AppendLine(""); - xml.WriteStartElement("desc"); - xml.WriteAttributeString("lang", "en"); - xml.WriteString(metadata.Description); - xml.WriteEndElement(); // desc - if (!string.IsNullOrWhiteSpace(metadata.ContentRating)) + if (!string.IsNullOrWhiteSpace(description)) + { + xml.WriteStartElement("desc"); + xml.WriteAttributeString("lang", "en"); + xml.WriteString(description); + xml.WriteEndElement(); // desc + } + + if (!string.IsNullOrWhiteSpace(contentRating)) { xml.WriteStartElement("rating"); xml.WriteAttributeString("system", "MPAA"); xml.WriteStartElement("value"); - xml.WriteString(metadata.ContentRating); + xml.WriteString(contentRating); xml.WriteEndElement(); // value xml.WriteEndElement(); // rating } diff --git a/ErsatzTV.Core/LanguageExtensions.cs b/ErsatzTV.Core/LanguageExtensions.cs index 537c1756..b181bc05 100644 --- a/ErsatzTV.Core/LanguageExtensions.cs +++ b/ErsatzTV.Core/LanguageExtensions.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using LanguageExt; +using Microsoft.Extensions.Logging; namespace ErsatzTV.Core { @@ -12,5 +13,24 @@ namespace ErsatzTV.Core validation.ToEither() .MapLeft(errors => errors.Join()) .MapAsync, TR>(e => e); + + public static Task LogFailure( + this TryAsync tryAsync, + T defaultValue, + ILogger logger, + string message, + params object[] args) => + tryAsync.IfFail( + ex => + { + logger.LogError(ex, message, args); + return defaultValue; + }); + + public static Task LogFailure( + this TryAsync tryAsync, + ILogger logger, + string message, + params object[] args) => LogFailure(tryAsync, Unit.Default, logger, message, args); } } diff --git a/ErsatzTV.Core/Metadata/ActionPlan.cs b/ErsatzTV.Core/Metadata/ActionPlan.cs deleted file mode 100644 index b10ab95f..00000000 --- a/ErsatzTV.Core/Metadata/ActionPlan.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace ErsatzTV.Core.Metadata -{ - public record ActionPlan(string TargetPath, ScanningAction TargetAction); -} diff --git a/ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs b/ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs index 5320ad3b..726708c4 100644 --- a/ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs +++ b/ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs @@ -2,35 +2,58 @@ using System.IO; using System.Text.RegularExpressions; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Metadata; namespace ErsatzTV.Core.Metadata { - public static class FallbackMetadataProvider + public class FallbackMetadataProvider : IFallbackMetadataProvider { - public static MediaMetadata GetFallbackMetadata(MediaItem mediaItem) + public TelevisionShowMetadata GetFallbackMetadataForShow(string showFolder) + { + string fileName = Path.GetFileName(showFolder); + var metadata = new TelevisionShowMetadata + { Source = MetadataSource.Fallback, Title = fileName ?? showFolder }; + return GetTelevisionShowMetadata(fileName, metadata); + } + + public static TelevisionEpisodeMetadata GetFallbackMetadata(TelevisionEpisodeMediaItem mediaItem) { string fileName = Path.GetFileName(mediaItem.Path); - var metadata = new MediaMetadata { Source = MetadataSource.Fallback, Title = fileName ?? mediaItem.Path }; + var metadata = new TelevisionEpisodeMetadata + { Source = MetadataSource.Fallback, Title = fileName ?? mediaItem.Path }; if (fileName != null) { - if (!(mediaItem.Source is LocalMediaSource localMediaSource)) + if (!(mediaItem.Source is LocalMediaSource)) { return metadata; } - return localMediaSource.MediaType switch + return GetEpisodeMetadata(fileName, metadata); + } + + return metadata; + } + + public static MovieMetadata GetFallbackMetadata(MovieMediaItem mediaItem) + { + string fileName = Path.GetFileName(mediaItem.Path); + var metadata = new MovieMetadata { Source = MetadataSource.Fallback, Title = fileName ?? mediaItem.Path }; + + if (fileName != null) + { + if (!(mediaItem.Source is LocalMediaSource)) { - MediaType.TvShow => GetTvShowMetadata(fileName, metadata), - MediaType.Movie => GetMovieMetadata(fileName, metadata), - _ => metadata - }; + return metadata; + } + + return GetMovieMetadata(fileName, metadata); } return metadata; } - private static MediaMetadata GetTvShowMetadata(string fileName, MediaMetadata metadata) + private static TelevisionEpisodeMetadata GetEpisodeMetadata(string fileName, TelevisionEpisodeMetadata metadata) { try { @@ -38,10 +61,9 @@ namespace ErsatzTV.Core.Metadata Match match = Regex.Match(fileName, PATTERN); if (match.Success) { - metadata.MediaType = MediaType.TvShow; metadata.Title = match.Groups[1].Value; - metadata.SeasonNumber = int.Parse(match.Groups[2].Value); - metadata.EpisodeNumber = int.Parse(match.Groups[3].Value); + metadata.Season = int.Parse(match.Groups[2].Value); + metadata.Episode = int.Parse(match.Groups[3].Value); } } catch (Exception) @@ -52,7 +74,7 @@ namespace ErsatzTV.Core.Metadata return metadata; } - private static MediaMetadata GetMovieMetadata(string fileName, MediaMetadata metadata) + private static MovieMetadata GetMovieMetadata(string fileName, MovieMetadata metadata) { try { @@ -60,9 +82,30 @@ namespace ErsatzTV.Core.Metadata Match match = Regex.Match(fileName, PATTERN); if (match.Success) { - metadata.MediaType = MediaType.Movie; metadata.Title = match.Groups[1].Value; - metadata.Aired = new DateTime(int.Parse(match.Groups[2].Value), 1, 1); + metadata.Year = int.Parse(match.Groups[2].Value); + } + } + catch (Exception) + { + // ignored + } + + return metadata; + } + + private static TelevisionShowMetadata GetTelevisionShowMetadata( + string fileName, + TelevisionShowMetadata metadata) + { + try + { + const string PATTERN = @"^(.*?)[\s.]+?[.\(](\d{4})[.\)].*$"; + Match match = Regex.Match(fileName, PATTERN); + if (match.Success) + { + metadata.Title = match.Groups[1].Value; + metadata.Year = int.Parse(match.Groups[2].Value); } } catch (Exception) diff --git a/ErsatzTV.Core/Metadata/LocalFileSystem.cs b/ErsatzTV.Core/Metadata/LocalFileSystem.cs index bb353a3f..3042e8d1 100644 --- a/ErsatzTV.Core/Metadata/LocalFileSystem.cs +++ b/ErsatzTV.Core/Metadata/LocalFileSystem.cs @@ -1,9 +1,9 @@ using System; +using System.Collections.Generic; using System.IO; -using System.Linq; +using System.Threading.Tasks; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Metadata; -using LanguageExt; using static LanguageExt.Prelude; namespace ErsatzTV.Core.Metadata @@ -16,52 +16,13 @@ namespace ErsatzTV.Core.Metadata public bool IsMediaSourceAccessible(LocalMediaSource localMediaSource) => Directory.Exists(localMediaSource.Folder); - public Seq FindRelevantVideos(LocalMediaSource localMediaSource) - { - Seq allDirectories = Directory - .GetDirectories(localMediaSource.Folder, "*", SearchOption.AllDirectories) - .ToSeq() - .Add(localMediaSource.Folder); + public IEnumerable ListSubdirectories(string folder) => + Try(Directory.EnumerateDirectories(folder)).IfFail(new List()); - // remove any directories with an .etvignore file locally, or in any parent directory - Seq excluded = allDirectories.Filter(ShouldExcludeDirectory); - Seq relevantDirectories = allDirectories - .Filter(d => !excluded.Any(d.StartsWith)) - .Filter(d => localMediaSource.MediaType == MediaType.Other || !IsExtrasFolder(d)); + public IEnumerable ListFiles(string folder) => + Try(Directory.EnumerateFiles(folder, "*", SearchOption.TopDirectoryOnly)).IfFail(new List()); - return relevantDirectories - .Collect(d => Directory.GetFiles(d, "*", SearchOption.TopDirectoryOnly)) - .Filter(file => KnownExtensions.Contains(Path.GetExtension(file))) - .OrderBy(identity) - .ToSeq(); - } - - public bool ShouldRefreshMetadata(LocalMediaSource localMediaSource, MediaItem mediaItem) - { - DateTime lastWrite = File.GetLastWriteTimeUtc(mediaItem.Path); - bool modified = lastWrite > mediaItem.LastWriteTime.IfNone(DateTime.MinValue); - return modified // media item has been modified - || mediaItem.Metadata == null // media item has no metadata - || mediaItem.Metadata.MediaType != localMediaSource.MediaType; // media item is typed incorrectly - } - - public bool ShouldRefreshPoster(MediaItem mediaItem) => - string.IsNullOrWhiteSpace(mediaItem.Poster); - - private static bool ShouldExcludeDirectory(string path) => File.Exists(Path.Combine(path, ".etvignore")); - - // see https://support.emby.media/support/solutions/articles/44001159102-movie-naming - private static bool IsExtrasFolder(string path) => - ExtraFolderNames.Contains(Path.GetFileName(path)?.ToLowerInvariant()); - - // @formatter:off - private static readonly Seq KnownExtensions = Seq( - ".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4", - ".m4p", ".m4v", ".avi", ".wmv", ".mov", ".mkv", ".ts"); - - private static readonly Seq ExtraFolderNames = Seq( - "extras", "specials", "shorts", "scenes", "featurettes", - "behind the scenes", "deleted scenes", "interviews", "trailers"); - // @formatter:on + public bool FileExists(string path) => File.Exists(path); + public Task ReadAllBytes(string path) => File.ReadAllBytesAsync(path); } } diff --git a/ErsatzTV.Core/Metadata/LocalFolderScanner.cs b/ErsatzTV.Core/Metadata/LocalFolderScanner.cs new file mode 100644 index 00000000..323aafb0 --- /dev/null +++ b/ErsatzTV.Core/Metadata/LocalFolderScanner.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Domain; +using ErsatzTV.Core.Interfaces.Images; +using ErsatzTV.Core.Interfaces.Metadata; +using LanguageExt; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Core.Metadata +{ + public abstract class LocalFolderScanner + { + public static readonly List VideoFileExtensions = new() + { + ".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4", + ".m4p", ".m4v", ".avi", ".wmv", ".mov", ".mkv", ".ts" + }; + + public static readonly List ImageFileExtensions = new() + { + "jpg", "jpeg", "png", "gif", "tbn" + }; + + public static readonly List ExtraFiles = new() + { + "behindthescenes", "deleted", "featurette", + "interview", "scene", "short", "trailer", "other" + }; + + public static readonly List ExtraDirectories = new List + { + "behind the scenes", "deleted scenes", "featurettes", + "interviews", "scenes", "shorts", "trailers", "other", + "extras", "specials" + } + .Map(s => $"{Path.DirectorySeparatorChar}{s}{Path.DirectorySeparatorChar}") + .ToList(); + + private readonly IImageCache _imageCache; + + private readonly ILocalFileSystem _localFileSystem; + private readonly ILocalStatisticsProvider _localStatisticsProvider; + private readonly ILogger _logger; + + protected LocalFolderScanner( + ILocalFileSystem localFileSystem, + ILocalStatisticsProvider localStatisticsProvider, + IImageCache imageCache, + ILogger logger) + { + _localFileSystem = localFileSystem; + _localStatisticsProvider = localStatisticsProvider; + _imageCache = imageCache; + _logger = logger; + } + + protected async Task> UpdateStatistics(T mediaItem, string ffprobePath) + where T : MediaItem + { + try + { + if (mediaItem.Statistics is null || + (mediaItem.Statistics.LastWriteTime ?? DateTime.MinValue) < + _localFileSystem.GetLastWriteTime(mediaItem.Path)) + { + _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", mediaItem.Path); + await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem); + } + + return mediaItem; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + + protected async Task SavePosterToDisk( + T show, + string posterPath, + Func> update, + int height = 220) where T : IHasAPoster + { + byte[] originalBytes = await _localFileSystem.ReadAllBytes(posterPath); + Either maybeHash = await _imageCache.ResizeAndSaveImage(originalBytes, height, null); + await maybeHash.Match( + hash => + { + show.Poster = hash; + show.PosterLastWriteTime = _localFileSystem.GetLastWriteTime(posterPath); + return update(show); + }, + error => + { + _logger.LogWarning("Unable to save poster to disk from {Path}: {Error}", posterPath, error.Value); + return Task.CompletedTask; + }); + } + + protected Task> SavePosterToDisk(string posterPath, int height = 220) => + _localFileSystem.ReadAllBytes(posterPath) + .Bind(bytes => _imageCache.ResizeAndSaveImage(bytes, height, null)); + } +} diff --git a/ErsatzTV.Core/Metadata/LocalMediaScanner.cs b/ErsatzTV.Core/Metadata/LocalMediaScanner.cs deleted file mode 100644 index b07a042f..00000000 --- a/ErsatzTV.Core/Metadata/LocalMediaScanner.cs +++ /dev/null @@ -1,289 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Interfaces.Images; -using ErsatzTV.Core.Interfaces.Metadata; -using ErsatzTV.Core.Interfaces.Repositories; -using ErsatzTV.Core.Interfaces.Scheduling; -using LanguageExt; -using Microsoft.Extensions.Logging; -using static LanguageExt.Prelude; -using Seq = LanguageExt.Seq; - -namespace ErsatzTV.Core.Metadata -{ - public class LocalMediaScanner : ILocalMediaScanner - { - private readonly IImageCache _imageCache; - private readonly ILocalFileSystem _localFileSystem; - private readonly ILocalMediaSourcePlanner _localMediaSourcePlanner; - private readonly ILocalMetadataProvider _localMetadataProvider; - private readonly ILocalStatisticsProvider _localStatisticsProvider; - private readonly ILogger _logger; - private readonly IMediaItemRepository _mediaItemRepository; - private readonly IPlayoutBuilder _playoutBuilder; - private readonly IPlayoutRepository _playoutRepository; - private readonly ISmartCollectionBuilder _smartCollectionBuilder; - - public LocalMediaScanner( - IMediaItemRepository mediaItemRepository, - IPlayoutRepository playoutRepository, - ILocalStatisticsProvider localStatisticsProvider, - ILocalMetadataProvider localMetadataProvider, - ISmartCollectionBuilder smartCollectionBuilder, - IPlayoutBuilder playoutBuilder, - ILocalMediaSourcePlanner localMediaSourcePlanner, - ILocalFileSystem localFileSystem, - IImageCache imageCache, - ILogger logger) - { - _mediaItemRepository = mediaItemRepository; - _playoutRepository = playoutRepository; - _localStatisticsProvider = localStatisticsProvider; - _localMetadataProvider = localMetadataProvider; - _smartCollectionBuilder = smartCollectionBuilder; - _playoutBuilder = playoutBuilder; - _localMediaSourcePlanner = localMediaSourcePlanner; - _localFileSystem = localFileSystem; - _imageCache = imageCache; - _logger = logger; - } - - public async Task ScanLocalMediaSource( - LocalMediaSource localMediaSource, - string ffprobePath, - ScanningMode scanningMode) - { - if (!_localFileSystem.IsMediaSourceAccessible(localMediaSource)) - { - _logger.LogWarning( - "Media source folder {Folder} does not exist or is inaccessible; skipping scan", - localMediaSource.Folder); - return unit; - } - - List knownMediaItems = await _mediaItemRepository.GetAllByMediaSourceId(localMediaSource.Id); - var modifiedPlayoutIds = new List(); - - Seq actions = _localMediaSourcePlanner.DetermineActions( - localMediaSource.MediaType, - knownMediaItems.ToSeq(), - FindAllFiles(localMediaSource)); - - foreach (LocalMediaSourcePlan action in actions) - { - Option maybeAddPlan = - action.ActionPlans.SingleOrDefault(plan => plan.TargetAction == ScanningAction.Add); - await maybeAddPlan.IfSomeAsync( - async plan => - { - Option maybeMediaItem = await AddMediaItem(localMediaSource, plan.TargetPath); - - // any actions other than "add" need to operate on a media item - maybeMediaItem.IfSome(mediaItem => action.Source = mediaItem); - }); - - foreach (ActionPlan plan in action.ActionPlans.OrderBy(plan => (int) plan.TargetAction)) - { - string sourcePath = action.Source.Match( - mediaItem => mediaItem.Path, - path => path); - - _logger.LogDebug( - "{Source}: {Action} with {File}", - Path.GetFileName(sourcePath), - plan.TargetAction, - Path.GetRelativePath(Path.GetDirectoryName(sourcePath) ?? string.Empty, plan.TargetPath)); - - await action.Source.Match( - async mediaItem => - { - var changed = false; - - switch (plan.TargetAction) - { - case ScanningAction.Remove: - await RemoveMissingItem(mediaItem); - break; - case ScanningAction.Poster: - await SavePosterForItem(mediaItem, plan.TargetPath); - break; - case ScanningAction.FallbackMetadata: - await RefreshFallbackMetadataForItem(mediaItem); - break; - case ScanningAction.SidecarMetadata: - await RefreshSidecarMetadataForItem(mediaItem, plan.TargetPath); - break; - case ScanningAction.Statistics: - changed = await RefreshStatisticsForItem(mediaItem, ffprobePath); - break; - case ScanningAction.Collections: - changed = await RefreshCollectionsForItem(mediaItem); - break; - } - - if (changed) - { - List ids = - await _playoutRepository.GetPlayoutIdsForMediaItems(Seq.create(mediaItem)); - modifiedPlayoutIds.AddRange(ids); - } - }, - path => - { - _logger.LogError("This is a bug, something went wrong processing {Path}", path); - return Task.CompletedTask; - }); - } - } - - foreach (int playoutId in modifiedPlayoutIds.Distinct()) - { - Option maybePlayout = await _playoutRepository.GetFull(playoutId); - await maybePlayout.Match( - async playout => - { - Playout result = await _playoutBuilder.BuildPlayoutItems(playout, true); - await _playoutRepository.Update(result); - }, - Task.CompletedTask); - } - - return unit; - } - - private Seq FindAllFiles(LocalMediaSource localMediaSource) - { - Seq allDirectories = Directory - .GetDirectories(localMediaSource.Folder, "*", SearchOption.AllDirectories) - .ToSeq() - .Add(localMediaSource.Folder); - - // remove any directories with an .etvignore file locally, or in any parent directory - Seq excluded = allDirectories.Filter(path => File.Exists(Path.Combine(path, ".etvignore"))); - Seq relevantDirectories = allDirectories - .Filter(d => !excluded.Any(d.StartsWith)); - // .Filter(d => localMediaSource.MediaType == MediaType.Other || !IsExtrasFolder(d)); - - return relevantDirectories - .Collect(d => Directory.GetFiles(d, "*", SearchOption.TopDirectoryOnly)) - .OrderBy(identity) - .ToSeq(); - } - - private async Task> AddMediaItem(MediaSource mediaSource, string path) - { - try - { - var mediaItem = new MediaItem - { - MediaSourceId = mediaSource.Id, - Path = path, - LastWriteTime = File.GetLastWriteTimeUtc(path) - }; - - await _mediaItemRepository.Add(mediaItem); - - return mediaItem; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to add media item for {Path}", path); - return None; - } - } - - private async Task RemoveMissingItem(MediaItem mediaItem) - { - try - { - await _mediaItemRepository.Delete(mediaItem.Id); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to remove missing local media item {MediaItem}", mediaItem.Path); - } - } - - private async Task SavePosterForItem(MediaItem mediaItem, string posterPath) - { - try - { - byte[] originalBytes = await File.ReadAllBytesAsync(posterPath); - Either maybeHash = await _imageCache.ResizeAndSaveImage(originalBytes, 220, null); - await maybeHash.Match( - hash => - { - mediaItem.Poster = hash; - mediaItem.PosterLastWriteTime = File.GetLastWriteTimeUtc(posterPath); - return _mediaItemRepository.Update(mediaItem); - }, - error => - { - _logger.LogWarning( - "Unable to save poster to disk from {Path}: {Error}", - posterPath, - error.Value); - return Task.CompletedTask; - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to refresh poster for media item {MediaItem}", mediaItem.Path); - } - } - - private async Task RefreshStatisticsForItem(MediaItem mediaItem, string ffprobePath) - { - try - { - return await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to refresh statistics for media item {MediaItem}", mediaItem.Path); - return false; - } - } - - private async Task RefreshCollectionsForItem(MediaItem mediaItem) - { - try - { - return await _smartCollectionBuilder.RefreshSmartCollections(mediaItem); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to refresh collections for media item {MediaItem}", mediaItem.Path); - return false; - } - } - - private async Task RefreshSidecarMetadataForItem(MediaItem mediaItem, string path) - { - try - { - await _localMetadataProvider.RefreshSidecarMetadata(mediaItem, path); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to refresh nfo metadata for media item {MediaItem}", mediaItem.Path); - } - } - - private async Task RefreshFallbackMetadataForItem(MediaItem mediaItem) - { - try - { - await _localMetadataProvider.RefreshFallbackMetadata(mediaItem); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to refresh fallback metadata for media item {MediaItem}", mediaItem.Path); - } - } - } -} diff --git a/ErsatzTV.Core/Metadata/LocalMediaSourcePlan.cs b/ErsatzTV.Core/Metadata/LocalMediaSourcePlan.cs deleted file mode 100644 index 2ede9678..00000000 --- a/ErsatzTV.Core/Metadata/LocalMediaSourcePlan.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Collections.Generic; -using ErsatzTV.Core.Domain; -using LanguageExt; - -namespace ErsatzTV.Core.Metadata -{ - public record LocalMediaSourcePlan(Either Source, List ActionPlans) - { - public Either Source { get; set; } = Source; - } -} diff --git a/ErsatzTV.Core/Metadata/LocalMediaSourcePlanner.cs b/ErsatzTV.Core/Metadata/LocalMediaSourcePlanner.cs deleted file mode 100644 index 91cfffd1..00000000 --- a/ErsatzTV.Core/Metadata/LocalMediaSourcePlanner.cs +++ /dev/null @@ -1,179 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Interfaces.Metadata; -using LanguageExt; -using static LanguageExt.Prelude; - -namespace ErsatzTV.Core.Metadata -{ - // TODO: this needs a better name - public class LocalMediaSourcePlanner : ILocalMediaSourcePlanner - { - private static readonly Seq ImageFileExtensions = Seq("jpg", "jpeg", "png", "gif", "tbn"); - private readonly ILocalFileSystem _localFileSystem; - - public LocalMediaSourcePlanner(ILocalFileSystem localFileSystem) => _localFileSystem = localFileSystem; - - public Seq DetermineActions( - MediaType mediaType, - Seq mediaItems, - Seq files) - { - var results = new IntermediateResults(); - Seq videoFiles = files.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f))) - .Filter(f => !IsExtra(f)); - - (Seq newFiles, Seq existingMediaItems) = videoFiles.Map( - s => mediaItems.Find(i => i.Path == s).ToEither(s)) - .Partition(); - - // new files - foreach (string file in newFiles) - { - results.Add(file, new ActionPlan(file, ScanningAction.Add)); - results.Add(file, new ActionPlan(file, ScanningAction.Statistics)); - - Option maybeNfoFile = LocateNfoFile(mediaType, files, file); - maybeNfoFile.BiIter( - nfoFile => - { - results.Add(file, new ActionPlan(nfoFile, ScanningAction.SidecarMetadata)); - results.Add(file, new ActionPlan(nfoFile, ScanningAction.Collections)); - }, - () => - { - results.Add(file, new ActionPlan(file, ScanningAction.FallbackMetadata)); - results.Add(file, new ActionPlan(file, ScanningAction.Collections)); - }); - - Option maybePoster = LocatePoster(mediaType, files, file); - maybePoster.IfSome( - posterFile => results.Add(file, new ActionPlan(posterFile, ScanningAction.Poster))); - } - - // existing media items - foreach (MediaItem mediaItem in existingMediaItems) - { - if ((mediaItem.LastWriteTime ?? DateTime.MinValue) < _localFileSystem.GetLastWriteTime(mediaItem.Path)) - { - results.Add(mediaItem, new ActionPlan(mediaItem.Path, ScanningAction.Statistics)); - } - - Option maybeNfoFile = LocateNfoFile(mediaType, files, mediaItem.Path); - maybeNfoFile.IfSome( - nfoFile => - { - if (mediaItem.Metadata == null || mediaItem.Metadata.Source == MetadataSource.Fallback || - (mediaItem.Metadata.LastWriteTime ?? DateTime.MinValue) < - _localFileSystem.GetLastWriteTime(nfoFile)) - { - results.Add(mediaItem, new ActionPlan(nfoFile, ScanningAction.SidecarMetadata)); - results.Add(mediaItem, new ActionPlan(nfoFile, ScanningAction.Collections)); - } - }); - - Option maybePoster = LocatePoster(mediaType, files, mediaItem.Path); - maybePoster.IfSome( - posterFile => - { - if (string.IsNullOrWhiteSpace(mediaItem.Poster) || - (mediaItem.PosterLastWriteTime ?? DateTime.MinValue) < - _localFileSystem.GetLastWriteTime(posterFile)) - { - results.Add(mediaItem, new ActionPlan(posterFile, ScanningAction.Poster)); - } - }); - } - - // missing media items - foreach (MediaItem mediaItem in mediaItems.Where(i => !files.Contains(i.Path))) - { - results.Add(mediaItem, new ActionPlan(mediaItem.Path, ScanningAction.Remove)); - } - - return results.Summarize(); - } - - private static bool IsExtra(string path) - { - string folder = Path.GetFileName(Path.GetDirectoryName(path) ?? string.Empty); - string file = Path.GetFileNameWithoutExtension(path); - return ExtraDirectories.Contains(folder, StringComparer.OrdinalIgnoreCase) - || ExtraFiles.Any(f => file.EndsWith(f, StringComparison.OrdinalIgnoreCase)); - } - - private static Option LocateNfoFile(MediaType mediaType, Seq files, string file) - { - switch (mediaType) - { - case MediaType.Movie: - string movieAsNfo = Path.ChangeExtension(file, "nfo"); - string movieNfo = Path.Combine(Path.GetDirectoryName(file) ?? string.Empty, "movie.nfo"); - return Seq(movieAsNfo, movieNfo) - .Filter(s => files.Contains(s)) - .HeadOrNone(); - case MediaType.TvShow: - string episodeAsNfo = Path.ChangeExtension(file, "nfo"); - return Optional(episodeAsNfo) - .Filter(s => files.Contains(s)) - .HeadOrNone(); - } - - return None; - } - - private static Option LocatePoster(MediaType mediaType, Seq files, string file) - { - string folder = Path.GetDirectoryName(file) ?? string.Empty; - - switch (mediaType) - { - case MediaType.Movie: - IEnumerable possibleMoviePosters = ImageFileExtensions.Collect( - ext => new[] { $"poster.{ext}", Path.GetFileNameWithoutExtension(file) + $"-poster.{ext}" }) - .Map(f => Path.Combine(folder, f)); - return possibleMoviePosters.Filter(p => files.Contains(p)).HeadOrNone(); - case MediaType.TvShow: - string parentFolder = Directory.GetParent(folder)?.FullName ?? string.Empty; - IEnumerable possibleTvPosters = ImageFileExtensions - .Collect(ext => new[] { $"poster.{ext}" }) - .Map(f => Path.Combine(parentFolder, f)); - return possibleTvPosters.Filter(p => files.Contains(p)).HeadOrNone(); - } - - return None; - } - - private class IntermediateResults - { - private readonly List, ActionPlan>> _rawResults = new(); - - public void Add(Either source, ActionPlan plan) => - _rawResults.Add(Tuple(source, plan)); - - public Seq Summarize() => - _rawResults - .GroupBy(t => t.Item1) - .Select(g => new LocalMediaSourcePlan(g.Key, g.Select(g2 => g2.Item2).ToList())) - .ToSeq(); - } - - // @formatter:off - private static readonly Seq VideoFileExtensions = Seq( - ".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4", - ".m4p", ".m4v", ".avi", ".wmv", ".mov", ".mkv", ".ts"); - - private static readonly Seq ExtraDirectories = Seq( - "behind the scenes", "deleted scenes", "featurettes", - "interviews", "scenes", "shorts", "trailers", "other", - "extras", "specials"); - - private static readonly Seq ExtraFiles = Seq( - "behindthescenes", "deleted", "featurette", - "interview", "scene", "short", "trailer", "other"); - // @formatter:on - } -} diff --git a/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs b/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs index 8d03cca2..8a291d2a 100644 --- a/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs +++ b/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs @@ -14,64 +14,136 @@ namespace ErsatzTV.Core.Metadata public class LocalMetadataProvider : ILocalMetadataProvider { private static readonly XmlSerializer MovieSerializer = new(typeof(MovieNfo)); - private static readonly XmlSerializer TvShowSerializer = new(typeof(TvShowEpisodeNfo)); + private static readonly XmlSerializer EpisodeSerializer = new(typeof(TvShowEpisodeNfo)); + private static readonly XmlSerializer TvShowSerializer = new(typeof(TvShowNfo)); + private readonly IFallbackMetadataProvider _fallbackMetadataProvider; + private readonly ILocalFileSystem _localFileSystem; private readonly ILogger _logger; private readonly IMediaItemRepository _mediaItemRepository; + private readonly ITelevisionRepository _televisionRepository; - public LocalMetadataProvider(IMediaItemRepository mediaItemRepository, ILogger logger) + public LocalMetadataProvider( + IMediaItemRepository mediaItemRepository, + ITelevisionRepository televisionRepository, + IFallbackMetadataProvider fallbackMetadataProvider, + ILocalFileSystem localFileSystem, + ILogger logger) { _mediaItemRepository = mediaItemRepository; + _televisionRepository = televisionRepository; + _fallbackMetadataProvider = fallbackMetadataProvider; + _localFileSystem = localFileSystem; _logger = logger; } - public async Task RefreshSidecarMetadata(MediaItem mediaItem, string path) + public Task GetMetadataForShow(string showFolder) { - Option maybeMetadata = await LoadMetadata(mediaItem, path); - await maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(mediaItem, metadata)); + string nfoFileName = Path.Combine(showFolder, "tvshow.nfo"); + return Optional(_localFileSystem.FileExists(nfoFileName)) + .Filter(identity).AsTask() + .Bind(_ => LoadTelevisionShowMetadata(nfoFileName)) + .IfNoneAsync(() => _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder).AsTask()) + .Map( + m => + { + m.SortTitle = GetSortTitle(m.Title); + return m; + }); } - public Task RefreshFallbackMetadata(MediaItem mediaItem) => - ApplyMetadataUpdate(mediaItem, FallbackMetadataProvider.GetFallbackMetadata(mediaItem)); + public Task RefreshSidecarMetadata(MediaItem mediaItem, string path) => + mediaItem switch + { + TelevisionEpisodeMediaItem e => LoadMetadata(e, path) + .Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(e, metadata))), + MovieMediaItem m => LoadMetadata(m, path) + .Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(m, metadata))), + _ => Task.FromResult(Unit.Default) + }; + + public Task RefreshSidecarMetadata(TelevisionShow televisionShow, string showFolder) => + LoadMetadata(televisionShow, showFolder).Bind( + maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(televisionShow, metadata))); - private async Task ApplyMetadataUpdate(MediaItem mediaItem, MediaMetadata metadata) - { - if (mediaItem.Metadata == null) + public Task RefreshFallbackMetadata(MediaItem mediaItem) => + mediaItem switch { - mediaItem.Metadata = new MediaMetadata(); - } + TelevisionEpisodeMediaItem e => ApplyMetadataUpdate(e, FallbackMetadataProvider.GetFallbackMetadata(e)) + .ToUnit(), + MovieMediaItem m => ApplyMetadataUpdate(m, FallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(), + _ => Task.FromResult(Unit.Default) + }; + + public Task RefreshFallbackMetadata(TelevisionShow televisionShow, string showFolder) => + ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder)) + .ToUnit(); + private async Task ApplyMetadataUpdate(TelevisionEpisodeMediaItem mediaItem, TelevisionEpisodeMetadata metadata) + { + mediaItem.Metadata ??= new TelevisionEpisodeMetadata { TelevisionEpisodeId = mediaItem.Id }; mediaItem.Metadata.Source = metadata.Source; mediaItem.Metadata.LastWriteTime = metadata.LastWriteTime; - mediaItem.Metadata.MediaType = metadata.MediaType; mediaItem.Metadata.Title = metadata.Title; - mediaItem.Metadata.Subtitle = metadata.Subtitle; - mediaItem.Metadata.SortTitle = GetSortTitle(metadata.Title ?? string.Empty); - mediaItem.Metadata.Description = metadata.Description; - mediaItem.Metadata.EpisodeNumber = metadata.EpisodeNumber; - mediaItem.Metadata.SeasonNumber = metadata.SeasonNumber; + mediaItem.Metadata.SortTitle = GetSortTitle(metadata.Title); + mediaItem.Metadata.Season = metadata.Season; + mediaItem.Metadata.Episode = metadata.Episode; + mediaItem.Metadata.Plot = metadata.Plot; mediaItem.Metadata.Aired = metadata.Aired; + + await _televisionRepository.Update(mediaItem); + } + + private async Task ApplyMetadataUpdate(MovieMediaItem mediaItem, MovieMetadata metadata) + { + mediaItem.Metadata ??= new MovieMetadata(); + mediaItem.Metadata.Source = metadata.Source; + mediaItem.Metadata.LastWriteTime = metadata.LastWriteTime; + mediaItem.Metadata.Title = metadata.Title; + mediaItem.Metadata.SortTitle = GetSortTitle(metadata.Title); + mediaItem.Metadata.Year = metadata.Year; + mediaItem.Metadata.Premiered = metadata.Premiered; + mediaItem.Metadata.Plot = metadata.Plot; + mediaItem.Metadata.Outline = metadata.Outline; + mediaItem.Metadata.Tagline = metadata.Tagline; mediaItem.Metadata.ContentRating = metadata.ContentRating; await _mediaItemRepository.Update(mediaItem); } - private static string GetSortTitle(string title) + private async Task ApplyMetadataUpdate(TelevisionShow televisionShow, TelevisionShowMetadata metadata) { - if (title.StartsWith("the ", StringComparison.OrdinalIgnoreCase)) + televisionShow.Metadata ??= new TelevisionShowMetadata(); + televisionShow.Metadata.Source = metadata.Source; + televisionShow.Metadata.LastWriteTime = metadata.LastWriteTime; + televisionShow.Metadata.Title = metadata.Title; + televisionShow.Metadata.Plot = metadata.Plot; + televisionShow.Metadata.Year = metadata.Year; + televisionShow.Metadata.SortTitle = GetSortTitle(metadata.Title); + + await _televisionRepository.Update(televisionShow); + } + + private async Task> LoadMetadata(MovieMediaItem mediaItem, string nfoFileName) + { + if (nfoFileName == null || !File.Exists(nfoFileName)) { - return title.Substring(4); + _logger.LogDebug("NFO file does not exist at {Path}", nfoFileName); + return None; } - if (title.StartsWith("Æ")) + if (!(mediaItem.Source is LocalMediaSource)) { - return title.Replace("Æ", "E"); + _logger.LogDebug("Media source {Name} is not a local media source", mediaItem.Source.Name); + return None; } - return title; + return await LoadMovieMetadata(mediaItem, nfoFileName); } - private async Task> LoadMetadata(MediaItem mediaItem, string nfoFileName) + private async Task> LoadMetadata( + TelevisionEpisodeMediaItem mediaItem, + string nfoFileName) { if (nfoFileName == null || !File.Exists(nfoFileName)) { @@ -79,71 +151,103 @@ namespace ErsatzTV.Core.Metadata return None; } - if (!(mediaItem.Source is LocalMediaSource localMediaSource)) + if (!(mediaItem.Source is LocalMediaSource)) { _logger.LogDebug("Media source {Name} is not a local media source", mediaItem.Source.Name); return None; } - return localMediaSource.MediaType switch + return await LoadEpisodeMetadata(mediaItem, nfoFileName); + } + + private async Task> LoadMetadata( + TelevisionShow televisionShow, + string nfoFileName) + { + if (nfoFileName == null || !File.Exists(nfoFileName)) { - MediaType.Movie => await LoadMovieMetadata(nfoFileName), - MediaType.TvShow => await LoadTvShowMetadata(nfoFileName), - _ => None - }; + _logger.LogDebug("NFO file does not exist at {Path}", nfoFileName); + return None; + } + + return await LoadTelevisionShowMetadata(nfoFileName); } - private async Task> LoadTvShowMetadata(string nfoFileName) + private async Task> LoadTelevisionShowMetadata(string nfoFileName) { try { await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read); - Option maybeNfo = TvShowSerializer.Deserialize(fileStream) as TvShowEpisodeNfo; - return maybeNfo.Match>( - nfo => new MediaMetadata + Option maybeNfo = TvShowSerializer.Deserialize(fileStream) as TvShowNfo; + return maybeNfo.Match>( + nfo => new TelevisionShowMetadata { Source = MetadataSource.Sidecar, LastWriteTime = File.GetLastWriteTimeUtc(nfoFileName), - MediaType = MediaType.TvShow, - Title = nfo.ShowTitle, - Subtitle = nfo.Title, - Description = nfo.Outline, - EpisodeNumber = nfo.Episode, - SeasonNumber = nfo.Season, - Aired = GetAired(nfo.Aired) + Title = nfo.Title, + Plot = nfo.Plot, + Year = nfo.Year }, None); } catch (Exception ex) { - _logger.LogDebug(ex, "Failed to read TV nfo metadata from {Path}", nfoFileName); + _logger.LogDebug(ex, "Failed to read TV show nfo metadata from {Path}", nfoFileName); return None; } } - private async Task> LoadMovieMetadata(string nfoFileName) + private async Task> LoadEpisodeMetadata(TelevisionEpisodeMediaItem mediaItem, string nfoFileName) + { + try + { + await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read); + Option maybeNfo = EpisodeSerializer.Deserialize(fileStream) as TvShowEpisodeNfo; + return maybeNfo.Match>( + nfo => new TelevisionEpisodeMetadata + { + Source = MetadataSource.Sidecar, + LastWriteTime = File.GetLastWriteTimeUtc(nfoFileName), + Title = nfo.Title, + Aired = GetAired(nfo.Aired), + Episode = nfo.Episode, + Season = nfo.Season, + Plot = nfo.Plot + }, + None); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to read TV episode nfo metadata from {Path}", nfoFileName); + return FallbackMetadataProvider.GetFallbackMetadata(mediaItem); + } + } + + private async Task> LoadMovieMetadata(MovieMediaItem mediaItem, string nfoFileName) { try { await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read); Option maybeNfo = MovieSerializer.Deserialize(fileStream) as MovieNfo; - return maybeNfo.Match>( - nfo => new MediaMetadata + return maybeNfo.Match>( + nfo => new MovieMetadata { Source = MetadataSource.Sidecar, LastWriteTime = File.GetLastWriteTimeUtc(nfoFileName), - MediaType = MediaType.Movie, Title = nfo.Title, - Description = nfo.Outline, - ContentRating = nfo.ContentRating, - Aired = GetAired(nfo.Premiered) + Year = nfo.Year, + Premiered = nfo.Premiered, + Plot = nfo.Plot, + Outline = nfo.Outline, + Tagline = nfo.Tagline, + ContentRating = nfo.ContentRating }, None); } catch (Exception ex) { _logger.LogDebug(ex, "Failed to read Movie nfo metadata from {Path}", nfoFileName); - return None; + return FallbackMetadataProvider.GetFallbackMetadata(mediaItem); } } @@ -162,6 +266,26 @@ namespace ErsatzTV.Core.Metadata return null; } + private static string GetSortTitle(string title) + { + if (string.IsNullOrWhiteSpace(title)) + { + return title; + } + + if (title.StartsWith("the ", StringComparison.OrdinalIgnoreCase)) + { + return title.Substring(4); + } + + if (title.StartsWith("Æ")) + { + return title.Replace("Æ", "E"); + } + + return title; + } + [XmlRoot("movie")] public class MovieNfo { @@ -171,11 +295,33 @@ namespace ErsatzTV.Core.Metadata [XmlElement("outline")] public string Outline { get; set; } + [XmlElement("year")] + public int Year { get; set; } + [XmlElement("mpaa")] public string ContentRating { get; set; } [XmlElement("premiered")] - public string Premiered { get; set; } + public DateTime Premiered { get; set; } + + [XmlElement("plot")] + public string Plot { get; set; } + + [XmlElement("tagline")] + public string Tagline { get; set; } + } + + [XmlRoot("tvshow")] + public class TvShowNfo + { + [XmlElement("title")] + public string Title { get; set; } + + [XmlElement("year")] + public int Year { get; set; } + + [XmlElement("plot")] + public string Plot { get; set; } } [XmlRoot("episodedetails")] @@ -187,9 +333,6 @@ namespace ErsatzTV.Core.Metadata [XmlElement("title")] public string Title { get; set; } - [XmlElement("outline")] - public string Outline { get; set; } - [XmlElement("episode")] public int Episode { get; set; } @@ -201,6 +344,9 @@ namespace ErsatzTV.Core.Metadata [XmlElement("aired")] public string Aired { get; set; } + + [XmlElement("plot")] + public string Plot { get; set; } } } } diff --git a/ErsatzTV.Core/Metadata/LocalPosterProvider.cs b/ErsatzTV.Core/Metadata/LocalPosterProvider.cs deleted file mode 100644 index a90d79ae..00000000 --- a/ErsatzTV.Core/Metadata/LocalPosterProvider.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Interfaces.Images; -using ErsatzTV.Core.Interfaces.Metadata; -using ErsatzTV.Core.Interfaces.Repositories; -using LanguageExt; -using Microsoft.Extensions.Logging; -using static LanguageExt.Prelude; - -namespace ErsatzTV.Core.Metadata -{ - public class LocalPosterProvider : ILocalPosterProvider - { - private static readonly string[] SupportedExtensions = { "jpg", "jpeg", "png", "gif", "tbn" }; - - private readonly IImageCache _imageCache; - private readonly ILogger _logger; - private readonly IMediaItemRepository _mediaItemRepository; - - public LocalPosterProvider( - IMediaItemRepository mediaItemRepository, - IImageCache imageCache, - ILogger logger) - { - _mediaItemRepository = mediaItemRepository; - _imageCache = imageCache; - _logger = logger; - } - - public async Task RefreshPoster(MediaItem mediaItem) - { - Option maybePosterPath = mediaItem.Metadata.MediaType switch - { - MediaType.Movie => RefreshMoviePoster(mediaItem), - MediaType.TvShow => RefreshTelevisionPoster(mediaItem), - _ => None - }; - - await maybePosterPath.Match( - path => SavePosterToDisk(mediaItem, path), - Task.CompletedTask); - } - - private static Option RefreshMoviePoster(MediaItem mediaItem) - { - string folder = Path.GetDirectoryName(mediaItem.Path); - if (folder != null) - { - IEnumerable possiblePaths = SupportedExtensions.Collect( - e => new[] { $"poster.{e}", Path.GetFileNameWithoutExtension(mediaItem.Path) + $"-poster.{e}" }); - Option maybePoster = - possiblePaths.Map(p => Path.Combine(folder, p)).FirstOrDefault(File.Exists); - return maybePoster; - } - - return None; - } - - private Option RefreshTelevisionPoster(MediaItem mediaItem) - { - string folder = Directory.GetParent(Path.GetDirectoryName(mediaItem.Path) ?? string.Empty)?.FullName; - if (folder != null) - { - IEnumerable possiblePaths = SupportedExtensions.Collect(e => new[] { $"poster.{e}" }); - Option maybePoster = - possiblePaths.Map(p => Path.Combine(folder, p)).FirstOrDefault(File.Exists); - return maybePoster; - } - - return None; - } - - public async Task SavePosterToDisk(MediaItem mediaItem, string posterPath) - { - byte[] originalBytes = await File.ReadAllBytesAsync(posterPath); - Either maybeHash = await _imageCache.ResizeAndSaveImage(originalBytes, 220, null); - await maybeHash.Match( - hash => - { - mediaItem.Poster = hash; - return _mediaItemRepository.Update(mediaItem); - }, - error => - { - _logger.LogWarning("Unable to save poster to disk from {Path}: {Error}", posterPath, error.Value); - return Task.CompletedTask; - }); - } - } -} diff --git a/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs b/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs index 9d1fc229..9be90b17 100644 --- a/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs +++ b/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs @@ -15,14 +15,17 @@ namespace ErsatzTV.Core.Metadata { public class LocalStatisticsProvider : ILocalStatisticsProvider { + private readonly ILocalFileSystem _localFileSystem; private readonly ILogger _logger; private readonly IMediaItemRepository _mediaItemRepository; public LocalStatisticsProvider( IMediaItemRepository mediaItemRepository, + ILocalFileSystem localFileSystem, ILogger logger) { _mediaItemRepository = mediaItemRepository; + _localFileSystem = localFileSystem; _logger = logger; } @@ -31,8 +34,8 @@ namespace ErsatzTV.Core.Metadata try { FFprobe ffprobe = await GetProbeOutput(ffprobePath, mediaItem); - MediaMetadata metadata = ProjectToMediaMetadata(ffprobe); - return await ApplyStatisticsUpdate(mediaItem, metadata); + MediaItemStatistics statistics = ProjectToMediaItemStatistics(ffprobe); + return await ApplyStatisticsUpdate(mediaItem, statistics); } catch (Exception ex) { @@ -43,23 +46,24 @@ namespace ErsatzTV.Core.Metadata private async Task ApplyStatisticsUpdate( MediaItem mediaItem, - MediaMetadata metadata) + MediaItemStatistics statistics) { - if (mediaItem.Metadata == null) + if (mediaItem.Statistics == null) { - mediaItem.Metadata = new MediaMetadata(); + mediaItem.Statistics = new MediaItemStatistics(); } - bool durationChange = mediaItem.Metadata.Duration != metadata.Duration; + bool durationChange = mediaItem.Statistics.Duration != statistics.Duration; - mediaItem.Metadata.Duration = metadata.Duration; - mediaItem.Metadata.AudioCodec = metadata.AudioCodec; - mediaItem.Metadata.SampleAspectRatio = metadata.SampleAspectRatio; - mediaItem.Metadata.DisplayAspectRatio = metadata.DisplayAspectRatio; - mediaItem.Metadata.Width = metadata.Width; - mediaItem.Metadata.Height = metadata.Height; - mediaItem.Metadata.VideoCodec = metadata.VideoCodec; - mediaItem.Metadata.VideoScanType = metadata.VideoScanType; + mediaItem.Statistics.LastWriteTime = _localFileSystem.GetLastWriteTime(mediaItem.Path); + mediaItem.Statistics.Duration = statistics.Duration; + mediaItem.Statistics.AudioCodec = statistics.AudioCodec; + mediaItem.Statistics.SampleAspectRatio = statistics.SampleAspectRatio; + mediaItem.Statistics.DisplayAspectRatio = statistics.DisplayAspectRatio; + mediaItem.Statistics.Width = statistics.Width; + mediaItem.Statistics.Height = statistics.Height; + mediaItem.Statistics.VideoCodec = statistics.VideoCodec; + mediaItem.Statistics.VideoScanType = statistics.VideoScanType; return await _mediaItemRepository.Update(mediaItem) && durationChange; } @@ -97,7 +101,7 @@ namespace ErsatzTV.Core.Metadata }); } - private MediaMetadata ProjectToMediaMetadata(FFprobe probeOutput) => + private MediaItemStatistics ProjectToMediaItemStatistics(FFprobe probeOutput) => Optional(probeOutput) .Filter(json => json?.format != null && json.streams != null) .ToValidation("Unable to parse ffprobe output") @@ -107,12 +111,12 @@ namespace ErsatzTV.Core.Metadata { var duration = TimeSpan.FromSeconds(double.Parse(json.format.duration)); - var metadata = new MediaMetadata { Duration = duration }; + var statistics = new MediaItemStatistics { Duration = duration }; FFprobeStream audioStream = json.streams.FirstOrDefault(s => s.codec_type == "audio"); if (audioStream != null) { - metadata = metadata with + statistics = statistics with { AudioCodec = audioStream.codec_name }; @@ -121,7 +125,7 @@ namespace ErsatzTV.Core.Metadata FFprobeStream videoStream = json.streams.FirstOrDefault(s => s.codec_type == "video"); if (videoStream != null) { - metadata = metadata with + statistics = statistics with { SampleAspectRatio = videoStream.sample_aspect_ratio, DisplayAspectRatio = videoStream.display_aspect_ratio, @@ -132,9 +136,9 @@ namespace ErsatzTV.Core.Metadata }; } - return metadata; + return statistics; }, - _ => new MediaMetadata()); + _ => new MediaItemStatistics()); private VideoScanType ScanTypeFromFieldOrder(string fieldOrder) => fieldOrder?.ToLowerInvariant() switch diff --git a/ErsatzTV.Core/Metadata/MovieFolderScanner.cs b/ErsatzTV.Core/Metadata/MovieFolderScanner.cs new file mode 100644 index 00000000..0c402e75 --- /dev/null +++ b/ErsatzTV.Core/Metadata/MovieFolderScanner.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Interfaces.Images; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using Microsoft.Extensions.Logging; +using static LanguageExt.Prelude; +using Seq = LanguageExt.Seq; + +namespace ErsatzTV.Core.Metadata +{ + public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner + { + private readonly ILocalFileSystem _localFileSystem; + private readonly ILocalMetadataProvider _localMetadataProvider; + private readonly ILogger _logger; + private readonly IMovieRepository _movieRepository; + + public MovieFolderScanner( + ILocalFileSystem localFileSystem, + IMovieRepository movieRepository, + ILocalStatisticsProvider localStatisticsProvider, + ILocalMetadataProvider localMetadataProvider, + IImageCache imageCache, + ILogger logger) + : base(localFileSystem, localStatisticsProvider, imageCache, logger) + { + _localFileSystem = localFileSystem; + _movieRepository = movieRepository; + _localMetadataProvider = localMetadataProvider; + _logger = logger; + } + + public async Task> ScanFolder(LocalMediaSource localMediaSource, string ffprobePath) + { + if (!_localFileSystem.IsMediaSourceAccessible(localMediaSource)) + { + return new MediaSourceInaccessible(); + } + + var folderQueue = new Queue(); + foreach (string folder in _localFileSystem.ListSubdirectories(localMediaSource.Folder).OrderBy(identity)) + { + folderQueue.Enqueue(folder); + } + + while (folderQueue.Count > 0) + { + string movieFolder = folderQueue.Dequeue(); + + var allFiles = _localFileSystem.ListFiles(movieFolder) + .Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f))) + .Filter( + f => !ExtraFiles.Any( + e => Path.GetFileNameWithoutExtension(f).EndsWith(e, StringComparison.OrdinalIgnoreCase))) + .ToList(); + + if (allFiles.Count == 0) + { + foreach (string subdirectory in _localFileSystem.ListSubdirectories(movieFolder).OrderBy(identity)) + { + folderQueue.Enqueue(subdirectory); + } + + continue; + } + + foreach (string file in allFiles.OrderBy(identity)) + { + // TODO: figure out how to rebuild playlists + Either x = await _movieRepository.GetOrAdd(localMediaSource.Id, file); + + Either maybeMovie = await x.AsTask() + .BindT(movie => UpdateStatistics(movie, ffprobePath).MapT(_ => movie)) + .BindT(UpdateMetadata) + .BindT(UpdatePoster); + + maybeMovie.IfLeft( + error => _logger.LogWarning("Error processing movie at {Path}: {Error}", file, error.Value)); + } + } + + return Unit.Default; + } + + private async Task> UpdateMetadata(MovieMediaItem movie) + { + try + { + await LocateNfoFile(movie).Match( + async nfoFile => + { + if (movie.Metadata == null || movie.Metadata.Source == MetadataSource.Fallback || + (movie.Metadata.LastWriteTime ?? DateTime.MinValue) < + _localFileSystem.GetLastWriteTime(nfoFile)) + { + _logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile); + await _localMetadataProvider.RefreshSidecarMetadata(movie, nfoFile); + } + }, + async () => + { + if (movie.Metadata == null) + { + _logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", movie.Path); + await _localMetadataProvider.RefreshFallbackMetadata(movie); + } + }); + + return movie; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + + private async Task> UpdatePoster(MovieMediaItem movie) + { + try + { + await LocatePoster(movie).IfSomeAsync( + async posterFile => + { + if (string.IsNullOrWhiteSpace(movie.Poster) || + (movie.PosterLastWriteTime ?? DateTime.MinValue) < + _localFileSystem.GetLastWriteTime(posterFile)) + { + _logger.LogDebug("Refreshing {Attribute} from {Path}", "Poster", posterFile); + await SavePosterToDisk(movie, posterFile, _movieRepository.Update, 440); + } + }); + + return movie; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + + private Option LocateNfoFile(MovieMediaItem movie) + { + string movieAsNfo = Path.ChangeExtension(movie.Path, "nfo"); + string movieNfo = Path.Combine(Path.GetDirectoryName(movie.Path) ?? string.Empty, "movie.nfo"); + return Seq.create(movieAsNfo, movieNfo) + .Filter(s => _localFileSystem.FileExists(s)) + .HeadOrNone(); + } + + private Option LocatePoster(MovieMediaItem movie) + { + string folder = Path.GetDirectoryName(movie.Path) ?? string.Empty; + IEnumerable possibleMoviePosters = ImageFileExtensions.Collect( + ext => new[] { $"poster.{ext}", Path.GetFileNameWithoutExtension(movie.Path) + $"-poster.{ext}" }) + .Map(f => Path.Combine(folder, f)); + Option result = possibleMoviePosters.Filter(p => _localFileSystem.FileExists(p)).HeadOrNone(); + return result; + } + } +} diff --git a/ErsatzTV.Core/Metadata/ScanningAction.cs b/ErsatzTV.Core/Metadata/ScanningAction.cs deleted file mode 100644 index e09b53de..00000000 --- a/ErsatzTV.Core/Metadata/ScanningAction.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace ErsatzTV.Core.Metadata -{ - public enum ScanningAction - { - None = 0, - Add = 1, - Remove = 2, - Statistics = 3, - SidecarMetadata = 4, - FallbackMetadata = 5, - Collections = 6, - Poster = 7 - } -} diff --git a/ErsatzTV.Core/Metadata/ScanningMode.cs b/ErsatzTV.Core/Metadata/ScanningMode.cs deleted file mode 100644 index e38fd6b3..00000000 --- a/ErsatzTV.Core/Metadata/ScanningMode.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace ErsatzTV.Core.Metadata -{ - public enum ScanningMode - { - Default = 0, - RescanAll = 1 - } -} diff --git a/ErsatzTV.Core/Metadata/SmartCollectionBuilder.cs b/ErsatzTV.Core/Metadata/SmartCollectionBuilder.cs deleted file mode 100644 index db84fa64..00000000 --- a/ErsatzTV.Core/Metadata/SmartCollectionBuilder.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Interfaces.Metadata; -using ErsatzTV.Core.Interfaces.Repositories; -using LanguageExt; -using static LanguageExt.Prelude; - -namespace ErsatzTV.Core.Metadata -{ - public class SmartCollectionBuilder : ISmartCollectionBuilder - { - private readonly IMediaCollectionRepository _mediaCollectionRepository; - - public SmartCollectionBuilder(IMediaCollectionRepository mediaCollectionRepository) => - _mediaCollectionRepository = mediaCollectionRepository; - - public async Task RefreshSmartCollections(MediaItem mediaItem) - { - var results = new List(); - - foreach (TelevisionMediaCollection collection in GetTelevisionCollections(mediaItem)) - { - results.Add(await _mediaCollectionRepository.InsertOrIgnore(collection)); - } - - return results.Any(identity); - } - - private IEnumerable GetTelevisionCollections(MediaItem mediaItem) - { - IList televisionMediaItems = new[] { mediaItem } - .Where(c => c.Metadata.MediaType == MediaType.TvShow) - .ToList(); - - IEnumerable televisionShowCollections = televisionMediaItems - .Map(c => c.Metadata.Title) - .Distinct().Map( - t => new TelevisionMediaCollection - { - Name = $"{t} - All Seasons", - ShowTitle = t, - SeasonNumber = null - }); - - IEnumerable televisionShowSeasonCollections = televisionMediaItems - .Map(c => new { c.Metadata.Title, c.Metadata.SeasonNumber }).Distinct() - .Map( - ts => - { - return Optional(ts.SeasonNumber).Map( - sn => new TelevisionMediaCollection - { - Name = $"{ts.Title} - Season {sn:00}", - ShowTitle = ts.Title, - SeasonNumber = sn - }); - }) - .Sequence().Flatten(); - - return Seq(televisionShowCollections, televisionShowSeasonCollections).Flatten(); - } - } -} diff --git a/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs b/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs new file mode 100644 index 00000000..d1fe84f2 --- /dev/null +++ b/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs @@ -0,0 +1,361 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Images; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using Microsoft.Extensions.Logging; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.Metadata +{ + public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScanner + { + private readonly ILocalFileSystem _localFileSystem; + private readonly ILocalMetadataProvider _localMetadataProvider; + private readonly ILogger _logger; + private readonly ITelevisionRepository _televisionRepository; + + public TelevisionFolderScanner( + ILocalFileSystem localFileSystem, + ITelevisionRepository televisionRepository, + ILocalStatisticsProvider localStatisticsProvider, + ILocalMetadataProvider localMetadataProvider, + IImageCache imageCache, + ILogger logger) : base( + localFileSystem, + localStatisticsProvider, + imageCache, + logger) + { + _localFileSystem = localFileSystem; + _televisionRepository = televisionRepository; + _localMetadataProvider = localMetadataProvider; + _logger = logger; + } + + public async Task ScanFolder(LocalMediaSource localMediaSource, string ffprobePath) + { + if (!_localFileSystem.IsMediaSourceAccessible(localMediaSource)) + { + _logger.LogWarning( + "Media source is not accessible or missing; skipping scan of {Folder}", + localMediaSource.Folder); + return Unit.Default; + } + + var allShowFolders = _localFileSystem.ListSubdirectories(localMediaSource.Folder) + .Filter(ShouldIncludeFolder) + .OrderBy(identity) + .ToList(); + + foreach (string showFolder in allShowFolders) + { + // TODO: check all sources for latest metadata? + Either maybeShow = + await FindOrCreateShow(localMediaSource.Id, showFolder) + .BindT(show => UpdateMetadataForShow(show, showFolder)) + .BindT(show => UpdatePosterForShow(show, showFolder)); + + await maybeShow.Match( + show => ScanSeasons(localMediaSource, ffprobePath, show, showFolder), + _ => Task.FromResult(Unit.Default)); + } + + await _televisionRepository.DeleteMissingSources(localMediaSource.Id, allShowFolders); + await _televisionRepository.DeleteEmptyShows(); + + return Unit.Default; + } + + private async Task> FindOrCreateShow( + int localMediaSourceId, + string showFolder) + { + Option maybeShowByPath = + await _televisionRepository.GetShowByPath(localMediaSourceId, showFolder); + return await maybeShowByPath.Match( + show => Right(show).AsTask(), + async () => + { + TelevisionShowMetadata metadata = await _localMetadataProvider.GetMetadataForShow(showFolder); + Option maybeShow = await _televisionRepository.GetShowByMetadata(metadata); + return await maybeShow.Match( + async show => + { + show.Sources.Add( + new LocalTelevisionShowSource + { + MediaSourceId = localMediaSourceId, + Path = showFolder, + TelevisionShow = show + }); + await _televisionRepository.Update(show); + return Right(show); + }, + async () => await _televisionRepository.AddShow(localMediaSourceId, showFolder, metadata)); + }); + } + + private async Task ScanSeasons( + LocalMediaSource localMediaSource, + string ffprobePath, + TelevisionShow show, + string showPath) + { + foreach (string seasonFolder in _localFileSystem.ListSubdirectories(showPath).Filter(ShouldIncludeFolder) + .OrderBy(identity)) + { + Option maybeSeasonNumber = SeasonNumberForFolder(seasonFolder); + await maybeSeasonNumber.IfSomeAsync( + async seasonNumber => + { + Either maybeSeason = await _televisionRepository + .GetOrAddSeason(show, seasonFolder, seasonNumber) + .BindT(UpdatePoster); + + await maybeSeason.Match( + season => ScanEpisodes(localMediaSource, ffprobePath, season), + _ => Task.FromResult(Unit.Default)); + }); + } + + return Unit.Default; + } + + private async Task ScanEpisodes( + LocalMediaSource localMediaSource, + string ffprobePath, + TelevisionSeason season) + { + foreach (string file in _localFileSystem.ListFiles(season.Path) + .Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f))).OrderBy(identity)) + { + // TODO: figure out how to rebuild playlists + Either maybeEpisode = await _televisionRepository + .GetOrAddEpisode(season, localMediaSource.Id, file) + .BindT(episode => UpdateStatistics(episode, ffprobePath).MapT(_ => episode)) + .BindT(UpdateMetadata) + .BindT(UpdateThumbnail); + + maybeEpisode.IfLeft( + error => _logger.LogWarning("Error processing episode at {Path}: {Error}", file, error.Value)); + } + + return Unit.Default; + } + + private async Task> UpdateMetadataForShow( + TelevisionShow show, + string showFolder) + { + try + { + await LocateNfoFileForShow(showFolder).Match( + async nfoFile => + { + if (show.Metadata == null || show.Metadata.Source == MetadataSource.Fallback || + (show.Metadata.LastWriteTime ?? DateTime.MinValue) < + _localFileSystem.GetLastWriteTime(nfoFile)) + { + _logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile); + await _localMetadataProvider.RefreshSidecarMetadata(show, nfoFile); + } + }, + async () => + { + if (show.Metadata == null) + { + _logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", showFolder); + await _localMetadataProvider.RefreshFallbackMetadata(show, showFolder); + } + }); + + return show; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + + private async Task> UpdateMetadata( + TelevisionEpisodeMediaItem episode) + { + try + { + await LocateNfoFile(episode).Match( + async nfoFile => + { + if (episode.Metadata == null || episode.Metadata.Source == MetadataSource.Fallback || + (episode.Metadata.LastWriteTime ?? DateTime.MinValue) < + _localFileSystem.GetLastWriteTime(nfoFile)) + { + _logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile); + await _localMetadataProvider.RefreshSidecarMetadata(episode, nfoFile); + } + }, + async () => + { + if (episode.Metadata == null) + { + _logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", episode.Path); + await _localMetadataProvider.RefreshFallbackMetadata(episode); + } + }); + + return episode; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + + private async Task> UpdatePosterForShow( + TelevisionShow show, + string showFolder) + { + try + { + await LocatePosterForShow(showFolder).IfSomeAsync( + async posterFile => + { + if (string.IsNullOrWhiteSpace(show.Poster) || + (show.PosterLastWriteTime ?? DateTime.MinValue) < + _localFileSystem.GetLastWriteTime(posterFile)) + { + _logger.LogDebug("Refreshing {Attribute} from {Path}", "Poster", posterFile); + Either maybePoster = await SavePosterToDisk(posterFile, 440); + await maybePoster.Match( + poster => + { + show.Poster = poster; + show.PosterLastWriteTime = _localFileSystem.GetLastWriteTime(posterFile); + return _televisionRepository.Update(show); + }, + error => + { + _logger.LogWarning( + "Unable to save poster to disk from {Path}: {Error}", + posterFile, + error.Value); + return Task.CompletedTask; + }); + } + }); + + return show; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + + private async Task> UpdatePoster(TelevisionSeason season) + { + try + { + await LocatePoster(season).IfSomeAsync( + async posterFile => + { + if (string.IsNullOrWhiteSpace(season.Poster) || + (season.PosterLastWriteTime ?? DateTime.MinValue) < + _localFileSystem.GetLastWriteTime(posterFile)) + { + _logger.LogDebug("Refreshing {Attribute} from {Path}", "Poster", posterFile); + await SavePosterToDisk(season, posterFile, _televisionRepository.Update, 440); + } + }); + + return season; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + + private async Task> UpdateThumbnail( + TelevisionEpisodeMediaItem episode) + { + try + { + await LocateThumbnail(episode).IfSomeAsync( + async posterFile => + { + if (string.IsNullOrWhiteSpace(episode.Poster) || + (episode.PosterLastWriteTime ?? DateTime.MinValue) < + _localFileSystem.GetLastWriteTime(posterFile)) + { + _logger.LogDebug("Refreshing {Attribute} from {Path}", "Thumbnail", posterFile); + await SavePosterToDisk(episode, posterFile, _televisionRepository.Update); + } + }); + + return episode; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + + private Option LocateNfoFileForShow(string showFolder) => + Optional(Path.Combine(showFolder, "tvshow.nfo")) + .Filter(s => _localFileSystem.FileExists(s)); + + private Option LocateNfoFile(TelevisionEpisodeMediaItem episode) => + Optional(Path.ChangeExtension(episode.Path, "nfo")) + .Filter(s => _localFileSystem.FileExists(s)); + + private Option LocatePosterForShow(string showFolder) => + ImageFileExtensions + .Map(ext => $"poster.{ext}") + .Map(f => Path.Combine(showFolder, f)) + .Filter(s => _localFileSystem.FileExists(s)) + .HeadOrNone(); + + private Option LocatePoster(TelevisionSeason season) + { + string folder = Path.GetDirectoryName(season.Path) ?? string.Empty; + return ImageFileExtensions + .Map(ext => Path.Combine(folder, $"season{season.Number:00}-poster.{ext}")) + .Filter(s => _localFileSystem.FileExists(s)) + .HeadOrNone(); + } + + private Option LocateThumbnail(TelevisionEpisodeMediaItem episode) + { + string folder = Path.GetDirectoryName(episode.Path) ?? string.Empty; + return ImageFileExtensions + .Map(ext => Path.GetFileNameWithoutExtension(episode.Path) + $"-thumb.{ext}") + .Map(f => Path.Combine(folder, f)) + .Filter(f => _localFileSystem.FileExists(f)) + .HeadOrNone(); + } + + private bool ShouldIncludeFolder(string folder) => + !Path.GetFileName(folder).StartsWith('.') && + !_localFileSystem.FileExists(Path.Combine(folder, ".etvignore")); + + private static Option SeasonNumberForFolder(string folder) + { + if (int.TryParse(folder.Split(" ").Last(), out int seasonNumber)) + { + return seasonNumber; + } + + if (folder.EndsWith("specials", StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + + return None; + } + } +} diff --git a/ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs b/ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs index d2f64d40..1bf7eeda 100644 --- a/ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs +++ b/ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs @@ -16,10 +16,7 @@ namespace ErsatzTV.Core.Scheduling IEnumerable mediaItems, MediaCollectionEnumeratorState state) { - _sortedMediaItems = mediaItems.OrderBy(c => c.Metadata.Aired ?? DateTime.MaxValue) - .ThenBy(c => c.Metadata.SeasonNumber) - .ThenBy(c => c.Metadata.EpisodeNumber) - .ToList(); + _sortedMediaItems = mediaItems.OrderBy(identity, new ChronologicalComparer()).ToList(); State = new MediaCollectionEnumeratorState { Seed = state.Seed }; while (State.Index < state.Index) @@ -33,5 +30,71 @@ namespace ErsatzTV.Core.Scheduling public Option Current => _sortedMediaItems.Any() ? _sortedMediaItems[State.Index] : None; public void MoveNext() => State.Index = (State.Index + 1) % _sortedMediaItems.Count; + + private class ChronologicalComparer : IComparer + { + public int Compare(MediaItem x, MediaItem y) + { + if (x == null || y == null) + { + return 0; + } + + DateTime date1 = x switch + { + TelevisionEpisodeMediaItem e => e.Metadata?.Aired ?? DateTime.MaxValue, + MovieMediaItem m => m.Metadata?.Premiered ?? DateTime.MaxValue, + _ => DateTime.MaxValue + }; + + DateTime date2 = y switch + { + TelevisionEpisodeMediaItem e => e.Metadata?.Aired ?? DateTime.MaxValue, + MovieMediaItem m => m.Metadata?.Premiered ?? DateTime.MaxValue, + _ => DateTime.MaxValue + }; + + if (date1 != date2) + { + return date1.CompareTo(date2); + } + + int season1 = x switch + { + TelevisionEpisodeMediaItem e => e.Metadata?.Season ?? int.MaxValue, + _ => int.MaxValue + }; + + int season2 = y switch + { + TelevisionEpisodeMediaItem e => e.Metadata?.Season ?? int.MaxValue, + _ => int.MaxValue + }; + + if (season1 != season2) + { + return season1.CompareTo(season2); + } + + int episode1 = x switch + { + TelevisionEpisodeMediaItem e => e.Metadata?.Episode ?? int.MaxValue, + _ => int.MaxValue + }; + + int episode2 = y switch + { + TelevisionEpisodeMediaItem e => e.Metadata?.Episode ?? int.MaxValue, + _ => int.MaxValue + }; + + if (episode1 != episode2) + { + return episode1.CompareTo(episode2); + } + + return x.Id.CompareTo(y.Id); + } + } } } diff --git a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs index 9a8dfc44..16d70a2c 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs @@ -1,11 +1,13 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading.Tasks; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Scheduling; using LanguageExt; +using LanguageExt.UnsafeValueAccess; using Microsoft.Extensions.Logging; using static LanguageExt.Prelude; using Map = LanguageExt.Map; @@ -17,12 +19,15 @@ namespace ErsatzTV.Core.Scheduling private static readonly Random Random = new(); private readonly ILogger _logger; private readonly IMediaCollectionRepository _mediaCollectionRepository; + private readonly ITelevisionRepository _televisionRepository; public PlayoutBuilder( IMediaCollectionRepository mediaCollectionRepository, + ITelevisionRepository televisionRepository, ILogger logger) { _mediaCollectionRepository = mediaCollectionRepository; + _televisionRepository = televisionRepository; _logger = logger; } @@ -38,13 +43,31 @@ namespace ErsatzTV.Core.Scheduling DateTimeOffset playoutFinish, bool rebuild = false) { - var collections = playout.ProgramSchedule.Items.Map(i => i.MediaCollection).Distinct().ToList(); + var collectionKeys = playout.ProgramSchedule.Items + .Map(CollectionKeyForItem) + .Distinct() + .ToList(); - IEnumerable>> tuples = await collections.Map( - async collection => + IEnumerable>> tuples = await collectionKeys.Map( + async collectionKey => { - Option> maybeItems = await _mediaCollectionRepository.GetItems(collection.Id); - return Tuple(collection, maybeItems.IfNone(new List())); + switch (collectionKey.CollectionType) + { + case ProgramScheduleItemCollectionType.Collection: + Option> maybeItems = + await _mediaCollectionRepository.GetItems(collectionKey.Id); + return Tuple(collectionKey, maybeItems.IfNone(new List())); + case ProgramScheduleItemCollectionType.TelevisionShow: + List showItems = + await _televisionRepository.GetShowItems(collectionKey.Id); + return Tuple(collectionKey, showItems.Cast().ToList()); + case ProgramScheduleItemCollectionType.TelevisionSeason: + List seasonItems = + await _televisionRepository.GetSeasonItems(collectionKey.Id); + return Tuple(collectionKey, seasonItems.Cast().ToList()); + default: + return Tuple(collectionKey, new List()); + } }).Sequence(); var collectionMediaItems = Map.createRange(tuples); @@ -56,6 +79,16 @@ namespace ErsatzTV.Core.Scheduling playout.Channel.Number, playout.Channel.Name); + Option emptyCollection = collectionMediaItems.Find(c => !c.Value.Any()).Map(c => c.Key); + if (emptyCollection.IsSome) + { + _logger.LogError( + "Unable to rebuild playout; collection {@CollectionKey} has no items!", + emptyCollection.ValueUnsafe()); + + return playout; + } + playout.Items ??= new List(); playout.ProgramScheduleAnchors ??= new List(); @@ -67,7 +100,7 @@ namespace ErsatzTV.Core.Scheduling } var sortedScheduleItems = playout.ProgramSchedule.Items.OrderBy(i => i.Index).ToList(); - Map collectionEnumerators = + Map collectionEnumerators = MapExtensions.Map(collectionMediaItems, (c, i) => GetMediaCollectionEnumerator(playout, c, i)); // find start anchor @@ -100,15 +133,14 @@ namespace ErsatzTV.Core.Scheduling multipleRemaining.IsSome, durationFinish.IsSome); - IMediaCollectionEnumerator enumerator = collectionEnumerators[scheduleItem.MediaCollection]; + IMediaCollectionEnumerator enumerator = collectionEnumerators[CollectionKeyForItem(scheduleItem)]; enumerator.Current.IfSome( mediaItem => { _logger.LogDebug( - "Scheduling media item: {ScheduleItemNumber} / {MediaCollectionId} - {MediaCollectionName} / {MediaItemId} - {MediaItemTitle} / {StartTime}", + "Scheduling media item: {ScheduleItemNumber} / {CollectionType} / {MediaItemId} - {MediaItemTitle} / {StartTime}", scheduleItem.Index, - scheduleItem.MediaCollection.Id, - scheduleItem.MediaCollection.Name, + scheduleItem.CollectionType, mediaItem.Id, DisplayTitle(mediaItem), itemStartTime); @@ -117,10 +149,10 @@ namespace ErsatzTV.Core.Scheduling { MediaItemId = mediaItem.Id, Start = itemStartTime, - Finish = itemStartTime + mediaItem.Metadata.Duration + Finish = itemStartTime + mediaItem.Statistics.Duration }; - currentTime = itemStartTime + mediaItem.Metadata.Duration; + currentTime = itemStartTime + mediaItem.Statistics.Duration; enumerator.MoveNext(); playout.Items.Add(playoutItem); @@ -166,7 +198,7 @@ namespace ErsatzTV.Core.Scheduling // is after, we need to move on to the next schedule item // eventually, spots probably have to fit in this gap bool willNotFinishInTime = currentTime <= peekScheduleItemStart && - currentTime + peekMediaItem.Metadata.Duration > + currentTime + peekMediaItem.Statistics.Duration > peekScheduleItemStart; if (willNotFinishInTime) { @@ -189,7 +221,7 @@ namespace ErsatzTV.Core.Scheduling bool willNotFinishInTime = currentTime <= durationFinish.IfNone(DateTime.MinValue) && - currentTime + peekMediaItem.Metadata.Duration > + currentTime + peekMediaItem.Statistics.Duration > durationFinish.IfNone(DateTime.MinValue); if (willNotFinishInTime) { @@ -285,14 +317,15 @@ namespace ErsatzTV.Core.Scheduling private static List BuildProgramScheduleAnchors( Playout playout, - Map collectionEnumerators) + Map collectionEnumerators) { var result = new List(); - foreach (MediaCollection collection in collectionEnumerators.Keys) + foreach (CollectionKey collectionKey in collectionEnumerators.Keys) { Option maybeExisting = playout.ProgramScheduleAnchors - .FirstOrDefault(a => a.MediaCollection == collection); + .FirstOrDefault( + a => a.CollectionType == collectionKey.CollectionType && a.CollectionId == collectionKey.Id); var maybeEnumeratorState = collectionEnumerators.GroupBy(e => e.Key, e => e.Value.State) .ToDictionary(mcs => mcs.Key, mcs => mcs.Head()); @@ -300,7 +333,7 @@ namespace ErsatzTV.Core.Scheduling PlayoutProgramScheduleAnchor scheduleAnchor = maybeExisting.Match( existing => { - existing.EnumeratorState = maybeEnumeratorState[collection]; + existing.EnumeratorState = maybeEnumeratorState[collectionKey]; return existing; }, () => new PlayoutProgramScheduleAnchor @@ -309,9 +342,9 @@ namespace ErsatzTV.Core.Scheduling PlayoutId = playout.Id, ProgramSchedule = playout.ProgramSchedule, ProgramScheduleId = playout.ProgramScheduleId, - MediaCollection = collection, - MediaCollectionId = collection.Id, - EnumeratorState = maybeEnumeratorState[collection] + CollectionType = collectionKey.CollectionType, + CollectionId = collectionKey.Id, + EnumeratorState = maybeEnumeratorState[collectionKey] }); result.Add(scheduleAnchor); @@ -322,12 +355,14 @@ namespace ErsatzTV.Core.Scheduling private static IMediaCollectionEnumerator GetMediaCollectionEnumerator( Playout playout, - MediaCollection mediaCollection, + CollectionKey collectionKey, List mediaItems) { Option maybeAnchor = playout.ProgramScheduleAnchors .FirstOrDefault( - a => a.ProgramScheduleId == playout.ProgramScheduleId && a.MediaCollectionId == mediaCollection.Id); + a => a.ProgramScheduleId == playout.ProgramScheduleId && a.CollectionType == + collectionKey.CollectionType + && a.CollectionId == collectionKey.Id); MediaCollectionEnumeratorState state = maybeAnchor.Match( anchor => anchor.EnumeratorState, @@ -348,8 +383,40 @@ namespace ErsatzTV.Core.Scheduling } private static string DisplayTitle(MediaItem mediaItem) => - mediaItem.Metadata.MediaType == MediaType.TvShow - ? $"{mediaItem.Metadata.Title} - s{mediaItem.Metadata.SeasonNumber:00}e{mediaItem.Metadata.EpisodeNumber:00}" - : mediaItem.Metadata.Title; + mediaItem switch + { + TelevisionEpisodeMediaItem e => e.Metadata != null + ? $"{e.Metadata.Title} - s{e.Metadata.Season:00}e{e.Metadata.Episode:00}" + : Path.GetFileName(e.Path), + MovieMediaItem m => m.Metadata?.Title ?? Path.GetFileName(m.Path), + _ => string.Empty + }; + + private static CollectionKey CollectionKeyForItem(ProgramScheduleItem item) => + item.CollectionType switch + { + ProgramScheduleItemCollectionType.Collection => new CollectionKey + { + CollectionType = item.CollectionType, + Id = item.MediaCollectionId.Value + }, + ProgramScheduleItemCollectionType.TelevisionShow => new CollectionKey + { + CollectionType = item.CollectionType, + Id = item.TelevisionShowId.Value + }, + ProgramScheduleItemCollectionType.TelevisionSeason => new CollectionKey + { + CollectionType = item.CollectionType, + Id = item.TelevisionSeasonId.Value + }, + _ => throw new ArgumentOutOfRangeException(nameof(item)) + }; + + private class CollectionKey : Record + { + public ProgramScheduleItemCollectionType CollectionType { get; set; } + public int Id { get; set; } + } } } diff --git a/ErsatzTV.Infrastructure/Data/Configurations/GenericIntegerIdConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/GenericIntegerIdConfiguration.cs index c2c793c4..40616253 100644 --- a/ErsatzTV.Infrastructure/Data/Configurations/GenericIntegerIdConfiguration.cs +++ b/ErsatzTV.Infrastructure/Data/Configurations/GenericIntegerIdConfiguration.cs @@ -7,6 +7,6 @@ namespace ErsatzTV.Infrastructure.Data.Configurations public class GenericIntegerIdConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) => - builder.HasNoKey().ToView(null); + builder.HasNoKey().ToView("No table or view exists for GenericIntegerId"); } } diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaCollectionSummaryConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaCollectionSummaryConfiguration.cs index 66187d03..97b1a673 100644 --- a/ErsatzTV.Infrastructure/Data/Configurations/MediaCollectionSummaryConfiguration.cs +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaCollectionSummaryConfiguration.cs @@ -7,6 +7,6 @@ namespace ErsatzTV.Infrastructure.Data.Configurations public class MediaCollectionSummaryConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) => - builder.HasNoKey().ToView(null); + builder.HasNoKey().ToView("No table or view exists for MediaCollectionSummary"); } } diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaItemConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaItemConfiguration.cs index b0681a22..dcb5df39 100644 --- a/ErsatzTV.Infrastructure/Data/Configurations/MediaItemConfiguration.cs +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaItemConfiguration.cs @@ -6,7 +6,10 @@ namespace ErsatzTV.Infrastructure.Data.Configurations { public class MediaItemConfiguration : IEntityTypeConfiguration { - public void Configure(EntityTypeBuilder builder) => - builder.OwnsOne(c => c.Metadata).WithOwner(); + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("MediaItems"); + builder.OwnsOne(c => c.Statistics).WithOwner(); + } } } diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaItemSummaryConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaItemSummaryConfiguration.cs index 58cb49c4..5bed7f58 100644 --- a/ErsatzTV.Infrastructure/Data/Configurations/MediaItemSummaryConfiguration.cs +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaItemSummaryConfiguration.cs @@ -7,6 +7,6 @@ namespace ErsatzTV.Infrastructure.Data.Configurations public class MediaItemSummaryConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) => - builder.HasNoKey().ToView(null); + builder.HasNoKey().ToView("No table or view exists for MediaItemSummary"); } } diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MovieMediaItemConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MovieMediaItemConfiguration.cs new file mode 100644 index 00000000..b1a5ca66 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/MovieMediaItemConfiguration.cs @@ -0,0 +1,19 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class MovieMediaItemConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Movies"); + + builder.HasOne(i => i.Metadata) + .WithOne(m => m.Movie) + .HasForeignKey(m => m.MovieId) + .OnDelete(DeleteBehavior.Cascade); + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MovieMetadataConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MovieMetadataConfiguration.cs new file mode 100644 index 00000000..70a6ffdb --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/MovieMetadataConfiguration.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class MovieMetadataConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => + builder.ToTable("MovieMetadata"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/PlayoutProgramScheduleAnchorConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/PlayoutProgramScheduleAnchorConfiguration.cs index b25a39e4..8585e920 100644 --- a/ErsatzTV.Infrastructure/Data/Configurations/PlayoutProgramScheduleAnchorConfiguration.cs +++ b/ErsatzTV.Infrastructure/Data/Configurations/PlayoutProgramScheduleAnchorConfiguration.cs @@ -6,11 +6,8 @@ namespace ErsatzTV.Infrastructure.Data.Configurations { public class PlayoutProgramScheduleAnchorConfiguration : IEntityTypeConfiguration { - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(a => new { a.PlayoutId, a.ProgramScheduleId, ContentGroupId = a.MediaCollectionId }); - - builder.OwnsOne(a => a.EnumeratorState); - } + public void Configure(EntityTypeBuilder builder) => + builder.OwnsOne(a => a.EnumeratorState) + .WithOwner(); } } diff --git a/ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemConfiguration.cs index 08ef8482..f3c72b35 100644 --- a/ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemConfiguration.cs +++ b/ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemConfiguration.cs @@ -6,7 +6,24 @@ namespace ErsatzTV.Infrastructure.Data.Configurations { public class ProgramScheduleItemConfiguration : IEntityTypeConfiguration { - public void Configure(EntityTypeBuilder builder) => + public void Configure(EntityTypeBuilder builder) + { builder.ToTable("ProgramScheduleItems"); + + builder.HasOne(i => i.MediaCollection) + .WithMany() + .HasForeignKey(i => i.MediaCollectionId) + .IsRequired(false); + + builder.HasOne(i => i.TelevisionShow) + .WithMany() + .HasForeignKey(i => i.TelevisionShowId) + .IsRequired(false); + + builder.HasOne(i => i.TelevisionSeason) + .WithMany() + .HasForeignKey(i => i.TelevisionSeasonId) + .IsRequired(false); + } } } diff --git a/ErsatzTV.Infrastructure/Data/Configurations/SimpleMediaCollectionConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/SimpleMediaCollectionConfiguration.cs index 66d11f58..5f79f77a 100644 --- a/ErsatzTV.Infrastructure/Data/Configurations/SimpleMediaCollectionConfiguration.cs +++ b/ErsatzTV.Infrastructure/Data/Configurations/SimpleMediaCollectionConfiguration.cs @@ -10,8 +10,21 @@ namespace ErsatzTV.Infrastructure.Data.Configurations { builder.ToTable("SimpleMediaCollections"); - builder.HasMany(cg => cg.Items) - .WithMany(c => c.SimpleMediaCollections); + builder.HasMany(c => c.Movies) + .WithMany(m => m.SimpleMediaCollections) + .UsingEntity(join => join.ToTable("SimpleMediaCollectionMovies")); + + builder.HasMany(c => c.TelevisionShows) + .WithMany(s => s.SimpleMediaCollections) + .UsingEntity(join => join.ToTable("SimpleMediaCollectionShows")); + + builder.HasMany(c => c.TelevisionSeasons) + .WithMany(s => s.SimpleMediaCollections) + .UsingEntity(join => join.ToTable("SimpleMediaCollectionSeasons")); + + builder.HasMany(c => c.TelevisionEpisodes) + .WithMany(e => e.SimpleMediaCollections) + .UsingEntity(join => join.ToTable("SimpleMediaCollectionEpisodes")); } } } diff --git a/ErsatzTV.Infrastructure/Data/Configurations/TelevisionEpisodeMediaItemConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/TelevisionEpisodeMediaItemConfiguration.cs new file mode 100644 index 00000000..829316bb --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/TelevisionEpisodeMediaItemConfiguration.cs @@ -0,0 +1,19 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class TelevisionEpisodeMediaItemConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("TelevisionEpisodes"); + + builder.HasOne(i => i.Metadata) + .WithOne(m => m.TelevisionEpisode) + .HasForeignKey(m => m.TelevisionEpisodeId) + .OnDelete(DeleteBehavior.Cascade); + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/TelevisionEpisodeMetadataConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/TelevisionEpisodeMetadataConfiguration.cs new file mode 100644 index 00000000..08c84c14 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/TelevisionEpisodeMetadataConfiguration.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class TelevisionEpisodeMetadataConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => + builder.ToTable("TelevisionEpisodeMetadata"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/TelevisionMediaCollectionConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/TelevisionMediaCollectionConfiguration.cs deleted file mode 100644 index e2345daf..00000000 --- a/ErsatzTV.Infrastructure/Data/Configurations/TelevisionMediaCollectionConfiguration.cs +++ /dev/null @@ -1,17 +0,0 @@ -using ErsatzTV.Core.Domain; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace ErsatzTV.Infrastructure.Data.Configurations -{ - public class TelevisionMediaCollectionConfiguration : IEntityTypeConfiguration - { - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("TelevisionMediaCollections"); - - builder.HasIndex(c => new { c.ShowTitle, c.SeasonNumber }) - .IsUnique(); - } - } -} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/TelevisionSeasonConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/TelevisionSeasonConfiguration.cs new file mode 100644 index 00000000..8ebc89e8 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/TelevisionSeasonConfiguration.cs @@ -0,0 +1,19 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class TelevisionSeasonConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("TelevisionSeasons"); + + builder.HasMany(season => season.Episodes) + .WithOne(episode => episode.Season) + .HasForeignKey(episode => episode.SeasonId) + .OnDelete(DeleteBehavior.Cascade); + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/TelevisionShowConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/TelevisionShowConfiguration.cs new file mode 100644 index 00000000..75464a52 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/TelevisionShowConfiguration.cs @@ -0,0 +1,27 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class TelevisionShowConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("TelevisionShows"); + + builder.HasOne(show => show.Metadata) + .WithOne(metadata => metadata.TelevisionShow) + .HasForeignKey(metadata => metadata.TelevisionShowId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(show => show.Seasons) + .WithOne(season => season.TelevisionShow); + + builder.HasMany(show => show.Sources) + .WithOne(source => source.TelevisionShow) + .HasForeignKey(source => source.TelevisionShowId) + .OnDelete(DeleteBehavior.Cascade); + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/TelevisionShowMetadataConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/TelevisionShowMetadataConfiguration.cs new file mode 100644 index 00000000..145dbc1c --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/TelevisionShowMetadataConfiguration.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class TelevisionShowMetadataConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => + builder.ToTable("TelevisionShowMetadata"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/DbInitializer.cs b/ErsatzTV.Infrastructure/Data/DbInitializer.cs index b2d9472f..1d01fd9c 100644 --- a/ErsatzTV.Infrastructure/Data/DbInitializer.cs +++ b/ErsatzTV.Infrastructure/Data/DbInitializer.cs @@ -55,14 +55,6 @@ namespace ErsatzTV.Infrastructure.Data context.Channels.Add(defaultChannel); context.SaveChanges(); - // TODO: clean this up - // var mediaSource = new LocalMediaSource - // { - // Name = "Default" - // }; - // context.MediaSources.Add(mediaSource); - // context.SaveChanges(); - // TODO: create looping static image that mentions configuring via web return Unit.Default; } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs index bceb67e6..a7c6f5dd 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs @@ -38,6 +38,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .Include(c => c.Playouts) .ThenInclude(p => p.Items) .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as TelevisionEpisodeMediaItem).Metadata) + .Include(c => c.Playouts) + .ThenInclude(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as MovieMediaItem).Metadata) .ToListAsync(); public async Task Update(Channel channel) diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs index 66425451..812a63a7 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using ErsatzTV.Core.AggregateModels; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; using LanguageExt; @@ -28,17 +27,42 @@ namespace ErsatzTV.Infrastructure.Data.Repositories _dbContext.MediaCollections.SingleOrDefaultAsync(c => c.Id == id).Map(Optional); public Task> GetSimpleMediaCollection(int id) => - Get(id).Map(c => c.OfType().HeadOrNone()); + _dbContext.SimpleMediaCollections + .SingleOrDefaultAsync(c => c.Id == id) + .Map(Optional); public Task> GetSimpleMediaCollectionWithItems(int id) => _dbContext.SimpleMediaCollections - .Include(s => s.Items) - .ThenInclude(i => i.Source) + .Include(s => s.Movies) + .ThenInclude(m => m.Source) + .Include(s => s.TelevisionShows) + .ThenInclude(s => s.Metadata) + .Include(s => s.TelevisionSeasons) + .Include(s => s.TelevisionEpisodes) + .ThenInclude(s => s.Metadata) .SingleOrDefaultAsync(c => c.Id == id) .Map(Optional); - public Task> GetTelevisionMediaCollection(int id) => - Get(id).Map(c => c.OfType().HeadOrNone()); + public Task> GetSimpleMediaCollectionWithItemsUntracked(int id) => + _dbContext.SimpleMediaCollections + .AsNoTracking() + .Include(s => s.Movies) + .ThenInclude(i => i.Source) + .Include(s => s.Movies) + .ThenInclude(m => m.Metadata) + .Include(s => s.TelevisionShows) + .ThenInclude(s => s.Metadata) + .Include(s => s.TelevisionSeasons) + .ThenInclude(s => s.TelevisionShow) + .ThenInclude(s => s.Metadata) + .Include(s => s.TelevisionEpisodes) + .ThenInclude(s => s.Metadata) + .Include(s => s.TelevisionEpisodes) + .ThenInclude(e => e.Season) + .ThenInclude(s => s.TelevisionShow) + .ThenInclude(s => s.Metadata) + .SingleOrDefaultAsync(c => c.Id == id) + .Map(Optional); public Task> GetSimpleMediaCollections() => _dbContext.SimpleMediaCollections.ToListAsync(); @@ -46,109 +70,101 @@ namespace ErsatzTV.Infrastructure.Data.Repositories public Task> GetAll() => _dbContext.MediaCollections.ToListAsync(); - public Task> GetSummaries(string searchString) => - _dbContext.MediaCollectionSummaries.FromSqlRaw( - @"SELECT mc.Id, mc.Name, Count(mismc.ItemsId) AS ItemCount, true AS IsSimple - FROM MediaCollections mc - INNER JOIN SimpleMediaCollections smc ON smc.Id = mc.Id - LEFT OUTER JOIN MediaItemSimpleMediaCollection mismc ON mismc.SimpleMediaCollectionsId = mc.Id - WHERE mc.Name LIKE {0} - GROUP BY mc.Id, mc.Name - UNION ALL - SELECT mc.Id, mc.Name, Count(mi.Id) AS ItemCount, false AS IsSimple - FROM MediaCollections mc - INNER JOIN TelevisionMediaCollections tmc ON tmc.Id = mc.Id - LEFT OUTER JOIN MediaItems mi ON (tmc.SeasonNumber IS NULL OR mi.Metadata_SeasonNumber = tmc.SeasonNumber) - AND mi.Metadata_Title = tmc.ShowTitle - WHERE mc.Name LIKE {0} - GROUP BY mc.Id, mc.Name", - $"%{searchString}%").ToListAsync(); - public Task>> GetItems(int id) => Get(id).MapT( collection => collection switch { SimpleMediaCollection s => SimpleItems(s), - TelevisionMediaCollection t => TelevisionItems(t), _ => throw new NotSupportedException($"Unsupported collection type {collection.GetType().Name}") }).Bind(x => x.Sequence()); public Task>> GetSimpleMediaCollectionItems(int id) => GetSimpleMediaCollection(id).MapT(SimpleItems).Bind(x => x.Sequence()); - public Task>> GetTelevisionMediaCollectionItems(int id) => - GetTelevisionMediaCollection(id).MapT(TelevisionItems).Bind(x => x.Sequence()); - public Task Update(SimpleMediaCollection collection) { _dbContext.SimpleMediaCollections.Update(collection); return _dbContext.SaveChangesAsync(); } - public async Task InsertOrIgnore(TelevisionMediaCollection collection) + public async Task Delete(int mediaCollectionId) { - if (!_dbContext.TelevisionMediaCollections.Any( - existing => existing.ShowTitle == collection.ShowTitle && - existing.SeasonNumber == collection.SeasonNumber)) - { - await _dbContext.TelevisionMediaCollections.AddAsync(collection); - return await _dbContext.SaveChangesAsync() > 0; - } - - // no change - return false; + MediaCollection mediaCollection = await _dbContext.MediaCollections.FindAsync(mediaCollectionId); + _dbContext.MediaCollections.Remove(mediaCollection); + await _dbContext.SaveChangesAsync(); } - public Task ReplaceItems(int collectionId, List mediaItems) => - GetSimpleMediaCollection(collectionId).IfSomeAsync( - async c => - { - await SimpleItems(c); + private async Task> SimpleItems(SimpleMediaCollection collection) + { + var result = new List(); - c.Items.Clear(); - foreach (MediaItem mediaItem in mediaItems) - { - c.Items.Add(mediaItem); - } + await _dbContext.Entry(collection).Collection(c => c.Movies).LoadAsync(); + result.AddRange(collection.Movies); - _dbContext.SimpleMediaCollections.Update(c); - await _dbContext.SaveChangesAsync(); - }); + result.AddRange(await GetTelevisionShowItems(collection)); + result.AddRange(await GetTelevisionSeasonItems(collection)); + result.AddRange(await GetTelevisionEpisodeItems(collection)); - public async Task Delete(int mediaCollectionId) - { - MediaCollection mediaCollection = await _dbContext.MediaCollections.FindAsync(mediaCollectionId); - _dbContext.MediaCollections.Remove(mediaCollection); - await _dbContext.SaveChangesAsync(); + return result.Distinct().ToList(); } - public async Task DeleteEmptyTelevisionCollections() + private async Task> GetTelevisionShowItems(SimpleMediaCollection collection) { - List ids = await _dbContext.GenericIntegerIds.FromSqlRaw( - @"SELECT mc.Id FROM MediaCollections mc -INNER JOIN TelevisionMediaCollections t on mc.Id = t.Id -WHERE NOT EXISTS -(SELECT 1 FROM MediaItems mi WHERE t.ShowTitle = mi.Metadata_Title AND (t.SeasonNumber IS NULL OR t.SeasonNumber = mi.Metadata_SeasonNumber))") - .Map(i => i.Id) + // TODO: would be nice to get the media items in one go, but ef... + List showItemIds = await _dbContext.GenericIntegerIds.FromSqlRaw( + @"select tmi.Id +from TelevisionEpisodes tmi +inner join TelevisionSeasons tsn on tsn.Id = tmi.SeasonId +inner join TelevisionShows ts on ts.Id = tsn.TelevisionShowId +inner join SimpleMediaCollectionShows s on s.TelevisionShowsId = ts.Id +where s.SimpleMediaCollectionsId = {0}", + collection.Id) + .Select(i => i.Id) .ToListAsync(); - List toDelete = - await _dbContext.MediaCollections.Where(mc => ids.Contains(mc.Id)).ToListAsync(); - _dbContext.MediaCollections.RemoveRange(toDelete); - - await _dbContext.SaveChangesAsync(); + return await _dbContext.TelevisionEpisodeMediaItems + .AsNoTracking() + .Include(e => e.Metadata) + .Where(mi => showItemIds.Contains(mi.Id)) + .ToListAsync(); } - private async Task> SimpleItems(SimpleMediaCollection collection) + private async Task> GetTelevisionSeasonItems(SimpleMediaCollection collection) { - await _dbContext.Entry(collection).Collection(c => c.Items).LoadAsync(); - return collection.Items.ToList(); + // TODO: would be nice to get the media items in one go, but ef... + List seasonItemIds = await _dbContext.GenericIntegerIds.FromSqlRaw( + @"select tmi.Id +from TelevisionEpisodes tmi +inner join TelevisionSeasons tsn on tsn.Id = tmi.SeasonId +inner join SimpleMediaCollectionSeasons s on s.TelevisionSeasonsId = tsn.Id +where s.SimpleMediaCollectionsId = {0}", + collection.Id) + .Select(i => i.Id) + .ToListAsync(); + + return await _dbContext.TelevisionEpisodeMediaItems + .AsNoTracking() + .Include(e => e.Metadata) + .Where(mi => seasonItemIds.Contains(mi.Id)) + .ToListAsync(); } - private Task> TelevisionItems(TelevisionMediaCollection collection) => - _dbContext.MediaItems - .Filter(c => c.Metadata.Title == collection.ShowTitle) - .Filter(c => collection.SeasonNumber == null || c.Metadata.SeasonNumber == collection.SeasonNumber) + private async Task> GetTelevisionEpisodeItems(SimpleMediaCollection collection) + { + // TODO: would be nice to get the media items in one go, but ef... + List episodeItemIds = await _dbContext.GenericIntegerIds.FromSqlRaw( + @"select s.TelevisionEpisodesId as Id +from SimpleMediaCollectionEpisodes s +where s.SimpleMediaCollectionsId = {0}", + collection.Id) + .Select(i => i.Id) + .ToListAsync(); + + return await _dbContext.TelevisionEpisodeMediaItems + .AsNoTracking() + .Include(e => e.Metadata) + .Where(mi => episodeItemIds.Contains(mi.Id)) .ToListAsync(); + } } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs index b419ff7d..add440fd 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using ErsatzTV.Core.AggregateModels; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; using LanguageExt; @@ -16,13 +15,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories public MediaItemRepository(TvContext dbContext) => _dbContext = dbContext; - public async Task Add(MediaItem mediaItem) - { - await _dbContext.MediaItems.AddAsync(mediaItem); - await _dbContext.SaveChangesAsync(); - return mediaItem.Id; - } - public Task> Get(int id) => _dbContext.MediaItems .Include(i => i.Source) @@ -33,81 +25,29 @@ namespace ErsatzTV.Infrastructure.Data.Repositories public Task> Search(string searchString) { - IQueryable data = from c in _dbContext.MediaItems.Include(c => c.Source) select c; + IQueryable episodeData = + from c in _dbContext.TelevisionEpisodeMediaItems.Include(c => c.Source) select c; if (!string.IsNullOrEmpty(searchString)) { - data = data.Where(c => EF.Functions.Like(c.Metadata.Title, $"%{searchString}%")); + episodeData = episodeData.Where(c => EF.Functions.Like(c.Metadata.Title, $"%{searchString}%")); } - return data.ToListAsync(); - } - - - public Task> GetPageByType(MediaType mediaType, int pageNumber, int pageSize) => - mediaType switch - { - MediaType.Movie => _dbContext.MediaItemSummaries.FromSqlRaw( - @"SELECT - Id AS MediaItemId, - Metadata_Title AS Title, - Metadata_SortTitle AS SortTitle, - substr(Metadata_Aired, 1, 4) AS Subtitle, - Poster -FROM MediaItems WHERE Metadata_MediaType=2 -ORDER BY Metadata_SortTitle -LIMIT {0} OFFSET {1}", - pageSize, - (pageNumber - 1) * pageSize) - .AsNoTracking() - .ToListAsync(), - MediaType.TvShow => _dbContext.MediaItemSummaries.FromSqlRaw( - @"SELECT - min(Id) AS MediaItemId, - Metadata_Title AS Title, - Metadata_SortTitle AS SortTitle, - count(*) || ' Episodes' AS Subtitle, - max(Poster) AS Poster -FROM MediaItems WHERE Metadata_MediaType=1 -GROUP BY Metadata_Title, Metadata_SortTitle -ORDER BY Metadata_SortTitle -LIMIT {0} OFFSET {1}", - pageSize, - (pageNumber - 1) * pageSize) - .AsNoTracking() - .ToListAsync(), - _ => Task.FromResult(new List()) - }; + IQueryable movieData = + from c in _dbContext.MovieMediaItems.Include(c => c.Source) select c; - public Task GetCountByType(MediaType mediaType) => - mediaType switch + if (!string.IsNullOrEmpty(searchString)) { - MediaType.Movie => _dbContext.MediaItems - .Filter(i => i.Metadata.MediaType == mediaType) - .CountAsync(), - MediaType.TvShow => _dbContext.MediaItems - .Filter(i => i.Metadata.MediaType == mediaType) - .GroupBy(i => new { i.Metadata.Title, i.Metadata.SortTitle }) - .CountAsync(), - _ => Task.FromResult(0) - }; + movieData = movieData.Where(c => EF.Functions.Like(c.Metadata.Title, $"%{searchString}%")); + } - public Task> GetAllByMediaSourceId(int mediaSourceId) => - _dbContext.MediaItems - .Filter(i => i.MediaSourceId == mediaSourceId) - .ToListAsync(); + return episodeData.OfType().Concat(movieData.OfType()).ToListAsync(); + } public async Task Update(MediaItem mediaItem) { _dbContext.MediaItems.Update(mediaItem); return await _dbContext.SaveChangesAsync() > 0; } - - public async Task Delete(int mediaItemId) - { - MediaItem mediaItem = await _dbContext.MediaItems.FindAsync(mediaItemId); - _dbContext.MediaItems.Remove(mediaItem); - await _dbContext.SaveChangesAsync(); - } } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs new file mode 100644 index 00000000..29b7a3a6 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Infrastructure.Data.Repositories +{ + public class MovieRepository : IMovieRepository + { + private readonly TvContext _dbContext; + + public MovieRepository(TvContext dbContext) => _dbContext = dbContext; + + public Task> GetMovie(int movieId) => + _dbContext.MovieMediaItems + .Include(m => m.Metadata) + .SingleOrDefaultAsync(m => m.Id == movieId) + .Map(Optional); + + public async Task> GetOrAdd(int mediaSourceId, string path) + { + Option maybeExisting = await _dbContext.MovieMediaItems + .Include(i => i.Metadata) + .SingleOrDefaultAsync(i => i.Path == path); + + return await maybeExisting.Match( + mediaItem => Right(mediaItem).AsTask(), + async () => await AddMovie(mediaSourceId, path)); + } + + public async Task Update(MovieMediaItem movie) + { + _dbContext.MovieMediaItems.Update(movie); + return await _dbContext.SaveChangesAsync() > 0; + } + + public Task GetMovieCount() => + _dbContext.MovieMediaItems + .AsNoTracking() + .CountAsync(); + + public Task> GetPagedMovies(int pageNumber, int pageSize) => + _dbContext.MovieMediaItems + .Include(s => s.Metadata) + .OrderBy(s => s.Metadata.SortTitle) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .AsNoTracking() + .ToListAsync(); + + private async Task> AddMovie(int mediaSourceId, string path) + { + try + { + var movie = new MovieMediaItem { MediaSourceId = mediaSourceId, Path = path }; + await _dbContext.MovieMediaItems.AddAsync(movie); + await _dbContext.SaveChangesAsync(); + return movie; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/PlayoutRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/PlayoutRepository.cs index 5b24b0b8..42e3657d 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/PlayoutRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/PlayoutRepository.cs @@ -35,6 +35,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .Include(p => p.ProgramSchedule) .ThenInclude(ps => ps.Items) .ThenInclude(psi => psi.MediaCollection) + .Include(p => p.ProgramSchedule) + .ThenInclude(ps => ps.Items) + .ThenInclude(psi => psi.TelevisionShow) .OrderBy(p => p.Id) // https://github.com/dotnet/efcore/issues/22579#issuecomment-694772289 .SingleOrDefaultAsync(p => p.Id == id); @@ -55,7 +58,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories public Task> GetPlayoutItems(int playoutId) => _dbContext.PlayoutItems .Include(i => i.MediaItem) - .ThenInclude(mi => mi.Metadata) + .ThenInclude(m => (m as MovieMediaItem).Metadata) + .Include(i => i.MediaItem) + .ThenInclude(m => (m as TelevisionEpisodeMediaItem).Metadata) .Filter(i => i.PlayoutId == playoutId) .ToListAsync(); diff --git a/ErsatzTV.Infrastructure/Data/Repositories/ProgramScheduleRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/ProgramScheduleRepository.cs index 7cb8150e..315abeaa 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/ProgramScheduleRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/ProgramScheduleRepository.cs @@ -57,7 +57,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories { await _dbContext.Entry(programSchedule).Collection(s => s.Items).LoadAsync(); await _dbContext.Entry(programSchedule).Collection(s => s.Items).Query() - .Include(i => i.MediaCollection).LoadAsync(); + .Include(i => i.MediaCollection) + .Include(i => i.TelevisionShow) + .ThenInclude(s => s.Metadata) + .Include(i => i.TelevisionSeason) + .ThenInclude(s => s.TelevisionShow) + .ThenInclude(s => s.Metadata) + .LoadAsync(); return programSchedule.Items; }).Sequence(); } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs new file mode 100644 index 00000000..8b7f3d42 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Infrastructure.Data.Repositories +{ + public class TelevisionRepository : ITelevisionRepository + { + private readonly TvContext _dbContext; + + public TelevisionRepository(TvContext dbContext) => _dbContext = dbContext; + + public async Task Update(TelevisionShow show) + { + _dbContext.TelevisionShows.Update(show); + return await _dbContext.SaveChangesAsync() > 0; + } + + public async Task Update(TelevisionSeason season) + { + _dbContext.TelevisionSeasons.Update(season); + return await _dbContext.SaveChangesAsync() > 0; + } + + public async Task Update(TelevisionEpisodeMediaItem episode) + { + _dbContext.TelevisionEpisodeMediaItems.Update(episode); + return await _dbContext.SaveChangesAsync() > 0; + } + + public Task> GetAllShows() => + _dbContext.TelevisionShows + .AsNoTracking() + .Include(s => s.Metadata) + .ToListAsync(); + + public Task> GetShow(int televisionShowId) => + _dbContext.TelevisionShows + .AsNoTracking() + .Filter(s => s.Id == televisionShowId) + .Include(s => s.Metadata) + .SingleOrDefaultAsync() + .Map(Optional); + + public Task GetShowCount() => + _dbContext.TelevisionShows + .AsNoTracking() + .CountAsync(); + + public Task> GetPagedShows(int pageNumber, int pageSize) => + _dbContext.TelevisionShows + .AsNoTracking() + .Include(s => s.Metadata) + .OrderBy(s => s.Metadata == null ? string.Empty : s.Metadata.SortTitle) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + public Task> GetAllSeasons() => + _dbContext.TelevisionSeasons + .AsNoTracking() + .Include(s => s.TelevisionShow) + .ThenInclude(s => s.Metadata) + .ToListAsync(); + + public Task> GetSeason(int televisionSeasonId) => + _dbContext.TelevisionSeasons + .AsNoTracking() + .Include(s => s.TelevisionShow) + .ThenInclude(s => s.Metadata) + .SingleOrDefaultAsync(s => s.Id == televisionSeasonId) + .Map(Optional); + + public Task GetSeasonCount(int televisionShowId) => + _dbContext.TelevisionSeasons + .AsNoTracking() + .Where(s => s.TelevisionShowId == televisionShowId) + .CountAsync(); + + public Task> GetPagedSeasons(int televisionShowId, int pageNumber, int pageSize) => + _dbContext.TelevisionSeasons + .AsNoTracking() + .Where(s => s.TelevisionShowId == televisionShowId) + .Include(s => s.TelevisionShow) + .ThenInclude(s => s.Metadata) + .OrderBy(s => s.Number) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + public Task> GetEpisode(int televisionEpisodeId) => + _dbContext.TelevisionEpisodeMediaItems + .AsNoTracking() + .Include(s => s.Season) + .Include(s => s.Metadata) + .SingleOrDefaultAsync(s => s.Id == televisionEpisodeId) + .Map(Optional); + + public Task GetEpisodeCount(int televisionSeasonId) => + _dbContext.TelevisionEpisodeMediaItems + .AsNoTracking() + .Where(e => e.SeasonId == televisionSeasonId) + .CountAsync(); + + public Task> GetPagedEpisodes( + int televisionSeasonId, + int pageNumber, + int pageSize) => + _dbContext.TelevisionEpisodeMediaItems + .AsNoTracking() + .Include(e => e.Metadata) + .Include(e => e.Season) + .ThenInclude(s => s.TelevisionShow) + .ThenInclude(s => s.Metadata) + .Where(e => e.SeasonId == televisionSeasonId) + .OrderBy(s => s.Metadata.Episode) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + public async Task> GetShowByPath(int mediaSourceId, string path) + { + Option maybeShowId = await _dbContext.LocalTelevisionShowSources + .SingleOrDefaultAsync(s => s.MediaSourceId == mediaSourceId && s.Path == path) + .Map(Optional) + .MapT(s => s.TelevisionShowId); + + return await maybeShowId.Match>>( + async id => await _dbContext.TelevisionShows + .Include(s => s.Metadata) + .Include(s => s.Sources) + .SingleOrDefaultAsync(s => s.Id == id), + () => Task.FromResult(Option.None)); + } + + public async Task> GetShowByMetadata(TelevisionShowMetadata metadata) + { + Option maybeShow = await _dbContext.TelevisionShows + .Include(s => s.Metadata) + .Where(s => s.Metadata.Title == metadata.Title && s.Metadata.Year == metadata.Year) + .SingleOrDefaultAsync() + .Map(Optional); + + await maybeShow.IfSomeAsync( + async show => + { + await _dbContext.Entry(show).Reference(s => s.Metadata).LoadAsync(); + await _dbContext.Entry(show).Collection(s => s.Sources).LoadAsync(); + }); + + return maybeShow; + } + + public async Task> AddShow( + int localMediaSourceId, + string showFolder, + TelevisionShowMetadata metadata) + { + try + { + var show = new TelevisionShow + { + Sources = new List(), + Metadata = metadata, + Seasons = new List() + }; + + show.Sources.Add( + new LocalTelevisionShowSource + { + MediaSourceId = localMediaSourceId, + Path = showFolder, + TelevisionShow = show + }); + + await _dbContext.TelevisionShows.AddAsync(show); + await _dbContext.SaveChangesAsync(); + + return show; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + + public async Task> GetOrAddSeason( + TelevisionShow show, + string path, + int seasonNumber) + { + Option maybeExisting = await _dbContext.TelevisionSeasons + .SingleOrDefaultAsync(i => i.Path == path); + + return await maybeExisting.Match( + season => Right(season).AsTask(), + () => AddSeason(show, path, seasonNumber)); + } + + public async Task> GetOrAddEpisode( + TelevisionSeason season, + int mediaSourceId, + string path) + { + Option maybeExisting = await _dbContext.TelevisionEpisodeMediaItems + .Include(i => i.Metadata) + .Include(i => i.Statistics) + .SingleOrDefaultAsync(i => i.Path == path); + + return await maybeExisting.Match( + episode => Right(episode).AsTask(), + () => AddEpisode(season, mediaSourceId, path)); + } + + public Task DeleteMissingSources(int localMediaSourceId, List allFolders) => + _dbContext.LocalTelevisionShowSources + .Where(s => s.MediaSourceId == localMediaSourceId && !allFolders.Contains(s.Path)) + .ToListAsync() + .Bind( + list => + { + _dbContext.LocalTelevisionShowSources.RemoveRange(list); + return _dbContext.SaveChangesAsync(); + }) + .ToUnit(); + + public Task DeleteEmptyShows() => + _dbContext.TelevisionShows + .Where(s => s.Sources.Count == 0) + .ToListAsync() + .Bind( + list => + { + _dbContext.TelevisionShows.RemoveRange(list); + return _dbContext.SaveChangesAsync(); + }) + .ToUnit(); + + public async Task> GetShowItems(int televisionShowId) + { + // TODO: would be nice to get the media items in one go, but ef... + List showItemIds = await _dbContext.GenericIntegerIds.FromSqlRaw( + @"select tmi.Id +from TelevisionEpisodes tmi +inner join TelevisionSeasons tsn on tsn.Id = tmi.SeasonId +inner join TelevisionShows ts on ts.Id = tsn.TelevisionShowId +where ts.Id = {0}", + televisionShowId) + .Select(i => i.Id) + .ToListAsync(); + + return await _dbContext.TelevisionEpisodeMediaItems + .AsNoTracking() + .Include(e => e.Metadata) + .Where(mi => showItemIds.Contains(mi.Id)) + .ToListAsync(); + } + + public async Task> GetSeasonItems(int televisionSeasonId) + { + // TODO: would be nice to get the media items in one go, but ef... + List seasonItemIds = await _dbContext.GenericIntegerIds.FromSqlRaw( + @"select tmi.Id +from TelevisionEpisodes tmi +inner join TelevisionSeasons tsn on tsn.Id = tmi.SeasonId +where tsn.Id = {0}", + televisionSeasonId) + .Select(i => i.Id) + .ToListAsync(); + + return await _dbContext.TelevisionEpisodeMediaItems + .AsNoTracking() + .Include(e => e.Metadata) + .Where(mi => seasonItemIds.Contains(mi.Id)) + .ToListAsync(); + } + + private async Task> AddSeason( + TelevisionShow show, + string path, + int seasonNumber) + { + try + { + var season = new TelevisionSeason + { + TelevisionShowId = show.Id, Path = path, Number = seasonNumber, + Episodes = new List() + }; + await _dbContext.TelevisionSeasons.AddAsync(season); + await _dbContext.SaveChangesAsync(); + return season; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + + private async Task> AddEpisode( + TelevisionSeason season, + int mediaSourceId, + string path) + { + try + { + var episode = new TelevisionEpisodeMediaItem + { + MediaSourceId = mediaSourceId, + SeasonId = season.Id, + Path = path + }; + await _dbContext.TelevisionEpisodeMediaItems.AddAsync(episode); + await _dbContext.SaveChangesAsync(); + return episode; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/TvContext.cs b/ErsatzTV.Infrastructure/Data/TvContext.cs index 6e881c60..688a8e88 100644 --- a/ErsatzTV.Infrastructure/Data/TvContext.cs +++ b/ErsatzTV.Infrastructure/Data/TvContext.cs @@ -19,15 +19,20 @@ namespace ErsatzTV.Infrastructure.Data public DbSet LocalMediaSources { get; set; } public DbSet PlexMediaSources { get; set; } public DbSet MediaItems { get; set; } + public DbSet MovieMediaItems { get; set; } + public DbSet TelevisionEpisodeMediaItems { get; set; } public DbSet MediaCollections { get; set; } public DbSet SimpleMediaCollections { get; set; } - public DbSet TelevisionMediaCollections { get; set; } public DbSet ProgramSchedules { get; set; } public DbSet Playouts { get; set; } public DbSet PlayoutItems { get; set; } public DbSet PlayoutProgramScheduleItemAnchors { get; set; } public DbSet FFmpegProfiles { get; set; } public DbSet Resolutions { get; set; } + public DbSet TelevisionShows { get; set; } + public DbSet LocalTelevisionShowSources { get; set; } + public DbSet TelevisionShowMetadata { get; set; } + public DbSet TelevisionSeasons { get; set; } // support raw sql queries public DbSet MediaCollectionSummaries { get; set; } diff --git a/ErsatzTV.Infrastructure/Migrations/20210219165123_TelevisionExpansion.Designer.cs b/ErsatzTV.Infrastructure/Migrations/20210219165123_TelevisionExpansion.Designer.cs new file mode 100644 index 00000000..5951589c --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210219165123_TelevisionExpansion.Designer.cs @@ -0,0 +1,1224 @@ +// +using System; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace ErsatzTV.Infrastructure.Migrations +{ + [DbContext(typeof(TvContext))] + [Migration("20210219165123_TelevisionExpansion")] + partial class TelevisionExpansion + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.3"); + + modelBuilder.Entity("ErsatzTV.Core.AggregateModels.GenericIntegerId", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.ToView("No table or view exists for GenericIntegerId"); + }); + + modelBuilder.Entity("ErsatzTV.Core.AggregateModels.MediaCollectionSummary", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("IsSimple") + .HasColumnType("INTEGER"); + + b.Property("ItemCount") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.ToView("No table or view exists for MediaCollectionSummary"); + }); + + modelBuilder.Entity("ErsatzTV.Core.AggregateModels.MediaItemSummary", b => + { + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Subtitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.ToView("No table or view exists for MediaItemSummary"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FFmpegProfileId") + .HasColumnType("INTEGER"); + + b.Property("Logo") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("StreamingMode") + .HasColumnType("INTEGER"); + + b.Property("UniqueId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FFmpegProfileId"); + + b.HasIndex("Number") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ConfigElement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("ConfigElements"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudioBitrate") + .HasColumnType("INTEGER"); + + b.Property("AudioBufferSize") + .HasColumnType("INTEGER"); + + b.Property("AudioChannels") + .HasColumnType("INTEGER"); + + b.Property("AudioCodec") + .HasColumnType("TEXT"); + + b.Property("AudioSampleRate") + .HasColumnType("INTEGER"); + + b.Property("AudioVolume") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizeAudio") + .HasColumnType("INTEGER"); + + b.Property("NormalizeAudioCodec") + .HasColumnType("INTEGER"); + + b.Property("NormalizeResolution") + .HasColumnType("INTEGER"); + + b.Property("NormalizeVideoCodec") + .HasColumnType("INTEGER"); + + b.Property("ResolutionId") + .HasColumnType("INTEGER"); + + b.Property("ThreadCount") + .HasColumnType("INTEGER"); + + b.Property("Transcode") + .HasColumnType("INTEGER"); + + b.Property("VideoBitrate") + .HasColumnType("INTEGER"); + + b.Property("VideoBufferSize") + .HasColumnType("INTEGER"); + + b.Property("VideoCodec") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ResolutionId"); + + b.ToTable("FFmpegProfiles"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MediaCollections"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b.Property("MediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("PosterLastWriteTime") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaSourceId"); + + b.ToTable("MediaItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MediaSources"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasColumnType("TEXT"); + + b.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("INTEGER"); + + b.Property("Outline") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("Premiered") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MovieId") + .IsUnique(); + + b.ToTable("MovieMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.Property("ProgramSchedulePlayoutType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("ProgramScheduleId"); + + b.ToTable("Playouts"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Finish") + .HasColumnType("TEXT"); + + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaItemId"); + + b.HasIndex("PlayoutId"); + + b.ToTable("PlayoutItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b => + { + b.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.Property("MediaCollectionId") + .HasColumnType("INTEGER"); + + b.HasKey("PlayoutId", "ProgramScheduleId", "MediaCollectionId"); + + b.HasIndex("MediaCollectionId"); + + b.HasIndex("ProgramScheduleId"); + + b.ToTable("PlayoutProgramScheduleItemAnchors"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("PlexMediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlexMediaSourceId"); + + b.ToTable("PlexMediaSourceConnections"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceLibrary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MediaType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PlexMediaSourceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlexMediaSourceId"); + + b.ToTable("PlexMediaSourceLibraries"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MediaCollectionPlaybackOrder") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ProgramSchedules"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("MediaCollectionId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaCollectionId"); + + b.HasIndex("ProgramScheduleId"); + + b.ToTable("ProgramScheduleItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Resolution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Resolutions"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionEpisodeMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Aired") + .HasColumnType("TEXT"); + + b.Property("Episode") + .HasColumnType("INTEGER"); + + b.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("Season") + .HasColumnType("INTEGER"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("TelevisionEpisodeId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TelevisionEpisodeId") + .IsUnique(); + + b.ToTable("TelevisionEpisodeMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionSeason", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("PosterLastWriteTime") + .HasColumnType("TEXT"); + + b.Property("TelevisionShowId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TelevisionShowId"); + + b.ToTable("TelevisionSeasons"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("PosterLastWriteTime") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TelevisionShows"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShowMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("TelevisionShowId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TelevisionShowId") + .IsUnique(); + + b.ToTable("TelevisionShowMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShowSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Discriminator") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TelevisionShowId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TelevisionShowId"); + + b.ToTable("TelevisionShowSource"); + + b.HasDiscriminator("Discriminator").HasValue("TelevisionShowSource"); + }); + + modelBuilder.Entity("MediaItemSimpleMediaCollection", b => + { + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.Property("SimpleMediaCollectionsId") + .HasColumnType("INTEGER"); + + b.HasKey("ItemsId", "SimpleMediaCollectionsId"); + + b.HasIndex("SimpleMediaCollectionsId"); + + b.ToTable("MediaItemSimpleMediaCollection"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SimpleMediaCollection", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaCollection"); + + b.ToTable("SimpleMediaCollections"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionMediaCollection", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaCollection"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("ShowTitle") + .HasColumnType("TEXT"); + + b.HasIndex("ShowTitle", "SeasonNumber") + .IsUnique(); + + b.ToTable("TelevisionMediaCollections"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMediaItem", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.Property("MetadataId") + .HasColumnType("INTEGER"); + + b.ToTable("Movies"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.Property("SeasonId") + .HasColumnType("INTEGER"); + + b.HasIndex("SeasonId"); + + b.ToTable("TelevisionEpisodes"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); + + b.Property("Folder") + .HasColumnType("TEXT"); + + b.Property("MediaType") + .HasColumnType("INTEGER"); + + b.ToTable("LocalMediaSources"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); + + b.Property("ClientIdentifier") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.ToTable("PlexMediaSources"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.Property("OfflineTail") + .HasColumnType("INTEGER"); + + b.Property("PlayoutDuration") + .HasColumnType("TEXT"); + + b.ToTable("ProgramScheduleDurationItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.ToTable("ProgramScheduleFloodItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.ToTable("ProgramScheduleMultipleItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.ToTable("ProgramScheduleOneItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalTelevisionShowSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.TelevisionShowSource"); + + b.Property("MediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasIndex("MediaSourceId"); + + b.HasDiscriminator().HasValue("LocalTelevisionShowSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile") + .WithMany() + .HasForeignKey("FFmpegProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FFmpegProfile"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b => + { + b.HasOne("ErsatzTV.Core.Domain.Resolution", "Resolution") + .WithMany() + .HasForeignKey("ResolutionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Resolution"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", "Source") + .WithMany() + .HasForeignKey("MediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("ErsatzTV.Core.Domain.MediaItemStatistics", "Statistics", b1 => + { + b1.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b1.Property("AudioCodec") + .HasColumnType("TEXT"); + + b1.Property("DisplayAspectRatio") + .HasColumnType("TEXT"); + + b1.Property("Duration") + .HasColumnType("TEXT"); + + b1.Property("Height") + .HasColumnType("INTEGER"); + + b1.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b1.Property("SampleAspectRatio") + .HasColumnType("TEXT"); + + b1.Property("VideoCodec") + .HasColumnType("TEXT"); + + b1.Property("VideoScanType") + .HasColumnType("INTEGER"); + + b1.Property("Width") + .HasColumnType("INTEGER"); + + b1.HasKey("MediaItemId"); + + b1.ToTable("MediaItems"); + + b1.WithOwner() + .HasForeignKey("MediaItemId"); + }); + + b.Navigation("Source"); + + b.Navigation("Statistics"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.MovieMediaItem", "Movie") + .WithOne("Metadata") + .HasForeignKey("ErsatzTV.Core.Domain.MovieMetadata", "MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Movie"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel") + .WithMany("Playouts") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany("Playouts") + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("ErsatzTV.Core.Domain.PlayoutAnchor", "Anchor", b1 => + { + b1.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b1.Property("NextScheduleItemId") + .HasColumnType("INTEGER"); + + b1.Property("NextStart") + .HasColumnType("TEXT"); + + b1.HasKey("PlayoutId"); + + b1.HasIndex("NextScheduleItemId"); + + b1.ToTable("Playouts"); + + b1.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", "NextScheduleItem") + .WithMany() + .HasForeignKey("NextScheduleItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b1.WithOwner() + .HasForeignKey("PlayoutId"); + + b1.Navigation("NextScheduleItem"); + }); + + b.Navigation("Anchor"); + + b.Navigation("Channel"); + + b.Navigation("ProgramSchedule"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem") + .WithMany() + .HasForeignKey("MediaItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout") + .WithMany("Items") + .HasForeignKey("PlayoutId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaItem"); + + b.Navigation("Playout"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaCollection", "MediaCollection") + .WithMany() + .HasForeignKey("MediaCollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout") + .WithMany("ProgramScheduleAnchors") + .HasForeignKey("PlayoutId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany() + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("ErsatzTV.Core.Domain.MediaCollectionEnumeratorState", "EnumeratorState", b1 => + { + b1.Property("PlayoutProgramScheduleAnchorPlayoutId") + .HasColumnType("INTEGER"); + + b1.Property("PlayoutProgramScheduleAnchorProgramScheduleId") + .HasColumnType("INTEGER"); + + b1.Property("PlayoutProgramScheduleAnchorMediaCollectionId") + .HasColumnType("INTEGER"); + + b1.Property("Index") + .HasColumnType("INTEGER"); + + b1.Property("Seed") + .HasColumnType("INTEGER"); + + b1.HasKey("PlayoutProgramScheduleAnchorPlayoutId", "PlayoutProgramScheduleAnchorProgramScheduleId", "PlayoutProgramScheduleAnchorMediaCollectionId"); + + b1.ToTable("PlayoutProgramScheduleItemAnchors"); + + b1.WithOwner() + .HasForeignKey("PlayoutProgramScheduleAnchorPlayoutId", "PlayoutProgramScheduleAnchorProgramScheduleId", "PlayoutProgramScheduleAnchorMediaCollectionId"); + }); + + b.Navigation("EnumeratorState"); + + b.Navigation("MediaCollection"); + + b.Navigation("Playout"); + + b.Navigation("ProgramSchedule"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceConnection", b => + { + b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", null) + .WithMany("Connections") + .HasForeignKey("PlexMediaSourceId"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceLibrary", b => + { + b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", null) + .WithMany("Libraries") + .HasForeignKey("PlexMediaSourceId"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaCollection", "MediaCollection") + .WithMany() + .HasForeignKey("MediaCollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany("Items") + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaCollection"); + + b.Navigation("ProgramSchedule"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionEpisodeMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", "TelevisionEpisode") + .WithOne("Metadata") + .HasForeignKey("ErsatzTV.Core.Domain.TelevisionEpisodeMetadata", "TelevisionEpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TelevisionEpisode"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionSeason", b => + { + b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", "TelevisionShow") + .WithMany("Seasons") + .HasForeignKey("TelevisionShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TelevisionShow"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShowMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", "TelevisionShow") + .WithOne("Metadata") + .HasForeignKey("ErsatzTV.Core.Domain.TelevisionShowMetadata", "TelevisionShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TelevisionShow"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShowSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", "TelevisionShow") + .WithMany("Sources") + .HasForeignKey("TelevisionShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TelevisionShow"); + }); + + modelBuilder.Entity("MediaItemSimpleMediaCollection", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.SimpleMediaCollection", null) + .WithMany() + .HasForeignKey("SimpleMediaCollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SimpleMediaCollection", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaCollection", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.SimpleMediaCollection", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionMediaCollection", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaCollection", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.TelevisionMediaCollection", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMediaItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.MovieMediaItem", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.TelevisionSeason", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.LocalMediaSource", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexMediaSource", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemOne", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalTelevisionShowSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.LocalMediaSource", "MediaSource") + .WithMany() + .HasForeignKey("MediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.Navigation("Playouts"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.Navigation("Items"); + + b.Navigation("ProgramScheduleAnchors"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b => + { + b.Navigation("Items"); + + b.Navigation("Playouts"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionSeason", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShow", b => + { + b.Navigation("Metadata"); + + b.Navigation("Seasons"); + + b.Navigation("Sources"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMediaItem", b => + { + b.Navigation("Metadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", b => + { + b.Navigation("Metadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.Navigation("Connections"); + + b.Navigation("Libraries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/20210219165123_TelevisionExpansion.cs b/ErsatzTV.Infrastructure/Migrations/20210219165123_TelevisionExpansion.cs new file mode 100644 index 00000000..581b0537 --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210219165123_TelevisionExpansion.cs @@ -0,0 +1,453 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace ErsatzTV.Infrastructure.Migrations +{ + public partial class TelevisionExpansion : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + "Metadata_Aired", + "MediaItems"); + + migrationBuilder.DropColumn( + "Metadata_ContentRating", + "MediaItems"); + + migrationBuilder.DropColumn( + "Metadata_Description", + "MediaItems"); + + migrationBuilder.DropColumn( + "Metadata_EpisodeNumber", + "MediaItems"); + + migrationBuilder.DropColumn( + "Metadata_MediaType", + "MediaItems"); + + migrationBuilder.DropColumn( + "Metadata_SeasonNumber", + "MediaItems"); + + migrationBuilder.DropColumn( + "Metadata_SortTitle", + "MediaItems"); + + migrationBuilder.DropColumn( + "Metadata_Source", + "MediaItems"); + + migrationBuilder.DropColumn( + "Metadata_Subtitle", + "MediaItems"); + + migrationBuilder.DropColumn( + "Metadata_Title", + "MediaItems"); + + migrationBuilder.RenameColumn( + "Metadata_Width", + "MediaItems", + "Statistics_Width"); + + migrationBuilder.RenameColumn( + "Metadata_VideoScanType", + "MediaItems", + "Statistics_VideoScanType"); + + migrationBuilder.RenameColumn( + "Metadata_VideoCodec", + "MediaItems", + "Statistics_VideoCodec"); + + migrationBuilder.RenameColumn( + "Metadata_SampleAspectRatio", + "MediaItems", + "Statistics_SampleAspectRatio"); + + migrationBuilder.RenameColumn( + "Metadata_LastWriteTime", + "MediaItems", + "Statistics_LastWriteTime"); + + migrationBuilder.RenameColumn( + "Metadata_Height", + "MediaItems", + "Statistics_Height"); + + migrationBuilder.RenameColumn( + "Metadata_Duration", + "MediaItems", + "Statistics_Duration"); + + migrationBuilder.RenameColumn( + "Metadata_DisplayAspectRatio", + "MediaItems", + "Statistics_DisplayAspectRatio"); + + migrationBuilder.RenameColumn( + "Metadata_AudioCodec", + "MediaItems", + "Statistics_AudioCodec"); + + migrationBuilder.CreateTable( + "Movies", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + MetadataId = table.Column("INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Movies", x => x.Id); + table.ForeignKey( + "FK_Movies_MediaItems_Id", + x => x.Id, + "MediaItems", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + "TelevisionShows", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Poster = table.Column("TEXT", nullable: true), + PosterLastWriteTime = table.Column("TEXT", nullable: true) + }, + constraints: table => { table.PrimaryKey("PK_TelevisionShows", x => x.Id); }); + + migrationBuilder.CreateTable( + "MovieMetadata", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + MovieId = table.Column("INTEGER", nullable: false), + Year = table.Column("INTEGER", nullable: true), + Premiered = table.Column("TEXT", nullable: true), + Plot = table.Column("TEXT", nullable: true), + Outline = table.Column("TEXT", nullable: true), + Tagline = table.Column("TEXT", nullable: true), + ContentRating = table.Column("TEXT", nullable: true), + Source = table.Column("INTEGER", nullable: false), + LastWriteTime = table.Column("TEXT", nullable: true), + Title = table.Column("TEXT", nullable: true), + SortTitle = table.Column("TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MovieMetadata", x => x.Id); + table.ForeignKey( + "FK_MovieMetadata_Movies_MovieId", + x => x.MovieId, + "Movies", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + "TelevisionSeasons", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + TelevisionShowId = table.Column("INTEGER", nullable: false), + Number = table.Column("INTEGER", nullable: false), + Path = table.Column("TEXT", nullable: true), + Poster = table.Column("TEXT", nullable: true), + PosterLastWriteTime = table.Column("TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TelevisionSeasons", x => x.Id); + table.ForeignKey( + "FK_TelevisionSeasons_TelevisionShows_TelevisionShowId", + x => x.TelevisionShowId, + "TelevisionShows", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + "TelevisionShowMetadata", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + TelevisionShowId = table.Column("INTEGER", nullable: false), + Source = table.Column("INTEGER", nullable: false), + LastWriteTime = table.Column("TEXT", nullable: true), + Title = table.Column("TEXT", nullable: true), + SortTitle = table.Column("TEXT", nullable: true), + Year = table.Column("INTEGER", nullable: true), + Plot = table.Column("TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TelevisionShowMetadata", x => x.Id); + table.ForeignKey( + "FK_TelevisionShowMetadata_TelevisionShows_TelevisionShowId", + x => x.TelevisionShowId, + "TelevisionShows", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + "TelevisionShowSource", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + TelevisionShowId = table.Column("INTEGER", nullable: false), + Discriminator = table.Column("TEXT", nullable: false), + MediaSourceId = table.Column("INTEGER", nullable: true), + Path = table.Column("TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TelevisionShowSource", x => x.Id); + table.ForeignKey( + "FK_TelevisionShowSource_LocalMediaSources_MediaSourceId", + x => x.MediaSourceId, + "LocalMediaSources", + "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + "FK_TelevisionShowSource_TelevisionShows_TelevisionShowId", + x => x.TelevisionShowId, + "TelevisionShows", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + "TelevisionEpisodes", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + SeasonId = table.Column("INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TelevisionEpisodes", x => x.Id); + table.ForeignKey( + "FK_TelevisionEpisodes_MediaItems_Id", + x => x.Id, + "MediaItems", + "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + "FK_TelevisionEpisodes_TelevisionSeasons_SeasonId", + x => x.SeasonId, + "TelevisionSeasons", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + "TelevisionEpisodeMetadata", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + TelevisionEpisodeId = table.Column("INTEGER", nullable: false), + Season = table.Column("INTEGER", nullable: false), + Episode = table.Column("INTEGER", nullable: false), + Plot = table.Column("TEXT", nullable: true), + Aired = table.Column("TEXT", nullable: true), + Source = table.Column("INTEGER", nullable: false), + LastWriteTime = table.Column("TEXT", nullable: true), + Title = table.Column("TEXT", nullable: true), + SortTitle = table.Column("TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TelevisionEpisodeMetadata", x => x.Id); + table.ForeignKey( + "FK_TelevisionEpisodeMetadata_TelevisionEpisodes_TelevisionEpisodeId", + x => x.TelevisionEpisodeId, + "TelevisionEpisodes", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + "IX_MovieMetadata_MovieId", + "MovieMetadata", + "MovieId", + unique: true); + + migrationBuilder.CreateIndex( + "IX_TelevisionEpisodeMetadata_TelevisionEpisodeId", + "TelevisionEpisodeMetadata", + "TelevisionEpisodeId", + unique: true); + + migrationBuilder.CreateIndex( + "IX_TelevisionEpisodes_SeasonId", + "TelevisionEpisodes", + "SeasonId"); + + migrationBuilder.CreateIndex( + "IX_TelevisionSeasons_TelevisionShowId", + "TelevisionSeasons", + "TelevisionShowId"); + + migrationBuilder.CreateIndex( + "IX_TelevisionShowMetadata_TelevisionShowId", + "TelevisionShowMetadata", + "TelevisionShowId", + unique: true); + + migrationBuilder.CreateIndex( + "IX_TelevisionShowSource_MediaSourceId", + "TelevisionShowSource", + "MediaSourceId"); + + migrationBuilder.CreateIndex( + "IX_TelevisionShowSource_TelevisionShowId", + "TelevisionShowSource", + "TelevisionShowId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + "MovieMetadata"); + + migrationBuilder.DropTable( + "TelevisionEpisodeMetadata"); + + migrationBuilder.DropTable( + "TelevisionShowMetadata"); + + migrationBuilder.DropTable( + "TelevisionShowSource"); + + migrationBuilder.DropTable( + "Movies"); + + migrationBuilder.DropTable( + "TelevisionEpisodes"); + + migrationBuilder.DropTable( + "TelevisionSeasons"); + + migrationBuilder.DropTable( + "TelevisionShows"); + + migrationBuilder.RenameColumn( + "Statistics_Width", + "MediaItems", + "Metadata_Width"); + + migrationBuilder.RenameColumn( + "Statistics_VideoScanType", + "MediaItems", + "Metadata_VideoScanType"); + + migrationBuilder.RenameColumn( + "Statistics_VideoCodec", + "MediaItems", + "Metadata_VideoCodec"); + + migrationBuilder.RenameColumn( + "Statistics_SampleAspectRatio", + "MediaItems", + "Metadata_SampleAspectRatio"); + + migrationBuilder.RenameColumn( + "Statistics_LastWriteTime", + "MediaItems", + "Metadata_LastWriteTime"); + + migrationBuilder.RenameColumn( + "Statistics_Height", + "MediaItems", + "Metadata_Height"); + + migrationBuilder.RenameColumn( + "Statistics_Duration", + "MediaItems", + "Metadata_Duration"); + + migrationBuilder.RenameColumn( + "Statistics_DisplayAspectRatio", + "MediaItems", + "Metadata_DisplayAspectRatio"); + + migrationBuilder.RenameColumn( + "Statistics_AudioCodec", + "MediaItems", + "Metadata_AudioCodec"); + + migrationBuilder.AddColumn( + "Metadata_Aired", + "MediaItems", + "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + "Metadata_ContentRating", + "MediaItems", + "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + "Metadata_Description", + "MediaItems", + "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + "Metadata_EpisodeNumber", + "MediaItems", + "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + "Metadata_MediaType", + "MediaItems", + "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + "Metadata_SeasonNumber", + "MediaItems", + "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + "Metadata_SortTitle", + "MediaItems", + "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + "Metadata_Source", + "MediaItems", + "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + "Metadata_Subtitle", + "MediaItems", + "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + "Metadata_Title", + "MediaItems", + "TEXT", + nullable: true); + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/20210220003018_CollectionsRework.Designer.cs b/ErsatzTV.Infrastructure/Migrations/20210220003018_CollectionsRework.Designer.cs new file mode 100644 index 00000000..ee59dd78 --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210220003018_CollectionsRework.Designer.cs @@ -0,0 +1,1289 @@ +// +using System; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace ErsatzTV.Infrastructure.Migrations +{ + [DbContext(typeof(TvContext))] + [Migration("20210220003018_CollectionsRework")] + partial class CollectionsRework + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.3"); + + modelBuilder.Entity("ErsatzTV.Core.AggregateModels.GenericIntegerId", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.ToView("No table or view exists for GenericIntegerId"); + }); + + modelBuilder.Entity("ErsatzTV.Core.AggregateModels.MediaCollectionSummary", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("IsSimple") + .HasColumnType("INTEGER"); + + b.Property("ItemCount") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.ToView("No table or view exists for MediaCollectionSummary"); + }); + + modelBuilder.Entity("ErsatzTV.Core.AggregateModels.MediaItemSummary", b => + { + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Subtitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.ToView("No table or view exists for MediaItemSummary"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FFmpegProfileId") + .HasColumnType("INTEGER"); + + b.Property("Logo") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("StreamingMode") + .HasColumnType("INTEGER"); + + b.Property("UniqueId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FFmpegProfileId"); + + b.HasIndex("Number") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ConfigElement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("ConfigElements"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudioBitrate") + .HasColumnType("INTEGER"); + + b.Property("AudioBufferSize") + .HasColumnType("INTEGER"); + + b.Property("AudioChannels") + .HasColumnType("INTEGER"); + + b.Property("AudioCodec") + .HasColumnType("TEXT"); + + b.Property("AudioSampleRate") + .HasColumnType("INTEGER"); + + b.Property("AudioVolume") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizeAudio") + .HasColumnType("INTEGER"); + + b.Property("NormalizeAudioCodec") + .HasColumnType("INTEGER"); + + b.Property("NormalizeResolution") + .HasColumnType("INTEGER"); + + b.Property("NormalizeVideoCodec") + .HasColumnType("INTEGER"); + + b.Property("ResolutionId") + .HasColumnType("INTEGER"); + + b.Property("ThreadCount") + .HasColumnType("INTEGER"); + + b.Property("Transcode") + .HasColumnType("INTEGER"); + + b.Property("VideoBitrate") + .HasColumnType("INTEGER"); + + b.Property("VideoBufferSize") + .HasColumnType("INTEGER"); + + b.Property("VideoCodec") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ResolutionId"); + + b.ToTable("FFmpegProfiles"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MediaCollections"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b.Property("MediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("PosterLastWriteTime") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaSourceId"); + + b.ToTable("MediaItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MediaSources"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasColumnType("TEXT"); + + b.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("INTEGER"); + + b.Property("Outline") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("Premiered") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MovieId") + .IsUnique(); + + b.ToTable("MovieMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.Property("ProgramSchedulePlayoutType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("ProgramScheduleId"); + + b.ToTable("Playouts"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Finish") + .HasColumnType("TEXT"); + + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaItemId"); + + b.HasIndex("PlayoutId"); + + b.ToTable("PlayoutItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b => + { + b.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.Property("MediaCollectionId") + .HasColumnType("INTEGER"); + + b.HasKey("PlayoutId", "ProgramScheduleId", "MediaCollectionId"); + + b.HasIndex("MediaCollectionId"); + + b.HasIndex("ProgramScheduleId"); + + b.ToTable("PlayoutProgramScheduleItemAnchors"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("PlexMediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlexMediaSourceId"); + + b.ToTable("PlexMediaSourceConnections"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceLibrary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MediaType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PlexMediaSourceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlexMediaSourceId"); + + b.ToTable("PlexMediaSourceLibraries"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MediaCollectionPlaybackOrder") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ProgramSchedules"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("MediaCollectionId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaCollectionId"); + + b.HasIndex("ProgramScheduleId"); + + b.ToTable("ProgramScheduleItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Resolution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Resolutions"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionEpisodeMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Aired") + .HasColumnType("TEXT"); + + b.Property("Episode") + .HasColumnType("INTEGER"); + + b.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("Season") + .HasColumnType("INTEGER"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("TelevisionEpisodeId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TelevisionEpisodeId") + .IsUnique(); + + b.ToTable("TelevisionEpisodeMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionSeason", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("PosterLastWriteTime") + .HasColumnType("TEXT"); + + b.Property("TelevisionShowId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TelevisionShowId"); + + b.ToTable("TelevisionSeasons"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("PosterLastWriteTime") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TelevisionShows"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShowMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("TelevisionShowId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TelevisionShowId") + .IsUnique(); + + b.ToTable("TelevisionShowMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShowSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Discriminator") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TelevisionShowId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TelevisionShowId"); + + b.ToTable("TelevisionShowSource"); + + b.HasDiscriminator("Discriminator").HasValue("TelevisionShowSource"); + }); + + modelBuilder.Entity("MovieMediaItemSimpleMediaCollection", b => + { + b.Property("MoviesId") + .HasColumnType("INTEGER"); + + b.Property("SimpleMediaCollectionsId") + .HasColumnType("INTEGER"); + + b.HasKey("MoviesId", "SimpleMediaCollectionsId"); + + b.HasIndex("SimpleMediaCollectionsId"); + + b.ToTable("SimpleMediaCollectionMovies"); + }); + + modelBuilder.Entity("SimpleMediaCollectionTelevisionEpisodeMediaItem", b => + { + b.Property("SimpleMediaCollectionsId") + .HasColumnType("INTEGER"); + + b.Property("TelevisionEpisodesId") + .HasColumnType("INTEGER"); + + b.HasKey("SimpleMediaCollectionsId", "TelevisionEpisodesId"); + + b.HasIndex("TelevisionEpisodesId"); + + b.ToTable("SimpleMediaCollectionEpisodes"); + }); + + modelBuilder.Entity("SimpleMediaCollectionTelevisionSeason", b => + { + b.Property("SimpleMediaCollectionsId") + .HasColumnType("INTEGER"); + + b.Property("TelevisionSeasonsId") + .HasColumnType("INTEGER"); + + b.HasKey("SimpleMediaCollectionsId", "TelevisionSeasonsId"); + + b.HasIndex("TelevisionSeasonsId"); + + b.ToTable("SimpleMediaCollectionSeasons"); + }); + + modelBuilder.Entity("SimpleMediaCollectionTelevisionShow", b => + { + b.Property("SimpleMediaCollectionsId") + .HasColumnType("INTEGER"); + + b.Property("TelevisionShowsId") + .HasColumnType("INTEGER"); + + b.HasKey("SimpleMediaCollectionsId", "TelevisionShowsId"); + + b.HasIndex("TelevisionShowsId"); + + b.ToTable("SimpleMediaCollectionShows"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SimpleMediaCollection", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaCollection"); + + b.ToTable("SimpleMediaCollections"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMediaItem", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.Property("MetadataId") + .HasColumnType("INTEGER"); + + b.ToTable("Movies"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.Property("SeasonId") + .HasColumnType("INTEGER"); + + b.HasIndex("SeasonId"); + + b.ToTable("TelevisionEpisodes"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); + + b.Property("Folder") + .HasColumnType("TEXT"); + + b.Property("MediaType") + .HasColumnType("INTEGER"); + + b.ToTable("LocalMediaSources"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); + + b.Property("ClientIdentifier") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.ToTable("PlexMediaSources"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.Property("OfflineTail") + .HasColumnType("INTEGER"); + + b.Property("PlayoutDuration") + .HasColumnType("TEXT"); + + b.ToTable("ProgramScheduleDurationItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.ToTable("ProgramScheduleFloodItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.ToTable("ProgramScheduleMultipleItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.ToTable("ProgramScheduleOneItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalTelevisionShowSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.TelevisionShowSource"); + + b.Property("MediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasIndex("MediaSourceId"); + + b.HasDiscriminator().HasValue("LocalTelevisionShowSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile") + .WithMany() + .HasForeignKey("FFmpegProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FFmpegProfile"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b => + { + b.HasOne("ErsatzTV.Core.Domain.Resolution", "Resolution") + .WithMany() + .HasForeignKey("ResolutionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Resolution"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", "Source") + .WithMany() + .HasForeignKey("MediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("ErsatzTV.Core.Domain.MediaItemStatistics", "Statistics", b1 => + { + b1.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b1.Property("AudioCodec") + .HasColumnType("TEXT"); + + b1.Property("DisplayAspectRatio") + .HasColumnType("TEXT"); + + b1.Property("Duration") + .HasColumnType("TEXT"); + + b1.Property("Height") + .HasColumnType("INTEGER"); + + b1.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b1.Property("SampleAspectRatio") + .HasColumnType("TEXT"); + + b1.Property("VideoCodec") + .HasColumnType("TEXT"); + + b1.Property("VideoScanType") + .HasColumnType("INTEGER"); + + b1.Property("Width") + .HasColumnType("INTEGER"); + + b1.HasKey("MediaItemId"); + + b1.ToTable("MediaItems"); + + b1.WithOwner() + .HasForeignKey("MediaItemId"); + }); + + b.Navigation("Source"); + + b.Navigation("Statistics"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.MovieMediaItem", "Movie") + .WithOne("Metadata") + .HasForeignKey("ErsatzTV.Core.Domain.MovieMetadata", "MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Movie"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel") + .WithMany("Playouts") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany("Playouts") + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("ErsatzTV.Core.Domain.PlayoutAnchor", "Anchor", b1 => + { + b1.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b1.Property("NextScheduleItemId") + .HasColumnType("INTEGER"); + + b1.Property("NextStart") + .HasColumnType("TEXT"); + + b1.HasKey("PlayoutId"); + + b1.HasIndex("NextScheduleItemId"); + + b1.ToTable("Playouts"); + + b1.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", "NextScheduleItem") + .WithMany() + .HasForeignKey("NextScheduleItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b1.WithOwner() + .HasForeignKey("PlayoutId"); + + b1.Navigation("NextScheduleItem"); + }); + + b.Navigation("Anchor"); + + b.Navigation("Channel"); + + b.Navigation("ProgramSchedule"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem") + .WithMany() + .HasForeignKey("MediaItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout") + .WithMany("Items") + .HasForeignKey("PlayoutId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaItem"); + + b.Navigation("Playout"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaCollection", "MediaCollection") + .WithMany() + .HasForeignKey("MediaCollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout") + .WithMany("ProgramScheduleAnchors") + .HasForeignKey("PlayoutId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany() + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("ErsatzTV.Core.Domain.MediaCollectionEnumeratorState", "EnumeratorState", b1 => + { + b1.Property("PlayoutProgramScheduleAnchorPlayoutId") + .HasColumnType("INTEGER"); + + b1.Property("PlayoutProgramScheduleAnchorProgramScheduleId") + .HasColumnType("INTEGER"); + + b1.Property("PlayoutProgramScheduleAnchorMediaCollectionId") + .HasColumnType("INTEGER"); + + b1.Property("Index") + .HasColumnType("INTEGER"); + + b1.Property("Seed") + .HasColumnType("INTEGER"); + + b1.HasKey("PlayoutProgramScheduleAnchorPlayoutId", "PlayoutProgramScheduleAnchorProgramScheduleId", "PlayoutProgramScheduleAnchorMediaCollectionId"); + + b1.ToTable("PlayoutProgramScheduleItemAnchors"); + + b1.WithOwner() + .HasForeignKey("PlayoutProgramScheduleAnchorPlayoutId", "PlayoutProgramScheduleAnchorProgramScheduleId", "PlayoutProgramScheduleAnchorMediaCollectionId"); + }); + + b.Navigation("EnumeratorState"); + + b.Navigation("MediaCollection"); + + b.Navigation("Playout"); + + b.Navigation("ProgramSchedule"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceConnection", b => + { + b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", null) + .WithMany("Connections") + .HasForeignKey("PlexMediaSourceId"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceLibrary", b => + { + b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", null) + .WithMany("Libraries") + .HasForeignKey("PlexMediaSourceId"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaCollection", "MediaCollection") + .WithMany() + .HasForeignKey("MediaCollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany("Items") + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaCollection"); + + b.Navigation("ProgramSchedule"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionEpisodeMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", "TelevisionEpisode") + .WithOne("Metadata") + .HasForeignKey("ErsatzTV.Core.Domain.TelevisionEpisodeMetadata", "TelevisionEpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TelevisionEpisode"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionSeason", b => + { + b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", "TelevisionShow") + .WithMany("Seasons") + .HasForeignKey("TelevisionShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TelevisionShow"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShowMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", "TelevisionShow") + .WithOne("Metadata") + .HasForeignKey("ErsatzTV.Core.Domain.TelevisionShowMetadata", "TelevisionShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TelevisionShow"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShowSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", "TelevisionShow") + .WithMany("Sources") + .HasForeignKey("TelevisionShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TelevisionShow"); + }); + + modelBuilder.Entity("MovieMediaItemSimpleMediaCollection", b => + { + b.HasOne("ErsatzTV.Core.Domain.MovieMediaItem", null) + .WithMany() + .HasForeignKey("MoviesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.SimpleMediaCollection", null) + .WithMany() + .HasForeignKey("SimpleMediaCollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimpleMediaCollectionTelevisionEpisodeMediaItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.SimpleMediaCollection", null) + .WithMany() + .HasForeignKey("SimpleMediaCollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", null) + .WithMany() + .HasForeignKey("TelevisionEpisodesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimpleMediaCollectionTelevisionSeason", b => + { + b.HasOne("ErsatzTV.Core.Domain.SimpleMediaCollection", null) + .WithMany() + .HasForeignKey("SimpleMediaCollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.TelevisionSeason", null) + .WithMany() + .HasForeignKey("TelevisionSeasonsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimpleMediaCollectionTelevisionShow", b => + { + b.HasOne("ErsatzTV.Core.Domain.SimpleMediaCollection", null) + .WithMany() + .HasForeignKey("SimpleMediaCollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", null) + .WithMany() + .HasForeignKey("TelevisionShowsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SimpleMediaCollection", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaCollection", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.SimpleMediaCollection", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMediaItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.MovieMediaItem", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.TelevisionSeason", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.LocalMediaSource", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexMediaSource", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemOne", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalTelevisionShowSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.LocalMediaSource", "MediaSource") + .WithMany() + .HasForeignKey("MediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.Navigation("Playouts"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.Navigation("Items"); + + b.Navigation("ProgramScheduleAnchors"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b => + { + b.Navigation("Items"); + + b.Navigation("Playouts"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionSeason", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShow", b => + { + b.Navigation("Metadata"); + + b.Navigation("Seasons"); + + b.Navigation("Sources"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMediaItem", b => + { + b.Navigation("Metadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", b => + { + b.Navigation("Metadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.Navigation("Connections"); + + b.Navigation("Libraries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/20210220003018_CollectionsRework.cs b/ErsatzTV.Infrastructure/Migrations/20210220003018_CollectionsRework.cs new file mode 100644 index 00000000..c27d17cc --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210220003018_CollectionsRework.cs @@ -0,0 +1,212 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace ErsatzTV.Infrastructure.Migrations +{ + public partial class CollectionsRework : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + "MediaItemSimpleMediaCollection"); + + migrationBuilder.DropTable( + "TelevisionMediaCollections"); + + migrationBuilder.CreateTable( + "SimpleMediaCollectionEpisodes", + table => new + { + SimpleMediaCollectionsId = table.Column("INTEGER", nullable: false), + TelevisionEpisodesId = table.Column("INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey( + "PK_SimpleMediaCollectionEpisodes", + x => new { x.SimpleMediaCollectionsId, x.TelevisionEpisodesId }); + table.ForeignKey( + "FK_SimpleMediaCollectionEpisodes_SimpleMediaCollections_SimpleMediaCollectionsId", + x => x.SimpleMediaCollectionsId, + "SimpleMediaCollections", + "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + "FK_SimpleMediaCollectionEpisodes_TelevisionEpisodes_TelevisionEpisodesId", + x => x.TelevisionEpisodesId, + "TelevisionEpisodes", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + "SimpleMediaCollectionMovies", + table => new + { + MoviesId = table.Column("INTEGER", nullable: false), + SimpleMediaCollectionsId = table.Column("INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey( + "PK_SimpleMediaCollectionMovies", + x => new { x.MoviesId, x.SimpleMediaCollectionsId }); + table.ForeignKey( + "FK_SimpleMediaCollectionMovies_Movies_MoviesId", + x => x.MoviesId, + "Movies", + "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + "FK_SimpleMediaCollectionMovies_SimpleMediaCollections_SimpleMediaCollectionsId", + x => x.SimpleMediaCollectionsId, + "SimpleMediaCollections", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + "SimpleMediaCollectionSeasons", + table => new + { + SimpleMediaCollectionsId = table.Column("INTEGER", nullable: false), + TelevisionSeasonsId = table.Column("INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey( + "PK_SimpleMediaCollectionSeasons", + x => new { x.SimpleMediaCollectionsId, x.TelevisionSeasonsId }); + table.ForeignKey( + "FK_SimpleMediaCollectionSeasons_SimpleMediaCollections_SimpleMediaCollectionsId", + x => x.SimpleMediaCollectionsId, + "SimpleMediaCollections", + "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + "FK_SimpleMediaCollectionSeasons_TelevisionSeasons_TelevisionSeasonsId", + x => x.TelevisionSeasonsId, + "TelevisionSeasons", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + "SimpleMediaCollectionShows", + table => new + { + SimpleMediaCollectionsId = table.Column("INTEGER", nullable: false), + TelevisionShowsId = table.Column("INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey( + "PK_SimpleMediaCollectionShows", + x => new { x.SimpleMediaCollectionsId, x.TelevisionShowsId }); + table.ForeignKey( + "FK_SimpleMediaCollectionShows_SimpleMediaCollections_SimpleMediaCollectionsId", + x => x.SimpleMediaCollectionsId, + "SimpleMediaCollections", + "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + "FK_SimpleMediaCollectionShows_TelevisionShows_TelevisionShowsId", + x => x.TelevisionShowsId, + "TelevisionShows", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + "IX_SimpleMediaCollectionEpisodes_TelevisionEpisodesId", + "SimpleMediaCollectionEpisodes", + "TelevisionEpisodesId"); + + migrationBuilder.CreateIndex( + "IX_SimpleMediaCollectionMovies_SimpleMediaCollectionsId", + "SimpleMediaCollectionMovies", + "SimpleMediaCollectionsId"); + + migrationBuilder.CreateIndex( + "IX_SimpleMediaCollectionSeasons_TelevisionSeasonsId", + "SimpleMediaCollectionSeasons", + "TelevisionSeasonsId"); + + migrationBuilder.CreateIndex( + "IX_SimpleMediaCollectionShows_TelevisionShowsId", + "SimpleMediaCollectionShows", + "TelevisionShowsId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + "SimpleMediaCollectionEpisodes"); + + migrationBuilder.DropTable( + "SimpleMediaCollectionMovies"); + + migrationBuilder.DropTable( + "SimpleMediaCollectionSeasons"); + + migrationBuilder.DropTable( + "SimpleMediaCollectionShows"); + + migrationBuilder.CreateTable( + "MediaItemSimpleMediaCollection", + table => new + { + ItemsId = table.Column("INTEGER", nullable: false), + SimpleMediaCollectionsId = table.Column("INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey( + "PK_MediaItemSimpleMediaCollection", + x => new { x.ItemsId, x.SimpleMediaCollectionsId }); + table.ForeignKey( + "FK_MediaItemSimpleMediaCollection_MediaItems_ItemsId", + x => x.ItemsId, + "MediaItems", + "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + "FK_MediaItemSimpleMediaCollection_SimpleMediaCollections_SimpleMediaCollectionsId", + x => x.SimpleMediaCollectionsId, + "SimpleMediaCollections", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + "TelevisionMediaCollections", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + SeasonNumber = table.Column("INTEGER", nullable: true), + ShowTitle = table.Column("TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TelevisionMediaCollections", x => x.Id); + table.ForeignKey( + "FK_TelevisionMediaCollections_MediaCollections_Id", + x => x.Id, + "MediaCollections", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + "IX_MediaItemSimpleMediaCollection_SimpleMediaCollectionsId", + "MediaItemSimpleMediaCollection", + "SimpleMediaCollectionsId"); + + migrationBuilder.CreateIndex( + "IX_TelevisionMediaCollections_ShowTitle_SeasonNumber", + "TelevisionMediaCollections", + new[] { "ShowTitle", "SeasonNumber" }, + unique: true); + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/20210220220723_ScheduleCollectionTypes.Designer.cs b/ErsatzTV.Infrastructure/Migrations/20210220220723_ScheduleCollectionTypes.Designer.cs new file mode 100644 index 00000000..cc378097 --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210220220723_ScheduleCollectionTypes.Designer.cs @@ -0,0 +1,1305 @@ +// +using System; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace ErsatzTV.Infrastructure.Migrations +{ + [DbContext(typeof(TvContext))] + [Migration("20210220220723_ScheduleCollectionTypes")] + partial class ScheduleCollectionTypes + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.3"); + + modelBuilder.Entity("ErsatzTV.Core.AggregateModels.GenericIntegerId", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.ToView("No table or view exists for GenericIntegerId"); + }); + + modelBuilder.Entity("ErsatzTV.Core.AggregateModels.MediaCollectionSummary", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("IsSimple") + .HasColumnType("INTEGER"); + + b.Property("ItemCount") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.ToView("No table or view exists for MediaCollectionSummary"); + }); + + modelBuilder.Entity("ErsatzTV.Core.AggregateModels.MediaItemSummary", b => + { + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Subtitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.ToView("No table or view exists for MediaItemSummary"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FFmpegProfileId") + .HasColumnType("INTEGER"); + + b.Property("Logo") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("StreamingMode") + .HasColumnType("INTEGER"); + + b.Property("UniqueId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FFmpegProfileId"); + + b.HasIndex("Number") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ConfigElement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("ConfigElements"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudioBitrate") + .HasColumnType("INTEGER"); + + b.Property("AudioBufferSize") + .HasColumnType("INTEGER"); + + b.Property("AudioChannels") + .HasColumnType("INTEGER"); + + b.Property("AudioCodec") + .HasColumnType("TEXT"); + + b.Property("AudioSampleRate") + .HasColumnType("INTEGER"); + + b.Property("AudioVolume") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizeAudio") + .HasColumnType("INTEGER"); + + b.Property("NormalizeAudioCodec") + .HasColumnType("INTEGER"); + + b.Property("NormalizeResolution") + .HasColumnType("INTEGER"); + + b.Property("NormalizeVideoCodec") + .HasColumnType("INTEGER"); + + b.Property("ResolutionId") + .HasColumnType("INTEGER"); + + b.Property("ThreadCount") + .HasColumnType("INTEGER"); + + b.Property("Transcode") + .HasColumnType("INTEGER"); + + b.Property("VideoBitrate") + .HasColumnType("INTEGER"); + + b.Property("VideoBufferSize") + .HasColumnType("INTEGER"); + + b.Property("VideoCodec") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ResolutionId"); + + b.ToTable("FFmpegProfiles"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MediaCollections"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b.Property("MediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("PosterLastWriteTime") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaSourceId"); + + b.ToTable("MediaItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MediaSources"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasColumnType("TEXT"); + + b.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("INTEGER"); + + b.Property("Outline") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("Premiered") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MovieId") + .IsUnique(); + + b.ToTable("MovieMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.Property("ProgramSchedulePlayoutType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("ProgramScheduleId"); + + b.ToTable("Playouts"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Finish") + .HasColumnType("TEXT"); + + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaItemId"); + + b.HasIndex("PlayoutId"); + + b.ToTable("PlayoutItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CollectionId") + .HasColumnType("INTEGER"); + + b.Property("CollectionType") + .HasColumnType("INTEGER"); + + b.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayoutId"); + + b.HasIndex("ProgramScheduleId"); + + b.ToTable("PlayoutProgramScheduleItemAnchors"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("PlexMediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlexMediaSourceId"); + + b.ToTable("PlexMediaSourceConnections"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceLibrary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MediaType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PlexMediaSourceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlexMediaSourceId"); + + b.ToTable("PlexMediaSourceLibraries"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MediaCollectionPlaybackOrder") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ProgramSchedules"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CollectionType") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("MediaCollectionId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("TelevisionSeasonId") + .HasColumnType("INTEGER"); + + b.Property("TelevisionShowId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MediaCollectionId"); + + b.HasIndex("ProgramScheduleId"); + + b.HasIndex("TelevisionSeasonId"); + + b.HasIndex("TelevisionShowId"); + + b.ToTable("ProgramScheduleItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Resolution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Resolutions"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionEpisodeMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Aired") + .HasColumnType("TEXT"); + + b.Property("Episode") + .HasColumnType("INTEGER"); + + b.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("Season") + .HasColumnType("INTEGER"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("TelevisionEpisodeId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TelevisionEpisodeId") + .IsUnique(); + + b.ToTable("TelevisionEpisodeMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionSeason", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("PosterLastWriteTime") + .HasColumnType("TEXT"); + + b.Property("TelevisionShowId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TelevisionShowId"); + + b.ToTable("TelevisionSeasons"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("PosterLastWriteTime") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TelevisionShows"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShowMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("TelevisionShowId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TelevisionShowId") + .IsUnique(); + + b.ToTable("TelevisionShowMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShowSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Discriminator") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TelevisionShowId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TelevisionShowId"); + + b.ToTable("TelevisionShowSource"); + + b.HasDiscriminator("Discriminator").HasValue("TelevisionShowSource"); + }); + + modelBuilder.Entity("MovieMediaItemSimpleMediaCollection", b => + { + b.Property("MoviesId") + .HasColumnType("INTEGER"); + + b.Property("SimpleMediaCollectionsId") + .HasColumnType("INTEGER"); + + b.HasKey("MoviesId", "SimpleMediaCollectionsId"); + + b.HasIndex("SimpleMediaCollectionsId"); + + b.ToTable("SimpleMediaCollectionMovies"); + }); + + modelBuilder.Entity("SimpleMediaCollectionTelevisionEpisodeMediaItem", b => + { + b.Property("SimpleMediaCollectionsId") + .HasColumnType("INTEGER"); + + b.Property("TelevisionEpisodesId") + .HasColumnType("INTEGER"); + + b.HasKey("SimpleMediaCollectionsId", "TelevisionEpisodesId"); + + b.HasIndex("TelevisionEpisodesId"); + + b.ToTable("SimpleMediaCollectionEpisodes"); + }); + + modelBuilder.Entity("SimpleMediaCollectionTelevisionSeason", b => + { + b.Property("SimpleMediaCollectionsId") + .HasColumnType("INTEGER"); + + b.Property("TelevisionSeasonsId") + .HasColumnType("INTEGER"); + + b.HasKey("SimpleMediaCollectionsId", "TelevisionSeasonsId"); + + b.HasIndex("TelevisionSeasonsId"); + + b.ToTable("SimpleMediaCollectionSeasons"); + }); + + modelBuilder.Entity("SimpleMediaCollectionTelevisionShow", b => + { + b.Property("SimpleMediaCollectionsId") + .HasColumnType("INTEGER"); + + b.Property("TelevisionShowsId") + .HasColumnType("INTEGER"); + + b.HasKey("SimpleMediaCollectionsId", "TelevisionShowsId"); + + b.HasIndex("TelevisionShowsId"); + + b.ToTable("SimpleMediaCollectionShows"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SimpleMediaCollection", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaCollection"); + + b.ToTable("SimpleMediaCollections"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMediaItem", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.Property("MetadataId") + .HasColumnType("INTEGER"); + + b.ToTable("Movies"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.Property("SeasonId") + .HasColumnType("INTEGER"); + + b.HasIndex("SeasonId"); + + b.ToTable("TelevisionEpisodes"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); + + b.Property("Folder") + .HasColumnType("TEXT"); + + b.Property("MediaType") + .HasColumnType("INTEGER"); + + b.ToTable("LocalMediaSources"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); + + b.Property("ClientIdentifier") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.ToTable("PlexMediaSources"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.Property("OfflineTail") + .HasColumnType("INTEGER"); + + b.Property("PlayoutDuration") + .HasColumnType("TEXT"); + + b.ToTable("ProgramScheduleDurationItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.ToTable("ProgramScheduleFloodItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.ToTable("ProgramScheduleMultipleItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.ToTable("ProgramScheduleOneItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalTelevisionShowSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.TelevisionShowSource"); + + b.Property("MediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasIndex("MediaSourceId"); + + b.HasDiscriminator().HasValue("LocalTelevisionShowSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile") + .WithMany() + .HasForeignKey("FFmpegProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FFmpegProfile"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b => + { + b.HasOne("ErsatzTV.Core.Domain.Resolution", "Resolution") + .WithMany() + .HasForeignKey("ResolutionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Resolution"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", "Source") + .WithMany() + .HasForeignKey("MediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("ErsatzTV.Core.Domain.MediaItemStatistics", "Statistics", b1 => + { + b1.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b1.Property("AudioCodec") + .HasColumnType("TEXT"); + + b1.Property("DisplayAspectRatio") + .HasColumnType("TEXT"); + + b1.Property("Duration") + .HasColumnType("TEXT"); + + b1.Property("Height") + .HasColumnType("INTEGER"); + + b1.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b1.Property("SampleAspectRatio") + .HasColumnType("TEXT"); + + b1.Property("VideoCodec") + .HasColumnType("TEXT"); + + b1.Property("VideoScanType") + .HasColumnType("INTEGER"); + + b1.Property("Width") + .HasColumnType("INTEGER"); + + b1.HasKey("MediaItemId"); + + b1.ToTable("MediaItems"); + + b1.WithOwner() + .HasForeignKey("MediaItemId"); + }); + + b.Navigation("Source"); + + b.Navigation("Statistics"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.MovieMediaItem", "Movie") + .WithOne("Metadata") + .HasForeignKey("ErsatzTV.Core.Domain.MovieMetadata", "MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Movie"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel") + .WithMany("Playouts") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany("Playouts") + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("ErsatzTV.Core.Domain.PlayoutAnchor", "Anchor", b1 => + { + b1.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b1.Property("NextScheduleItemId") + .HasColumnType("INTEGER"); + + b1.Property("NextStart") + .HasColumnType("TEXT"); + + b1.HasKey("PlayoutId"); + + b1.HasIndex("NextScheduleItemId"); + + b1.ToTable("Playouts"); + + b1.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", "NextScheduleItem") + .WithMany() + .HasForeignKey("NextScheduleItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b1.WithOwner() + .HasForeignKey("PlayoutId"); + + b1.Navigation("NextScheduleItem"); + }); + + b.Navigation("Anchor"); + + b.Navigation("Channel"); + + b.Navigation("ProgramSchedule"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem") + .WithMany() + .HasForeignKey("MediaItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout") + .WithMany("Items") + .HasForeignKey("PlayoutId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaItem"); + + b.Navigation("Playout"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b => + { + b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout") + .WithMany("ProgramScheduleAnchors") + .HasForeignKey("PlayoutId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany() + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("ErsatzTV.Core.Domain.MediaCollectionEnumeratorState", "EnumeratorState", b1 => + { + b1.Property("PlayoutProgramScheduleAnchorId") + .HasColumnType("INTEGER"); + + b1.Property("Index") + .HasColumnType("INTEGER"); + + b1.Property("Seed") + .HasColumnType("INTEGER"); + + b1.HasKey("PlayoutProgramScheduleAnchorId"); + + b1.ToTable("PlayoutProgramScheduleItemAnchors"); + + b1.WithOwner() + .HasForeignKey("PlayoutProgramScheduleAnchorId"); + }); + + b.Navigation("EnumeratorState"); + + b.Navigation("Playout"); + + b.Navigation("ProgramSchedule"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceConnection", b => + { + b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", null) + .WithMany("Connections") + .HasForeignKey("PlexMediaSourceId"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceLibrary", b => + { + b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", null) + .WithMany("Libraries") + .HasForeignKey("PlexMediaSourceId"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaCollection", "MediaCollection") + .WithMany() + .HasForeignKey("MediaCollectionId"); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany("Items") + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.TelevisionSeason", "TelevisionSeason") + .WithMany() + .HasForeignKey("TelevisionSeasonId"); + + b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", "TelevisionShow") + .WithMany() + .HasForeignKey("TelevisionShowId"); + + b.Navigation("MediaCollection"); + + b.Navigation("ProgramSchedule"); + + b.Navigation("TelevisionSeason"); + + b.Navigation("TelevisionShow"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionEpisodeMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", "TelevisionEpisode") + .WithOne("Metadata") + .HasForeignKey("ErsatzTV.Core.Domain.TelevisionEpisodeMetadata", "TelevisionEpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TelevisionEpisode"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionSeason", b => + { + b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", "TelevisionShow") + .WithMany("Seasons") + .HasForeignKey("TelevisionShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TelevisionShow"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShowMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", "TelevisionShow") + .WithOne("Metadata") + .HasForeignKey("ErsatzTV.Core.Domain.TelevisionShowMetadata", "TelevisionShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TelevisionShow"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShowSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", "TelevisionShow") + .WithMany("Sources") + .HasForeignKey("TelevisionShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TelevisionShow"); + }); + + modelBuilder.Entity("MovieMediaItemSimpleMediaCollection", b => + { + b.HasOne("ErsatzTV.Core.Domain.MovieMediaItem", null) + .WithMany() + .HasForeignKey("MoviesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.SimpleMediaCollection", null) + .WithMany() + .HasForeignKey("SimpleMediaCollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimpleMediaCollectionTelevisionEpisodeMediaItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.SimpleMediaCollection", null) + .WithMany() + .HasForeignKey("SimpleMediaCollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", null) + .WithMany() + .HasForeignKey("TelevisionEpisodesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimpleMediaCollectionTelevisionSeason", b => + { + b.HasOne("ErsatzTV.Core.Domain.SimpleMediaCollection", null) + .WithMany() + .HasForeignKey("SimpleMediaCollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.TelevisionSeason", null) + .WithMany() + .HasForeignKey("TelevisionSeasonsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimpleMediaCollectionTelevisionShow", b => + { + b.HasOne("ErsatzTV.Core.Domain.SimpleMediaCollection", null) + .WithMany() + .HasForeignKey("SimpleMediaCollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", null) + .WithMany() + .HasForeignKey("TelevisionShowsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SimpleMediaCollection", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaCollection", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.SimpleMediaCollection", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMediaItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.MovieMediaItem", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.TelevisionSeason", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.LocalMediaSource", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexMediaSource", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemOne", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalTelevisionShowSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.LocalMediaSource", "MediaSource") + .WithMany() + .HasForeignKey("MediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.Navigation("Playouts"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.Navigation("Items"); + + b.Navigation("ProgramScheduleAnchors"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b => + { + b.Navigation("Items"); + + b.Navigation("Playouts"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionSeason", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShow", b => + { + b.Navigation("Metadata"); + + b.Navigation("Seasons"); + + b.Navigation("Sources"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMediaItem", b => + { + b.Navigation("Metadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", b => + { + b.Navigation("Metadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.Navigation("Connections"); + + b.Navigation("Libraries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/20210220220723_ScheduleCollectionTypes.cs b/ErsatzTV.Infrastructure/Migrations/20210220220723_ScheduleCollectionTypes.cs new file mode 100644 index 00000000..22c5aabe --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210220220723_ScheduleCollectionTypes.cs @@ -0,0 +1,209 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace ErsatzTV.Infrastructure.Migrations +{ + public partial class ScheduleCollectionTypes : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + "FK_PlayoutProgramScheduleItemAnchors_MediaCollections_MediaCollectionId", + "PlayoutProgramScheduleItemAnchors"); + + migrationBuilder.DropForeignKey( + "FK_ProgramScheduleItems_MediaCollections_MediaCollectionId", + "ProgramScheduleItems"); + + migrationBuilder.DropPrimaryKey( + "PK_PlayoutProgramScheduleItemAnchors", + "PlayoutProgramScheduleItemAnchors"); + + migrationBuilder.DropIndex( + "IX_PlayoutProgramScheduleItemAnchors_MediaCollectionId", + "PlayoutProgramScheduleItemAnchors"); + + migrationBuilder.RenameColumn( + "MediaCollectionId", + "PlayoutProgramScheduleItemAnchors", + "CollectionType"); + + migrationBuilder.AlterColumn( + "MediaCollectionId", + "ProgramScheduleItems", + "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AddColumn( + "CollectionType", + "ProgramScheduleItems", + "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + "TelevisionSeasonId", + "ProgramScheduleItems", + "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + "TelevisionShowId", + "ProgramScheduleItems", + "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + "Id", + "PlayoutProgramScheduleItemAnchors", + "INTEGER", + nullable: false, + defaultValue: 0) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder.AddColumn( + "CollectionId", + "PlayoutProgramScheduleItemAnchors", + "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddPrimaryKey( + "PK_PlayoutProgramScheduleItemAnchors", + "PlayoutProgramScheduleItemAnchors", + "Id"); + + migrationBuilder.CreateIndex( + "IX_ProgramScheduleItems_TelevisionSeasonId", + "ProgramScheduleItems", + "TelevisionSeasonId"); + + migrationBuilder.CreateIndex( + "IX_ProgramScheduleItems_TelevisionShowId", + "ProgramScheduleItems", + "TelevisionShowId"); + + migrationBuilder.CreateIndex( + "IX_PlayoutProgramScheduleItemAnchors_PlayoutId", + "PlayoutProgramScheduleItemAnchors", + "PlayoutId"); + + migrationBuilder.AddForeignKey( + "FK_ProgramScheduleItems_MediaCollections_MediaCollectionId", + "ProgramScheduleItems", + "MediaCollectionId", + "MediaCollections", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + "FK_ProgramScheduleItems_TelevisionSeasons_TelevisionSeasonId", + "ProgramScheduleItems", + "TelevisionSeasonId", + "TelevisionSeasons", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + "FK_ProgramScheduleItems_TelevisionShows_TelevisionShowId", + "ProgramScheduleItems", + "TelevisionShowId", + "TelevisionShows", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + "FK_ProgramScheduleItems_MediaCollections_MediaCollectionId", + "ProgramScheduleItems"); + + migrationBuilder.DropForeignKey( + "FK_ProgramScheduleItems_TelevisionSeasons_TelevisionSeasonId", + "ProgramScheduleItems"); + + migrationBuilder.DropForeignKey( + "FK_ProgramScheduleItems_TelevisionShows_TelevisionShowId", + "ProgramScheduleItems"); + + migrationBuilder.DropIndex( + "IX_ProgramScheduleItems_TelevisionSeasonId", + "ProgramScheduleItems"); + + migrationBuilder.DropIndex( + "IX_ProgramScheduleItems_TelevisionShowId", + "ProgramScheduleItems"); + + migrationBuilder.DropPrimaryKey( + "PK_PlayoutProgramScheduleItemAnchors", + "PlayoutProgramScheduleItemAnchors"); + + migrationBuilder.DropIndex( + "IX_PlayoutProgramScheduleItemAnchors_PlayoutId", + "PlayoutProgramScheduleItemAnchors"); + + migrationBuilder.DropColumn( + "CollectionType", + "ProgramScheduleItems"); + + migrationBuilder.DropColumn( + "TelevisionSeasonId", + "ProgramScheduleItems"); + + migrationBuilder.DropColumn( + "TelevisionShowId", + "ProgramScheduleItems"); + + migrationBuilder.DropColumn( + "Id", + "PlayoutProgramScheduleItemAnchors"); + + migrationBuilder.DropColumn( + "CollectionId", + "PlayoutProgramScheduleItemAnchors"); + + migrationBuilder.RenameColumn( + "CollectionType", + "PlayoutProgramScheduleItemAnchors", + "MediaCollectionId"); + + migrationBuilder.AlterColumn( + "MediaCollectionId", + "ProgramScheduleItems", + "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AddPrimaryKey( + "PK_PlayoutProgramScheduleItemAnchors", + "PlayoutProgramScheduleItemAnchors", + new[] { "PlayoutId", "ProgramScheduleId", "MediaCollectionId" }); + + migrationBuilder.CreateIndex( + "IX_PlayoutProgramScheduleItemAnchors_MediaCollectionId", + "PlayoutProgramScheduleItemAnchors", + "MediaCollectionId"); + + migrationBuilder.AddForeignKey( + "FK_PlayoutProgramScheduleItemAnchors_MediaCollections_MediaCollectionId", + "PlayoutProgramScheduleItemAnchors", + "MediaCollectionId", + "MediaCollections", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + "FK_ProgramScheduleItems_MediaCollections_MediaCollectionId", + "ProgramScheduleItems", + "MediaCollectionId", + "MediaCollections", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/20210221215810_RemoveScheduleItemsAndPosters.Designer.cs b/ErsatzTV.Infrastructure/Migrations/20210221215810_RemoveScheduleItemsAndPosters.Designer.cs new file mode 100644 index 00000000..43d14bf0 --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210221215810_RemoveScheduleItemsAndPosters.Designer.cs @@ -0,0 +1,1305 @@ +// +using System; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace ErsatzTV.Infrastructure.Migrations +{ + [DbContext(typeof(TvContext))] + [Migration("20210221215810_RemoveScheduleItemsAndPosters")] + partial class RemoveScheduleItemsAndPosters + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.3"); + + modelBuilder.Entity("ErsatzTV.Core.AggregateModels.GenericIntegerId", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.ToView("No table or view exists for GenericIntegerId"); + }); + + modelBuilder.Entity("ErsatzTV.Core.AggregateModels.MediaCollectionSummary", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("IsSimple") + .HasColumnType("INTEGER"); + + b.Property("ItemCount") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.ToView("No table or view exists for MediaCollectionSummary"); + }); + + modelBuilder.Entity("ErsatzTV.Core.AggregateModels.MediaItemSummary", b => + { + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Subtitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.ToView("No table or view exists for MediaItemSummary"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FFmpegProfileId") + .HasColumnType("INTEGER"); + + b.Property("Logo") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("StreamingMode") + .HasColumnType("INTEGER"); + + b.Property("UniqueId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FFmpegProfileId"); + + b.HasIndex("Number") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ConfigElement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("ConfigElements"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudioBitrate") + .HasColumnType("INTEGER"); + + b.Property("AudioBufferSize") + .HasColumnType("INTEGER"); + + b.Property("AudioChannels") + .HasColumnType("INTEGER"); + + b.Property("AudioCodec") + .HasColumnType("TEXT"); + + b.Property("AudioSampleRate") + .HasColumnType("INTEGER"); + + b.Property("AudioVolume") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizeAudio") + .HasColumnType("INTEGER"); + + b.Property("NormalizeAudioCodec") + .HasColumnType("INTEGER"); + + b.Property("NormalizeResolution") + .HasColumnType("INTEGER"); + + b.Property("NormalizeVideoCodec") + .HasColumnType("INTEGER"); + + b.Property("ResolutionId") + .HasColumnType("INTEGER"); + + b.Property("ThreadCount") + .HasColumnType("INTEGER"); + + b.Property("Transcode") + .HasColumnType("INTEGER"); + + b.Property("VideoBitrate") + .HasColumnType("INTEGER"); + + b.Property("VideoBufferSize") + .HasColumnType("INTEGER"); + + b.Property("VideoCodec") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ResolutionId"); + + b.ToTable("FFmpegProfiles"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MediaCollections"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b.Property("MediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("PosterLastWriteTime") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaSourceId"); + + b.ToTable("MediaItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MediaSources"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasColumnType("TEXT"); + + b.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("INTEGER"); + + b.Property("Outline") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("Premiered") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MovieId") + .IsUnique(); + + b.ToTable("MovieMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.Property("ProgramSchedulePlayoutType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("ProgramScheduleId"); + + b.ToTable("Playouts"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Finish") + .HasColumnType("TEXT"); + + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaItemId"); + + b.HasIndex("PlayoutId"); + + b.ToTable("PlayoutItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CollectionId") + .HasColumnType("INTEGER"); + + b.Property("CollectionType") + .HasColumnType("INTEGER"); + + b.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayoutId"); + + b.HasIndex("ProgramScheduleId"); + + b.ToTable("PlayoutProgramScheduleItemAnchors"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("PlexMediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlexMediaSourceId"); + + b.ToTable("PlexMediaSourceConnections"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceLibrary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MediaType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PlexMediaSourceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlexMediaSourceId"); + + b.ToTable("PlexMediaSourceLibraries"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MediaCollectionPlaybackOrder") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ProgramSchedules"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CollectionType") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("MediaCollectionId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("TelevisionSeasonId") + .HasColumnType("INTEGER"); + + b.Property("TelevisionShowId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MediaCollectionId"); + + b.HasIndex("ProgramScheduleId"); + + b.HasIndex("TelevisionSeasonId"); + + b.HasIndex("TelevisionShowId"); + + b.ToTable("ProgramScheduleItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Resolution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Resolutions"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionEpisodeMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Aired") + .HasColumnType("TEXT"); + + b.Property("Episode") + .HasColumnType("INTEGER"); + + b.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("Season") + .HasColumnType("INTEGER"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("TelevisionEpisodeId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TelevisionEpisodeId") + .IsUnique(); + + b.ToTable("TelevisionEpisodeMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionSeason", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("PosterLastWriteTime") + .HasColumnType("TEXT"); + + b.Property("TelevisionShowId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TelevisionShowId"); + + b.ToTable("TelevisionSeasons"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("PosterLastWriteTime") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TelevisionShows"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShowMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("TelevisionShowId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TelevisionShowId") + .IsUnique(); + + b.ToTable("TelevisionShowMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShowSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Discriminator") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TelevisionShowId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TelevisionShowId"); + + b.ToTable("TelevisionShowSource"); + + b.HasDiscriminator("Discriminator").HasValue("TelevisionShowSource"); + }); + + modelBuilder.Entity("MovieMediaItemSimpleMediaCollection", b => + { + b.Property("MoviesId") + .HasColumnType("INTEGER"); + + b.Property("SimpleMediaCollectionsId") + .HasColumnType("INTEGER"); + + b.HasKey("MoviesId", "SimpleMediaCollectionsId"); + + b.HasIndex("SimpleMediaCollectionsId"); + + b.ToTable("SimpleMediaCollectionMovies"); + }); + + modelBuilder.Entity("SimpleMediaCollectionTelevisionEpisodeMediaItem", b => + { + b.Property("SimpleMediaCollectionsId") + .HasColumnType("INTEGER"); + + b.Property("TelevisionEpisodesId") + .HasColumnType("INTEGER"); + + b.HasKey("SimpleMediaCollectionsId", "TelevisionEpisodesId"); + + b.HasIndex("TelevisionEpisodesId"); + + b.ToTable("SimpleMediaCollectionEpisodes"); + }); + + modelBuilder.Entity("SimpleMediaCollectionTelevisionSeason", b => + { + b.Property("SimpleMediaCollectionsId") + .HasColumnType("INTEGER"); + + b.Property("TelevisionSeasonsId") + .HasColumnType("INTEGER"); + + b.HasKey("SimpleMediaCollectionsId", "TelevisionSeasonsId"); + + b.HasIndex("TelevisionSeasonsId"); + + b.ToTable("SimpleMediaCollectionSeasons"); + }); + + modelBuilder.Entity("SimpleMediaCollectionTelevisionShow", b => + { + b.Property("SimpleMediaCollectionsId") + .HasColumnType("INTEGER"); + + b.Property("TelevisionShowsId") + .HasColumnType("INTEGER"); + + b.HasKey("SimpleMediaCollectionsId", "TelevisionShowsId"); + + b.HasIndex("TelevisionShowsId"); + + b.ToTable("SimpleMediaCollectionShows"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SimpleMediaCollection", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaCollection"); + + b.ToTable("SimpleMediaCollections"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMediaItem", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.Property("MetadataId") + .HasColumnType("INTEGER"); + + b.ToTable("Movies"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.Property("SeasonId") + .HasColumnType("INTEGER"); + + b.HasIndex("SeasonId"); + + b.ToTable("TelevisionEpisodes"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); + + b.Property("Folder") + .HasColumnType("TEXT"); + + b.Property("MediaType") + .HasColumnType("INTEGER"); + + b.ToTable("LocalMediaSources"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); + + b.Property("ClientIdentifier") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.ToTable("PlexMediaSources"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.Property("OfflineTail") + .HasColumnType("INTEGER"); + + b.Property("PlayoutDuration") + .HasColumnType("TEXT"); + + b.ToTable("ProgramScheduleDurationItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.ToTable("ProgramScheduleFloodItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.ToTable("ProgramScheduleMultipleItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.ToTable("ProgramScheduleOneItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalTelevisionShowSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.TelevisionShowSource"); + + b.Property("MediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasIndex("MediaSourceId"); + + b.HasDiscriminator().HasValue("LocalTelevisionShowSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile") + .WithMany() + .HasForeignKey("FFmpegProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FFmpegProfile"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b => + { + b.HasOne("ErsatzTV.Core.Domain.Resolution", "Resolution") + .WithMany() + .HasForeignKey("ResolutionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Resolution"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", "Source") + .WithMany() + .HasForeignKey("MediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("ErsatzTV.Core.Domain.MediaItemStatistics", "Statistics", b1 => + { + b1.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b1.Property("AudioCodec") + .HasColumnType("TEXT"); + + b1.Property("DisplayAspectRatio") + .HasColumnType("TEXT"); + + b1.Property("Duration") + .HasColumnType("TEXT"); + + b1.Property("Height") + .HasColumnType("INTEGER"); + + b1.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b1.Property("SampleAspectRatio") + .HasColumnType("TEXT"); + + b1.Property("VideoCodec") + .HasColumnType("TEXT"); + + b1.Property("VideoScanType") + .HasColumnType("INTEGER"); + + b1.Property("Width") + .HasColumnType("INTEGER"); + + b1.HasKey("MediaItemId"); + + b1.ToTable("MediaItems"); + + b1.WithOwner() + .HasForeignKey("MediaItemId"); + }); + + b.Navigation("Source"); + + b.Navigation("Statistics"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.MovieMediaItem", "Movie") + .WithOne("Metadata") + .HasForeignKey("ErsatzTV.Core.Domain.MovieMetadata", "MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Movie"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel") + .WithMany("Playouts") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany("Playouts") + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("ErsatzTV.Core.Domain.PlayoutAnchor", "Anchor", b1 => + { + b1.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b1.Property("NextScheduleItemId") + .HasColumnType("INTEGER"); + + b1.Property("NextStart") + .HasColumnType("TEXT"); + + b1.HasKey("PlayoutId"); + + b1.HasIndex("NextScheduleItemId"); + + b1.ToTable("Playouts"); + + b1.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", "NextScheduleItem") + .WithMany() + .HasForeignKey("NextScheduleItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b1.WithOwner() + .HasForeignKey("PlayoutId"); + + b1.Navigation("NextScheduleItem"); + }); + + b.Navigation("Anchor"); + + b.Navigation("Channel"); + + b.Navigation("ProgramSchedule"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem") + .WithMany() + .HasForeignKey("MediaItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout") + .WithMany("Items") + .HasForeignKey("PlayoutId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaItem"); + + b.Navigation("Playout"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b => + { + b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout") + .WithMany("ProgramScheduleAnchors") + .HasForeignKey("PlayoutId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany() + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("ErsatzTV.Core.Domain.MediaCollectionEnumeratorState", "EnumeratorState", b1 => + { + b1.Property("PlayoutProgramScheduleAnchorId") + .HasColumnType("INTEGER"); + + b1.Property("Index") + .HasColumnType("INTEGER"); + + b1.Property("Seed") + .HasColumnType("INTEGER"); + + b1.HasKey("PlayoutProgramScheduleAnchorId"); + + b1.ToTable("PlayoutProgramScheduleItemAnchors"); + + b1.WithOwner() + .HasForeignKey("PlayoutProgramScheduleAnchorId"); + }); + + b.Navigation("EnumeratorState"); + + b.Navigation("Playout"); + + b.Navigation("ProgramSchedule"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceConnection", b => + { + b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", null) + .WithMany("Connections") + .HasForeignKey("PlexMediaSourceId"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceLibrary", b => + { + b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", null) + .WithMany("Libraries") + .HasForeignKey("PlexMediaSourceId"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaCollection", "MediaCollection") + .WithMany() + .HasForeignKey("MediaCollectionId"); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany("Items") + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.TelevisionSeason", "TelevisionSeason") + .WithMany() + .HasForeignKey("TelevisionSeasonId"); + + b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", "TelevisionShow") + .WithMany() + .HasForeignKey("TelevisionShowId"); + + b.Navigation("MediaCollection"); + + b.Navigation("ProgramSchedule"); + + b.Navigation("TelevisionSeason"); + + b.Navigation("TelevisionShow"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionEpisodeMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", "TelevisionEpisode") + .WithOne("Metadata") + .HasForeignKey("ErsatzTV.Core.Domain.TelevisionEpisodeMetadata", "TelevisionEpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TelevisionEpisode"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionSeason", b => + { + b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", "TelevisionShow") + .WithMany("Seasons") + .HasForeignKey("TelevisionShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TelevisionShow"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShowMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", "TelevisionShow") + .WithOne("Metadata") + .HasForeignKey("ErsatzTV.Core.Domain.TelevisionShowMetadata", "TelevisionShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TelevisionShow"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShowSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", "TelevisionShow") + .WithMany("Sources") + .HasForeignKey("TelevisionShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TelevisionShow"); + }); + + modelBuilder.Entity("MovieMediaItemSimpleMediaCollection", b => + { + b.HasOne("ErsatzTV.Core.Domain.MovieMediaItem", null) + .WithMany() + .HasForeignKey("MoviesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.SimpleMediaCollection", null) + .WithMany() + .HasForeignKey("SimpleMediaCollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimpleMediaCollectionTelevisionEpisodeMediaItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.SimpleMediaCollection", null) + .WithMany() + .HasForeignKey("SimpleMediaCollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", null) + .WithMany() + .HasForeignKey("TelevisionEpisodesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimpleMediaCollectionTelevisionSeason", b => + { + b.HasOne("ErsatzTV.Core.Domain.SimpleMediaCollection", null) + .WithMany() + .HasForeignKey("SimpleMediaCollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.TelevisionSeason", null) + .WithMany() + .HasForeignKey("TelevisionSeasonsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimpleMediaCollectionTelevisionShow", b => + { + b.HasOne("ErsatzTV.Core.Domain.SimpleMediaCollection", null) + .WithMany() + .HasForeignKey("SimpleMediaCollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", null) + .WithMany() + .HasForeignKey("TelevisionShowsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SimpleMediaCollection", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaCollection", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.SimpleMediaCollection", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMediaItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.MovieMediaItem", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.TelevisionSeason", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.LocalMediaSource", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexMediaSource", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemOne", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalTelevisionShowSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.LocalMediaSource", "MediaSource") + .WithMany() + .HasForeignKey("MediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.Navigation("Playouts"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.Navigation("Items"); + + b.Navigation("ProgramScheduleAnchors"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b => + { + b.Navigation("Items"); + + b.Navigation("Playouts"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionSeason", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionShow", b => + { + b.Navigation("Metadata"); + + b.Navigation("Seasons"); + + b.Navigation("Sources"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMediaItem", b => + { + b.Navigation("Metadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", b => + { + b.Navigation("Metadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.Navigation("Connections"); + + b.Navigation("Libraries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/20210221215810_RemoveScheduleItemsAndPosters.cs b/ErsatzTV.Infrastructure/Migrations/20210221215810_RemoveScheduleItemsAndPosters.cs new file mode 100644 index 00000000..d57bdccd --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210221215810_RemoveScheduleItemsAndPosters.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace ErsatzTV.Infrastructure.Migrations +{ + public partial class RemoveScheduleItemsAndPosters : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + // delete program schedule items that referenced television collections (that no longer exist) + migrationBuilder.Sql( + "delete from ProgramScheduleItems where MediaCollectionId not in (select Id from SimpleMediaCollections)"); + + // delete television collections that no longer exist/work + migrationBuilder.Sql( + "delete from MediaCollections where Id not in (select Id from SimpleMediaCollections)"); + + // delete all posters so they are all re-cached with a higher resolution + migrationBuilder.Sql("update MediaItems set Poster = null"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs b/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs index 7f072691..ebc91a0a 100644 --- a/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs +++ b/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs @@ -22,6 +22,8 @@ namespace ErsatzTV.Infrastructure.Migrations { b.Property("Id") .HasColumnType("INTEGER"); + + b.ToView("No table or view exists for GenericIntegerId"); }); modelBuilder.Entity( @@ -39,6 +41,8 @@ namespace ErsatzTV.Infrastructure.Migrations b.Property("Name") .HasColumnType("TEXT"); + + b.ToView("No table or view exists for MediaCollectionSummary"); }); modelBuilder.Entity( @@ -59,6 +63,8 @@ namespace ErsatzTV.Infrastructure.Migrations b.Property("Title") .HasColumnType("TEXT"); + + b.ToView("No table or view exists for MediaItemSummary"); }); modelBuilder.Entity( @@ -256,6 +262,55 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("MediaSources"); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.MovieMetadata", + b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasColumnType("TEXT"); + + b.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("INTEGER"); + + b.Property("Outline") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("Premiered") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MovieId") + .IsUnique(); + + b.ToTable("MovieMetadata"); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.Playout", b => @@ -315,18 +370,25 @@ namespace ErsatzTV.Infrastructure.Migrations "ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b => { - b.Property("PlayoutId") + b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("ProgramScheduleId") + b.Property("CollectionId") .HasColumnType("INTEGER"); - b.Property("MediaCollectionId") + b.Property("CollectionType") .HasColumnType("INTEGER"); - b.HasKey("PlayoutId", "ProgramScheduleId", "MediaCollectionId"); + b.Property("PlayoutId") + .HasColumnType("INTEGER"); - b.HasIndex("MediaCollectionId"); + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayoutId"); b.HasIndex("ProgramScheduleId"); @@ -414,10 +476,13 @@ namespace ErsatzTV.Infrastructure.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("CollectionType") + .HasColumnType("INTEGER"); + b.Property("Index") .HasColumnType("INTEGER"); - b.Property("MediaCollectionId") + b.Property("MediaCollectionId") .HasColumnType("INTEGER"); b.Property("ProgramScheduleId") @@ -426,12 +491,22 @@ namespace ErsatzTV.Infrastructure.Migrations b.Property("StartTime") .HasColumnType("TEXT"); + b.Property("TelevisionSeasonId") + .HasColumnType("INTEGER"); + + b.Property("TelevisionShowId") + .HasColumnType("INTEGER"); + b.HasKey("Id"); b.HasIndex("MediaCollectionId"); b.HasIndex("ProgramScheduleId"); + b.HasIndex("TelevisionSeasonId"); + + b.HasIndex("TelevisionShowId"); + b.ToTable("ProgramScheduleItems"); }); @@ -458,20 +533,224 @@ namespace ErsatzTV.Infrastructure.Migrations }); modelBuilder.Entity( - "MediaItemSimpleMediaCollection", + "ErsatzTV.Core.Domain.TelevisionEpisodeMetadata", + b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Aired") + .HasColumnType("TEXT"); + + b.Property("Episode") + .HasColumnType("INTEGER"); + + b.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("Season") + .HasColumnType("INTEGER"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("TelevisionEpisodeId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TelevisionEpisodeId") + .IsUnique(); + + b.ToTable("TelevisionEpisodeMetadata"); + }); + + modelBuilder.Entity( + "ErsatzTV.Core.Domain.TelevisionSeason", + b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("PosterLastWriteTime") + .HasColumnType("TEXT"); + + b.Property("TelevisionShowId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TelevisionShowId"); + + b.ToTable("TelevisionSeasons"); + }); + + modelBuilder.Entity( + "ErsatzTV.Core.Domain.TelevisionShow", + b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("PosterLastWriteTime") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TelevisionShows"); + }); + + modelBuilder.Entity( + "ErsatzTV.Core.Domain.TelevisionShowMetadata", + b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("TelevisionShowId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TelevisionShowId") + .IsUnique(); + + b.ToTable("TelevisionShowMetadata"); + }); + + modelBuilder.Entity( + "ErsatzTV.Core.Domain.TelevisionShowSource", b => { - b.Property("ItemsId") + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Discriminator") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TelevisionShowId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TelevisionShowId"); + + b.ToTable("TelevisionShowSource"); + + b.HasDiscriminator("Discriminator").HasValue("TelevisionShowSource"); + }); + + modelBuilder.Entity( + "MovieMediaItemSimpleMediaCollection", + b => + { + b.Property("MoviesId") .HasColumnType("INTEGER"); b.Property("SimpleMediaCollectionsId") .HasColumnType("INTEGER"); - b.HasKey("ItemsId", "SimpleMediaCollectionsId"); + b.HasKey("MoviesId", "SimpleMediaCollectionsId"); b.HasIndex("SimpleMediaCollectionsId"); - b.ToTable("MediaItemSimpleMediaCollection"); + b.ToTable("SimpleMediaCollectionMovies"); + }); + + modelBuilder.Entity( + "SimpleMediaCollectionTelevisionEpisodeMediaItem", + b => + { + b.Property("SimpleMediaCollectionsId") + .HasColumnType("INTEGER"); + + b.Property("TelevisionEpisodesId") + .HasColumnType("INTEGER"); + + b.HasKey("SimpleMediaCollectionsId", "TelevisionEpisodesId"); + + b.HasIndex("TelevisionEpisodesId"); + + b.ToTable("SimpleMediaCollectionEpisodes"); + }); + + modelBuilder.Entity( + "SimpleMediaCollectionTelevisionSeason", + b => + { + b.Property("SimpleMediaCollectionsId") + .HasColumnType("INTEGER"); + + b.Property("TelevisionSeasonsId") + .HasColumnType("INTEGER"); + + b.HasKey("SimpleMediaCollectionsId", "TelevisionSeasonsId"); + + b.HasIndex("TelevisionSeasonsId"); + + b.ToTable("SimpleMediaCollectionSeasons"); + }); + + modelBuilder.Entity( + "SimpleMediaCollectionTelevisionShow", + b => + { + b.Property("SimpleMediaCollectionsId") + .HasColumnType("INTEGER"); + + b.Property("TelevisionShowsId") + .HasColumnType("INTEGER"); + + b.HasKey("SimpleMediaCollectionsId", "TelevisionShowsId"); + + b.HasIndex("TelevisionShowsId"); + + b.ToTable("SimpleMediaCollectionShows"); }); modelBuilder.Entity( @@ -484,21 +763,29 @@ namespace ErsatzTV.Infrastructure.Migrations }); modelBuilder.Entity( - "ErsatzTV.Core.Domain.TelevisionMediaCollection", + "ErsatzTV.Core.Domain.MovieMediaItem", b => { - b.HasBaseType("ErsatzTV.Core.Domain.MediaCollection"); + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); - b.Property("SeasonNumber") + b.Property("MetadataId") .HasColumnType("INTEGER"); - b.Property("ShowTitle") - .HasColumnType("TEXT"); + b.ToTable("Movies"); + }); - b.HasIndex("ShowTitle", "SeasonNumber") - .IsUnique(); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", + b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); - b.ToTable("TelevisionMediaCollections"); + b.Property("SeasonId") + .HasColumnType("INTEGER"); + + b.HasIndex("SeasonId"); + + b.ToTable("TelevisionEpisodes"); }); modelBuilder.Entity( @@ -576,6 +863,23 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("ProgramScheduleOneItems"); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.LocalTelevisionShowSource", + b => + { + b.HasBaseType("ErsatzTV.Core.Domain.TelevisionShowSource"); + + b.Property("MediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasIndex("MediaSourceId"); + + b.HasDiscriminator().HasValue("LocalTelevisionShowSource"); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.Channel", b => @@ -613,61 +917,31 @@ namespace ErsatzTV.Infrastructure.Migrations .IsRequired(); b.OwnsOne( - "ErsatzTV.Core.Domain.MediaMetadata", - "Metadata", + "ErsatzTV.Core.Domain.MediaItemStatistics", + "Statistics", b1 => { b1.Property("MediaItemId") .HasColumnType("INTEGER"); - b1.Property("Aired") - .HasColumnType("TEXT"); - b1.Property("AudioCodec") .HasColumnType("TEXT"); - b1.Property("ContentRating") - .HasColumnType("TEXT"); - - b1.Property("Description") - .HasColumnType("TEXT"); - b1.Property("DisplayAspectRatio") .HasColumnType("TEXT"); b1.Property("Duration") .HasColumnType("TEXT"); - b1.Property("EpisodeNumber") - .HasColumnType("INTEGER"); - b1.Property("Height") .HasColumnType("INTEGER"); b1.Property("LastWriteTime") .HasColumnType("TEXT"); - b1.Property("MediaType") - .HasColumnType("INTEGER"); - b1.Property("SampleAspectRatio") .HasColumnType("TEXT"); - b1.Property("SeasonNumber") - .HasColumnType("INTEGER"); - - b1.Property("SortTitle") - .HasColumnType("TEXT"); - - b1.Property("Source") - .HasColumnType("INTEGER"); - - b1.Property("Subtitle") - .HasColumnType("TEXT"); - - b1.Property("Title") - .HasColumnType("TEXT"); - b1.Property("VideoCodec") .HasColumnType("TEXT"); @@ -685,9 +959,22 @@ namespace ErsatzTV.Infrastructure.Migrations .HasForeignKey("MediaItemId"); }); - b.Navigation("Metadata"); - b.Navigation("Source"); + + b.Navigation("Statistics"); + }); + + modelBuilder.Entity( + "ErsatzTV.Core.Domain.MovieMetadata", + b => + { + b.HasOne("ErsatzTV.Core.Domain.MovieMediaItem", "Movie") + .WithOne("Metadata") + .HasForeignKey("ErsatzTV.Core.Domain.MovieMetadata", "MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Movie"); }); modelBuilder.Entity( @@ -770,12 +1057,6 @@ namespace ErsatzTV.Infrastructure.Migrations "ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b => { - b.HasOne("ErsatzTV.Core.Domain.MediaCollection", "MediaCollection") - .WithMany() - .HasForeignKey("MediaCollectionId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout") .WithMany("ProgramScheduleAnchors") .HasForeignKey("PlayoutId") @@ -793,13 +1074,7 @@ namespace ErsatzTV.Infrastructure.Migrations "EnumeratorState", b1 => { - b1.Property("PlayoutProgramScheduleAnchorPlayoutId") - .HasColumnType("INTEGER"); - - b1.Property("PlayoutProgramScheduleAnchorProgramScheduleId") - .HasColumnType("INTEGER"); - - b1.Property("PlayoutProgramScheduleAnchorMediaCollectionId") + b1.Property("PlayoutProgramScheduleAnchorId") .HasColumnType("INTEGER"); b1.Property("Index") @@ -808,24 +1083,16 @@ namespace ErsatzTV.Infrastructure.Migrations b1.Property("Seed") .HasColumnType("INTEGER"); - b1.HasKey( - "PlayoutProgramScheduleAnchorPlayoutId", - "PlayoutProgramScheduleAnchorProgramScheduleId", - "PlayoutProgramScheduleAnchorMediaCollectionId"); + b1.HasKey("PlayoutProgramScheduleAnchorId"); b1.ToTable("PlayoutProgramScheduleItemAnchors"); b1.WithOwner() - .HasForeignKey( - "PlayoutProgramScheduleAnchorPlayoutId", - "PlayoutProgramScheduleAnchorProgramScheduleId", - "PlayoutProgramScheduleAnchorMediaCollectionId"); + .HasForeignKey("PlayoutProgramScheduleAnchorId"); }); b.Navigation("EnumeratorState"); - b.Navigation("MediaCollection"); - b.Navigation("Playout"); b.Navigation("ProgramSchedule"); @@ -855,9 +1122,7 @@ namespace ErsatzTV.Infrastructure.Migrations { b.HasOne("ErsatzTV.Core.Domain.MediaCollection", "MediaCollection") .WithMany() - .HasForeignKey("MediaCollectionId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + .HasForeignKey("MediaCollectionId"); b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") .WithMany("Items") @@ -865,18 +1130,82 @@ namespace ErsatzTV.Infrastructure.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.HasOne("ErsatzTV.Core.Domain.TelevisionSeason", "TelevisionSeason") + .WithMany() + .HasForeignKey("TelevisionSeasonId"); + + b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", "TelevisionShow") + .WithMany() + .HasForeignKey("TelevisionShowId"); + b.Navigation("MediaCollection"); b.Navigation("ProgramSchedule"); + + b.Navigation("TelevisionSeason"); + + b.Navigation("TelevisionShow"); }); modelBuilder.Entity( - "MediaItemSimpleMediaCollection", + "ErsatzTV.Core.Domain.TelevisionEpisodeMetadata", b => { - b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + b.HasOne("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", "TelevisionEpisode") + .WithOne("Metadata") + .HasForeignKey("ErsatzTV.Core.Domain.TelevisionEpisodeMetadata", "TelevisionEpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TelevisionEpisode"); + }); + + modelBuilder.Entity( + "ErsatzTV.Core.Domain.TelevisionSeason", + b => + { + b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", "TelevisionShow") + .WithMany("Seasons") + .HasForeignKey("TelevisionShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TelevisionShow"); + }); + + modelBuilder.Entity( + "ErsatzTV.Core.Domain.TelevisionShowMetadata", + b => + { + b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", "TelevisionShow") + .WithOne("Metadata") + .HasForeignKey("ErsatzTV.Core.Domain.TelevisionShowMetadata", "TelevisionShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TelevisionShow"); + }); + + modelBuilder.Entity( + "ErsatzTV.Core.Domain.TelevisionShowSource", + b => + { + b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", "TelevisionShow") + .WithMany("Sources") + .HasForeignKey("TelevisionShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TelevisionShow"); + }); + + modelBuilder.Entity( + "MovieMediaItemSimpleMediaCollection", + b => + { + b.HasOne("ErsatzTV.Core.Domain.MovieMediaItem", null) .WithMany() - .HasForeignKey("ItemsId") + .HasForeignKey("MoviesId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -887,6 +1216,57 @@ namespace ErsatzTV.Infrastructure.Migrations .IsRequired(); }); + modelBuilder.Entity( + "SimpleMediaCollectionTelevisionEpisodeMediaItem", + b => + { + b.HasOne("ErsatzTV.Core.Domain.SimpleMediaCollection", null) + .WithMany() + .HasForeignKey("SimpleMediaCollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", null) + .WithMany() + .HasForeignKey("TelevisionEpisodesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity( + "SimpleMediaCollectionTelevisionSeason", + b => + { + b.HasOne("ErsatzTV.Core.Domain.SimpleMediaCollection", null) + .WithMany() + .HasForeignKey("SimpleMediaCollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.TelevisionSeason", null) + .WithMany() + .HasForeignKey("TelevisionSeasonsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity( + "SimpleMediaCollectionTelevisionShow", + b => + { + b.HasOne("ErsatzTV.Core.Domain.SimpleMediaCollection", null) + .WithMany() + .HasForeignKey("SimpleMediaCollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", null) + .WithMany() + .HasForeignKey("TelevisionShowsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.SimpleMediaCollection", b => @@ -899,14 +1279,33 @@ namespace ErsatzTV.Infrastructure.Migrations }); modelBuilder.Entity( - "ErsatzTV.Core.Domain.TelevisionMediaCollection", + "ErsatzTV.Core.Domain.MovieMediaItem", b => { - b.HasOne("ErsatzTV.Core.Domain.MediaCollection", null) + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.MovieMediaItem", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity( + "ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", + b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) .WithOne() - .HasForeignKey("ErsatzTV.Core.Domain.TelevisionMediaCollection", "Id") + .HasForeignKey("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", "Id") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.TelevisionSeason", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); }); modelBuilder.Entity( @@ -975,6 +1374,19 @@ namespace ErsatzTV.Infrastructure.Migrations .IsRequired(); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.LocalTelevisionShowSource", + b => + { + b.HasOne("ErsatzTV.Core.Domain.LocalMediaSource", "MediaSource") + .WithMany() + .HasForeignKey("MediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaSource"); + }); + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => { b.Navigation("Playouts"); }); modelBuilder.Entity( @@ -995,6 +1407,23 @@ namespace ErsatzTV.Infrastructure.Migrations b.Navigation("Playouts"); }); + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionSeason", b => { b.Navigation("Episodes"); }); + + modelBuilder.Entity( + "ErsatzTV.Core.Domain.TelevisionShow", + b => + { + b.Navigation("Metadata"); + + b.Navigation("Seasons"); + + b.Navigation("Sources"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMediaItem", b => { b.Navigation("Metadata"); }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", b => { b.Navigation("Metadata"); }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.PlexMediaSource", b => diff --git a/ErsatzTV.sln.DotSettings b/ErsatzTV.sln.DotSettings index 59acb62f..b3fe1e0f 100644 --- a/ErsatzTV.sln.DotSettings +++ b/ErsatzTV.sln.DotSettings @@ -1,9 +1,11 @@  + True DTO HDHR SAR True True + True True True True @@ -13,6 +15,7 @@ True True True + True True True True @@ -35,4 +38,5 @@ True True True + True True \ No newline at end of file diff --git a/ErsatzTV/Controllers/Api/MediaCollectionsController.cs b/ErsatzTV/Controllers/Api/MediaCollectionsController.cs index dfda3dc4..5c2e6c1c 100644 --- a/ErsatzTV/Controllers/Api/MediaCollectionsController.cs +++ b/ErsatzTV/Controllers/Api/MediaCollectionsController.cs @@ -44,14 +44,5 @@ namespace ErsatzTV.Controllers.Api [ProducesResponseType(404)] public Task GetItems(int id) => _mediator.Send(new GetSimpleMediaCollectionItems(id)).ToActionResult(); - - [HttpPut("{id}/items")] - [ProducesResponseType(typeof(IEnumerable), 200)] - [ProducesResponseType(404)] - public Task PutItems( - int id, - [Required] [FromBody] - List mediaItemIds) => - _mediator.Send(new ReplaceSimpleMediaCollectionItems(id, mediaItemIds)).ToActionResult(); } } diff --git a/ErsatzTV/Controllers/Api/MediaItemsController.cs b/ErsatzTV/Controllers/Api/MediaItemsController.cs index a0ae0620..a6f3779b 100644 --- a/ErsatzTV/Controllers/Api/MediaItemsController.cs +++ b/ErsatzTV/Controllers/Api/MediaItemsController.cs @@ -1,8 +1,6 @@ using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; using ErsatzTV.Application.MediaItems; -using ErsatzTV.Application.MediaItems.Commands; using ErsatzTV.Application.MediaItems.Queries; using ErsatzTV.Extensions; using MediatR; @@ -19,14 +17,6 @@ namespace ErsatzTV.Controllers.Api public MediaItemsController(IMediator mediator) => _mediator = mediator; - [HttpPost] - [ProducesResponseType(typeof(MediaItemViewModel), 200)] - [ProducesResponseType(400)] - public Task Add( - [Required] [FromBody] - CreateMediaItem createMediaItem) => - _mediator.Send(createMediaItem).ToActionResult(); - [HttpGet("{mediaItemId}")] [ProducesResponseType(typeof(MediaItemViewModel), 200)] [ProducesResponseType(404)] @@ -37,13 +27,5 @@ namespace ErsatzTV.Controllers.Api [ProducesResponseType(typeof(IEnumerable), 200)] public Task GetAll() => _mediator.Send(new GetAllMediaItems()).ToActionResult(); - - [HttpDelete] - [ProducesResponseType(200)] - [ProducesResponseType(400)] - public Task Delete( - [Required] [FromBody] - DeleteMediaItem deleteMediaItem) => - _mediator.Send(deleteMediaItem).ToActionResult(); } } diff --git a/ErsatzTV/ErsatzTV.csproj b/ErsatzTV/ErsatzTV.csproj index 4ebab80a..96955e2b 100644 --- a/ErsatzTV/ErsatzTV.csproj +++ b/ErsatzTV/ErsatzTV.csproj @@ -20,7 +20,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/ErsatzTV/Pages/Channels.razor b/ErsatzTV/Pages/Channels.razor index b5da26a8..c88e1c0c 100644 --- a/ErsatzTV/Pages/Channels.razor +++ b/ErsatzTV/Pages/Channels.razor @@ -7,61 +7,63 @@ @inject IDialogService Dialog @inject IMediator Mediator - - - Channels - - - - - - - - - - - - Number - - Logo - - Name - - Streaming Mode - FFmpeg Profile - - - - @context.Number - - @if (!string.IsNullOrWhiteSpace(context.Logo)) - { - - } - - @context.Name - @context.StreamingMode - - @if (context.StreamingMode == StreamingMode.TransportStream) - { - @_ffmpegProfiles.Find(p => p.Id == context.FFmpegProfileId)?.Name - } - - - - - Edit - - - Delete - - - - - - - Add Channel - + + + + Channels + + + + + + + + + + + + Number + + Logo + + Name + + Streaming Mode + FFmpeg Profile + + + + @context.Number + + @if (!string.IsNullOrWhiteSpace(context.Logo)) + { + + } + + @context.Name + @context.StreamingMode + + @if (context.StreamingMode == StreamingMode.TransportStream) + { + @_ffmpegProfiles.Find(p => p.Id == context.FFmpegProfileId)?.Name + } + + + + + Edit + + + Delete + + + + + + + Add Channel + + @code { private List _channels; diff --git a/ErsatzTV/Pages/FFmpeg.razor b/ErsatzTV/Pages/FFmpeg.razor index e3698837..c4860533 100644 --- a/ErsatzTV/Pages/FFmpeg.razor +++ b/ErsatzTV/Pages/FFmpeg.razor @@ -5,90 +5,92 @@ @inject IDialogService Dialog @inject IMediator Mediator - - - - FFmpeg Settings - - - - - - - - - - - @foreach (FFmpegProfileViewModel profile in _ffmpegProfiles) - { - @profile.Name - } - - - - - - Save Settings - - + + + + + FFmpeg Settings + + + + + + + + + + + @foreach (FFmpegProfileViewModel profile in _ffmpegProfiles) + { + @profile.Name + } + + + + + + Save Settings + + - - - FFmpeg Profiles - - Colored settings will be normalized - - - - - - - - - - - Name - Transcode - Resolution - Video Codec - Audio Codec - - - - @context.Name - - @(context.Transcode ? "Yes" : "No") - - - - @context.Resolution.Name - - - - - @context.VideoCodec - - - - - @context.AudioCodec - - - - - - Edit - - - Delete - - - - - - - Add Profile - + + + FFmpeg Profiles + + Colored settings will be normalized + + + + + + + + + + + Name + Transcode + Resolution + Video Codec + Audio Codec + + + + @context.Name + + @(context.Transcode ? "Yes" : "No") + + + + @context.Resolution.Name + + + + + @context.VideoCodec + + + + + @context.AudioCodec + + + + + + Edit + + + Delete + + + + + + + Add Profile + + @code { diff --git a/ErsatzTV/Pages/Index.razor b/ErsatzTV/Pages/Index.razor index 1001c7e3..17533ab7 100644 --- a/ErsatzTV/Pages/Index.razor +++ b/ErsatzTV/Pages/Index.razor @@ -30,8 +30,9 @@ Media Collections - Media collections have a name and contain a logical grouping of media items. - Television media collections are automatically created and maintained, while manually-created media collections allow further customization. + Media collections have a name and contain a logical grouping of media items. + Collections may contain television shows, television seasons, television episodes or movies. + Collections containing television shows and television seasons are automatically updated as media is added or removed from the linked shows and seasons. diff --git a/ErsatzTV/Pages/LocalMediaSourceEditor.razor b/ErsatzTV/Pages/LocalMediaSourceEditor.razor index de192ddb..e69f2d04 100644 --- a/ErsatzTV/Pages/LocalMediaSourceEditor.razor +++ b/ErsatzTV/Pages/LocalMediaSourceEditor.razor @@ -1,7 +1,6 @@ @page "/media/sources/local/add" @using ErsatzTV.Application.MediaSources.Commands @using ErsatzTV.Application.MediaSources -@using ErsatzTV.Core.Metadata @inject NavigationManager NavigationManager @inject ILogger Logger @inject ISnackbar Snackbar @@ -17,7 +16,7 @@ - @foreach (MediaType mediaType in Enum.GetValues()) + @foreach (MediaType mediaType in new[] { MediaType.TvShow, MediaType.Movie }) { @mediaType } @@ -88,7 +87,7 @@ { if (Locker.LockMediaSource(vm.Id)) { - await Channel.WriteAsync(new ScanLocalMediaSource(vm.Id, ScanningMode.Default)); + await Channel.WriteAsync(new ScanLocalMediaSource(vm.Id)); NavigationManager.NavigateTo("/media/sources"); } }); diff --git a/ErsatzTV/Pages/Logs.razor b/ErsatzTV/Pages/Logs.razor index 23e43dce..d8e3980e 100644 --- a/ErsatzTV/Pages/Logs.razor +++ b/ErsatzTV/Pages/Logs.razor @@ -3,23 +3,25 @@ @using ErsatzTV.Application.Logs.Queries @inject IMediator Mediator - - - Timestamp - Level - Message - Properties - - - @context.Timestamp - @context.Level - @context.RenderedMessage - @context.Properties - - - - - + + + + Timestamp + Level + Message + Properties + + + @context.Timestamp + @context.Level + @context.RenderedMessage + @context.Properties + + + + + + @code { private List _logEntries; diff --git a/ErsatzTV/Pages/MediaCollectionEditor.razor b/ErsatzTV/Pages/MediaCollectionEditor.razor index 2bd73f8a..ca417b1b 100644 --- a/ErsatzTV/Pages/MediaCollectionEditor.razor +++ b/ErsatzTV/Pages/MediaCollectionEditor.razor @@ -1,4 +1,4 @@ -@page "/media/collections/{Id:int}" +@page "/media/collections/{Id:int}/edit" @page "/media/collections/add" @using ErsatzTV.Application.MediaCollections @using ErsatzTV.Application.MediaCollections.Commands @@ -75,7 +75,7 @@ Snackbar.Add(error.Value, Severity.Error); Logger.LogError("Error saving simple media collection: {Error}", error.Value); }, - () => NavigationManager.NavigateTo("/media/collections")); + () => NavigationManager.NavigateTo(_model.Id > 0 ? $"/media/collections/{_model.Id}" : "/media/collections")); } } diff --git a/ErsatzTV/Pages/MediaCollectionItems.razor b/ErsatzTV/Pages/MediaCollectionItems.razor new file mode 100644 index 00000000..a5f5569a --- /dev/null +++ b/ErsatzTV/Pages/MediaCollectionItems.razor @@ -0,0 +1,168 @@ +@page "/media/collections/{Id:int}" +@using ErsatzTV.Application.MediaCards +@using ErsatzTV.Application.MediaCards.Queries +@using ErsatzTV.Application.MediaCollections.Commands +@inject NavigationManager NavigationManager +@inject IMediator Mediator +@inject ILogger Logger +@inject ISnackbar Snackbar +@inject IDialogService Dialog + +
+ @_data.Name + +
+ +@if (_data.MovieCards.Any()) +{ + Movies + + + @foreach (MovieCardViewModel card in _data.MovieCards) + { + + } + +} + +@if (_data.ShowCards.Any()) +{ + Television Shows + + + @foreach (TelevisionShowCardViewModel card in _data.ShowCards) + { + + } + +} + +@if (_data.SeasonCards.Any()) +{ + Television Seasons + + + @foreach (TelevisionSeasonCardViewModel card in _data.SeasonCards) + { + + } + +} + +@if (_data.EpisodeCards.Any()) +{ + Television Episodes + + + @foreach (TelevisionEpisodeCardViewModel card in _data.EpisodeCards.OrderBy(e => e.Aired)) + { + + } + +} + +@code { + + [Parameter] + public int Id { get; set; } + + private SimpleMediaCollectionCardResultsViewModel _data; + + protected override async Task OnParametersSetAsync() => await RefreshData(); + + private async Task RefreshData() + { + Either maybeResult = + await Mediator.Send(new GetSimpleMediaCollectionCards(Id)); + + maybeResult.Match( + result => _data = result, + error => NavigationManager.NavigateTo("404")); + } + + private async Task RemoveMovieFromCollection(MediaCardViewModel vm) + { + if (vm is MovieCardViewModel movie) + { + var request = new RemoveItemsFromSimpleMediaCollection(Id) + { + MovieIds = new List { movie.MovieId } + }; + + await RemoveItemsWithConfirmation("movie", $"{movie.Title} ({movie.Subtitle})", request); + } + } + + private async Task RemoveShowFromCollection(MediaCardViewModel vm) + { + if (vm is TelevisionShowCardViewModel show) + { + var request = new RemoveItemsFromSimpleMediaCollection(Id) + { + TelevisionShowIds = new List { show.TelevisionShowId } + }; + + await RemoveItemsWithConfirmation("show", $"{show.Title} ({show.Subtitle})", request); + } + } + + private async Task RemoveSeasonFromCollection(MediaCardViewModel vm) + { + if (vm is TelevisionSeasonCardViewModel season) + { + var request = new RemoveItemsFromSimpleMediaCollection(Id) + { + TelevisionSeasonIds = new List { season.TelevisionSeasonId } + }; + + await RemoveItemsWithConfirmation("season", $"{season.ShowTitle} - {season.Title}", request); + } + } + + private async Task RemoveEpisodeFromCollection(MediaCardViewModel vm) + { + if (vm is TelevisionEpisodeCardViewModel episode) + { + var request = new RemoveItemsFromSimpleMediaCollection(Id) + { + TelevisionEpisodeIds = new List { episode.EpisodeId } + }; + + await RemoveItemsWithConfirmation("episode", $"{episode.ShowTitle} - {episode.Title}", request); + } + } + + private async Task RemoveItemsWithConfirmation( + string entityType, + string entityName, + RemoveItemsFromSimpleMediaCollection request) + { + var parameters = new DialogParameters { { "EntityType", entityType }, { "EntityName", entityName } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = Dialog.Show("Remove From Collection", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Cancelled) + { + await Mediator.Send(request); + await RefreshData(); + } + } + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/MediaCollectionItemsEditor.razor b/ErsatzTV/Pages/MediaCollectionItemsEditor.razor deleted file mode 100644 index fbe2df0c..00000000 --- a/ErsatzTV/Pages/MediaCollectionItemsEditor.razor +++ /dev/null @@ -1,121 +0,0 @@ -@page "/media/collections/{Id:int}/items" -@using ErsatzTV.Application.MediaCollections -@using ErsatzTV.Application.MediaCollections.Commands -@using ErsatzTV.Application.MediaCollections.Queries -@using ErsatzTV.Application.MediaItems -@using ErsatzTV.Application.MediaItems.Queries -@using Unit = LanguageExt.Unit -@inject NavigationManager NavigationManager -@inject IMediator Mediator -@inject ILogger Logger -@inject ISnackbar Snackbar - - - - @_mediaCollection.Name Media Items - - - Source - Type - Title - Duration - - - @context.Source - @context.MediaType - @context.Title - @context.Duration - - - @if (_collectionItems.Any()) - { - - } - - - - - - All Media Items - - - - - - Source - Type - Title - Duration - - - @context.Source - @context.MediaType - @context.Title - @context.Duration - - - - - - - Add Results - - -@code { - - [Parameter] - public int Id { get; set; } - - private MediaCollectionViewModel _mediaCollection; - private IEnumerable _collectionItems; - - protected override async Task OnParametersSetAsync() => await LoadMediaCollectionAsync(); - - private List _mediaItemIds; - private IEnumerable _pagedData; - private MudTable _table; - - private int _totalItems; - private string _searchString; - - private async Task> ServerReload(TableState state) - { - List data = await Mediator.Send(new SearchAllMediaItems(_searchString)); - - _mediaItemIds = data.Map(c => c.Id).ToList(); - _totalItems = data.Count; - - _pagedData = data.OrderBy(c => c.Id).Skip(state.Page * state.PageSize).Take(state.PageSize).ToArray(); - return new TableData { TotalItems = _totalItems, Items = _pagedData }; - } - - private async Task OnSearch(string text) - { - _searchString = text; - await _table.ReloadServerData(); - } - - private async Task AddResultsAsync() - { - Either result = await Mediator.Send(new AddItemsToSimpleMediaCollection(Id, _mediaItemIds)); - await result.Match( - async _ => await LoadMediaCollectionAsync(), - error => - { - Snackbar.Add(error.Value, Severity.Error); - Logger.LogError("Error adding items to media collection: {Error}", error.Value); - return Task.CompletedTask; - }); - } - - private async Task LoadMediaCollectionAsync() - { - Option>> maybeResult = - await Mediator.Send(new GetSimpleMediaCollectionWithItemsById(Id)); - maybeResult.Match( - result => (_mediaCollection, _collectionItems) = result, - () => NavigationManager.NavigateTo("404")); - } - -} \ No newline at end of file diff --git a/ErsatzTV/Pages/MediaCollections.razor b/ErsatzTV/Pages/MediaCollections.razor index d34c2523..c1c43bfb 100644 --- a/ErsatzTV/Pages/MediaCollections.razor +++ b/ErsatzTV/Pages/MediaCollections.razor @@ -2,107 +2,50 @@ @using ErsatzTV.Application.MediaCollections @using ErsatzTV.Application.MediaCollections.Commands @using ErsatzTV.Application.MediaCollections.Queries +@using ErsatzTV.Application.MediaCards @inject IDialogService Dialog @inject IMediator Mediator - - - Media Collections - - - - - - - - - - - Name - Media Items - - - - - @if (context.IsSimple) - { - @context.Name - } - else - { - @context.Name - } - - @context.ItemCount - - @if (context.IsSimple) - { - - - Edit Properties - - - Edit Media Items - - - Delete - - - } - - - - - - - - Add Media Collection - + + @foreach (MediaCollectionViewModel card in _data) + { + + } + + + + + Add Media Collection + + @code { - private IEnumerable _pagedData; - private MudTable _table; + private List _data; + + protected override Task OnParametersSetAsync() => RefreshData(); - private int _totalItems; - private string _searchString; + private async Task RefreshData() => + _data = await Mediator.Send(new GetAllMediaCollections()); - private async Task DeleteMediaCollectionAsync(MediaCollectionSummaryViewModel mediaCollection) + private async Task DeleteMediaCollection(MediaCardViewModel vm) { - if (mediaCollection.IsSimple) + if (vm is MediaCollectionViewModel collection) { - var parameters = new DialogParameters { { "EntityType", "media collection" }, { "EntityName", mediaCollection.Name } }; + var parameters = new DialogParameters { { "EntityType", "media collection" }, { "EntityName", collection.Name } }; var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; IDialogReference dialog = Dialog.Show("Delete Media Collection", parameters, options); DialogResult result = await dialog.Result; if (!result.Cancelled) { - await Mediator.Send(new DeleteSimpleMediaCollection(mediaCollection.Id)); - await _table.ReloadServerData(); + await Mediator.Send(new DeleteSimpleMediaCollection(collection.Id)); + await RefreshData(); } } } - private async Task> ServerReload(TableState state) - { - List aggregateData = - await Mediator.Send(new GetMediaCollectionSummaries(_searchString)); - - _totalItems = aggregateData.Count; - - _pagedData = aggregateData - .Skip(_totalItems <= state.PageSize ? 0 : state.Page * state.PageSize) - .Take(state.PageSize) - .OrderBy(c => c.Name); - - return new TableData { TotalItems = _totalItems, Items = _pagedData }; - } - - private async Task OnSearch(string text) - { - _searchString = text; - await _table.ReloadServerData(); - } - } \ No newline at end of file diff --git a/ErsatzTV/Pages/MediaMovieItems.razor b/ErsatzTV/Pages/MediaMovieItems.razor deleted file mode 100644 index 185814ec..00000000 --- a/ErsatzTV/Pages/MediaMovieItems.razor +++ /dev/null @@ -1,8 +0,0 @@ -@page "/media/movies/items" -@inject IMediator Mediator - - - -@code { - -} \ No newline at end of file diff --git a/ErsatzTV/Pages/MediaOtherItems.razor b/ErsatzTV/Pages/MediaOtherItems.razor deleted file mode 100644 index d944a4f2..00000000 --- a/ErsatzTV/Pages/MediaOtherItems.razor +++ /dev/null @@ -1,8 +0,0 @@ -@page "/media/other/items" -@inject IMediator Mediator - - - -@code { - -} \ No newline at end of file diff --git a/ErsatzTV/Pages/MediaSources.razor b/ErsatzTV/Pages/MediaSources.razor index cef3d2ff..0cdbee15 100644 --- a/ErsatzTV/Pages/MediaSources.razor +++ b/ErsatzTV/Pages/MediaSources.razor @@ -1,13 +1,15 @@ @page "/media/sources" - - - - - @* *@ - @* *@ - @* *@ - + + + + + + @* *@ + @* *@ + @* *@ + + @code { diff --git a/ErsatzTV/Pages/MediaTvItems.razor b/ErsatzTV/Pages/MediaTvItems.razor deleted file mode 100644 index 69703faf..00000000 --- a/ErsatzTV/Pages/MediaTvItems.razor +++ /dev/null @@ -1,8 +0,0 @@ -@page "/media/tv/items" -@inject IMediator Mediator - - - -@code { - -} \ No newline at end of file diff --git a/ErsatzTV/Pages/Movie.razor b/ErsatzTV/Pages/Movie.razor new file mode 100644 index 00000000..3004229c --- /dev/null +++ b/ErsatzTV/Pages/Movie.razor @@ -0,0 +1,76 @@ +@page "/media/movies/{MovieId:int}" +@using ErsatzTV.Application.Movies +@using ErsatzTV.Application.Movies.Queries +@using ErsatzTV.Application.MediaCollections +@using ErsatzTV.Application.MediaCollections.Commands +@inject IMediator Mediator +@inject IDialogService Dialog +@inject NavigationManager NavigationManager + + + + +
+ @if (!string.IsNullOrWhiteSpace(_movie.Poster)) + { + + + + } + +
+ @_movie.Title + @_movie.Year + @_movie.Plot +
+ + Add To Collection + +
+
+
+
+
+
+ +@code { + + [Parameter] + public int MovieId { get; set; } + + private MovieViewModel _movie; + + private List _breadcrumbs; + + protected override Task OnParametersSetAsync() => RefreshData(); + + private async Task RefreshData() + { + await Mediator.Send(new GetMovieById(MovieId)) + .IfSomeAsync(vm => _movie = vm); + + _breadcrumbs = new List + { + new("Movies", "/media/movies"), + new($"{_movie.Title} ({_movie.Year})", null, true) + }; + } + + private async Task AddToCollection() + { + var parameters = new DialogParameters { { "EntityType", "movie" }, { "EntityName", _movie.Title } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = Dialog.Show("Add To Collection", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Cancelled && result.Data is MediaCollectionViewModel collection) + { + await Mediator.Send(new AddMovieToSimpleMediaCollection(collection.Id, MovieId)); + NavigationManager.NavigateTo($"/media/collections/{collection.Id}"); + } + } + +} \ No newline at end of file diff --git a/ErsatzTV/Shared/MediaItemsGrid.razor b/ErsatzTV/Pages/MovieList.razor similarity index 73% rename from ErsatzTV/Shared/MediaItemsGrid.razor rename to ErsatzTV/Pages/MovieList.razor index 84a4ca52..add94084 100644 --- a/ErsatzTV/Shared/MediaItemsGrid.razor +++ b/ErsatzTV/Pages/MovieList.razor @@ -1,5 +1,6 @@ -@using ErsatzTV.Application.MediaItems -@using ErsatzTV.Application.MediaItems.Queries +@page "/media/movies" +@using ErsatzTV.Application.MediaCards +@using ErsatzTV.Application.MediaCards.Queries @inject IMediator Mediator @@ -19,26 +20,24 @@ - @foreach (AggregateMediaItemViewModel item in _data.DataPage) + @foreach (MovieCardViewModel card in _data.Cards.Where(d => !string.IsNullOrWhiteSpace(d.Title))) { - + } @code { - - [Parameter] - public MediaType MediaType { get; set; } - private int PageSize => 100; private int _pageNumber = 1; - private AggregateMediaItemResults _data; + private MovieCardResultsViewModel _data; protected override Task OnParametersSetAsync() => RefreshData(); private async Task RefreshData() => - _data = await Mediator.Send(new GetAggregateMediaItems(MediaType, _pageNumber, PageSize)); + _data = await Mediator.Send(new GetMovieCards(_pageNumber, PageSize)); private async Task PrevPage() { diff --git a/ErsatzTV/Pages/Playouts.razor b/ErsatzTV/Pages/Playouts.razor index 7acb1daf..91406323 100644 --- a/ErsatzTV/Pages/Playouts.razor +++ b/ErsatzTV/Pages/Playouts.razor @@ -5,58 +5,60 @@ @inject IDialogService Dialog @inject IMediator Mediator - - - Playouts - - - - - - - - - Id - Channel - Schedule - @* Playout Type *@ - - - - @context.Id - @context.Channel.Number - @context.Channel.Name - @context.ProgramSchedule.Name - @* @context.ProgramSchedulePlayoutType *@ - - - - - - - Add Playout - - -@if (_selectedPlayoutItems != null) -{ - + + - Playout Detail + Playouts + + + + + + - Start - Media Item - Duration + Id + Channel + Schedule + @* Playout Type *@ + - @context.Start.ToString("G") - @context.Title - @context.Duration + @context.Id + @context.Channel.Number - @context.Channel.Name + @context.ProgramSchedule.Name + @* @context.ProgramSchedulePlayoutType *@ + + + - - - -} + + Add Playout + + + @if (_selectedPlayoutItems != null) + { + + + Playout Detail + + + Start + Media Item + Duration + + + @context.Start.ToString("G") + @context.Title + @context.Duration + + + + + + } + @code { private List _playouts; diff --git a/ErsatzTV/Pages/ScheduleItemsEditor.razor b/ErsatzTV/Pages/ScheduleItemsEditor.razor index b37b9187..ceb6319e 100644 --- a/ErsatzTV/Pages/ScheduleItemsEditor.razor +++ b/ErsatzTV/Pages/ScheduleItemsEditor.razor @@ -4,6 +4,8 @@ @using ErsatzTV.Application.ProgramSchedules @using ErsatzTV.Application.ProgramSchedules.Commands @using ErsatzTV.Application.ProgramSchedules.Queries +@using ErsatzTV.Application.Television +@using ErsatzTV.Application.Television.Queries @inject NavigationManager NavigationManager @inject ILogger Logger @inject ISnackbar Snackbar @@ -18,12 +20,16 @@ + + Start Time Media Collection Playout Mode + + @@ -31,12 +37,12 @@ @(context.StartType == StartType.Fixed ? context.StartTime?.ToString(@"hh\:mm") ?? string.Empty : "Dynamic") - + - @context.MediaCollection.Name + @context.CollectionName - + @context.PlayoutMode @if (context.PlayoutMode == PlayoutMode.Multiple && context.MultipleCount.HasValue) @@ -46,14 +52,28 @@ - + + + + + + + + + + - + Add Schedule Item @@ -74,7 +94,39 @@ }
- + + @foreach (ProgramScheduleItemCollectionType collectionType in Enum.GetValues()) + { + @collectionType + } + + @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.Collection) + { + + } + @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.TelevisionShow) + { + + } + @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.TelevisionSeason) + { + + } @foreach (PlayoutMode playoutMode in Enum.GetValues()) { @@ -99,7 +151,8 @@ private ProgramScheduleItemsEditViewModel _schedule; private List _mediaCollections; - private Option _defaultCollection; + private List _televisionShows; + private List _televisionSeasons; private ProgramScheduleItemEditViewModel _selectedItem; @@ -108,7 +161,8 @@ private async Task LoadScheduleItems() { _mediaCollections = await Mediator.Send(new GetAllMediaCollections()); - _defaultCollection = _mediaCollections.HeadOrNone(); + _televisionShows = await Mediator.Send(new GetAllTelevisionShows()); + _televisionSeasons = await Mediator.Send(new GetAllTelevisionSeasons()); string name = string.Empty; Option maybeSchedule = await Mediator.Send(new GetProgramScheduleById(Id)); @@ -131,7 +185,10 @@ StartType = item.StartType, StartTime = item.StartTime, PlayoutMode = item.PlayoutMode, - MediaCollection = item.MediaCollection + CollectionType = item.CollectionType, + MediaCollection = item.MediaCollection, + TelevisionShow = item.TelevisionShow, + TelevisionSeason = item.TelevisionSeason }; switch (item) @@ -154,11 +211,10 @@ { Index = _schedule.Items.Map(i => i.Index).DefaultIfEmpty().Max() + 1, StartType = StartType.Dynamic, - PlayoutMode = PlayoutMode.One + PlayoutMode = PlayoutMode.One, + CollectionType = ProgramScheduleItemCollectionType.Collection }; - _defaultCollection.IfSome(c => item.MediaCollection = c); - _schedule.Items.Add(item); _selectedItem = item; } @@ -169,9 +225,33 @@ _schedule.Items.Remove(item); } + private void MoveItemUp(ProgramScheduleItemEditViewModel item) + { + // swap with lower index + ProgramScheduleItemEditViewModel toSwap = _schedule.Items.OrderByDescending(x => x.Index).First(x => x.Index < item.Index); + int temp = toSwap.Index; + toSwap.Index = item.Index; + item.Index = temp; + } + + private void MoveItemDown(ProgramScheduleItemEditViewModel item) + { + // swap with higher index + ProgramScheduleItemEditViewModel toSwap = _schedule.Items.OrderBy(x => x.Index).First(x => x.Index > item.Index); + int temp = toSwap.Index; + toSwap.Index = item.Index; + item.Index = temp; + } + private Task> SearchMediaCollections(string value) => _mediaCollections.Filter(c => c.Name.Contains(value ?? string.Empty, StringComparison.OrdinalIgnoreCase)).AsTask(); + private Task> SearchTelevisionShows(string value) => + _televisionShows.Filter(s => s.Title.Contains(value ?? string.Empty, StringComparison.OrdinalIgnoreCase)).AsTask(); + + private Task> SearchTelevisionSeasons(string value) => + _televisionSeasons.Filter(s => s.Title.Contains(value ?? string.Empty, StringComparison.OrdinalIgnoreCase)).AsTask(); + private async Task SaveChanges() { var items = _schedule.Items.Map(item => new ReplaceProgramScheduleItem( @@ -179,7 +259,10 @@ item.StartType, item.StartTime, item.PlayoutMode, - item.MediaCollection.Id, + item.CollectionType, + item.MediaCollection?.Id, + item.TelevisionShow?.Id, + item.TelevisionSeason?.Id, item.MultipleCount, item.PlayoutDuration, item.PlayoutMode == PlayoutMode.Duration ? item.OfflineTail.IfNone(false) : null)).ToList(); @@ -189,7 +272,7 @@ errorMessages.HeadOrNone().Match( error => { - Snackbar.Add($"Unexpected error saving schedule: {error.Value}"); + Snackbar.Add($"Unexpected error saving schedule: {error.Value}", Severity.Error); Logger.LogError("Unexpected error saving schedule: {Error}", error.Value); }, () => NavigationManager.NavigateTo("/schedules")); diff --git a/ErsatzTV/Pages/Schedules.razor b/ErsatzTV/Pages/Schedules.razor index 18677da3..80398576 100644 --- a/ErsatzTV/Pages/Schedules.razor +++ b/ErsatzTV/Pages/Schedules.razor @@ -5,44 +5,46 @@ @inject IDialogService Dialog @inject IMediator Mediator - - - Schedules - - - - - - - - - Id - Name - Media Collection Playback Order - - - - @context.Id - @context.Name - @context.MediaCollectionPlaybackOrder - - - - Edit Properties - - - Edit Schedule Items - - - Delete - - - - - - - Add Schedule - + + + + Schedules + + + + + + + + + Id + Name + Media Collection Playback Order + + + + @context.Id + @context.Name + @context.MediaCollectionPlaybackOrder + + + + Edit Properties + + + Edit Schedule Items + + + Delete + + + + + + + Add Schedule + + @if (_selectedScheduleItems != null) { @@ -59,7 +61,7 @@ @(context.StartType == StartType.Fixed ? context.StartTime?.ToString(@"hh\:mm") ?? string.Empty : "Dynamic") - @context.MediaCollection.Name + @context.Name @context.PlayoutMode diff --git a/ErsatzTV/Pages/TelevisionEpisode.razor b/ErsatzTV/Pages/TelevisionEpisode.razor new file mode 100644 index 00000000..fbf3af3a --- /dev/null +++ b/ErsatzTV/Pages/TelevisionEpisode.razor @@ -0,0 +1,82 @@ +@page "/media/tv/episodes/{EpisodeId:int}" +@using ErsatzTV.Application.Television +@using ErsatzTV.Application.Television.Queries +@using ErsatzTV.Application.MediaCollections +@using ErsatzTV.Application.MediaCollections.Commands +@inject IMediator Mediator +@inject IDialogService Dialog +@inject NavigationManager NavigationManager + + + + +
+ @if (!string.IsNullOrWhiteSpace(_episode.Poster)) + { + + + + } + +
+ @_episode.Title + @_season.Plot + @_episode.Plot +
+ + Add To Collection + +
+
+
+
+
+
+ +@code { + + [Parameter] + public int EpisodeId { get; set; } + + private TelevisionEpisodeViewModel _episode; + private TelevisionSeasonViewModel _season; + + private List _breadcrumbs; + + protected override Task OnParametersSetAsync() => RefreshData(); + + private async Task RefreshData() + { + await Mediator.Send(new GetTelevisionEpisodeById(EpisodeId)) + .IfSomeAsync(vm => _episode = vm); + + await Mediator.Send(new GetTelevisionSeasonById(_episode.SeasonId)) + .IfSomeAsync(vm => _season = vm); + + _breadcrumbs = new List + { + new("TV Shows", "/media/tv/shows"), + new($"{_season.Title} ({_season.Year})", $"/media/tv/shows/{_season.ShowId}"), + new(_season.Plot, $"/media/tv/seasons/{_episode.SeasonId}"), + new($"Episode {_episode.Episode}", null, true) + }; + } + + private async Task AddToCollection() + { + var parameters = new DialogParameters { { "EntityType", "episode" }, { "EntityName", _episode.Title } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = Dialog.Show("Add To Collection", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Cancelled && result.Data is MediaCollectionViewModel collection) + { + await Mediator.Send(new AddTelevisionEpisodeToSimpleMediaCollection(collection.Id, EpisodeId)); + NavigationManager.NavigateTo($"/media/collections/{collection.Id}"); + } + } + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/TelevisionEpisodeList.razor b/ErsatzTV/Pages/TelevisionEpisodeList.razor new file mode 100644 index 00000000..74743c51 --- /dev/null +++ b/ErsatzTV/Pages/TelevisionEpisodeList.razor @@ -0,0 +1,122 @@ +@page "/media/tv/seasons/{SeasonId:int}" +@using ErsatzTV.Application.Television +@using ErsatzTV.Application.Television.Queries +@using ErsatzTV.Application.MediaCards +@using ErsatzTV.Application.MediaCards.Queries +@using ErsatzTV.Application.MediaCollections +@using ErsatzTV.Application.MediaCollections.Commands +@using ErsatzTV.Application.ProgramSchedules +@using ErsatzTV.Application.ProgramSchedules.Commands +@inject IMediator Mediator +@inject IDialogService Dialog +@inject NavigationManager NavigationManager + + + + +
+ @if (!string.IsNullOrWhiteSpace(_season.Poster)) + { + + + + } + +
+ @_season.Title + @_season.Year + @_season.Plot +
+ + Add To Collection + + + Add To Schedule + +
+
+
+
+
+
+ + + @foreach (TelevisionEpisodeCardViewModel card in _data.Cards) + { + + } + + +@code { + + [Parameter] + public int SeasonId { get; set; } + + private TelevisionSeasonViewModel _season; + + private int _pageSize => 100; + private readonly int _pageNumber = 1; + + private TelevisionEpisodeCardResultsViewModel _data; + + private List _breadcrumbs; + + protected override Task OnParametersSetAsync() => RefreshData(); + + private async Task RefreshData() + { + await Mediator.Send(new GetTelevisionSeasonById(SeasonId)) + .IfSomeAsync(vm => _season = vm); + + _data = await Mediator.Send(new GetTelevisionEpisodeCards(SeasonId, _pageNumber, _pageSize)); + + _breadcrumbs = new List + { + new("TV Shows", "/media/tv/shows"), + new($"{_season.Title} ({_season.Year})", $"/media/tv/shows/{_season.ShowId}"), + new(_season.Plot, null, true) + }; + } + + private async Task AddToCollection() + { + var parameters = new DialogParameters { { "EntityType", "season" }, { "EntityName", $"{_season.Title} - {_season.Plot}" } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = Dialog.Show("Add To Collection", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Cancelled && result.Data is MediaCollectionViewModel collection) + { + await Mediator.Send(new AddTelevisionSeasonToSimpleMediaCollection(collection.Id, SeasonId)); + NavigationManager.NavigateTo($"/media/collections/{collection.Id}"); + } + } + + + private async Task AddToSchedule() + { + var parameters = new DialogParameters { { "EntityType", "season" }, { "EntityName", $"{_season.Title} - {_season.Plot}" } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = Dialog.Show("Add To Schedule", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Cancelled && result.Data is ProgramScheduleViewModel schedule) + { + await Mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionSeason, null, null, SeasonId, null, null, null)); + NavigationManager.NavigateTo($"/schedules/{schedule.Id}/items"); + } + } + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/TelevisionSeasonList.razor b/ErsatzTV/Pages/TelevisionSeasonList.razor new file mode 100644 index 00000000..e7b11e38 --- /dev/null +++ b/ErsatzTV/Pages/TelevisionSeasonList.razor @@ -0,0 +1,117 @@ +@page "/media/tv/shows/{ShowId:int}" +@using ErsatzTV.Application.Television +@using ErsatzTV.Application.Television.Queries +@using ErsatzTV.Application.MediaCards +@using ErsatzTV.Application.MediaCards.Queries +@using ErsatzTV.Application.MediaCollections +@using ErsatzTV.Application.MediaCollections.Commands +@using ErsatzTV.Application.ProgramSchedules +@using ErsatzTV.Application.ProgramSchedules.Commands +@inject IMediator Mediator +@inject IDialogService Dialog +@inject NavigationManager NavigationManager + + + + +
+ @if (!string.IsNullOrWhiteSpace(_show.Poster)) + { + + + + } + +
+ @_show.Title + @_show.Year + @_show.Plot +
+ + Add To Collection + + + Add To Schedule + +
+
+
+
+
+
+ + + @foreach (TelevisionSeasonCardViewModel card in _data.Cards) + { + + } + + +@code { + + [Parameter] + public int ShowId { get; set; } + + private TelevisionShowViewModel _show; + + private int _pageSize => 100; + private readonly int _pageNumber = 1; + + private TelevisionSeasonCardResultsViewModel _data; + + private List _breadcrumbs; + + protected override Task OnParametersSetAsync() => RefreshData(); + + private async Task RefreshData() + { + await Mediator.Send(new GetTelevisionShowById(ShowId)) + .IfSomeAsync(vm => _show = vm); + + _data = await Mediator.Send(new GetTelevisionSeasonCards(ShowId, _pageNumber, _pageSize)); + + _breadcrumbs = new List + { + new("TV Shows", "/media/tv/shows"), + new($"{_show.Title} ({_show.Year})", null, true) + }; + } + + private async Task AddToCollection() + { + var parameters = new DialogParameters { { "EntityType", "show" }, { "EntityName", _show.Title } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = Dialog.Show("Add To Collection", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Cancelled && result.Data is MediaCollectionViewModel collection) + { + await Mediator.Send(new AddTelevisionShowToSimpleMediaCollection(collection.Id, ShowId)); + NavigationManager.NavigateTo($"/media/collections/{collection.Id}"); + } + } + + private async Task AddToSchedule() + { + var parameters = new DialogParameters { { "EntityType", "show" }, { "EntityName", _show.Title } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = Dialog.Show("Add To Schedule", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Cancelled && result.Data is ProgramScheduleViewModel schedule) + { + await Mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionShow, null, ShowId, null, null, null, null)); + NavigationManager.NavigateTo($"/schedules/{schedule.Id}/items"); + } + } + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/TelevisionShowList.razor b/ErsatzTV/Pages/TelevisionShowList.razor new file mode 100644 index 00000000..78424d02 --- /dev/null +++ b/ErsatzTV/Pages/TelevisionShowList.razor @@ -0,0 +1,52 @@ +@page "/media/tv/shows" +@using ErsatzTV.Application.MediaCards +@using ErsatzTV.Application.MediaCards.Queries +@inject IMediator Mediator + + + + + + + @Math.Min((_pageNumber - 1) * _pageSize + 1, _data.Count)-@Math.Min(_data.Count, _pageNumber * _pageSize) of @_data.Count + + + + + + + + @foreach (TelevisionShowCardViewModel card in _data.Cards) + { + + } + + +@code { + private int _pageSize => 100; + private int _pageNumber = 1; + + private TelevisionShowCardResultsViewModel _data; + + protected override Task OnParametersSetAsync() => RefreshData(); + + private async Task RefreshData() => + _data = await Mediator.Send(new GetTelevisionShowCards(_pageNumber, _pageSize)); + + private async Task PrevPage() + { + _pageNumber -= 1; + await RefreshData(); + } + + private async Task NextPage() + { + _pageNumber += 1; + await RefreshData(); + } + +} \ No newline at end of file diff --git a/ErsatzTV/Properties/Annotations.cs b/ErsatzTV/Properties/Annotations.cs new file mode 100644 index 00000000..928c91c4 --- /dev/null +++ b/ErsatzTV/Properties/Annotations.cs @@ -0,0 +1,1460 @@ +/* MIT License + +Copyright (c) 2016 JetBrains http://www.jetbrains.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. */ + +using System; + +// ReSharper disable InheritdocConsiderUsage + +#pragma warning disable 1591 +// ReSharper disable UnusedMember.Global +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedAutoPropertyAccessor.Global +// ReSharper disable IntroduceOptionalParameters.Global +// ReSharper disable MemberCanBeProtected.Global +// ReSharper disable InconsistentNaming + +namespace ErsatzTV.Annotations +{ + /// + /// Indicates that the value of the marked element could be null sometimes, + /// so checking for null is required before its usage. + /// + /// + /// + /// [CanBeNull] object Test() => null; + /// + /// void UseTest() { + /// var p = Test(); + /// var s = p.ToString(); // Warning: Possible 'System.NullReferenceException' + /// } + /// + /// + [AttributeUsage( + AttributeTargets.Method | AttributeTargets.Parameter | AttributeTargets.Property | + AttributeTargets.Delegate | AttributeTargets.Field | AttributeTargets.Event | + AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.GenericParameter)] + public sealed class CanBeNullAttribute : Attribute + { + } + + /// + /// Indicates that the value of the marked element can never be null. + /// + /// + /// + /// [NotNull] object Foo() { + /// return null; // Warning: Possible 'null' assignment + /// } + /// + /// + [AttributeUsage( + AttributeTargets.Method | AttributeTargets.Parameter | AttributeTargets.Property | + AttributeTargets.Delegate | AttributeTargets.Field | AttributeTargets.Event | + AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.GenericParameter)] + public sealed class NotNullAttribute : Attribute + { + } + + /// + /// Can be applied to symbols of types derived from IEnumerable as well as to symbols of Task + /// and Lazy classes to indicate that the value of a collection item, of the Task.Result property + /// or of the Lazy.Value property can never be null. + /// + /// + /// + /// public void Foo([ItemNotNull]List<string> books) + /// { + /// foreach (var book in books) { + /// if (book != null) // Warning: Expression is always true + /// Console.WriteLine(book.ToUpper()); + /// } + /// } + /// + /// + [AttributeUsage( + AttributeTargets.Method | AttributeTargets.Parameter | AttributeTargets.Property | + AttributeTargets.Delegate | AttributeTargets.Field)] + public sealed class ItemNotNullAttribute : Attribute + { + } + + /// + /// Can be applied to symbols of types derived from IEnumerable as well as to symbols of Task + /// and Lazy classes to indicate that the value of a collection item, of the Task.Result property + /// or of the Lazy.Value property can be null. + /// + /// + /// + /// public void Foo([ItemCanBeNull]List<string> books) + /// { + /// foreach (var book in books) + /// { + /// // Warning: Possible 'System.NullReferenceException' + /// Console.WriteLine(book.ToUpper()); + /// } + /// } + /// + /// + [AttributeUsage( + AttributeTargets.Method | AttributeTargets.Parameter | AttributeTargets.Property | + AttributeTargets.Delegate | AttributeTargets.Field)] + public sealed class ItemCanBeNullAttribute : Attribute + { + } + + /// + /// Indicates that the marked method builds string by the format pattern and (optional) arguments. + /// The parameter, which contains the format string, should be given in constructor. The format string + /// should be in -like form. + /// + /// + /// + /// [StringFormatMethod("message")] + /// void ShowError(string message, params object[] args) { /* do something */ } + /// + /// void Foo() { + /// ShowError("Failed: {0}"); // Warning: Non-existing argument in format string + /// } + /// + /// + [AttributeUsage( + AttributeTargets.Constructor | AttributeTargets.Method | + AttributeTargets.Property | AttributeTargets.Delegate)] + public sealed class StringFormatMethodAttribute : Attribute + { + /// + /// Specifies which parameter of an annotated method should be treated as the format string + /// + public StringFormatMethodAttribute( + [NotNull] + string formatParameterName) => FormatParameterName = formatParameterName; + + [NotNull] + public string FormatParameterName { get; } + } + + /// + /// Use this annotation to specify a type that contains static or const fields + /// with values for the annotated property/field/parameter. + /// The specified type will be used to improve completion suggestions. + /// + /// + /// + /// namespace TestNamespace + /// { + /// public class Constants + /// { + /// public static int INT_CONST = 1; + /// public const string STRING_CONST = "1"; + /// } + /// + /// public class Class1 + /// { + /// [ValueProvider("TestNamespace.Constants")] public int myField; + /// public void Foo([ValueProvider("TestNamespace.Constants")] string str) { } + /// + /// public void Test() + /// { + /// Foo(/*try completion here*/);// + /// myField = /*try completion here*/ + /// } + /// } + /// } + /// + /// + [AttributeUsage( + AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.Field, + AllowMultiple = true)] + public sealed class ValueProviderAttribute : Attribute + { + public ValueProviderAttribute( + [NotNull] + string name) => Name = name; + + [NotNull] + public string Name { get; } + } + + /// + /// Indicates that the integral value falls into the specified interval. + /// It's allowed to specify multiple non-intersecting intervals. + /// Values of interval boundaries are inclusive. + /// + /// + /// + /// void Foo([ValueRange(0, 100)] int value) { + /// if (value == -1) { // Warning: Expression is always 'false' + /// ... + /// } + /// } + /// + /// + [AttributeUsage( + AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property | + AttributeTargets.Method | AttributeTargets.Delegate, + AllowMultiple = true)] + public sealed class ValueRangeAttribute : Attribute + { + public ValueRangeAttribute(long from, long to) + { + From = from; + To = to; + } + + public ValueRangeAttribute(ulong from, ulong to) + { + From = from; + To = to; + } + + public ValueRangeAttribute(long value) => From = To = value; + + public ValueRangeAttribute(ulong value) => From = To = value; + + public object From { get; } + public object To { get; } + } + + /// + /// Indicates that the integral value never falls below zero. + /// + /// + /// + /// void Foo([NonNegativeValue] int value) { + /// if (value == -1) { // Warning: Expression is always 'false' + /// ... + /// } + /// } + /// + /// + [AttributeUsage( + AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property | + AttributeTargets.Method | AttributeTargets.Delegate)] + public sealed class NonNegativeValueAttribute : Attribute + { + } + + /// + /// Indicates that the function argument should be a string literal and match one + /// of the parameters of the caller function. For example, ReSharper annotates + /// the parameter of . + /// + /// + /// + /// void Foo(string param) { + /// if (param == null) + /// throw new ArgumentNullException("par"); // Warning: Cannot resolve symbol + /// } + /// + /// + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class InvokerParameterNameAttribute : Attribute + { + } + + /// + /// Indicates that the method is contained in a type that implements + /// System.ComponentModel.INotifyPropertyChanged interface and this method + /// is used to notify that some property value changed. + /// + /// + /// The method should be non-static and conform to one of the supported signatures: + /// + /// + /// NotifyChanged(string) + /// + /// + /// NotifyChanged(params string[]) + /// + /// + /// NotifyChanged{T}(Expression{Func{T}}) + /// + /// + /// NotifyChanged{T,U}(Expression{Func{T,U}}) + /// + /// + /// SetProperty{T}(ref T, T, string) + /// + /// + /// + /// + /// + /// public class Foo : INotifyPropertyChanged { + /// public event PropertyChangedEventHandler PropertyChanged; + /// + /// [NotifyPropertyChangedInvocator] + /// protected virtual void NotifyChanged(string propertyName) { ... } + /// + /// string _name; + /// + /// public string Name { + /// get { return _name; } + /// set { _name = value; NotifyChanged("LastName"); /* Warning */ } + /// } + /// } + /// + /// Examples of generated notifications: + /// + /// + /// NotifyChanged("Property") + /// + /// + /// NotifyChanged(() => Property) + /// + /// + /// NotifyChanged((VM x) => x.Property) + /// + /// + /// SetProperty(ref myField, value, "Property") + /// + /// + /// + [AttributeUsage(AttributeTargets.Method)] + public sealed class NotifyPropertyChangedInvocatorAttribute : Attribute + { + public NotifyPropertyChangedInvocatorAttribute() + { + } + + public NotifyPropertyChangedInvocatorAttribute( + [NotNull] + string parameterName) => ParameterName = parameterName; + + [CanBeNull] + public string ParameterName { get; } + } + + /// + /// Describes dependency between method input and output. + /// + /// + ///

Function Definition Table syntax:

+ /// + /// FDT ::= FDTRow [;FDTRow]* + /// FDTRow ::= Input => Output | Output <= Input + /// Input ::= ParameterName: Value [, Input]* + /// Output ::= [ParameterName: Value]* {halt|stop|void|nothing|Value} + /// Value ::= true | false | null | notnull | canbenull + /// + /// If the method has a single input parameter, its name could be omitted.
+ /// Using halt (or void/nothing, which is the same) for the method output + /// means that the method doesn't return normally (throws or terminates the process).
+ /// Value canbenull is only applicable for output parameters.
+ /// You can use multiple [ContractAnnotation] for each FDT row, or use single attribute + /// with rows separated by semicolon. There is no notion of order rows, all rows are checked + /// for applicability and applied per each program state tracked by the analysis engine.
+ ///
+ /// + /// + /// + /// + /// [ContractAnnotation("=> halt")] + /// public void TerminationMethod() + /// + /// + /// + /// + /// [ContractAnnotation("null <= param:null")] // reverse condition syntax + /// public string GetName(string surname) + /// + /// + /// + /// + /// [ContractAnnotation("s:null => true")] + /// public bool IsNullOrEmpty(string s) // string.IsNullOrEmpty() + /// + /// + /// + /// + /// // A method that returns null if the parameter is null, + /// // and not null if the parameter is not null + /// [ContractAnnotation("null => null; notnull => notnull")] + /// public object Transform(object data) + /// + /// + /// + /// + /// [ContractAnnotation("=> true, result: notnull; => false, result: null")] + /// public bool TryParse(string s, out Person result) + /// + /// + /// + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + public sealed class ContractAnnotationAttribute : Attribute + { + public ContractAnnotationAttribute( + [NotNull] + string contract) + : this(contract, false) + { + } + + public ContractAnnotationAttribute( + [NotNull] + string contract, + bool forceFullStates) + { + Contract = contract; + ForceFullStates = forceFullStates; + } + + [NotNull] + public string Contract { get; } + + public bool ForceFullStates { get; } + } + + /// + /// Indicates whether the marked element should be localized. + /// + /// + /// + /// [LocalizationRequiredAttribute(true)] + /// class Foo { + /// string str = "my string"; // Warning: Localizable string + /// } + /// + /// + [AttributeUsage(AttributeTargets.All)] + public sealed class LocalizationRequiredAttribute : Attribute + { + public LocalizationRequiredAttribute() : this(true) + { + } + + public LocalizationRequiredAttribute(bool required) => Required = required; + + public bool Required { get; } + } + + /// + /// Indicates that the value of the marked type (or its derivatives) + /// cannot be compared using '==' or '!=' operators and Equals() + /// should be used instead. However, using '==' or '!=' for comparison + /// with null is always permitted. + /// + /// + /// + /// [CannotApplyEqualityOperator] + /// class NoEquality { } + /// + /// class UsesNoEquality { + /// void Test() { + /// var ca1 = new NoEquality(); + /// var ca2 = new NoEquality(); + /// if (ca1 != null) { // OK + /// bool condition = ca1 == ca2; // Warning + /// } + /// } + /// } + /// + /// + [AttributeUsage(AttributeTargets.Interface | AttributeTargets.Class | AttributeTargets.Struct)] + public sealed class CannotApplyEqualityOperatorAttribute : Attribute + { + } + + /// + /// When applied to a target attribute, specifies a requirement for any type marked + /// with the target attribute to implement or inherit specific type or types. + /// + /// + /// + /// [BaseTypeRequired(typeof(IComponent)] // Specify requirement + /// class ComponentAttribute : Attribute { } + /// + /// [Component] // ComponentAttribute requires implementing IComponent interface + /// class MyComponent : IComponent { } + /// + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + [BaseTypeRequired(typeof(Attribute))] + public sealed class BaseTypeRequiredAttribute : Attribute + { + public BaseTypeRequiredAttribute( + [NotNull] + Type baseType) => BaseType = baseType; + + [NotNull] + public Type BaseType { get; } + } + + /// + /// Indicates that the marked symbol is used implicitly (e.g. via reflection, in external library), + /// so this symbol will not be reported as unused (as well as by other usage inspections). + /// + [AttributeUsage(AttributeTargets.All)] + public sealed class UsedImplicitlyAttribute : Attribute + { + public UsedImplicitlyAttribute() + : this(ImplicitUseKindFlags.Default, ImplicitUseTargetFlags.Default) + { + } + + public UsedImplicitlyAttribute(ImplicitUseKindFlags useKindFlags) + : this(useKindFlags, ImplicitUseTargetFlags.Default) + { + } + + public UsedImplicitlyAttribute(ImplicitUseTargetFlags targetFlags) + : this(ImplicitUseKindFlags.Default, targetFlags) + { + } + + public UsedImplicitlyAttribute(ImplicitUseKindFlags useKindFlags, ImplicitUseTargetFlags targetFlags) + { + UseKindFlags = useKindFlags; + TargetFlags = targetFlags; + } + + public ImplicitUseKindFlags UseKindFlags { get; } + + public ImplicitUseTargetFlags TargetFlags { get; } + } + + /// + /// Can be applied to attributes, type parameters, and parameters of a type assignable from + /// . + /// When applied to an attribute, the decorated attribute behaves the same as . + /// When applied to a type parameter or to a parameter of type , indicates that the + /// corresponding type + /// is used implicitly. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.GenericParameter | AttributeTargets.Parameter)] + public sealed class MeansImplicitUseAttribute : Attribute + { + public MeansImplicitUseAttribute() + : this(ImplicitUseKindFlags.Default, ImplicitUseTargetFlags.Default) + { + } + + public MeansImplicitUseAttribute(ImplicitUseKindFlags useKindFlags) + : this(useKindFlags, ImplicitUseTargetFlags.Default) + { + } + + public MeansImplicitUseAttribute(ImplicitUseTargetFlags targetFlags) + : this(ImplicitUseKindFlags.Default, targetFlags) + { + } + + public MeansImplicitUseAttribute(ImplicitUseKindFlags useKindFlags, ImplicitUseTargetFlags targetFlags) + { + UseKindFlags = useKindFlags; + TargetFlags = targetFlags; + } + + [UsedImplicitly] + public ImplicitUseKindFlags UseKindFlags { get; } + + [UsedImplicitly] + public ImplicitUseTargetFlags TargetFlags { get; } + } + + /// + /// Specify the details of implicitly used symbol when it is marked + /// with or . + /// + [Flags] + public enum ImplicitUseKindFlags + { + Default = Access | Assign | InstantiatedWithFixedConstructorSignature, + + /// Only entity marked with attribute considered used. + Access = 1, + + /// Indicates implicit assignment to a member. + Assign = 2, + + /// + /// Indicates implicit instantiation of a type with fixed constructor signature. + /// That means any unused constructor parameters won't be reported as such. + /// + InstantiatedWithFixedConstructorSignature = 4, + + /// Indicates implicit instantiation of a type. + InstantiatedNoFixedConstructorSignature = 8 + } + + /// + /// Specify what is considered to be used implicitly when marked + /// with or . + /// + [Flags] + public enum ImplicitUseTargetFlags + { + Default = Itself, + Itself = 1, + + /// Members of entity marked with attribute are considered used. + Members = 2, + + /// Inherited entities are considered used. + WithInheritors = 4, + + /// Entity marked with attribute and all its members considered used. + WithMembers = Itself | Members + } + + /// + /// This attribute is intended to mark publicly available API + /// which should not be removed and so is treated as used. + /// + [MeansImplicitUse(ImplicitUseTargetFlags.WithMembers)] + [AttributeUsage(AttributeTargets.All, Inherited = false)] + public sealed class PublicAPIAttribute : Attribute + { + public PublicAPIAttribute() + { + } + + public PublicAPIAttribute( + [NotNull] + string comment) => Comment = comment; + + [CanBeNull] + public string Comment { get; } + } + + /// + /// Tells code analysis engine if the parameter is completely handled when the invoked method is on stack. + /// If the parameter is a delegate, indicates that delegate is executed while the method is executed. + /// If the parameter is an enumerable, indicates that it is enumerated while the method is executed. + /// + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class InstantHandleAttribute : Attribute + { + } + + /// + /// Indicates that a method does not make any observable state changes. + /// The same as System.Diagnostics.Contracts.PureAttribute. + /// + /// + /// + /// [Pure] int Multiply(int x, int y) => x * y; + /// + /// void M() { + /// Multiply(123, 42); // Warning: Return value of pure method is not used + /// } + /// + /// + [AttributeUsage(AttributeTargets.Method)] + public sealed class PureAttribute : Attribute + { + } + + /// + /// Indicates that the return value of the method invocation must be used. + /// + /// + /// Methods decorated with this attribute (in contrast to pure methods) might change state, + /// but make no sense without using their return value.
+ /// Similarly to , this attribute + /// will help detecting usages of the method when the return value in not used. + /// Additionally, you can optionally specify a custom message, which will be used when showing warnings, e.g. + /// [MustUseReturnValue("Use the return value to...")]. + ///
+ [AttributeUsage(AttributeTargets.Method)] + public sealed class MustUseReturnValueAttribute : Attribute + { + public MustUseReturnValueAttribute() + { + } + + public MustUseReturnValueAttribute( + [NotNull] + string justification) => Justification = justification; + + [CanBeNull] + public string Justification { get; } + } + + /// + /// Indicates the type member or parameter of some type, that should be used instead of all other ways + /// to get the value of that type. This annotation is useful when you have some "context" value evaluated + /// and stored somewhere, meaning that all other ways to get this value must be consolidated with existing one. + /// + /// + /// + /// class Foo { + /// [ProvidesContext] IBarService _barService = ...; + /// + /// void ProcessNode(INode node) { + /// DoSomething(node, node.GetGlobalServices().Bar); + /// // ^ Warning: use value of '_barService' field + /// } + /// } + /// + /// + [AttributeUsage( + AttributeTargets.Field | AttributeTargets.Property | AttributeTargets.Parameter | AttributeTargets.Method | + AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Struct | + AttributeTargets.GenericParameter)] + public sealed class ProvidesContextAttribute : Attribute + { + } + + /// + /// Indicates that a parameter is a path to a file or a folder within a web project. + /// Path can be relative or absolute, starting from web root (~). + /// + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class PathReferenceAttribute : Attribute + { + public PathReferenceAttribute() + { + } + + public PathReferenceAttribute( + [NotNull] [PathReference] + string basePath) => BasePath = basePath; + + [CanBeNull] + public string BasePath { get; } + } + + /// + /// An extension method marked with this attribute is processed by code completion + /// as a 'Source Template'. When the extension method is completed over some expression, its source code + /// is automatically expanded like a template at call site. + /// + /// + /// Template method body can contain valid source code and/or special comments starting with '$'. + /// Text inside these comments is added as source code when the template is applied. Template parameters + /// can be used either as additional method parameters or as identifiers wrapped in two '$' signs. + /// Use the attribute to specify macros for parameters. + /// + /// + /// In this example, the 'forEach' method is a source template available over all values + /// of enumerable types, producing ordinary C# 'foreach' statement and placing caret inside block: + /// + /// [SourceTemplate] + /// public static void forEach<T>(this IEnumerable<T> xs) { + /// foreach (var x in xs) { + /// //$ $END$ + /// } + /// } + /// + /// + [AttributeUsage(AttributeTargets.Method)] + public sealed class SourceTemplateAttribute : Attribute + { + } + + /// + /// Allows specifying a macro for a parameter of a source template. + /// + /// + /// You can apply the attribute on the whole method or on any of its additional parameters. The macro expression + /// is defined in the property. When applied on a method, the target + /// template parameter is defined in the property. To apply the macro silently + /// for the parameter, set the property value = -1. + /// + /// + /// Applying the attribute on a source template method: + /// + /// [SourceTemplate, Macro(Target = "item", Expression = "suggestVariableName()")] + /// public static void forEach<T>(this IEnumerable<T> collection) { + /// foreach (var item in collection) { + /// //$ $END$ + /// } + /// } + /// + /// Applying the attribute on a template method parameter: + /// + /// [SourceTemplate] + /// public static void something(this Entity x, [Macro(Expression = "guid()", Editable = -1)] string newguid) { + /// /*$ var $x$Id = "$newguid$" + x.ToString(); + /// x.DoSomething($x$Id); */ + /// } + /// + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Method, AllowMultiple = true)] + public sealed class MacroAttribute : Attribute + { + /// + /// Allows specifying a macro that will be executed for a source template + /// parameter when the template is expanded. + /// + [CanBeNull] + public string Expression { get; set; } + + /// + /// Allows specifying which occurrence of the target parameter becomes editable when the template is deployed. + /// + /// + /// If the target parameter is used several times in the template, only one occurrence becomes editable; + /// other occurrences are changed synchronously. To specify the zero-based index of the editable occurrence, + /// use values >= 0. To make the parameter non-editable when the template is expanded, use -1. + /// + public int Editable { get; set; } + + /// + /// Identifies the target parameter of a source template if the + /// is applied on a template method. + /// + [CanBeNull] + public string Target { get; set; } + } + + [AttributeUsage( + AttributeTargets.Assembly | AttributeTargets.Field | AttributeTargets.Property, + AllowMultiple = true)] + public sealed class AspMvcAreaMasterLocationFormatAttribute : Attribute + { + public AspMvcAreaMasterLocationFormatAttribute( + [NotNull] + string format) => Format = format; + + [NotNull] + public string Format { get; } + } + + [AttributeUsage( + AttributeTargets.Assembly | AttributeTargets.Field | AttributeTargets.Property, + AllowMultiple = true)] + public sealed class AspMvcAreaPartialViewLocationFormatAttribute : Attribute + { + public AspMvcAreaPartialViewLocationFormatAttribute( + [NotNull] + string format) => Format = format; + + [NotNull] + public string Format { get; } + } + + [AttributeUsage( + AttributeTargets.Assembly | AttributeTargets.Field | AttributeTargets.Property, + AllowMultiple = true)] + public sealed class AspMvcAreaViewLocationFormatAttribute : Attribute + { + public AspMvcAreaViewLocationFormatAttribute( + [NotNull] + string format) => Format = format; + + [NotNull] + public string Format { get; } + } + + [AttributeUsage( + AttributeTargets.Assembly | AttributeTargets.Field | AttributeTargets.Property, + AllowMultiple = true)] + public sealed class AspMvcMasterLocationFormatAttribute : Attribute + { + public AspMvcMasterLocationFormatAttribute( + [NotNull] + string format) => Format = format; + + [NotNull] + public string Format { get; } + } + + [AttributeUsage( + AttributeTargets.Assembly | AttributeTargets.Field | AttributeTargets.Property, + AllowMultiple = true)] + public sealed class AspMvcPartialViewLocationFormatAttribute : Attribute + { + public AspMvcPartialViewLocationFormatAttribute( + [NotNull] + string format) => Format = format; + + [NotNull] + public string Format { get; } + } + + [AttributeUsage( + AttributeTargets.Assembly | AttributeTargets.Field | AttributeTargets.Property, + AllowMultiple = true)] + public sealed class AspMvcViewLocationFormatAttribute : Attribute + { + public AspMvcViewLocationFormatAttribute( + [NotNull] + string format) => Format = format; + + [NotNull] + public string Format { get; } + } + + /// + /// ASP.NET MVC attribute. If applied to a parameter, indicates that the parameter + /// is an MVC action. If applied to a method, the MVC action name is calculated + /// implicitly from the context. Use this attribute for custom wrappers similar to + /// System.Web.Mvc.Html.ChildActionExtensions.RenderAction(HtmlHelper, String). + /// + [AttributeUsage( + AttributeTargets.Parameter | AttributeTargets.Method | AttributeTargets.Field | AttributeTargets.Property)] + public sealed class AspMvcActionAttribute : Attribute + { + public AspMvcActionAttribute() + { + } + + public AspMvcActionAttribute( + [NotNull] + string anonymousProperty) => AnonymousProperty = anonymousProperty; + + [CanBeNull] + public string AnonymousProperty { get; } + } + + /// + /// ASP.NET MVC attribute. Indicates that the marked parameter is an MVC area. + /// Use this attribute for custom wrappers similar to + /// System.Web.Mvc.Html.ChildActionExtensions.RenderAction(HtmlHelper, String). + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property)] + public sealed class AspMvcAreaAttribute : Attribute + { + public AspMvcAreaAttribute() + { + } + + public AspMvcAreaAttribute( + [NotNull] + string anonymousProperty) => AnonymousProperty = anonymousProperty; + + [CanBeNull] + public string AnonymousProperty { get; } + } + + /// + /// ASP.NET MVC attribute. If applied to a parameter, indicates that the parameter is + /// an MVC controller. If applied to a method, the MVC controller name is calculated + /// implicitly from the context. Use this attribute for custom wrappers similar to + /// System.Web.Mvc.Html.ChildActionExtensions.RenderAction(HtmlHelper, String, String). + /// + [AttributeUsage( + AttributeTargets.Parameter | AttributeTargets.Method | AttributeTargets.Field | AttributeTargets.Property)] + public sealed class AspMvcControllerAttribute : Attribute + { + public AspMvcControllerAttribute() + { + } + + public AspMvcControllerAttribute( + [NotNull] + string anonymousProperty) => AnonymousProperty = anonymousProperty; + + [CanBeNull] + public string AnonymousProperty { get; } + } + + /// + /// ASP.NET MVC attribute. Indicates that the marked parameter is an MVC Master. Use this attribute + /// for custom wrappers similar to System.Web.Mvc.Controller.View(String, String). + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property)] + public sealed class AspMvcMasterAttribute : Attribute + { + } + + /// + /// ASP.NET MVC attribute. Indicates that the marked parameter is an MVC model type. Use this attribute + /// for custom wrappers similar to System.Web.Mvc.Controller.View(String, Object). + /// + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class AspMvcModelTypeAttribute : Attribute + { + } + + /// + /// ASP.NET MVC attribute. If applied to a parameter, indicates that the parameter is an MVC + /// partial view. If applied to a method, the MVC partial view name is calculated implicitly + /// from the context. Use this attribute for custom wrappers similar to + /// System.Web.Mvc.Html.RenderPartialExtensions.RenderPartial(HtmlHelper, String). + /// + [AttributeUsage( + AttributeTargets.Parameter | AttributeTargets.Method | AttributeTargets.Field | AttributeTargets.Property)] + public sealed class AspMvcPartialViewAttribute : Attribute + { + } + + /// + /// ASP.NET MVC attribute. Allows disabling inspections for MVC views within a class or a method. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] + public sealed class AspMvcSuppressViewErrorAttribute : Attribute + { + } + + /// + /// ASP.NET MVC attribute. Indicates that a parameter is an MVC display template. + /// Use this attribute for custom wrappers similar to + /// System.Web.Mvc.Html.DisplayExtensions.DisplayForModel(HtmlHelper, String). + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property)] + public sealed class AspMvcDisplayTemplateAttribute : Attribute + { + } + + /// + /// ASP.NET MVC attribute. Indicates that the marked parameter is an MVC editor template. + /// Use this attribute for custom wrappers similar to + /// System.Web.Mvc.Html.EditorExtensions.EditorForModel(HtmlHelper, String). + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property)] + public sealed class AspMvcEditorTemplateAttribute : Attribute + { + } + + /// + /// ASP.NET MVC attribute. Indicates that the marked parameter is an MVC template. + /// Use this attribute for custom wrappers similar to + /// System.ComponentModel.DataAnnotations.UIHintAttribute(System.String). + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property)] + public sealed class AspMvcTemplateAttribute : Attribute + { + } + + /// + /// ASP.NET MVC attribute. If applied to a parameter, indicates that the parameter + /// is an MVC view component. If applied to a method, the MVC view name is calculated implicitly + /// from the context. Use this attribute for custom wrappers similar to + /// System.Web.Mvc.Controller.View(Object). + /// + [AttributeUsage( + AttributeTargets.Parameter | AttributeTargets.Method | AttributeTargets.Field | AttributeTargets.Property)] + public sealed class AspMvcViewAttribute : Attribute + { + } + + /// + /// ASP.NET MVC attribute. If applied to a parameter, indicates that the parameter + /// is an MVC view component name. + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property)] + public sealed class AspMvcViewComponentAttribute : Attribute + { + } + + /// + /// ASP.NET MVC attribute. If applied to a parameter, indicates that the parameter + /// is an MVC view component view. If applied to a method, the MVC view component view name is default. + /// + [AttributeUsage( + AttributeTargets.Parameter | AttributeTargets.Method | AttributeTargets.Field | AttributeTargets.Property)] + public sealed class AspMvcViewComponentViewAttribute : Attribute + { + } + + /// + /// ASP.NET MVC attribute. When applied to a parameter of an attribute, + /// indicates that this parameter is an MVC action name. + /// + /// + /// + /// [ActionName("Foo")] + /// public ActionResult Login(string returnUrl) { + /// ViewBag.ReturnUrl = Url.Action("Foo"); // OK + /// return RedirectToAction("Bar"); // Error: Cannot resolve action + /// } + /// + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] + public sealed class AspMvcActionSelectorAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.Field)] + public sealed class HtmlElementAttributesAttribute : Attribute + { + public HtmlElementAttributesAttribute() + { + } + + public HtmlElementAttributesAttribute( + [NotNull] + string name) => Name = name; + + [CanBeNull] + public string Name { get; } + } + + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property)] + public sealed class HtmlAttributeValueAttribute : Attribute + { + public HtmlAttributeValueAttribute( + [NotNull] + string name) => Name = name; + + [NotNull] + public string Name { get; } + } + + /// + /// Razor attribute. Indicates that the marked parameter or method is a Razor section. + /// Use this attribute for custom wrappers similar to + /// System.Web.WebPages.WebPageBase.RenderSection(String). + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Method)] + public sealed class RazorSectionAttribute : Attribute + { + } + + /// + /// Indicates how method, constructor invocation, or property access + /// over collection type affects the contents of the collection. + /// Use to specify the access type. + /// + /// + /// Using this attribute only makes sense if all collection methods are marked with this attribute. + /// + /// + /// + /// public class MyStringCollection : List<string> + /// { + /// [CollectionAccess(CollectionAccessType.Read)] + /// public string GetFirstString() + /// { + /// return this.ElementAt(0); + /// } + /// } + /// class Test + /// { + /// public void Foo() + /// { + /// // Warning: Contents of the collection is never updated + /// var col = new MyStringCollection(); + /// string x = col.GetFirstString(); + /// } + /// } + /// + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Constructor | AttributeTargets.Property)] + public sealed class CollectionAccessAttribute : Attribute + { + public CollectionAccessAttribute(CollectionAccessType collectionAccessType) => + CollectionAccessType = collectionAccessType; + + public CollectionAccessType CollectionAccessType { get; } + } + + /// + /// Provides a value for the to define + /// how the collection method invocation affects the contents of the collection. + /// + [Flags] + public enum CollectionAccessType + { + /// Method does not use or modify content of the collection. + None = 0, + + /// Method only reads content of the collection but does not modify it. + Read = 1, + + /// Method can change content of the collection but does not add new elements. + ModifyExistingContent = 2, + + /// Method can add new elements to the collection. + UpdatedContent = ModifyExistingContent | 4 + } + + /// + /// Indicates that the marked method is assertion method, i.e. it halts the control flow if + /// one of the conditions is satisfied. To set the condition, mark one of the parameters with + /// attribute. + /// + [AttributeUsage(AttributeTargets.Method)] + public sealed class AssertionMethodAttribute : Attribute + { + } + + /// + /// Indicates the condition parameter of the assertion method. The method itself should be + /// marked by attribute. The mandatory argument of + /// the attribute is the assertion type. + /// + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class AssertionConditionAttribute : Attribute + { + public AssertionConditionAttribute(AssertionConditionType conditionType) => ConditionType = conditionType; + + public AssertionConditionType ConditionType { get; } + } + + /// + /// Specifies assertion type. If the assertion method argument satisfies the condition, + /// then the execution continues. Otherwise, execution is assumed to be halted. + /// + public enum AssertionConditionType + { + /// Marked parameter should be evaluated to true. + IS_TRUE = 0, + + /// Marked parameter should be evaluated to false. + IS_FALSE = 1, + + /// Marked parameter should be evaluated to null value. + IS_NULL = 2, + + /// Marked parameter should be evaluated to not null value. + IS_NOT_NULL = 3 + } + + /// + /// Indicates that the marked method unconditionally terminates control flow execution. + /// For example, it could unconditionally throw exception. + /// + [Obsolete("Use [ContractAnnotation('=> halt')] instead")] + [AttributeUsage(AttributeTargets.Method)] + public sealed class TerminatesProgramAttribute : Attribute + { + } + + /// + /// Indicates that method is pure LINQ method, with postponed enumeration (like Enumerable.Select, + /// .Where). This annotation allows inference of [InstantHandle] annotation for parameters + /// of delegate type by analyzing LINQ method chains. + /// + [AttributeUsage(AttributeTargets.Method)] + public sealed class LinqTunnelAttribute : Attribute + { + } + + /// + /// Indicates that IEnumerable passed as a parameter is not enumerated. + /// Use this annotation to suppress the 'Possible multiple enumeration of IEnumerable' inspection. + /// + /// + /// + /// static void ThrowIfNull<T>([NoEnumeration] T v, string n) where T : class + /// { + /// // custom check for null but no enumeration + /// } + /// + /// void Foo(IEnumerable<string> values) + /// { + /// ThrowIfNull(values, nameof(values)); + /// var x = values.ToList(); // No warnings about multiple enumeration + /// } + /// + /// + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class NoEnumerationAttribute : Attribute + { + } + + /// + /// Indicates that the marked parameter is a regular expression pattern. + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property)] + public sealed class RegexPatternAttribute : Attribute + { + } + + /// + /// Prevents the Member Reordering feature from tossing members of the marked class. + /// + /// + /// The attribute must be mentioned in your member reordering patterns. + /// + [AttributeUsage( + AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Struct | AttributeTargets.Enum)] + public sealed class NoReorderAttribute : Attribute + { + } + + /// + /// XAML attribute. Indicates the type that has ItemsSource property and should be treated + /// as ItemsControl-derived type, to enable inner items DataContext type resolve. + /// + [AttributeUsage(AttributeTargets.Class)] + public sealed class XamlItemsControlAttribute : Attribute + { + } + + /// + /// XAML attribute. Indicates the property of some BindingBase-derived type, that + /// is used to bind some item of ItemsControl-derived type. This annotation will + /// enable the DataContext type resolve for XAML bindings for such properties. + /// + /// + /// Property should have the tree ancestor of the ItemsControl type or + /// marked with the attribute. + /// + [AttributeUsage(AttributeTargets.Property)] + public sealed class XamlItemBindingOfItemsControlAttribute : Attribute + { + } + + /// + /// XAML attribute. Indicates the property of some Style-derived type, that + /// is used to style items of ItemsControl-derived type. This annotation will + /// enable the DataContext type resolve for XAML bindings for such properties. + /// + /// + /// Property should have the tree ancestor of the ItemsControl type or + /// marked with the attribute. + /// + [AttributeUsage(AttributeTargets.Property)] + public sealed class XamlItemStyleOfItemsControlAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + public sealed class AspChildControlTypeAttribute : Attribute + { + public AspChildControlTypeAttribute( + [NotNull] + string tagName, + [NotNull] + Type controlType) + { + TagName = tagName; + ControlType = controlType; + } + + [NotNull] + public string TagName { get; } + + [NotNull] + public Type ControlType { get; } + } + + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Method)] + public sealed class AspDataFieldAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Method)] + public sealed class AspDataFieldsAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Property)] + public sealed class AspMethodPropertyAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + public sealed class AspRequiredAttributeAttribute : Attribute + { + public AspRequiredAttributeAttribute( + [NotNull] + string attribute) => Attribute = attribute; + + [NotNull] + public string Attribute { get; } + } + + [AttributeUsage(AttributeTargets.Property)] + public sealed class AspTypePropertyAttribute : Attribute + { + public AspTypePropertyAttribute(bool createConstructorReferences) => + CreateConstructorReferences = createConstructorReferences; + + public bool CreateConstructorReferences { get; } + } + + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] + public sealed class RazorImportNamespaceAttribute : Attribute + { + public RazorImportNamespaceAttribute( + [NotNull] + string name) => Name = name; + + [NotNull] + public string Name { get; } + } + + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] + public sealed class RazorInjectionAttribute : Attribute + { + public RazorInjectionAttribute( + [NotNull] + string type, + [NotNull] + string fieldName) + { + Type = type; + FieldName = fieldName; + } + + [NotNull] + public string Type { get; } + + [NotNull] + public string FieldName { get; } + } + + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] + public sealed class RazorDirectiveAttribute : Attribute + { + public RazorDirectiveAttribute( + [NotNull] + string directive) => Directive = directive; + + [NotNull] + public string Directive { get; } + } + + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] + public sealed class RazorPageBaseTypeAttribute : Attribute + { + public RazorPageBaseTypeAttribute( + [NotNull] + string baseType) => BaseType = baseType; + + public RazorPageBaseTypeAttribute( + [NotNull] + string baseType, + string pageName) + { + BaseType = baseType; + PageName = pageName; + } + + [NotNull] + public string BaseType { get; } + + [CanBeNull] + public string PageName { get; } + } + + [AttributeUsage(AttributeTargets.Method)] + public sealed class RazorHelperCommonAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Property)] + public sealed class RazorLayoutAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Method)] + public sealed class RazorWriteLiteralMethodAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Method)] + public sealed class RazorWriteMethodAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class RazorWriteMethodParameterAttribute : Attribute + { + } +} diff --git a/ErsatzTV/Services/SchedulerService.cs b/ErsatzTV/Services/SchedulerService.cs index 8b91872b..4501d0ff 100644 --- a/ErsatzTV/Services/SchedulerService.cs +++ b/ErsatzTV/Services/SchedulerService.cs @@ -7,7 +7,6 @@ using ErsatzTV.Application; using ErsatzTV.Application.MediaSources.Commands; using ErsatzTV.Application.Playouts.Commands; using ErsatzTV.Core.Interfaces.Locking; -using ErsatzTV.Core.Metadata; using ErsatzTV.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -83,7 +82,7 @@ namespace ErsatzTV.Services if (_entityLocker.LockMediaSource(mediaSourceId)) { await _channel.WriteAsync( - new ScanLocalMediaSource(mediaSourceId, ScanningMode.Default), + new ScanLocalMediaSource(mediaSourceId), cancellationToken); } } diff --git a/ErsatzTV/Services/WorkerService.cs b/ErsatzTV/Services/WorkerService.cs index 75538af4..caf56afd 100644 --- a/ErsatzTV/Services/WorkerService.cs +++ b/ErsatzTV/Services/WorkerService.cs @@ -3,7 +3,6 @@ using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; using ErsatzTV.Application; -using ErsatzTV.Application.MediaItems.Commands; using ErsatzTV.Application.MediaSources.Commands; using ErsatzTV.Application.Playouts.Commands; using ErsatzTV.Core; @@ -56,28 +55,6 @@ namespace ErsatzTV.Services buildPlayout.PlayoutId, error.Value)); break; - case RefreshMediaItem refreshMediaItem: - string type = refreshMediaItem switch - { - // RefreshMediaItemMetadata => "metadata", - RefreshMediaItemStatistics => "statistics", - RefreshMediaItemCollections => "collections", - RefreshMediaItemPoster => "poster", - _ => "" - }; - - // TODO: different request types for different media source types? - Either refreshMediaItemResult = - await mediator.Send(refreshMediaItem, cancellationToken); - refreshMediaItemResult.Match( - _ => _logger.LogDebug( - $"Refreshed {type} for media item {{MediaItemId}}", - refreshMediaItem.MediaItemId), - error => _logger.LogWarning( - $"Unable to refresh {type} for media item {{MediaItemId}}: {{Error}}", - refreshMediaItem.MediaItemId, - error.Value)); - break; case ScanLocalMediaSource scanLocalMediaSource: Either scanResult = await mediator.Send( scanLocalMediaSource, diff --git a/ErsatzTV/Shared/AddToCollectionDialog.razor b/ErsatzTV/Shared/AddToCollectionDialog.razor new file mode 100644 index 00000000..b76f9d3e --- /dev/null +++ b/ErsatzTV/Shared/AddToCollectionDialog.razor @@ -0,0 +1,58 @@ +@using ErsatzTV.Application.MediaCollections +@using ErsatzTV.Application.MediaCollections.Queries +@inject IMediator Mediator + + + + + + + + @foreach (MediaCollectionViewModel collection in _collections) + { + @collection.Name + } + + + + Cancel + + Add To Collection + + + + +@code { + + [CascadingParameter] + MudDialogInstance MudDialog { get; set; } + + [Parameter] + public string EntityType { get; set; } + + [Parameter] + public string EntityName { get; set; } + + [Parameter] + public string DetailText { get; set; } + + [Parameter] + public string DetailHighlight { get; set; } + + private List _collections; + + private MediaCollectionViewModel _selectedCollection; + + protected override async Task OnParametersSetAsync() => + _collections = await Mediator.Send(new GetAllSimpleMediaCollections()); + + private string FormatText() => $"Select the collection to add the {EntityType} {EntityName}"; + + private void Submit() => MudDialog.Close(DialogResult.Ok(_selectedCollection)); + + private void Cancel() => MudDialog.Cancel(); + +} \ No newline at end of file diff --git a/ErsatzTV/Shared/AddToScheduleDialog.razor b/ErsatzTV/Shared/AddToScheduleDialog.razor new file mode 100644 index 00000000..1468ccfe --- /dev/null +++ b/ErsatzTV/Shared/AddToScheduleDialog.razor @@ -0,0 +1,58 @@ +@using ErsatzTV.Application.ProgramSchedules +@using ErsatzTV.Application.ProgramSchedules.Queries +@inject IMediator Mediator + + + + + + + + @foreach (ProgramScheduleViewModel schedule in _schedules) + { + @schedule.Name + } + + + + Cancel + + Add To Schedule + + + + +@code { + + [CascadingParameter] + MudDialogInstance MudDialog { get; set; } + + [Parameter] + public string EntityType { get; set; } + + [Parameter] + public string EntityName { get; set; } + + [Parameter] + public string DetailText { get; set; } + + [Parameter] + public string DetailHighlight { get; set; } + + private List _schedules; + + private ProgramScheduleViewModel _selectedSchedule; + + protected override async Task OnParametersSetAsync() => + _schedules = await Mediator.Send(new GetAllProgramSchedules()); + + private string FormatText() => $"Select the schedule to add the {EntityType} {EntityName}"; + + private void Submit() => MudDialog.Close(DialogResult.Ok(_selectedSchedule)); + + private void Cancel() => MudDialog.Cancel(); + +} \ No newline at end of file diff --git a/ErsatzTV/Shared/LocalMediaSources.razor b/ErsatzTV/Shared/LocalMediaSources.razor index 163e4d28..d34e114e 100644 --- a/ErsatzTV/Shared/LocalMediaSources.razor +++ b/ErsatzTV/Shared/LocalMediaSources.razor @@ -1,7 +1,6 @@ @using ErsatzTV.Application.MediaSources @using ErsatzTV.Application.MediaSources.Commands @using ErsatzTV.Application.MediaSources.Queries -@using ErsatzTV.Core.Metadata @implements IDisposable @inject IDialogService Dialog @inject IMediator Mediator @@ -90,7 +89,7 @@ { if (Locker.LockMediaSource(mediaSource.Id)) { - await Channel.WriteAsync(new ScanLocalMediaSource(mediaSource.Id, ScanningMode.RescanAll)); + await Channel.WriteAsync(new ScanLocalMediaSource(mediaSource.Id)); StateHasChanged(); } } diff --git a/ErsatzTV/Shared/MainLayout.razor b/ErsatzTV/Shared/MainLayout.razor index c5536e26..e0d55ba4 100644 --- a/ErsatzTV/Shared/MainLayout.razor +++ b/ErsatzTV/Shared/MainLayout.razor @@ -28,9 +28,8 @@ FFmpeg Media Sources - TV Shows - Movies - Other Items + TV Shows + Movies Media Collections Schedules @@ -68,7 +67,8 @@ Palette = new Palette { DrawerBackground = current.Palette.Background, - Background = current.Palette.BackgroundGrey + Background = current.Palette.BackgroundGrey, + Tertiary = Colors.Shades.White } }; } diff --git a/ErsatzTV/Shared/MediaCard.razor b/ErsatzTV/Shared/MediaCard.razor index 44fea3a4..30d59868 100644 --- a/ErsatzTV/Shared/MediaCard.razor +++ b/ErsatzTV/Shared/MediaCard.razor @@ -1,37 +1,88 @@ -@using ErsatzTV.Application.MediaItems -@using ErsatzTV.Application.MediaItems.Commands +@using ErsatzTV.Application.MediaCards @using Unit = LanguageExt.Unit @inject IMediator Mediator -
- - @if (string.IsNullOrWhiteSpace(Data.Poster)) - { - - @Placeholder(Data.SortTitle) - - } - - +
+ @if (!string.IsNullOrWhiteSpace(Link)) + { +
+ + @if (string.IsNullOrWhiteSpace(Data.Poster)) + { + + @GetPlaceholder(Data.SortTitle) + + } + +
+ + + @if (DeleteClicked.HasDelegate) + { + + } +
+
+ } + else + { + + @if (string.IsNullOrWhiteSpace(Data.Poster)) + { + + @GetPlaceholder(Data.SortTitle) + + } + + } - @Data.Title + @(Title ?? Data.Title) - @Data.Subtitle + @(Subtitle ?? Data.Subtitle)
@code { [Parameter] - public AggregateMediaItemViewModel Data { get; set; } + public MediaCardViewModel Data { get; set; } + + [Parameter] + public string Link { get; set; } [Parameter] public EventCallback DataRefreshed { get; set; } - private string Placeholder(string sortTitle) + [Parameter] + public string Placeholder { get; set; } + + [Parameter] + public string Title { get; set; } + + [Parameter] + public string Subtitle { get; set; } + + [Parameter] + public string ContainerClass { get; set; } + + [Parameter] + public string CardClass { get; set; } + + [Parameter] + public EventCallback DeleteClicked { get; set; } + + private string GetPlaceholder(string sortTitle) { - string first = sortTitle.Substring(0, 1).ToUpperInvariant(); + if (Placeholder != null) + { + return Placeholder; + } + + string first = sortTitle?.Substring(0, 1).ToUpperInvariant() ?? string.Empty; return int.TryParse(first, out _) ? "#" : first; } @@ -39,13 +90,4 @@ ? "position: relative" : $"position: relative; background-image: url(/posters/{Data.Poster}); background-size: cover"; - private async Task RefreshMetadata() - { - // TODO: how should we refresh an entire television show? - await Mediator.Send(new RefreshMediaItemMetadata(Data.MediaItemId)); - await Mediator.Send(new RefreshMediaItemCollections(Data.MediaItemId)); - await Mediator.Send(new RefreshMediaItemPoster(Data.MediaItemId)); - await DataRefreshed.InvokeAsync(); - } - } \ No newline at end of file diff --git a/ErsatzTV/Shared/RemoveFromCollectionDialog.razor b/ErsatzTV/Shared/RemoveFromCollectionDialog.razor new file mode 100644 index 00000000..da643724 --- /dev/null +++ b/ErsatzTV/Shared/RemoveFromCollectionDialog.razor @@ -0,0 +1,43 @@ +@inject IMediator Mediator + + + + + + + + + Cancel + + Remove From Collection + + + + +@code { + + [CascadingParameter] + MudDialogInstance MudDialog { get; set; } + + [Parameter] + public string EntityType { get; set; } + + [Parameter] + public string EntityName { get; set; } + + [Parameter] + public string DetailText { get; set; } + + [Parameter] + public string DetailHighlight { get; set; } + + private string FormatText() => $"Do you really want to remove the {EntityType} {EntityName} from this collection?"; + + private void Submit() => MudDialog.Close(DialogResult.Ok(true)); + + private void Cancel() => MudDialog.Cancel(); + +} \ No newline at end of file diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index c50781ff..85210d13 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -171,16 +171,17 @@ namespace ErsatzTV services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); - services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddHostedService(); services.AddHostedService(); diff --git a/ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs b/ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs index c9f04651..df676355 100644 --- a/ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs +++ b/ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs @@ -1,11 +1,16 @@ using System; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using ErsatzTV.Annotations; using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Application.Television; using ErsatzTV.Core.Domain; namespace ErsatzTV.ViewModels { - public class ProgramScheduleItemEditViewModel + public class ProgramScheduleItemEditViewModel : INotifyPropertyChanged { + private ProgramScheduleItemCollectionType _collectionType; private int? _multipleCount; private bool? _offlineTail; private TimeSpan? _playoutDuration; @@ -22,7 +27,48 @@ namespace ErsatzTV.ViewModels } public PlayoutMode PlayoutMode { get; set; } + + public ProgramScheduleItemCollectionType CollectionType + { + get => _collectionType; + set + { + _collectionType = value; + + switch (CollectionType) + { + case ProgramScheduleItemCollectionType.Collection: + TelevisionShow = null; + TelevisionSeason = null; + break; + case ProgramScheduleItemCollectionType.TelevisionShow: + MediaCollection = null; + TelevisionSeason = null; + break; + case ProgramScheduleItemCollectionType.TelevisionSeason: + MediaCollection = null; + TelevisionShow = null; + break; + } + + OnPropertyChanged(nameof(MediaCollection)); + OnPropertyChanged(nameof(TelevisionShow)); + OnPropertyChanged(nameof(TelevisionSeason)); + } + } + public MediaCollectionViewModel MediaCollection { get; set; } + public TelevisionShowViewModel TelevisionShow { get; set; } + public TelevisionSeasonViewModel TelevisionSeason { get; set; } + + public string CollectionName => CollectionType switch + { + ProgramScheduleItemCollectionType.Collection => MediaCollection?.Name, + ProgramScheduleItemCollectionType.TelevisionShow => $"{TelevisionShow?.Title} ({TelevisionShow?.Year})", + ProgramScheduleItemCollectionType.TelevisionSeason => + $"{TelevisionSeason?.Title} ({TelevisionSeason?.Plot})", + _ => string.Empty + }; public int? MultipleCount { @@ -41,5 +87,13 @@ namespace ErsatzTV.ViewModels get => PlayoutMode == PlayoutMode.Duration ? _offlineTail : null; set => _offlineTail = value; } + + public event PropertyChangedEventHandler PropertyChanged; + + [NotifyPropertyChangedInvocator] + protected virtual void OnPropertyChanged( + [CallerMemberName] + string propertyName = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } } diff --git a/ErsatzTV/wwwroot/css/site.css b/ErsatzTV/wwwroot/css/site.css index d3f31315..58ddf384 100644 --- a/ErsatzTV/wwwroot/css/site.css +++ b/ErsatzTV/wwwroot/css/site.css @@ -6,9 +6,11 @@ .media-card-container { width: 152px; } +.media-card-episode-container { width: 392px; } + .media-card { display: flex; - filter: brightness(100%); + /*filter: brightness(100%);*/ flex-direction: column; height: 220px; justify-content: center; @@ -16,7 +18,9 @@ width: 152px; } -.media-card:hover { filter: brightness(80%); } +.media-card-episode { width: 392px; } + +.media-card:hover { /*filter: brightness(75%);*/ } .media-card-title { overflow: hidden; @@ -34,4 +38,18 @@ right: 0; } -.media-card:hover .media-card-menu { display: block; } \ No newline at end of file +.media-card:hover .media-card-menu { display: block; } + +.media-card-overlay { + background: rgba(0, 0, 0, 0.4); + border-radius: 4px; + bottom: 0; + left: 0; + opacity: 0; + position: absolute; + right: 0; + top: 0; + transition: opacity 0.2s; +} + +.media-card-overlay:hover { opacity: 1; } \ No newline at end of file diff --git a/README.md b/README.md index ba76e270..1532e857 100644 --- a/README.md +++ b/README.md @@ -9,28 +9,28 @@ Want to join the community or have a question? Join us on [Discord](https://disc - Use local media files and optional sidecar [NFO metadata](https://kodi.wiki/view/NFO_files); no need for a full media server - IPTV server and HDHomeRun emulation support a wide range of client applications - Channel-specific streaming mode (MPEG-TS or HLS) and transcoding settings -- Automatic creation of television media collections +- Collection-based scheduling, with collections containing television shows, seasons, episodes and movies - Powerful scheduling options such as chronological collection playback throughout the day or over multiple days -- OpenAPI spec for easy scripting from other languages (available while running at `/swagger/v1/swagger.json`) -- Command line project for easy shell scripting ## In Development - [Plex](https://www.plex.tv/) media, metadata and collections -- Published Docker image ## Planned Features - [Jellyfin](https://jellyfin.org/) media, metadata and collections -- Meta collections to logically group other collections - Run as a Windows service - Spots to fill unscheduled gaps ## Preview -### ErsatzTV UI +### Television Show -![ErsatzTV UI](docs/ersatztv-ui-channels.png) +![Television Show](docs/television-show.png) + +### Media Collection + +![Media Collection](docs/media-collection.png) ### Plex Live TV diff --git a/docs/media-collection.png b/docs/media-collection.png new file mode 100644 index 00000000..a05877b8 Binary files /dev/null and b/docs/media-collection.png differ diff --git a/docs/television-show.png b/docs/television-show.png new file mode 100644 index 00000000..4f3ec119 Binary files /dev/null and b/docs/television-show.png differ diff --git a/generated/ErsatzTV.Api.Sdk/.openapi-generator/FILES b/generated/ErsatzTV.Api.Sdk/.openapi-generator/FILES index 410fe939..bbda04b5 100644 --- a/generated/ErsatzTV.Api.Sdk/.openapi-generator/FILES +++ b/generated/ErsatzTV.Api.Sdk/.openapi-generator/FILES @@ -11,13 +11,11 @@ src/ErsatzTV.Api.Sdk.Test/Model/AddProgramScheduleItemTests.cs src/ErsatzTV.Api.Sdk.Test/Model/ChannelViewModelTests.cs src/ErsatzTV.Api.Sdk.Test/Model/CreateChannelTests.cs src/ErsatzTV.Api.Sdk.Test/Model/CreateFFmpegProfileTests.cs -src/ErsatzTV.Api.Sdk.Test/Model/CreateMediaItemTests.cs src/ErsatzTV.Api.Sdk.Test/Model/CreatePlayoutTests.cs src/ErsatzTV.Api.Sdk.Test/Model/CreateProgramScheduleTests.cs src/ErsatzTV.Api.Sdk.Test/Model/CreateSimpleMediaCollectionTests.cs src/ErsatzTV.Api.Sdk.Test/Model/DeleteChannelTests.cs src/ErsatzTV.Api.Sdk.Test/Model/DeleteFFmpegProfileTests.cs -src/ErsatzTV.Api.Sdk.Test/Model/DeleteMediaItemTests.cs src/ErsatzTV.Api.Sdk.Test/Model/DeletePlayoutTests.cs src/ErsatzTV.Api.Sdk.Test/Model/DeleteProgramScheduleTests.cs src/ErsatzTV.Api.Sdk.Test/Model/FFmpegProfileViewModelTests.cs @@ -71,13 +69,11 @@ src/ErsatzTV.Api.Sdk/Model/AddProgramScheduleItem.cs src/ErsatzTV.Api.Sdk/Model/ChannelViewModel.cs src/ErsatzTV.Api.Sdk/Model/CreateChannel.cs src/ErsatzTV.Api.Sdk/Model/CreateFFmpegProfile.cs -src/ErsatzTV.Api.Sdk/Model/CreateMediaItem.cs src/ErsatzTV.Api.Sdk/Model/CreatePlayout.cs src/ErsatzTV.Api.Sdk/Model/CreateProgramSchedule.cs src/ErsatzTV.Api.Sdk/Model/CreateSimpleMediaCollection.cs src/ErsatzTV.Api.Sdk/Model/DeleteChannel.cs src/ErsatzTV.Api.Sdk/Model/DeleteFFmpegProfile.cs -src/ErsatzTV.Api.Sdk/Model/DeleteMediaItem.cs src/ErsatzTV.Api.Sdk/Model/DeletePlayout.cs src/ErsatzTV.Api.Sdk/Model/DeleteProgramSchedule.cs src/ErsatzTV.Api.Sdk/Model/FFmpegProfileViewModel.cs diff --git a/generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Api/MediaItemsApi.cs b/generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Api/MediaItemsApi.cs index 4f975fe2..183fd463 100644 --- a/generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Api/MediaItemsApi.cs +++ b/generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Api/MediaItemsApi.cs @@ -30,24 +30,6 @@ namespace ErsatzTV.Api.Sdk.Api /// /// /// Thrown when fails to make API call - /// - /// - void ApiMediaItemsDelete(DeleteMediaItem deleteMediaItem); - - /// - /// - /// - /// - /// - /// - /// Thrown when fails to make API call - /// - /// ApiResponse of Object(void) - ApiResponse ApiMediaItemsDeleteWithHttpInfo(DeleteMediaItem deleteMediaItem); - /// - /// - /// - /// Thrown when fails to make API call /// List<MediaItemViewModel> List ApiMediaItemsGet(); @@ -78,24 +60,6 @@ namespace ErsatzTV.Api.Sdk.Api /// /// ApiResponse of MediaItemViewModel ApiResponse ApiMediaItemsMediaItemIdGetWithHttpInfo(int mediaItemId); - /// - /// - /// - /// Thrown when fails to make API call - /// - /// MediaItemViewModel - MediaItemViewModel ApiMediaItemsPost(CreateMediaItem createMediaItem); - - /// - /// - /// - /// - /// - /// - /// Thrown when fails to make API call - /// - /// ApiResponse of MediaItemViewModel - ApiResponse ApiMediaItemsPostWithHttpInfo(CreateMediaItem createMediaItem); #endregion Synchronous Operations } @@ -112,29 +76,6 @@ namespace ErsatzTV.Api.Sdk.Api /// /// /// Thrown when fails to make API call - /// - /// Cancellation Token to cancel the request. - /// Task of void - System.Threading.Tasks.Task ApiMediaItemsDeleteAsync(DeleteMediaItem deleteMediaItem, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// - /// - /// - /// - /// - /// - /// Thrown when fails to make API call - /// - /// Cancellation Token to cancel the request. - /// Task of ApiResponse - System.Threading.Tasks.Task> ApiMediaItemsDeleteWithHttpInfoAsync(DeleteMediaItem deleteMediaItem, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - /// - /// - /// - /// - /// - /// - /// Thrown when fails to make API call /// Cancellation Token to cancel the request. /// Task of List<MediaItemViewModel> System.Threading.Tasks.Task> ApiMediaItemsGetAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); @@ -172,29 +113,6 @@ namespace ErsatzTV.Api.Sdk.Api /// Cancellation Token to cancel the request. /// Task of ApiResponse (MediaItemViewModel) System.Threading.Tasks.Task> ApiMediaItemsMediaItemIdGetWithHttpInfoAsync(int mediaItemId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - /// - /// - /// - /// - /// - /// - /// Thrown when fails to make API call - /// - /// Cancellation Token to cancel the request. - /// Task of MediaItemViewModel - System.Threading.Tasks.Task ApiMediaItemsPostAsync(CreateMediaItem createMediaItem, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// - /// - /// - /// - /// - /// - /// Thrown when fails to make API call - /// - /// Cancellation Token to cancel the request. - /// Task of ApiResponse (MediaItemViewModel) - System.Threading.Tasks.Task> ApiMediaItemsPostWithHttpInfoAsync(CreateMediaItem createMediaItem, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); #endregion Asynchronous Operations } @@ -315,127 +233,6 @@ namespace ErsatzTV.Api.Sdk.Api set { _exceptionFactory = value; } } - /// - /// - /// - /// Thrown when fails to make API call - /// - /// - public void ApiMediaItemsDelete(DeleteMediaItem deleteMediaItem) - { - ApiMediaItemsDeleteWithHttpInfo(deleteMediaItem); - } - - /// - /// - /// - /// Thrown when fails to make API call - /// - /// ApiResponse of Object(void) - public ErsatzTV.Api.Sdk.Client.ApiResponse ApiMediaItemsDeleteWithHttpInfo(DeleteMediaItem deleteMediaItem) - { - // verify the required parameter 'deleteMediaItem' is set - if (deleteMediaItem == null) - throw new ErsatzTV.Api.Sdk.Client.ApiException(400, "Missing required parameter 'deleteMediaItem' when calling MediaItemsApi->ApiMediaItemsDelete"); - - ErsatzTV.Api.Sdk.Client.RequestOptions localVarRequestOptions = new ErsatzTV.Api.Sdk.Client.RequestOptions(); - - String[] _contentTypes = new String[] { - "application/json-patch+json", - "application/json", - "text/json", - "application/_*+json" - }; - - // to determine the Accept header - String[] _accepts = new String[] { - "application/json" - }; - - var localVarContentType = ErsatzTV.Api.Sdk.Client.ClientUtils.SelectHeaderContentType(_contentTypes); - if (localVarContentType != null) localVarRequestOptions.HeaderParameters.Add("Content-Type", localVarContentType); - - var localVarAccept = ErsatzTV.Api.Sdk.Client.ClientUtils.SelectHeaderAccept(_accepts); - if (localVarAccept != null) localVarRequestOptions.HeaderParameters.Add("Accept", localVarAccept); - - localVarRequestOptions.Data = deleteMediaItem; - - - // make the HTTP request - var localVarResponse = this.Client.Delete("/api/media/items", localVarRequestOptions, this.Configuration); - - if (this.ExceptionFactory != null) - { - Exception _exception = this.ExceptionFactory("ApiMediaItemsDelete", localVarResponse); - if (_exception != null) throw _exception; - } - - return localVarResponse; - } - - /// - /// - /// - /// Thrown when fails to make API call - /// - /// Cancellation Token to cancel the request. - /// Task of void - public async System.Threading.Tasks.Task ApiMediaItemsDeleteAsync(DeleteMediaItem deleteMediaItem, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - await ApiMediaItemsDeleteWithHttpInfoAsync(deleteMediaItem, cancellationToken).ConfigureAwait(false); - } - - /// - /// - /// - /// Thrown when fails to make API call - /// - /// Cancellation Token to cancel the request. - /// Task of ApiResponse - public async System.Threading.Tasks.Task> ApiMediaItemsDeleteWithHttpInfoAsync(DeleteMediaItem deleteMediaItem, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - // verify the required parameter 'deleteMediaItem' is set - if (deleteMediaItem == null) - throw new ErsatzTV.Api.Sdk.Client.ApiException(400, "Missing required parameter 'deleteMediaItem' when calling MediaItemsApi->ApiMediaItemsDelete"); - - - ErsatzTV.Api.Sdk.Client.RequestOptions localVarRequestOptions = new ErsatzTV.Api.Sdk.Client.RequestOptions(); - - String[] _contentTypes = new String[] { - "application/json-patch+json", - "application/json", - "text/json", - "application/_*+json" - }; - - // to determine the Accept header - String[] _accepts = new String[] { - "application/json" - }; - - - var localVarContentType = ErsatzTV.Api.Sdk.Client.ClientUtils.SelectHeaderContentType(_contentTypes); - if (localVarContentType != null) localVarRequestOptions.HeaderParameters.Add("Content-Type", localVarContentType); - - var localVarAccept = ErsatzTV.Api.Sdk.Client.ClientUtils.SelectHeaderAccept(_accepts); - if (localVarAccept != null) localVarRequestOptions.HeaderParameters.Add("Accept", localVarAccept); - - localVarRequestOptions.Data = deleteMediaItem; - - - // make the HTTP request - - var localVarResponse = await this.AsynchronousClient.DeleteAsync("/api/media/items", localVarRequestOptions, this.Configuration, cancellationToken).ConfigureAwait(false); - - if (this.ExceptionFactory != null) - { - Exception _exception = this.ExceptionFactory("ApiMediaItemsDelete", localVarResponse); - if (_exception != null) throw _exception; - } - - return localVarResponse; - } - /// /// /// @@ -644,128 +441,5 @@ namespace ErsatzTV.Api.Sdk.Api return localVarResponse; } - /// - /// - /// - /// Thrown when fails to make API call - /// - /// MediaItemViewModel - public MediaItemViewModel ApiMediaItemsPost(CreateMediaItem createMediaItem) - { - ErsatzTV.Api.Sdk.Client.ApiResponse localVarResponse = ApiMediaItemsPostWithHttpInfo(createMediaItem); - return localVarResponse.Data; - } - - /// - /// - /// - /// Thrown when fails to make API call - /// - /// ApiResponse of MediaItemViewModel - public ErsatzTV.Api.Sdk.Client.ApiResponse ApiMediaItemsPostWithHttpInfo(CreateMediaItem createMediaItem) - { - // verify the required parameter 'createMediaItem' is set - if (createMediaItem == null) - throw new ErsatzTV.Api.Sdk.Client.ApiException(400, "Missing required parameter 'createMediaItem' when calling MediaItemsApi->ApiMediaItemsPost"); - - ErsatzTV.Api.Sdk.Client.RequestOptions localVarRequestOptions = new ErsatzTV.Api.Sdk.Client.RequestOptions(); - - String[] _contentTypes = new String[] { - "application/json-patch+json", - "application/json", - "text/json", - "application/_*+json" - }; - - // to determine the Accept header - String[] _accepts = new String[] { - "application/json" - }; - - var localVarContentType = ErsatzTV.Api.Sdk.Client.ClientUtils.SelectHeaderContentType(_contentTypes); - if (localVarContentType != null) localVarRequestOptions.HeaderParameters.Add("Content-Type", localVarContentType); - - var localVarAccept = ErsatzTV.Api.Sdk.Client.ClientUtils.SelectHeaderAccept(_accepts); - if (localVarAccept != null) localVarRequestOptions.HeaderParameters.Add("Accept", localVarAccept); - - localVarRequestOptions.Data = createMediaItem; - - - // make the HTTP request - var localVarResponse = this.Client.Post("/api/media/items", localVarRequestOptions, this.Configuration); - - if (this.ExceptionFactory != null) - { - Exception _exception = this.ExceptionFactory("ApiMediaItemsPost", localVarResponse); - if (_exception != null) throw _exception; - } - - return localVarResponse; - } - - /// - /// - /// - /// Thrown when fails to make API call - /// - /// Cancellation Token to cancel the request. - /// Task of MediaItemViewModel - public async System.Threading.Tasks.Task ApiMediaItemsPostAsync(CreateMediaItem createMediaItem, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - ErsatzTV.Api.Sdk.Client.ApiResponse localVarResponse = await ApiMediaItemsPostWithHttpInfoAsync(createMediaItem, cancellationToken).ConfigureAwait(false); - return localVarResponse.Data; - } - - /// - /// - /// - /// Thrown when fails to make API call - /// - /// Cancellation Token to cancel the request. - /// Task of ApiResponse (MediaItemViewModel) - public async System.Threading.Tasks.Task> ApiMediaItemsPostWithHttpInfoAsync(CreateMediaItem createMediaItem, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - // verify the required parameter 'createMediaItem' is set - if (createMediaItem == null) - throw new ErsatzTV.Api.Sdk.Client.ApiException(400, "Missing required parameter 'createMediaItem' when calling MediaItemsApi->ApiMediaItemsPost"); - - - ErsatzTV.Api.Sdk.Client.RequestOptions localVarRequestOptions = new ErsatzTV.Api.Sdk.Client.RequestOptions(); - - String[] _contentTypes = new String[] { - "application/json-patch+json", - "application/json", - "text/json", - "application/_*+json" - }; - - // to determine the Accept header - String[] _accepts = new String[] { - "application/json" - }; - - - var localVarContentType = ErsatzTV.Api.Sdk.Client.ClientUtils.SelectHeaderContentType(_contentTypes); - if (localVarContentType != null) localVarRequestOptions.HeaderParameters.Add("Content-Type", localVarContentType); - - var localVarAccept = ErsatzTV.Api.Sdk.Client.ClientUtils.SelectHeaderAccept(_accepts); - if (localVarAccept != null) localVarRequestOptions.HeaderParameters.Add("Accept", localVarAccept); - - localVarRequestOptions.Data = createMediaItem; - - - // make the HTTP request - - var localVarResponse = await this.AsynchronousClient.PostAsync("/api/media/items", localVarRequestOptions, this.Configuration, cancellationToken).ConfigureAwait(false); - - if (this.ExceptionFactory != null) - { - Exception _exception = this.ExceptionFactory("ApiMediaItemsPost", localVarResponse); - if (_exception != null) throw _exception; - } - - return localVarResponse; - } - } } diff --git a/generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/CreateMediaItem.cs b/generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/CreateMediaItem.cs deleted file mode 100644 index d515d284..00000000 --- a/generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/CreateMediaItem.cs +++ /dev/null @@ -1,139 +0,0 @@ -/* - * ErsatzTV API - * - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: v1 - * Generated by: https://github.com/openapitools/openapi-generator.git - */ - - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; -using System.IO; -using System.Runtime.Serialization; -using System.Text; -using System.Text.RegularExpressions; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -using Newtonsoft.Json.Linq; -using System.ComponentModel.DataAnnotations; -using OpenAPIDateConverter = ErsatzTV.Api.Sdk.Client.OpenAPIDateConverter; - -namespace ErsatzTV.Api.Sdk.Model -{ - /// - /// CreateMediaItem - /// - [DataContract(Name = "CreateMediaItem")] - public partial class CreateMediaItem : IEquatable, IValidatableObject - { - /// - /// Initializes a new instance of the class. - /// - /// mediaSourceId. - /// path. - public CreateMediaItem(int mediaSourceId = default(int), string path = default(string)) - { - this.MediaSourceId = mediaSourceId; - this.Path = path; - } - - /// - /// Gets or Sets MediaSourceId - /// - [DataMember(Name = "mediaSourceId", EmitDefaultValue = false)] - public int MediaSourceId { get; set; } - - /// - /// Gets or Sets Path - /// - [DataMember(Name = "path", EmitDefaultValue = true)] - public string Path { get; set; } - - /// - /// Returns the string presentation of the object - /// - /// String presentation of the object - public override string ToString() - { - var sb = new StringBuilder(); - sb.Append("class CreateMediaItem {\n"); - sb.Append(" MediaSourceId: ").Append(MediaSourceId).Append("\n"); - sb.Append(" Path: ").Append(Path).Append("\n"); - sb.Append("}\n"); - return sb.ToString(); - } - - /// - /// Returns the JSON string presentation of the object - /// - /// JSON string presentation of the object - public virtual string ToJson() - { - return Newtonsoft.Json.JsonConvert.SerializeObject(this, Newtonsoft.Json.Formatting.Indented); - } - - /// - /// Returns true if objects are equal - /// - /// Object to be compared - /// Boolean - public override bool Equals(object input) - { - return this.Equals(input as CreateMediaItem); - } - - /// - /// Returns true if CreateMediaItem instances are equal - /// - /// Instance of CreateMediaItem to be compared - /// Boolean - public bool Equals(CreateMediaItem input) - { - if (input == null) - return false; - - return - ( - this.MediaSourceId == input.MediaSourceId || - this.MediaSourceId.Equals(input.MediaSourceId) - ) && - ( - this.Path == input.Path || - (this.Path != null && - this.Path.Equals(input.Path)) - ); - } - - /// - /// Gets the hash code - /// - /// Hash code - public override int GetHashCode() - { - unchecked // Overflow is fine, just wrap - { - int hashCode = 41; - hashCode = hashCode * 59 + this.MediaSourceId.GetHashCode(); - if (this.Path != null) - hashCode = hashCode * 59 + this.Path.GetHashCode(); - return hashCode; - } - } - - /// - /// To validate all properties of the instance - /// - /// Validation context - /// Validation Result - IEnumerable IValidatableObject.Validate(ValidationContext validationContext) - { - yield break; - } - } - -} diff --git a/generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/DeleteMediaItem.cs b/generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/DeleteMediaItem.cs deleted file mode 100644 index 5c5f8c71..00000000 --- a/generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/DeleteMediaItem.cs +++ /dev/null @@ -1,123 +0,0 @@ -/* - * ErsatzTV API - * - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: v1 - * Generated by: https://github.com/openapitools/openapi-generator.git - */ - - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; -using System.IO; -using System.Runtime.Serialization; -using System.Text; -using System.Text.RegularExpressions; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -using Newtonsoft.Json.Linq; -using System.ComponentModel.DataAnnotations; -using OpenAPIDateConverter = ErsatzTV.Api.Sdk.Client.OpenAPIDateConverter; - -namespace ErsatzTV.Api.Sdk.Model -{ - /// - /// DeleteMediaItem - /// - [DataContract(Name = "DeleteMediaItem")] - public partial class DeleteMediaItem : IEquatable, IValidatableObject - { - /// - /// Initializes a new instance of the class. - /// - /// mediaItemId. - public DeleteMediaItem(int mediaItemId = default(int)) - { - this.MediaItemId = mediaItemId; - } - - /// - /// Gets or Sets MediaItemId - /// - [DataMember(Name = "mediaItemId", EmitDefaultValue = false)] - public int MediaItemId { get; set; } - - /// - /// Returns the string presentation of the object - /// - /// String presentation of the object - public override string ToString() - { - var sb = new StringBuilder(); - sb.Append("class DeleteMediaItem {\n"); - sb.Append(" MediaItemId: ").Append(MediaItemId).Append("\n"); - sb.Append("}\n"); - return sb.ToString(); - } - - /// - /// Returns the JSON string presentation of the object - /// - /// JSON string presentation of the object - public virtual string ToJson() - { - return Newtonsoft.Json.JsonConvert.SerializeObject(this, Newtonsoft.Json.Formatting.Indented); - } - - /// - /// Returns true if objects are equal - /// - /// Object to be compared - /// Boolean - public override bool Equals(object input) - { - return this.Equals(input as DeleteMediaItem); - } - - /// - /// Returns true if DeleteMediaItem instances are equal - /// - /// Instance of DeleteMediaItem to be compared - /// Boolean - public bool Equals(DeleteMediaItem input) - { - if (input == null) - return false; - - return - ( - this.MediaItemId == input.MediaItemId || - this.MediaItemId.Equals(input.MediaItemId) - ); - } - - /// - /// Gets the hash code - /// - /// Hash code - public override int GetHashCode() - { - unchecked // Overflow is fine, just wrap - { - int hashCode = 41; - hashCode = hashCode * 59 + this.MediaItemId.GetHashCode(); - return hashCode; - } - } - - /// - /// To validate all properties of the instance - /// - /// Validation context - /// Validation Result - IEnumerable IValidatableObject.Validate(ValidationContext validationContext) - { - yield break; - } - } - -} diff --git a/generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/PlayoutChannelViewModel.cs b/generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/PlayoutChannelViewModel.cs index 2b3c355e..05cadfa8 100644 --- a/generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/PlayoutChannelViewModel.cs +++ b/generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/Model/PlayoutChannelViewModel.cs @@ -35,10 +35,12 @@ namespace ErsatzTV.Api.Sdk.Model /// Initializes a new instance of the class. /// /// id. + /// number. /// name. - public PlayoutChannelViewModel(int id = default(int), string name = default(string)) + public PlayoutChannelViewModel(int id = default(int), int number = default(int), string name = default(string)) { this.Id = id; + this.Number = number; this.Name = name; } @@ -48,6 +50,12 @@ namespace ErsatzTV.Api.Sdk.Model [DataMember(Name = "id", EmitDefaultValue = false)] public int Id { get; set; } + /// + /// Gets or Sets Number + /// + [DataMember(Name = "number", EmitDefaultValue = false)] + public int Number { get; set; } + /// /// Gets or Sets Name /// @@ -63,6 +71,7 @@ namespace ErsatzTV.Api.Sdk.Model var sb = new StringBuilder(); sb.Append("class PlayoutChannelViewModel {\n"); sb.Append(" Id: ").Append(Id).Append("\n"); + sb.Append(" Number: ").Append(Number).Append("\n"); sb.Append(" Name: ").Append(Name).Append("\n"); sb.Append("}\n"); return sb.ToString(); @@ -102,6 +111,10 @@ namespace ErsatzTV.Api.Sdk.Model this.Id == input.Id || this.Id.Equals(input.Id) ) && + ( + this.Number == input.Number || + this.Number.Equals(input.Number) + ) && ( this.Name == input.Name || (this.Name != null && @@ -119,6 +132,7 @@ namespace ErsatzTV.Api.Sdk.Model { int hashCode = 41; hashCode = hashCode * 59 + this.Id.GetHashCode(); + hashCode = hashCode * 59 + this.Number.GetHashCode(); if (this.Name != null) hashCode = hashCode * 59 + this.Name.GetHashCode(); return hashCode;