diff --git a/ErsatzTV.Application/MediaItems/Commands/CreateMediaItem.cs b/ErsatzTV.Application/MediaItems/Commands/CreateMediaItem.cs deleted file mode 100644 index 01252d9f5..000000000 --- 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 f478f58c3..000000000 --- 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 f1a1abd66..000000000 --- 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 59357d6fa..000000000 --- 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 608fe29ac..000000000 --- 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 49ab14ef9..000000000 --- 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 b7d78bb95..000000000 --- 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 4c3c1bd95..000000000 --- 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 7bc3b695f..000000000 --- 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 c6d7ddb85..000000000 --- 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 c0a6f3ace..000000000 --- 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 f25ecec46..000000000 --- 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 7d9b1c758..000000000 --- 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 6680e7dc2..59fb7c5f7 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/MediaSources/Commands/ScanLocalMediaSource.cs b/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs index 818949a2d..0e08d61da 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 655f5df9a..1f98dc31d 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/Playouts/Mapper.cs b/ErsatzTV.Application/Playouts/Mapper.cs index 937939822..f2167f721 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.CommandLine/Commands/MediaCollections/MediaCollectionMediaItemsCommand.cs b/ErsatzTV.CommandLine/Commands/MediaCollections/MediaCollectionMediaItemsCommand.cs deleted file mode 100644 index 259be438f..000000000 --- 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 850f73dc9..000000000 --- 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 10576a1b6..000000000 --- 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 6497ebb1e..b4c4ae7fd 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/Metadata/FallbackMetadataProviderTests.cs b/ErsatzTV.Core.Tests/Metadata/FallbackMetadataProviderTests.cs index 859575595..6d1a5ad67 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 24b21e45c..000000000 --- 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/Scheduling/ChronologicalContentTests.cs b/ErsatzTV.Core.Tests/Scheduling/ChronologicalContentTests.cs index 9a9e35b74..c0022aa6a 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 d67f4d558..1e5d45013 100644 --- a/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs @@ -604,10 +604,11 @@ namespace ErsatzTV.Core.Tests.Scheduling }; private static MediaItem TestMovie(int id, TimeSpan duration, DateTime aired) => - new() + new MovieMediaItem { 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) diff --git a/ErsatzTV.Core.Tests/Scheduling/RandomizedContentTests.cs b/ErsatzTV.Core.Tests/Scheduling/RandomizedContentTests.cs index 8992c6ede..13f3d8f0d 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 f767f3f12..eeeaef98f 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/MediaItem.cs b/ErsatzTV.Core/Domain/MediaItem.cs index 5136d3b8e..4501a4a38 100644 --- a/ErsatzTV.Core/Domain/MediaItem.cs +++ b/ErsatzTV.Core/Domain/MediaItem.cs @@ -1,18 +1,22 @@ 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 MediaMetadata Metadata { get; set; } + public MediaItemStatistics Statistics { get; set; } + public DateTime? LastWriteTime { get; set; } + public IList SimpleMediaCollections { 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 000000000..4d46e8944 --- /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/MediaItemStatistics.cs b/ErsatzTV.Core/Domain/MediaItemStatistics.cs new file mode 100644 index 000000000..56b0f59ce --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaItemStatistics.cs @@ -0,0 +1,18 @@ +using System; +using ErsatzTV.Core.Interfaces.FFmpeg; + +namespace ErsatzTV.Core.Domain +{ + public record MediaItemStatistics : IDisplaySize + { + 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 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 000000000..b521540fc --- /dev/null +++ b/ErsatzTV.Core/Domain/MovieMediaItem.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Domain +{ + public class MovieMediaItem : MediaItem + { + public int MetadataId { get; set; } + public MovieMetadata Metadata { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/MovieMetadata.cs b/ErsatzTV.Core/Domain/MovieMetadata.cs new file mode 100644 index 000000000..3c4009eea --- /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/TelevisionEpisodeMediaItem.cs b/ErsatzTV.Core/Domain/TelevisionEpisodeMediaItem.cs new file mode 100644 index 000000000..b86731ec5 --- /dev/null +++ b/ErsatzTV.Core/Domain/TelevisionEpisodeMediaItem.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Core.Domain +{ + public class TelevisionEpisodeMediaItem : MediaItem + { + public int SeasonId { get; set; } + public TelevisionSeason Season { get; set; } + public TelevisionEpisodeMetadata Metadata { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/TelevisionEpisodeMetadata.cs b/ErsatzTV.Core/Domain/TelevisionEpisodeMetadata.cs new file mode 100644 index 000000000..9b210e4ea --- /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/TelevisionSeason.cs b/ErsatzTV.Core/Domain/TelevisionSeason.cs new file mode 100644 index 000000000..600a2aa11 --- /dev/null +++ b/ErsatzTV.Core/Domain/TelevisionSeason.cs @@ -0,0 +1,18 @@ +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 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 000000000..4f0d2ef4f --- /dev/null +++ b/ErsatzTV.Core/Domain/TelevisionShow.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using ErsatzTV.Core.Interfaces.Domain; + +namespace ErsatzTV.Core.Domain +{ + public class TelevisionShow : IHasAPoster + { + public int Id { get; set; } + public int MediaSourceId { get; set; } + public MediaSource Source { get; set; } + public TelevisionShowMetadata Metadata { get; set; } + public List Seasons { get; set; } + public string Path { get; set; } + public string Poster { get; set; } + public DateTime? PosterLastWriteTime { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/TelevisionShowMetadata.cs b/ErsatzTV.Core/Domain/TelevisionShowMetadata.cs new file mode 100644 index 000000000..914de83d9 --- /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/FFmpeg/FFmpegPlaybackSettingsCalculator.cs b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs index f06e755d5..479b906f9 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 cfb134731..99fd9ad87 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 000000000..b50abd5a6 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Domain/IHasAPoster.cs @@ -0,0 +1,11 @@ +using System; + +namespace ErsatzTV.Core.Interfaces.Domain +{ + public interface IHasAPoster + { + public string Path { get; set; } + public string Poster { get; set; } + public DateTime? PosterLastWriteTime { get; set; } + } +} diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs index d2d0bb575..b1a59f689 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs @@ -1,6 +1,6 @@ using System; +using System.Collections.Generic; using ErsatzTV.Core.Domain; -using LanguageExt; namespace ErsatzTV.Core.Interfaces.Metadata { @@ -8,8 +8,8 @@ namespace ErsatzTV.Core.Interfaces.Metadata { 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); + public IEnumerable ListSubdirectories(string folder); + public IEnumerable ListFiles(string folder); + public bool FileExists(string path); } } diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalMediaScanner.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalMediaScanner.cs index 4b8ed7595..7444cfd40 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/ILocalMediaScanner.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/ILocalMediaScanner.cs @@ -1,15 +1,12 @@ 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); + Task ScanMovies(LocalMediaSource localMediaSource, string ffprobePath); + Task ScanTelevision(LocalMediaSource localMediaSource, string ffprobePath); } } diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalMediaSourcePlanner.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalMediaSourcePlanner.cs deleted file mode 100644 index 555463b53..000000000 --- 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 8e413e1a4..8b5655611 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs @@ -1,11 +1,14 @@ 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 RefreshSidecarMetadata(MediaItem mediaItem, string path); + Task RefreshSidecarMetadata(TelevisionShow televisionShow, string path); + Task RefreshFallbackMetadata(MediaItem mediaItem); + Task RefreshFallbackMetadata(TelevisionShow televisionShow); } } diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalPosterProvider.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalPosterProvider.cs index ab47e5da0..675342fc8 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/ILocalPosterProvider.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/ILocalPosterProvider.cs @@ -5,6 +5,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata { public interface ILocalPosterProvider { - Task RefreshPoster(MediaItem mediaItem); + public Task RefreshPoster(MediaItem mediaItem); + public Task SavePosterToDisk(MediaItem mediaItem, string posterPath); } } diff --git a/ErsatzTV.Core/Interfaces/Metadata/IMovieFolderScanner.cs b/ErsatzTV.Core/Interfaces/Metadata/IMovieFolderScanner.cs new file mode 100644 index 000000000..a0ace810d --- /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 index 0983b5fd7..25a09767d 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/ISmartCollectionBuilder.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/ISmartCollectionBuilder.cs @@ -5,6 +5,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata { public interface ISmartCollectionBuilder { - Task RefreshSmartCollections(MediaItem mediaItem); + Task RefreshSmartCollections(TelevisionEpisodeMediaItem mediaItem); } } diff --git a/ErsatzTV.Core/Interfaces/Metadata/ITelevisionFolderScanner.cs b/ErsatzTV.Core/Interfaces/Metadata/ITelevisionFolderScanner.cs new file mode 100644 index 000000000..b000216be --- /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/Repositories/IMediaItemRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs index 513ef76e9..a3a913983 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs @@ -16,6 +16,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories public Task GetCountByType(MediaType mediaType); public Task> GetAllByMediaSourceId(int mediaSourceId); public Task Update(MediaItem mediaItem); - public Task Delete(int mediaItemId); + public Task Delete(int mediaItemId); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs new file mode 100644 index 000000000..7f30e84b9 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Repositories +{ + public interface IMovieRepository + { + public Task> GetOrAdd(int mediaSourceId, string path); + } +} diff --git a/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs new file mode 100644 index 000000000..43a03cf35 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Repositories +{ + public interface ITelevisionRepository + { + public Task Add(TelevisionShow show); + public Task> GetAllByMediaSourceId(int mediaSourceId); + public Task Update(TelevisionShow show); + public Task Update(TelevisionEpisodeMediaItem episode); + + public Task> GetOrAddShow(int mediaSourceId, string path); + + public Task> GetOrAddSeason( + TelevisionShow show, + string path, + int seasonNumber); + + public Task> GetOrAddEpisode( + TelevisionSeason season, + int mediaSourceId, + string path); + } +} diff --git a/ErsatzTV.Core/Iptv/ChannelGuide.cs b/ErsatzTV.Core/Iptv/ChannelGuide.cs index f4f71b886..55d371714 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 537c1756e..b181bc05e 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 b10ab95fd..000000000 --- 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 5320ad3b0..e816b2432 100644 --- a/ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs +++ b/ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs @@ -7,30 +7,52 @@ namespace ErsatzTV.Core.Metadata { public static class FallbackMetadataProvider { - public static MediaMetadata GetFallbackMetadata(MediaItem mediaItem) + public static TelevisionShowMetadata GetFallbackMetadata(TelevisionShow televisionShow) + { + string fileName = Path.GetFileName(televisionShow.Path); + var metadata = new TelevisionShowMetadata + { Source = MetadataSource.Fallback, Title = fileName ?? televisionShow.Path }; + 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 +60,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 +73,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 +81,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 bb353a3fc..d95f82a04 100644 --- a/ErsatzTV.Core/Metadata/LocalFileSystem.cs +++ b/ErsatzTV.Core/Metadata/LocalFileSystem.cs @@ -1,9 +1,8 @@ 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 @@ -16,52 +15,12 @@ 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); } } diff --git a/ErsatzTV.Core/Metadata/LocalFolderScanner.cs b/ErsatzTV.Core/Metadata/LocalFolderScanner.cs new file mode 100644 index 000000000..946850217 --- /dev/null +++ b/ErsatzTV.Core/Metadata/LocalFolderScanner.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 Microsoft.Extensions.Logging; + +namespace ErsatzTV.Core.Metadata +{ + public abstract class LocalFolderScanner + { + protected static readonly List VideoFileExtensions = new() + { + ".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4", + ".m4p", ".m4v", ".avi", ".wmv", ".mov", ".mkv", ".ts" + }; + + protected static readonly List ImageFileExtensions = new() + { + "jpg", "jpeg", "png", "gif", "tbn" + }; + + protected static readonly List ExtraFiles = new() + { + "behindthescenes", "deleted", "featurette", + "interview", "scene", "short", "trailer", "other" + }; + + protected 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 ILocalFileSystem _localFileSystem; + private readonly ILocalStatisticsProvider _localStatisticsProvider; + private readonly ILogger _logger; + + protected LocalFolderScanner( + ILocalFileSystem localFileSystem, + ILocalStatisticsProvider localStatisticsProvider, + ILogger logger) + { + _localFileSystem = localFileSystem; + _localStatisticsProvider = localStatisticsProvider; + _logger = logger; + } + + protected async Task> UpdateStatistics(T mediaItem, string ffprobePath) + where T : MediaItem + { + try + { + if (mediaItem.Statistics is null || + mediaItem.LastWriteTime < _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); + } + } + } +} diff --git a/ErsatzTV.Core/Metadata/LocalMediaScanner.cs b/ErsatzTV.Core/Metadata/LocalMediaScanner.cs deleted file mode 100644 index b07a042fe..000000000 --- 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 2ede96786..000000000 --- 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 91cfffd1f..000000000 --- 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 c9d480757..9c4c7fc62 100644 --- a/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs +++ b/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs @@ -14,52 +14,116 @@ 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 ILogger _logger; private readonly IMediaItemRepository _mediaItemRepository; + private readonly ITelevisionRepository _televisionRepository; - public LocalMetadataProvider(IMediaItemRepository mediaItemRepository, ILogger logger) + public LocalMetadataProvider( + IMediaItemRepository mediaItemRepository, + ITelevisionRepository televisionRepository, + ILogger logger) { _mediaItemRepository = mediaItemRepository; + _televisionRepository = televisionRepository; _logger = logger; } - public async Task RefreshSidecarMetadata(MediaItem mediaItem, string path) - { - Option maybeMetadata = await LoadMetadata(mediaItem, path); - await maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(mediaItem, metadata)); - } + 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 path) => + LoadMetadata(televisionShow, path).Bind( + maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(televisionShow, metadata))); + + public Task RefreshFallbackMetadata(MediaItem mediaItem) => + mediaItem switch + { + TelevisionEpisodeMediaItem e => ApplyMetadataUpdate(e, FallbackMetadataProvider.GetFallbackMetadata(e)) + .ToUnit(), + MovieMediaItem m => ApplyMetadataUpdate(m, FallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(), + _ => Task.FromResult(Unit.Default) + }; - public Task RefreshFallbackMetadata(MediaItem mediaItem) => - ApplyMetadataUpdate(mediaItem, FallbackMetadataProvider.GetFallbackMetadata(mediaItem)); + public Task RefreshFallbackMetadata(TelevisionShow televisionShow) => + ApplyMetadataUpdate(televisionShow, FallbackMetadataProvider.GetFallbackMetadata(televisionShow)).ToUnit(); - private async Task ApplyMetadataUpdate(MediaItem mediaItem, MediaMetadata metadata) + private async Task ApplyMetadataUpdate(TelevisionEpisodeMediaItem mediaItem, TelevisionEpisodeMetadata metadata) { if (mediaItem.Metadata == null) { - mediaItem.Metadata = new MediaMetadata(); + 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 = (metadata.Title ?? string.Empty).ToLowerInvariant().StartsWith("the ") ? metadata.Title?.Substring(4) : metadata.Title; - mediaItem.Metadata.Description = metadata.Description; - mediaItem.Metadata.EpisodeNumber = metadata.EpisodeNumber; - mediaItem.Metadata.SeasonNumber = metadata.SeasonNumber; + 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) + { + if (mediaItem.Metadata == null) + { + mediaItem.Metadata = new MovieMetadata(); + } + + mediaItem.Metadata.Source = metadata.Source; + mediaItem.Metadata.LastWriteTime = metadata.LastWriteTime; + mediaItem.Metadata.Title = metadata.Title; + mediaItem.Metadata.SortTitle = + (metadata.Title ?? string.Empty).ToLowerInvariant().StartsWith("the ") + ? metadata.Title?.Substring(4) + : 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 async Task> LoadMetadata(MediaItem mediaItem, string nfoFileName) + private async Task ApplyMetadataUpdate(TelevisionShow televisionShow, TelevisionShowMetadata metadata) + { + if (televisionShow.Metadata == null) + { + 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 = + (metadata.Title ?? string.Empty).ToLowerInvariant().StartsWith("the ") + ? metadata.Title?.Substring(4) + : metadata.Title; + + await _televisionRepository.Update(televisionShow); + } + + private async Task> LoadMetadata(MovieMediaItem mediaItem, string nfoFileName) { if (nfoFileName == null || !File.Exists(nfoFileName)) { @@ -67,64 +131,121 @@ 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 LoadMovieMetadata(nfoFileName); + } + + private async Task> LoadMetadata( + TelevisionEpisodeMediaItem mediaItem, + 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; + } + + if (!(mediaItem.Source is LocalMediaSource)) + { + _logger.LogDebug("Media source {Name} is not a local media source", mediaItem.Source.Name); + return None; + } + + return await LoadEpisodeMetadata(nfoFileName); } - private async Task> LoadTvShowMetadata(string nfoFileName) + private async Task> LoadMetadata( + TelevisionShow televisionShow, + string nfoFileName) + { + if (nfoFileName == null || !File.Exists(nfoFileName)) + { + _logger.LogDebug("NFO file does not exist at {Path}", nfoFileName); + return None; + } + + if (!(televisionShow.Source is LocalMediaSource)) + { + _logger.LogDebug("Media source {Name} is not a local media source", televisionShow.Source.Name); + return None; + } + + return await LoadTelevisionShowMetadata(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), + Title = nfo.Title, + Plot = nfo.Plot, + Year = nfo.Year + }, + None); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to read TV show nfo metadata from {Path}", nfoFileName); + return None; + } + } + + private async Task> LoadEpisodeMetadata(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), - MediaType = MediaType.TvShow, Title = nfo.ShowTitle, - Subtitle = nfo.Title, - Description = nfo.Outline, - EpisodeNumber = nfo.Episode, - SeasonNumber = nfo.Season, - Aired = GetAired(nfo.Aired) + Aired = GetAired(nfo.Aired), + Episode = nfo.Episode, + Season = nfo.Season, + Plot = nfo.Plot }, None); } catch (Exception ex) { - _logger.LogDebug(ex, "Failed to read TV nfo metadata from {Path}", nfoFileName); + _logger.LogDebug(ex, "Failed to read TV episode nfo metadata from {Path}", nfoFileName); return None; } } - private async Task> LoadMovieMetadata(string nfoFileName) + private async Task> LoadMovieMetadata(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); } @@ -159,11 +280,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")] @@ -175,9 +318,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; } @@ -189,6 +329,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 index a90d79ae6..b2f72a1cf 100644 --- a/ErsatzTV.Core/Metadata/LocalPosterProvider.cs +++ b/ErsatzTV.Core/Metadata/LocalPosterProvider.cs @@ -32,10 +32,10 @@ namespace ErsatzTV.Core.Metadata public async Task RefreshPoster(MediaItem mediaItem) { - Option maybePosterPath = mediaItem.Metadata.MediaType switch + Option maybePosterPath = mediaItem switch { - MediaType.Movie => RefreshMoviePoster(mediaItem), - MediaType.TvShow => RefreshTelevisionPoster(mediaItem), + MovieMediaItem m => RefreshMoviePoster(m), + TelevisionEpisodeMediaItem e => RefreshTelevisionPoster(e), _ => None }; @@ -44,6 +44,23 @@ namespace ErsatzTV.Core.Metadata Task.CompletedTask); } + 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; + }); + } + private static Option RefreshMoviePoster(MediaItem mediaItem) { string folder = Path.GetDirectoryName(mediaItem.Path); @@ -72,22 +89,5 @@ namespace ErsatzTV.Core.Metadata 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 9d1fc2298..8da36d3a5 100644 --- a/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs +++ b/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs @@ -31,8 +31,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 +43,23 @@ 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.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 +97,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 +107,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 +121,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 +132,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 000000000..c10f86e2c --- /dev/null +++ b/ErsatzTV.Core/Metadata/MovieFolderScanner.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Core.Metadata +{ + public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner + { + private readonly ILocalFileSystem _localFileSystem; + private readonly ILocalMetadataProvider _localMetadataProvider; + private readonly ILocalPosterProvider _localPosterProvider; + private readonly ILogger _logger; + private readonly IMovieRepository _movieRepository; + + public MovieFolderScanner( + ILocalFileSystem localFileSystem, + IMovieRepository movieRepository, + ILocalStatisticsProvider localStatisticsProvider, + ILocalMetadataProvider localMetadataProvider, + ILocalPosterProvider localPosterProvider, + ILogger logger) + : base(localFileSystem, localStatisticsProvider, logger) + { + _localFileSystem = localFileSystem; + _movieRepository = movieRepository; + _localMetadataProvider = localMetadataProvider; + _localPosterProvider = localPosterProvider; + _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; + } + + foreach (string movieFolder in _localFileSystem.ListSubdirectories(localMediaSource.Folder)) + { + foreach (string file in _localFileSystem.ListFiles(movieFolder) + .Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))) + { + // TODO: figure out how to rebuild playlists + Either maybeMovie = await _movieRepository + .GetOrAdd(localMediaSource.Id, file) + .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 < _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 < _localFileSystem.GetLastWriteTime(posterFile)) + { + _logger.LogDebug("Refreshing {Attribute} from {Path}", "Poster", posterFile); + await _localPosterProvider.SavePosterToDisk(movie, posterFile); + } + }); + + 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)); + return possibleMoviePosters.Filter(p => _localFileSystem.FileExists(p)).HeadOrNone(); + } + } +} diff --git a/ErsatzTV.Core/Metadata/ScanningAction.cs b/ErsatzTV.Core/Metadata/ScanningAction.cs deleted file mode 100644 index e09b53def..000000000 --- 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 e38fd6b37..000000000 --- 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 index db84fa647..c17262354 100644 --- a/ErsatzTV.Core/Metadata/SmartCollectionBuilder.cs +++ b/ErsatzTV.Core/Metadata/SmartCollectionBuilder.cs @@ -16,7 +16,7 @@ namespace ErsatzTV.Core.Metadata public SmartCollectionBuilder(IMediaCollectionRepository mediaCollectionRepository) => _mediaCollectionRepository = mediaCollectionRepository; - public async Task RefreshSmartCollections(MediaItem mediaItem) + public async Task RefreshSmartCollections(TelevisionEpisodeMediaItem mediaItem) { var results = new List(); @@ -28,11 +28,9 @@ namespace ErsatzTV.Core.Metadata return results.Any(identity); } - private IEnumerable GetTelevisionCollections(MediaItem mediaItem) + private IEnumerable GetTelevisionCollections(TelevisionEpisodeMediaItem mediaItem) { - IList televisionMediaItems = new[] { mediaItem } - .Where(c => c.Metadata.MediaType == MediaType.TvShow) - .ToList(); + var televisionMediaItems = new List { mediaItem }; IEnumerable televisionShowCollections = televisionMediaItems .Map(c => c.Metadata.Title) @@ -45,11 +43,11 @@ namespace ErsatzTV.Core.Metadata }); IEnumerable televisionShowSeasonCollections = televisionMediaItems - .Map(c => new { c.Metadata.Title, c.Metadata.SeasonNumber }).Distinct() + .Map(c => new { c.Metadata.Title, c.Metadata.Season }).Distinct() .Map( ts => { - return Optional(ts.SeasonNumber).Map( + return Optional(ts.Season).Map( sn => new TelevisionMediaCollection { Name = $"{ts.Title} - Season {sn:00}", diff --git a/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs b/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs new file mode 100644 index 000000000..678bb695c --- /dev/null +++ b/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs @@ -0,0 +1,296 @@ +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 IImageCache _imageCache; + 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, logger) + { + _localFileSystem = localFileSystem; + _televisionRepository = televisionRepository; + _localMetadataProvider = localMetadataProvider; + _imageCache = imageCache; + _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; + } + + foreach (string showFolder in _localFileSystem.ListSubdirectories(localMediaSource.Folder) + .Filter(ShouldIncludeFolder)) + { + Either maybeShow = await _televisionRepository + .GetOrAddShow(localMediaSource.Id, showFolder) + .BindT(UpdateMetadata) + .BindT(UpdatePoster); + + await maybeShow.Match( + show => ScanSeasons(localMediaSource, ffprobePath, show), + _ => Task.FromResult(Unit.Default)); + } + + return Unit.Default; + } + + private async Task ScanSeasons(LocalMediaSource localMediaSource, string ffprobePath, TelevisionShow show) + { + foreach (string seasonFolder in _localFileSystem.ListSubdirectories(show.Path).Filter(ShouldIncludeFolder)) + { + Option maybeSeasonNumber = SeasonNumberForFolder(seasonFolder); + await maybeSeasonNumber.IfSomeAsync( + async seasonNumber => + { + Either maybeSeason = await _televisionRepository + .GetOrAddSeason(show, seasonFolder, seasonNumber); + + 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)))) + { + // 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> UpdateMetadata(TelevisionShow show) + { + try + { + await LocateNfoFile(show).Match( + async nfoFile => + { + if (show.Metadata == null || show.Metadata.Source == MetadataSource.Fallback || + show.Metadata.LastWriteTime < _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", show.Path); + await _localMetadataProvider.RefreshFallbackMetadata(show); + } + }); + + 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 < _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> UpdatePoster(TelevisionShow show) + { + try + { + await LocatePoster(show).IfSomeAsync( + async posterFile => + { + if (string.IsNullOrWhiteSpace(show.Poster) || + show.PosterLastWriteTime < _localFileSystem.GetLastWriteTime(posterFile)) + { + _logger.LogDebug("Refreshing {Attribute} from {Path}", "Poster", posterFile); + await SavePosterToDisk(show, posterFile); + } + }); + + return show; + } + 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 < _localFileSystem.GetLastWriteTime(posterFile)) + { + _logger.LogDebug("Refreshing {Attribute} from {Path}", "Thumbnail", posterFile); + await SavePosterToDisk(episode, posterFile); + } + }); + + return episode; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + + private Option LocateNfoFile(TelevisionShow show) => + Optional(Path.Combine(show.Path, "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 LocatePoster(TelevisionShow show) => + ImageFileExtensions + .Collect(ext => new[] { $"poster.{ext}" }) + .Map(f => Path.Combine(show.Path, f)) + .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(); + } + + // TODO: refactor this to work with both scanners, everything that needs a poster + // also, return either ??? + private async Task SavePosterToDisk(TelevisionShow show, string posterPath) + { + byte[] originalBytes = await File.ReadAllBytesAsync(posterPath); + Either maybeHash = await _imageCache.ResizeAndSaveImage(originalBytes, 220, null); + await maybeHash.Match( + hash => + { + show.Poster = hash; + return _televisionRepository.Update(show); + }, + error => + { + _logger.LogWarning("Unable to save poster to disk from {Path}: {Error}", posterPath, error.Value); + return Task.CompletedTask; + }); + } + + private async Task SavePosterToDisk(TelevisionEpisodeMediaItem episode, string posterPath) + { + byte[] originalBytes = await File.ReadAllBytesAsync(posterPath); + Either maybeHash = await _imageCache.ResizeAndSaveImage(originalBytes, 220, null); + await maybeHash.Match( + hash => + { + episode.Poster = hash; + return _televisionRepository.Update(episode); + }, + error => + { + _logger.LogWarning("Unable to save poster to disk from {Path}: {Error}", posterPath, error.Value); + return Task.CompletedTask; + }); + } + + 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 d2f64d404..1bf7eeda4 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 9a8dfc442..acf835b82 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading.Tasks; using ErsatzTV.Core.Domain; @@ -117,10 +118,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 +167,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 +190,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) { @@ -348,8 +349,13 @@ 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 + }; } } diff --git a/ErsatzTV.Infrastructure/Data/Configurations/GenericIntegerIdConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/GenericIntegerIdConfiguration.cs index c2c793c40..406162533 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 66187d03c..97b1a6730 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 b0681a228..bd1c81b89 100644 --- a/ErsatzTV.Infrastructure/Data/Configurations/MediaItemConfiguration.cs +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaItemConfiguration.cs @@ -7,6 +7,6 @@ namespace ErsatzTV.Infrastructure.Data.Configurations public class MediaItemConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) => - builder.OwnsOne(c => c.Metadata).WithOwner(); + builder.OwnsOne(c => c.Statistics).WithOwner(); } } diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaItemSummaryConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaItemSummaryConfiguration.cs index 58cb49c43..5bed7f586 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 000000000..b1a5ca665 --- /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 000000000..70a6ffdbb --- /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/TelevisionEpisodeMediaItemConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/TelevisionEpisodeMediaItemConfiguration.cs new file mode 100644 index 000000000..829316bbb --- /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 000000000..08c84c143 --- /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/TelevisionSeasonConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/TelevisionSeasonConfiguration.cs new file mode 100644 index 000000000..8ebc89e8c --- /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 000000000..8cdff4f64 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/TelevisionShowConfiguration.cs @@ -0,0 +1,22 @@ +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); + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/TelevisionShowMetadataConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/TelevisionShowMetadataConfiguration.cs new file mode 100644 index 000000000..145dbc1c8 --- /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/Repositories/MediaCollectionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs index 664254510..26e1378be 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs @@ -55,11 +55,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories 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 + SELECT mc.Id, mc.Name, Count(tem.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 + LEFT OUTER JOIN TelevisionEpisodeMetadata tem ON (tmc.SeasonNumber IS NULL OR tem.Season = tmc.SeasonNumber) + AND tem.Title = tmc.ShowTitle WHERE mc.Name LIKE {0} GROUP BY mc.Id, mc.Name", $"%{searchString}%").ToListAsync(); @@ -145,10 +145,13 @@ WHERE NOT EXISTS return collection.Items.ToList(); } + // TODO: reimplement this private Task> TelevisionItems(TelevisionMediaCollection collection) => - _dbContext.MediaItems - .Filter(c => c.Metadata.Title == collection.ShowTitle) - .Filter(c => collection.SeasonNumber == null || c.Metadata.SeasonNumber == collection.SeasonNumber) - .ToListAsync(); + new List().AsTask(); + + // _dbContext.MediaItems + // .Filter(c => c.Metadata.Title == collection.ShowTitle) + // .Filter(c => collection.SeasonNumber == null || c.Metadata.SeasonNumber == collection.SeasonNumber) + // .ToListAsync(); } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs index b419ff7d0..94aa3cdc1 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs @@ -33,14 +33,23 @@ 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(); + IQueryable movieData = + from c in _dbContext.MovieMediaItems.Include(c => c.Source) select c; + + if (!string.IsNullOrEmpty(searchString)) + { + movieData = movieData.Where(c => EF.Functions.Like(c.Metadata.Title, $"%{searchString}%")); + } + + return episodeData.OfType().Concat(movieData.OfType()).ToListAsync(); } @@ -49,13 +58,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories { 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 + m.Id AS MediaItemId, + mm.Title AS Title, + mm.SortTitle AS SortTitle, + mm.Year AS Subtitle, + mi.Poster AS Poster +FROM Movies m +INNER JOIN MediaItems mi on m.Id = mi.Id +INNER JOIN MovieMetadata mm on mm.MovieId = m.Id +ORDER BY SortTitle LIMIT {0} OFFSET {1}", pageSize, (pageNumber - 1) * pageSize) @@ -63,14 +74,15 @@ LIMIT {0} OFFSET {1}", .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 + min(ts.Id) AS MediaItemId, + tsm.Title AS Title, + tsm.SortTitle AS SortTitle, + tsm.Year AS Subtitle, + max(ts.Poster) AS Poster +FROM TelevisionShows ts +INNER JOIN TelevisionShowMetadata tsm on tsm.TelevisionShowId = ts.Id +GROUP BY tsm.Title, tsm.SortTitle, tsm.Year +ORDER BY tsm.SortTitle LIMIT {0} OFFSET {1}", pageSize, (pageNumber - 1) * pageSize) @@ -82,11 +94,9 @@ LIMIT {0} OFFSET {1}", public Task GetCountByType(MediaType mediaType) => mediaType switch { - MediaType.Movie => _dbContext.MediaItems - .Filter(i => i.Metadata.MediaType == mediaType) + MediaType.Movie => _dbContext.MovieMediaItems .CountAsync(), - MediaType.TvShow => _dbContext.MediaItems - .Filter(i => i.Metadata.MediaType == mediaType) + MediaType.TvShow => _dbContext.TelevisionShows .GroupBy(i => new { i.Metadata.Title, i.Metadata.SortTitle }) .CountAsync(), _ => Task.FromResult(0) @@ -103,11 +113,12 @@ LIMIT {0} OFFSET {1}", 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(); - } + public Task Delete(int mediaItemId) => + _dbContext.MediaItems.FindAsync(mediaItemId).AsTask().Bind( + mediaItem => + { + _dbContext.MediaItems.Remove(mediaItem); + return _dbContext.SaveChangesAsync(); + }).ToUnit(); } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs new file mode 100644 index 000000000..19ee7b811 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs @@ -0,0 +1,44 @@ +using System; +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 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)); + } + + 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 5b24b0b8d..028124bf2 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/PlayoutRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/PlayoutRepository.cs @@ -52,10 +52,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .SingleOrDefaultAsync(); } + // TODO: does this actually work? 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/TelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs new file mode 100644 index 000000000..eb8e0ab6b --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +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 Task Add(TelevisionShow show) => + _dbContext.TelevisionShows.AddAsync(show).AsTask() + .Bind(_ => _dbContext.SaveChangesAsync()) + .ToUnit(); + + public Task> GetAllByMediaSourceId(int mediaSourceId) => + _dbContext.TelevisionShows + .Include(s => s.Metadata) + .Include(s => s.Seasons) + .ThenInclude(s => s.Episodes) + .ToListAsync(); + + public async Task Update(TelevisionShow show) + { + _dbContext.TelevisionShows.Update(show); + return await _dbContext.SaveChangesAsync() > 0; + } + + public async Task Update(TelevisionEpisodeMediaItem episode) + { + _dbContext.TelevisionEpisodeMediaItems.Update(episode); + return await _dbContext.SaveChangesAsync() > 0; + } + + public async Task> GetOrAddShow(int mediaSourceId, string path) + { + Option maybeExisting = await _dbContext.TelevisionShows + .Include(i => i.Metadata) + .SingleOrDefaultAsync(i => i.Path == path); + + return await maybeExisting.Match( + show => Right(show).AsTask(), + () => AddShow(mediaSourceId, path)); + } + + 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) + .SingleOrDefaultAsync(i => i.Path == path); + + return await maybeExisting.Match( + episode => Right(episode).AsTask(), + () => AddEpisode(season, mediaSourceId, path)); + } + + private async Task> AddShow(int mediaSourceId, string path) + { + try + { + var show = new TelevisionShow + { MediaSourceId = mediaSourceId, Path = path, Seasons = new List() }; + await _dbContext.TelevisionShows.AddAsync(show); + await _dbContext.SaveChangesAsync(); + return show; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + + 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 6e881c608..9e3eedd37 100644 --- a/ErsatzTV.Infrastructure/Data/TvContext.cs +++ b/ErsatzTV.Infrastructure/Data/TvContext.cs @@ -19,6 +19,8 @@ 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; } @@ -28,6 +30,8 @@ namespace ErsatzTV.Infrastructure.Data public DbSet PlayoutProgramScheduleItemAnchors { get; set; } public DbSet FFmpegProfiles { get; set; } public DbSet Resolutions { get; set; } + public DbSet TelevisionShows { get; set; } + public DbSet TelevisionSeasons { get; set; } // support raw sql queries public DbSet MediaCollectionSummaries { get; set; } diff --git a/ErsatzTV.Infrastructure/Migrations/20210218214650_Statistics.Designer.cs b/ErsatzTV.Infrastructure/Migrations/20210218214650_Statistics.Designer.cs new file mode 100644 index 000000000..72749e306 --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210218214650_Statistics.Designer.cs @@ -0,0 +1,1182 @@ +// +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("20210218214650_Statistics")] + partial class Statistics + { + 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("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("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("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.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.TelevisionShow", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", "Source") + .WithMany() + .HasForeignKey("MediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Source"); + }); + + 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("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.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"); + }); + + 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/20210218214650_Statistics.cs b/ErsatzTV.Infrastructure/Migrations/20210218214650_Statistics.cs new file mode 100644 index 000000000..89b133f11 --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210218214650_Statistics.cs @@ -0,0 +1,428 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace ErsatzTV.Infrastructure.Migrations +{ + public partial class Statistics : 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), + MediaSourceId = 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_TelevisionShows", x => x.Id); + table.ForeignKey( + "FK_TelevisionShows_MediaSources_MediaSourceId", + x => x.MediaSourceId, + "MediaSources", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + 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( + "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_TelevisionShows_MediaSourceId", + "TelevisionShows", + "MediaSourceId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + "MovieMetadata"); + + migrationBuilder.DropTable( + "TelevisionEpisodeMetadata"); + + migrationBuilder.DropTable( + "TelevisionShowMetadata"); + + 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/TvContextModelSnapshot.cs b/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs index 7f072691e..54c642edf 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 => @@ -457,6 +512,143 @@ namespace ErsatzTV.Infrastructure.Migrations 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("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("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( "MediaItemSimpleMediaCollection", b => @@ -501,6 +693,32 @@ namespace ErsatzTV.Infrastructure.Migrations 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 => @@ -613,61 +831,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 +873,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( @@ -870,6 +1071,58 @@ namespace ErsatzTV.Infrastructure.Migrations 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.TelevisionShow", + b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", "Source") + .WithMany() + .HasForeignKey("MediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Source"); + }); + + 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( "MediaItemSimpleMediaCollection", b => @@ -909,6 +1162,36 @@ namespace ErsatzTV.Infrastructure.Migrations .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 => @@ -995,6 +1278,21 @@ 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"); + }); + + 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 59acb62f7..302e12cb6 100644 --- a/ErsatzTV.sln.DotSettings +++ b/ErsatzTV.sln.DotSettings @@ -4,6 +4,7 @@ SAR True True + True True True True @@ -13,6 +14,7 @@ True True True + True True True True @@ -35,4 +37,5 @@ True True True + True True \ No newline at end of file diff --git a/ErsatzTV/Controllers/Api/MediaItemsController.cs b/ErsatzTV/Controllers/Api/MediaItemsController.cs index a0ae06206..a6f3779bd 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/Pages/LocalMediaSourceEditor.razor b/ErsatzTV/Pages/LocalMediaSourceEditor.razor index de192ddbe..e69f2d04b 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/Services/SchedulerService.cs b/ErsatzTV/Services/SchedulerService.cs index 8b91872ba..4501d0fff 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 75538af40..caf56afd5 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/LocalMediaSources.razor b/ErsatzTV/Shared/LocalMediaSources.razor index 163e4d28e..d34e114e1 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 c5536e260..9f5e86798 100644 --- a/ErsatzTV/Shared/MainLayout.razor +++ b/ErsatzTV/Shared/MainLayout.razor @@ -30,7 +30,6 @@ Media Sources TV Shows Movies - Other Items Media Collections Schedules diff --git a/ErsatzTV/Shared/MediaCard.razor b/ErsatzTV/Shared/MediaCard.razor index 44fea3a41..8ef46e897 100644 --- a/ErsatzTV/Shared/MediaCard.razor +++ b/ErsatzTV/Shared/MediaCard.razor @@ -1,5 +1,4 @@ @using ErsatzTV.Application.MediaItems -@using ErsatzTV.Application.MediaItems.Commands @using Unit = LanguageExt.Unit @inject IMediator Mediator @@ -11,7 +10,6 @@ @Placeholder(Data.SortTitle) } - @Data.Title @@ -39,13 +37,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/Startup.cs b/ErsatzTV/Startup.cs index c50781ff0..0736c2839 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -171,16 +171,22 @@ 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.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddHostedService(); services.AddHostedService(); diff --git a/generated/ErsatzTV.Api.Sdk/.openapi-generator/FILES b/generated/ErsatzTV.Api.Sdk/.openapi-generator/FILES index 410fe9397..bbda04b5f 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 4f975fe25..183fd4638 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 d515d284d..000000000 --- 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 5c5f8c715..000000000 --- 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 2b3c355eb..05cadfa83 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;