From 9d40caebd6af9a68f7a42859d9b95d8f62d590ed Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Sat, 13 Feb 2021 18:15:18 -0600 Subject: [PATCH] rework media layout (#22) * replace media items tables with card grids * style cleanup * add basic paging * sort and page in the db * optimize sql for movies * support movie posters * resize movie posters and store in cache with channel logos * fix bug preventing folders with more than 50 chars as local media sources * support tv posters --- ErsatzTV.Application/IMediaCard.cs | 9 + .../Images/Commands/SaveImageToDiskHandler.cs | 35 +- .../MediaItems/AggregateMediaItemResults.cs | 6 + .../MediaItems/AggregateMediaItemViewModel.cs | 6 +- .../Commands/CreateMediaItemHandler.cs | 6 +- .../Commands/RefreshMediaItemPoster.cs | 9 + .../Queries/GetAggregateMediaItems.cs | 5 +- .../Queries/GetAggregateMediaItemsHandler.cs | 40 +- .../Commands/CreateLocalMediaSourceHandler.cs | 14 +- .../Commands/ScanLocalMediaSourceHandler.cs | 2 +- .../AggregateModels/MediaItemSummary.cs | 4 + ErsatzTV.Core/Domain/MediaItem.cs | 1 + ErsatzTV.Core/Domain/MediaMetadata.cs | 1 + .../Interfaces/Images/IImageCache.cs | 11 + .../Metadata/ILocalPosterProvider.cs | 10 + .../Repositories/IMediaItemRepository.cs | 4 +- ErsatzTV.Core/Metadata/LocalMediaScanner.cs | 4 + .../Metadata/LocalMetadataProvider.cs | 4 + ErsatzTV.Core/Metadata/LocalPosterProvider.cs | 90 ++ .../GenericIntegerIdConfiguration.cs | 2 +- .../Configurations/LogEntryConfiguration.cs | 12 - .../MediaCollectionSummaryConfiguration.cs | 2 +- .../MediaItemSummaryConfiguration.cs | 12 + ErsatzTV.Infrastructure/Data/LogContext.cs | 2 +- .../Data/Repositories/MediaItemRepository.cs | 51 +- ErsatzTV.Infrastructure/Data/TvContext.cs | 6 + .../ErsatzTV.Infrastructure.csproj | 1 + ErsatzTV.Infrastructure/Images/ImageCache.cs | 64 ++ ...210213155419_MetadataSortTitle.Designer.cs | 879 +++++++++++++++++ .../20210213155419_MetadataSortTitle.cs | 30 + ...20210213221040_MediaItemPoster.Designer.cs | 893 ++++++++++++++++++ .../20210213221040_MediaItemPoster.cs | 48 + .../Migrations/TvContextModelSnapshot.cs | 25 +- ErsatzTV/Controllers/PostersController.cs | 28 + ErsatzTV/Pages/LocalMediaSourceEditor.razor | 13 +- ErsatzTV/Pages/MediaItems.razor | 17 - ErsatzTV/Pages/MediaMovieItems.razor | 8 + ErsatzTV/Pages/MediaOtherItems.razor | 8 + ErsatzTV/Pages/MediaTvItems.razor | 8 + ErsatzTV/Services/WorkerService.cs | 1 + ErsatzTV/Shared/LocalMediaSources.razor | 2 +- ErsatzTV/Shared/MainLayout.razor | 6 +- ErsatzTV/Shared/MediaCard.razor | 34 + ErsatzTV/Shared/MediaItemTable.razor | 66 -- ErsatzTV/Shared/MediaItemsGrid.razor | 55 ++ ErsatzTV/Startup.cs | 4 + ErsatzTV/_Imports.razor | 1 + ErsatzTV/wwwroot/css/site.css | 25 +- 48 files changed, 2386 insertions(+), 178 deletions(-) create mode 100644 ErsatzTV.Application/IMediaCard.cs create mode 100644 ErsatzTV.Application/MediaItems/AggregateMediaItemResults.cs create mode 100644 ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemPoster.cs create mode 100644 ErsatzTV.Core/AggregateModels/MediaItemSummary.cs create mode 100644 ErsatzTV.Core/Interfaces/Images/IImageCache.cs create mode 100644 ErsatzTV.Core/Interfaces/Metadata/ILocalPosterProvider.cs create mode 100644 ErsatzTV.Core/Metadata/LocalPosterProvider.cs delete mode 100644 ErsatzTV.Infrastructure/Data/Configurations/LogEntryConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/MediaItemSummaryConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Images/ImageCache.cs create mode 100644 ErsatzTV.Infrastructure/Migrations/20210213155419_MetadataSortTitle.Designer.cs create mode 100644 ErsatzTV.Infrastructure/Migrations/20210213155419_MetadataSortTitle.cs create mode 100644 ErsatzTV.Infrastructure/Migrations/20210213221040_MediaItemPoster.Designer.cs create mode 100644 ErsatzTV.Infrastructure/Migrations/20210213221040_MediaItemPoster.cs create mode 100644 ErsatzTV/Controllers/PostersController.cs delete mode 100644 ErsatzTV/Pages/MediaItems.razor create mode 100644 ErsatzTV/Pages/MediaMovieItems.razor create mode 100644 ErsatzTV/Pages/MediaOtherItems.razor create mode 100644 ErsatzTV/Pages/MediaTvItems.razor create mode 100644 ErsatzTV/Shared/MediaCard.razor delete mode 100644 ErsatzTV/Shared/MediaItemTable.razor create mode 100644 ErsatzTV/Shared/MediaItemsGrid.razor diff --git a/ErsatzTV.Application/IMediaCard.cs b/ErsatzTV.Application/IMediaCard.cs new file mode 100644 index 000000000..a50da9758 --- /dev/null +++ b/ErsatzTV.Application/IMediaCard.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Application +{ + public interface IMediaCard + { + string Title { get; } + string SortTitle { get; } + string Subtitle { get; } + } +} diff --git a/ErsatzTV.Application/Images/Commands/SaveImageToDiskHandler.cs b/ErsatzTV.Application/Images/Commands/SaveImageToDiskHandler.cs index a22ea7d09..13243443d 100644 --- a/ErsatzTV.Application/Images/Commands/SaveImageToDiskHandler.cs +++ b/ErsatzTV.Application/Images/Commands/SaveImageToDiskHandler.cs @@ -1,9 +1,7 @@ -using System; -using System.IO; -using System.Security.Cryptography; -using System.Threading; +using System.Threading; using System.Threading.Tasks; using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.Images; using LanguageExt; using MediatR; @@ -11,33 +9,12 @@ namespace ErsatzTV.Application.Images.Commands { public class SaveImageToDiskHandler : IRequestHandler> { - private static readonly SHA1CryptoServiceProvider Crypto; + private readonly IImageCache _imageCache; - static SaveImageToDiskHandler() => Crypto = new SHA1CryptoServiceProvider(); + public SaveImageToDiskHandler(IImageCache imageCache) => _imageCache = imageCache; - public async Task> Handle( + public Task> Handle( SaveImageToDisk request, - CancellationToken cancellationToken) - { - try - { - byte[] hash = Crypto.ComputeHash(request.Buffer); - string hex = BitConverter.ToString(hash).Replace("-", string.Empty); - - string fileName = Path.Combine(FileSystemLayout.ImageCacheFolder, hex); - - if (!Directory.Exists(FileSystemLayout.ImageCacheFolder)) - { - Directory.CreateDirectory(FileSystemLayout.ImageCacheFolder); - } - - await File.WriteAllBytesAsync(fileName, request.Buffer, cancellationToken); - return hex; - } - catch (Exception ex) - { - return BaseError.New(ex.Message); - } - } + CancellationToken cancellationToken) => _imageCache.SaveImage(request.Buffer); } } diff --git a/ErsatzTV.Application/MediaItems/AggregateMediaItemResults.cs b/ErsatzTV.Application/MediaItems/AggregateMediaItemResults.cs new file mode 100644 index 000000000..9c38e3ea7 --- /dev/null +++ b/ErsatzTV.Application/MediaItems/AggregateMediaItemResults.cs @@ -0,0 +1,6 @@ +using System.Collections.Generic; + +namespace ErsatzTV.Application.MediaItems +{ + public record AggregateMediaItemResults(int Count, List DataPage); +} diff --git a/ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs b/ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs index 7d51ffa50..31d5a0ca3 100644 --- a/ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs +++ b/ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs @@ -1,4 +1,8 @@ namespace ErsatzTV.Application.MediaItems { - public record AggregateMediaItemViewModel(string Source, string Title, int Count, string Duration); + public record AggregateMediaItemViewModel( + string Title, + string Subtitle, + string SortTitle, + string Poster); } diff --git a/ErsatzTV.Application/MediaItems/Commands/CreateMediaItemHandler.cs b/ErsatzTV.Application/MediaItems/Commands/CreateMediaItemHandler.cs index 5002cfc7c..53509737f 100644 --- a/ErsatzTV.Application/MediaItems/Commands/CreateMediaItemHandler.cs +++ b/ErsatzTV.Application/MediaItems/Commands/CreateMediaItemHandler.cs @@ -16,6 +16,7 @@ namespace ErsatzTV.Application.MediaItems.Commands { private readonly IConfigElementRepository _configElementRepository; private readonly ILocalMetadataProvider _localMetadataProvider; + private readonly ILocalPosterProvider _localPosterProvider; private readonly ILocalStatisticsProvider _localStatisticsProvider; private readonly IMediaItemRepository _mediaItemRepository; private readonly IMediaSourceRepository _mediaSourceRepository; @@ -27,7 +28,8 @@ namespace ErsatzTV.Application.MediaItems.Commands IConfigElementRepository configElementRepository, ISmartCollectionBuilder smartCollectionBuilder, ILocalMetadataProvider localMetadataProvider, - ILocalStatisticsProvider localStatisticsProvider) + ILocalStatisticsProvider localStatisticsProvider, + ILocalPosterProvider localPosterProvider) { _mediaItemRepository = mediaItemRepository; _mediaSourceRepository = mediaSourceRepository; @@ -35,6 +37,7 @@ namespace ErsatzTV.Application.MediaItems.Commands _smartCollectionBuilder = smartCollectionBuilder; _localMetadataProvider = localMetadataProvider; _localStatisticsProvider = localStatisticsProvider; + _localPosterProvider = localPosterProvider; } public Task> Handle( @@ -50,6 +53,7 @@ namespace ErsatzTV.Application.MediaItems.Commands await _localStatisticsProvider.RefreshStatistics(parameters.FFprobePath, parameters.MediaItem); await _localMetadataProvider.RefreshMetadata(parameters.MediaItem); + await _localPosterProvider.RefreshPoster(parameters.MediaItem); await _smartCollectionBuilder.RefreshSmartCollections(parameters.MediaItem); return ProjectToViewModel(parameters.MediaItem); diff --git a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemPoster.cs b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemPoster.cs new file mode 100644 index 000000000..c6d7ddb85 --- /dev/null +++ b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemPoster.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Application.MediaItems.Commands +{ + public record RefreshMediaItemPoster : RefreshMediaItem + { + public RefreshMediaItemPoster(int mediaItemId) : base(mediaItemId) + { + } + } +} diff --git a/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItems.cs b/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItems.cs index cfd1f2dc8..c552bf0cd 100644 --- a/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItems.cs +++ b/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItems.cs @@ -1,9 +1,8 @@ -using System.Collections.Generic; -using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain; using MediatR; namespace ErsatzTV.Application.MediaItems.Queries { public record GetAggregateMediaItems - (MediaType MediaType, string SearchString) : IRequest>; + (MediaType MediaType, int PageNumber, int PageSize) : IRequest; } diff --git a/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs b/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs index 189916267..93ee700af 100644 --- a/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs +++ b/ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs @@ -1,46 +1,42 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; -using ErsatzTV.Core.Domain; +using ErsatzTV.Core.AggregateModels; using ErsatzTV.Core.Interfaces.Repositories; using MediatR; namespace ErsatzTV.Application.MediaItems.Queries { public class - GetAggregateMediaItemsHandler : IRequestHandler> + GetAggregateMediaItemsHandler : IRequestHandler { private readonly IMediaItemRepository _mediaItemRepository; public GetAggregateMediaItemsHandler(IMediaItemRepository mediaItemRepository) => _mediaItemRepository = mediaItemRepository; - public async Task> Handle( + public async Task Handle( GetAggregateMediaItems request, CancellationToken cancellationToken) { - IEnumerable allItems = await _mediaItemRepository.GetAll(request.MediaType); + int count = await _mediaItemRepository.GetCountByType(request.MediaType); - if (!string.IsNullOrEmpty(request.SearchString)) - { - allItems = allItems.Filter( - i => i.Metadata?.Title.Contains(request.SearchString, StringComparison.OrdinalIgnoreCase) == - true); - } + IEnumerable allItems = await _mediaItemRepository.GetPageByType( + request.MediaType, + request.PageNumber, + request.PageSize); - return allItems.GroupBy(c => new { c.Source.Name, c.Metadata.Title }).Map( - group => new AggregateMediaItemViewModel( - group.Key.Name, - group.Key.Title, - group.Count(), - group.Count() == 1 ? DisplayDuration(group.Head()) : string.Empty)) + var results = allItems + .Map( + s => new AggregateMediaItemViewModel( + s.Title, + s.Subtitle, + s.SortTitle, + s.Poster)) .ToList(); - } - private static string DisplayDuration(MediaItem mediaItem) => string.Format( - mediaItem.Metadata?.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}", - mediaItem.Metadata?.Duration); + return new AggregateMediaItemResults(count, results); + } } } diff --git a/ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSourceHandler.cs b/ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSourceHandler.cs index 6b093f844..da307a55e 100644 --- a/ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSourceHandler.cs +++ b/ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSourceHandler.cs @@ -41,28 +41,28 @@ namespace ErsatzTV.Application.MediaSources.Commands Folder = folder }); - private async Task> ValidateName(CreateLocalMediaSource createCollection) + private async Task> ValidateName(CreateLocalMediaSource request) { List allNames = await _mediaSourceRepository.GetAll() .Map(list => list.Map(c => c.Name).ToList()); - Validation result1 = createCollection.NotEmpty(c => c.Name) - .Bind(_ => createCollection.NotLongerThan(50)(c => c.Name)); + Validation result1 = request.NotEmpty(c => c.Name) + .Bind(_ => request.NotLongerThan(50)(c => c.Name)); - var result2 = Optional(createCollection.Name) + var result2 = Optional(request.Name) .Filter(name => !allNames.Contains(name)) .ToValidation("Media source name must be unique"); - return (result1, result2).Apply((_, _) => createCollection.Name); + return (result1, result2).Apply((_, _) => request.Name); } - private async Task> ValidateFolder(CreateLocalMediaSource createCollection) + private async Task> ValidateFolder(CreateLocalMediaSource request) { List allFolders = await _mediaSourceRepository.GetAll() .Map(list => list.OfType().Map(c => c.Folder).ToList()); - return Optional(createCollection.Folder) + return Optional(request.Folder) .Filter(folder => allFolders.ForAll(f => !AreSubPaths(f, folder))) .ToValidation("Folder must not belong to another media source"); } diff --git a/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs b/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs index 76134bf01..968f456cf 100644 --- a/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs +++ b/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs @@ -31,7 +31,7 @@ namespace ErsatzTV.Application.MediaSources.Commands Validate(request) .MapT( p => _localMediaScanner.ScanLocalMediaSource(p.LocalMediaSource, p.FFprobePath) - .Map(_ => p.LocalMediaSource.Name)) + .Map(_ => p.LocalMediaSource.Folder)) .Bind(v => v.ToEitherAsync()); private async Task> Validate(ScanLocalMediaSource request) => diff --git a/ErsatzTV.Core/AggregateModels/MediaItemSummary.cs b/ErsatzTV.Core/AggregateModels/MediaItemSummary.cs new file mode 100644 index 000000000..90461143b --- /dev/null +++ b/ErsatzTV.Core/AggregateModels/MediaItemSummary.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Core.AggregateModels +{ + public record MediaItemSummary(string Title, string SortTitle, string Subtitle, string Poster); +} diff --git a/ErsatzTV.Core/Domain/MediaItem.cs b/ErsatzTV.Core/Domain/MediaItem.cs index 39adcc026..d5e8fd7b7 100644 --- a/ErsatzTV.Core/Domain/MediaItem.cs +++ b/ErsatzTV.Core/Domain/MediaItem.cs @@ -9,6 +9,7 @@ namespace ErsatzTV.Core.Domain public int MediaSourceId { get; set; } public MediaSource Source { get; set; } public string Path { get; set; } + public string Poster { get; set; } public MediaMetadata Metadata { get; set; } public DateTime? LastWriteTime { get; set; } public IList SimpleMediaCollections { get; set; } diff --git a/ErsatzTV.Core/Domain/MediaMetadata.cs b/ErsatzTV.Core/Domain/MediaMetadata.cs index 30d93bf1a..efe2774bd 100644 --- a/ErsatzTV.Core/Domain/MediaMetadata.cs +++ b/ErsatzTV.Core/Domain/MediaMetadata.cs @@ -12,6 +12,7 @@ namespace ErsatzTV.Core.Domain public string AudioCodec { get; set; } public MediaType MediaType { get; set; } public string Title { get; set; } + public string SortTitle { get; set; } public string Subtitle { get; set; } public string Description { get; set; } public int? SeasonNumber { get; set; } diff --git a/ErsatzTV.Core/Interfaces/Images/IImageCache.cs b/ErsatzTV.Core/Interfaces/Images/IImageCache.cs new file mode 100644 index 000000000..5d031a050 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Images/IImageCache.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Images +{ + public interface IImageCache + { + Task> ResizeAndSaveImage(byte[] imageBuffer, int? height, int? width); + Task> SaveImage(byte[] imageBuffer); + } +} diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalPosterProvider.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalPosterProvider.cs new file mode 100644 index 000000000..ab47e5da0 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Metadata/ILocalPosterProvider.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Interfaces.Metadata +{ + public interface ILocalPosterProvider + { + Task RefreshPoster(MediaItem mediaItem); + } +} diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs index 9c6961c4c..1f09bb9a7 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; +using ErsatzTV.Core.AggregateModels; using ErsatzTV.Core.Domain; using LanguageExt; @@ -11,7 +12,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories public Task> Get(int id); public Task> GetAll(); public Task> Search(string searchString); - public Task> GetAll(MediaType mediaType); + public Task> GetPageByType(MediaType mediaType, int pageNumber, int pageSize); + public Task GetCountByType(MediaType mediaType); public Task> GetAllByMediaSourceId(int mediaSourceId); public Task Update(MediaItem mediaItem); public Task Delete(int mediaItemId); diff --git a/ErsatzTV.Core/Metadata/LocalMediaScanner.cs b/ErsatzTV.Core/Metadata/LocalMediaScanner.cs index 54cf57a9c..82def5ae3 100644 --- a/ErsatzTV.Core/Metadata/LocalMediaScanner.cs +++ b/ErsatzTV.Core/Metadata/LocalMediaScanner.cs @@ -16,6 +16,7 @@ namespace ErsatzTV.Core.Metadata public class LocalMediaScanner : ILocalMediaScanner { private readonly ILocalMetadataProvider _localMetadataProvider; + private readonly ILocalPosterProvider _localPosterProvider; private readonly ILocalStatisticsProvider _localStatisticsProvider; private readonly ILogger _logger; private readonly IMediaItemRepository _mediaItemRepository; @@ -28,6 +29,7 @@ namespace ErsatzTV.Core.Metadata IPlayoutRepository playoutRepository, ILocalStatisticsProvider localStatisticsProvider, ILocalMetadataProvider localMetadataProvider, + ILocalPosterProvider localPosterProvider, ISmartCollectionBuilder smartCollectionBuilder, IPlayoutBuilder playoutBuilder, ILogger logger) @@ -36,6 +38,7 @@ namespace ErsatzTV.Core.Metadata _playoutRepository = playoutRepository; _localStatisticsProvider = localStatisticsProvider; _localMetadataProvider = localMetadataProvider; + _localPosterProvider = localPosterProvider; _smartCollectionBuilder = smartCollectionBuilder; _playoutBuilder = playoutBuilder; _logger = logger; @@ -135,6 +138,7 @@ namespace ErsatzTV.Core.Metadata { await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem); await _localMetadataProvider.RefreshMetadata(mediaItem); + await _localPosterProvider.RefreshPoster(mediaItem); await _smartCollectionBuilder.RefreshSmartCollections(mediaItem); } } diff --git a/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs b/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs index cf364add7..9846c891f 100644 --- a/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs +++ b/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs @@ -43,6 +43,10 @@ namespace ErsatzTV.Core.Metadata 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; diff --git a/ErsatzTV.Core/Metadata/LocalPosterProvider.cs b/ErsatzTV.Core/Metadata/LocalPosterProvider.cs new file mode 100644 index 000000000..800fd94b4 --- /dev/null +++ b/ErsatzTV.Core/Metadata/LocalPosterProvider.cs @@ -0,0 +1,90 @@ +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Images; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using Microsoft.Extensions.Logging; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.Metadata +{ + public class LocalPosterProvider : ILocalPosterProvider + { + private readonly IImageCache _imageCache; + private readonly ILogger _logger; + private readonly IMediaItemRepository _mediaItemRepository; + + public LocalPosterProvider( + IMediaItemRepository mediaItemRepository, + IImageCache imageCache, + ILogger logger) + { + _mediaItemRepository = mediaItemRepository; + _imageCache = imageCache; + _logger = logger; + } + + public async Task RefreshPoster(MediaItem mediaItem) + { + Option maybePosterPath = mediaItem.Metadata.MediaType switch + { + MediaType.Movie => RefreshMoviePoster(mediaItem), + MediaType.TvShow => RefreshTelevisionPoster(mediaItem), + _ => None + }; + + await maybePosterPath.Match( + path => SavePosterToDisk(mediaItem, path), + Task.CompletedTask); + } + + private static Option RefreshMoviePoster(MediaItem mediaItem) + { + string folder = Path.GetDirectoryName(mediaItem.Path); + if (folder != null) + { + string[] possiblePaths = + { "poster.jpg", Path.GetFileNameWithoutExtension(mediaItem.Path) + "-poster.jpg" }; + Option maybePoster = + possiblePaths.Map(p => Path.Combine(folder, p)).FirstOrDefault(File.Exists); + return maybePoster; + } + + return None; + } + + private Option RefreshTelevisionPoster(MediaItem mediaItem) + { + string folder = Directory.GetParent(Path.GetDirectoryName(mediaItem.Path) ?? string.Empty)?.FullName; + if (folder != null) + { + string[] possiblePaths = { "poster.jpg" }; + Option maybePoster = + possiblePaths.Map(p => Path.Combine(folder, p)).FirstOrDefault(File.Exists); + return maybePoster; + } + + return None; + } + + private 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.Infrastructure/Data/Configurations/GenericIntegerIdConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/GenericIntegerIdConfiguration.cs index f5842dc06..c2c793c40 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(); + builder.HasNoKey().ToView(null); } } diff --git a/ErsatzTV.Infrastructure/Data/Configurations/LogEntryConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/LogEntryConfiguration.cs deleted file mode 100644 index e79223e0d..000000000 --- a/ErsatzTV.Infrastructure/Data/Configurations/LogEntryConfiguration.cs +++ /dev/null @@ -1,12 +0,0 @@ -using ErsatzTV.Core.Domain; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace ErsatzTV.Infrastructure.Data.Configurations -{ - public class LogEntryConfiguration : IEntityTypeConfiguration - { - public void Configure(EntityTypeBuilder builder) => - builder.ToTable("Logs"); - } -} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaCollectionSummaryConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaCollectionSummaryConfiguration.cs index 17a64ae0e..66187d03c 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(); + builder.HasNoKey().ToView(null); } } diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaItemSummaryConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaItemSummaryConfiguration.cs new file mode 100644 index 000000000..58cb49c43 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaItemSummaryConfiguration.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core.AggregateModels; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class MediaItemSummaryConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => + builder.HasNoKey().ToView(null); + } +} diff --git a/ErsatzTV.Infrastructure/Data/LogContext.cs b/ErsatzTV.Infrastructure/Data/LogContext.cs index 875bf84bd..67680cd93 100644 --- a/ErsatzTV.Infrastructure/Data/LogContext.cs +++ b/ErsatzTV.Infrastructure/Data/LogContext.cs @@ -15,7 +15,7 @@ namespace ErsatzTV.Infrastructure.Data protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); - builder.ApplyConfigurationsFromAssembly(typeof(LogContext).Assembly); + builder.Entity().ToTable("Logs"); } } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs index 670b67d81..089659449 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using ErsatzTV.Core.AggregateModels; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; using LanguageExt; @@ -40,11 +41,51 @@ namespace ErsatzTV.Infrastructure.Data.Repositories } - public Task> GetAll(MediaType mediaType) => - _dbContext.MediaItems - .Include(i => i.Source) - .Filter(i => i.Metadata.MediaType == mediaType) - .ToListAsync(); + public Task> GetPageByType(MediaType mediaType, int pageNumber, int pageSize) => + mediaType switch + { + MediaType.Movie => _dbContext.MediaItemSummaries.FromSqlRaw( + @"SELECT + Metadata_Title AS Title, + Metadata_SortTitle AS SortTitle, + substr(Metadata_Aired, 1, 4) AS Subtitle, + Poster +FROM MediaItems WHERE Metadata_MediaType=2 +ORDER BY Metadata_SortTitle +LIMIT {0} OFFSET {1}", + pageSize, + (pageNumber - 1) * pageSize) + .AsNoTracking() + .ToListAsync(), + MediaType.TvShow => _dbContext.MediaItemSummaries.FromSqlRaw( + @"SELECT + Metadata_Title AS Title, + Metadata_SortTitle AS SortTitle, + count(*) || ' Episodes' AS Subtitle, + min(Poster) AS Poster +FROM MediaItems WHERE Metadata_MediaType=1 +GROUP BY Metadata_Title, Metadata_SortTitle +ORDER BY Metadata_SortTitle +LIMIT {0} OFFSET {1}", + pageSize, + (pageNumber - 1) * pageSize) + .AsNoTracking() + .ToListAsync(), + _ => Task.FromResult(new List()) + }; + + public Task GetCountByType(MediaType mediaType) => + mediaType switch + { + MediaType.Movie => _dbContext.MediaItems + .Filter(i => i.Metadata.MediaType == mediaType) + .CountAsync(), + MediaType.TvShow => _dbContext.MediaItems + .Filter(i => i.Metadata.MediaType == mediaType) + .GroupBy(i => new { i.Metadata.Title, i.Metadata.SortTitle }) + .CountAsync(), + _ => Task.FromResult(0) + }; public Task> GetAllByMediaSourceId(int mediaSourceId) => _dbContext.MediaItems diff --git a/ErsatzTV.Infrastructure/Data/TvContext.cs b/ErsatzTV.Infrastructure/Data/TvContext.cs index a8866e1f5..6e881c608 100644 --- a/ErsatzTV.Infrastructure/Data/TvContext.cs +++ b/ErsatzTV.Infrastructure/Data/TvContext.cs @@ -32,6 +32,7 @@ namespace ErsatzTV.Infrastructure.Data // support raw sql queries public DbSet MediaCollectionSummaries { get; set; } public DbSet GenericIntegerIds { get; set; } + public DbSet MediaItemSummaries { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.UseLoggerFactory(_loggerFactory); @@ -39,6 +40,11 @@ namespace ErsatzTV.Infrastructure.Data protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); + + builder.Ignore(); + builder.Ignore(); + builder.Ignore(); + builder.ApplyConfigurationsFromAssembly(typeof(TvContext).Assembly); } } diff --git a/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj index 6f4df44d0..db9613c4a 100644 --- a/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj +++ b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj @@ -13,6 +13,7 @@ + diff --git a/ErsatzTV.Infrastructure/Images/ImageCache.cs b/ErsatzTV.Infrastructure/Images/ImageCache.cs new file mode 100644 index 000000000..0c467781e --- /dev/null +++ b/ErsatzTV.Infrastructure/Images/ImageCache.cs @@ -0,0 +1,64 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.Images; +using LanguageExt; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Processing; + +namespace ErsatzTV.Infrastructure.Images +{ + public class ImageCache : IImageCache + { + private static readonly SHA1CryptoServiceProvider Crypto; + + static ImageCache() => Crypto = new SHA1CryptoServiceProvider(); + + public async Task> ResizeAndSaveImage(byte[] imageBuffer, int? height, int? width) + { + await using var inStream = new MemoryStream(imageBuffer); + using var image = await Image.LoadAsync(inStream); + + Size size = height.HasValue ? new Size { Height = height.Value } : new Size { Width = width.Value }; + + image.Mutate( + i => i.Resize( + new ResizeOptions + { + Mode = ResizeMode.Max, + Size = size + })); + + await using var outStream = new MemoryStream(); + await image.SaveAsync(outStream, new JpegEncoder { Quality = 90 }); + + return await SaveImage(outStream.ToArray()); + } + + public async Task> SaveImage(byte[] imageBuffer) + { + try + { + byte[] hash = Crypto.ComputeHash(imageBuffer); + string hex = BitConverter.ToString(hash).Replace("-", string.Empty); + + string fileName = Path.Combine(FileSystemLayout.ImageCacheFolder, hex); + + if (!Directory.Exists(FileSystemLayout.ImageCacheFolder)) + { + Directory.CreateDirectory(FileSystemLayout.ImageCacheFolder); + } + + await File.WriteAllBytesAsync(fileName, imageBuffer); + return hex; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/20210213155419_MetadataSortTitle.Designer.cs b/ErsatzTV.Infrastructure/Migrations/20210213155419_MetadataSortTitle.Designer.cs new file mode 100644 index 000000000..5c03e0de9 --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210213155419_MetadataSortTitle.Designer.cs @@ -0,0 +1,879 @@ +// +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("20210213155419_MetadataSortTitle")] + partial class MetadataSortTitle + { + 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.ToTable("GenericIntegerIds"); + }); + + 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.ToTable("MediaCollectionSummaries"); + }); + + 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.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.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("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.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.MediaMetadata", "Metadata", 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("MediaType") + .HasColumnType("INTEGER"); + + b1.Property("SampleAspectRatio") + .HasColumnType("TEXT"); + + b1.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b1.Property("SortTitle") + .HasColumnType("TEXT"); + + b1.Property("Subtitle") + .HasColumnType("TEXT"); + + b1.Property("Title") + .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("Metadata"); + + b.Navigation("Source"); + }); + + 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("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.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.PlexMediaSource", b => + { + b.Navigation("Connections"); + + b.Navigation("Libraries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/20210213155419_MetadataSortTitle.cs b/ErsatzTV.Infrastructure/Migrations/20210213155419_MetadataSortTitle.cs new file mode 100644 index 000000000..112d422f8 --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210213155419_MetadataSortTitle.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace ErsatzTV.Infrastructure.Migrations +{ + public partial class MetadataSortTitle : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + "Metadata_SortTitle", + "MediaItems", + "TEXT", + nullable: true); + + migrationBuilder.Sql( + @"UPDATE MediaItems +SET Metadata_SortTitle = Metadata_Title"); + + migrationBuilder.Sql( + @"UPDATE MediaItems +SET Metadata_SortTitle = substr(Metadata_Title, 5) +WHERE Metadata_Title LIKE 'the %'"); + } + + protected override void Down(MigrationBuilder migrationBuilder) => + migrationBuilder.DropColumn( + "Metadata_SortTitle", + "MediaItems"); + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/20210213221040_MediaItemPoster.Designer.cs b/ErsatzTV.Infrastructure/Migrations/20210213221040_MediaItemPoster.Designer.cs new file mode 100644 index 000000000..281a65d95 --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210213221040_MediaItemPoster.Designer.cs @@ -0,0 +1,893 @@ +// +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("20210213221040_MediaItemPoster")] + partial class MediaItemPoster + { + 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"); + }); + + 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"); + }); + + modelBuilder.Entity("ErsatzTV.Core.AggregateModels.MediaItemSummary", b => + { + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Subtitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + }); + + 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.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.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("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.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.MediaMetadata", "Metadata", 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("MediaType") + .HasColumnType("INTEGER"); + + b1.Property("SampleAspectRatio") + .HasColumnType("TEXT"); + + b1.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b1.Property("SortTitle") + .HasColumnType("TEXT"); + + b1.Property("Subtitle") + .HasColumnType("TEXT"); + + b1.Property("Title") + .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("Metadata"); + + b.Navigation("Source"); + }); + + 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("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.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.PlexMediaSource", b => + { + b.Navigation("Connections"); + + b.Navigation("Libraries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/20210213221040_MediaItemPoster.cs b/ErsatzTV.Infrastructure/Migrations/20210213221040_MediaItemPoster.cs new file mode 100644 index 000000000..b0f47f9c1 --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210213221040_MediaItemPoster.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace ErsatzTV.Infrastructure.Migrations +{ + public partial class MediaItemPoster : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + "GenericIntegerIds"); + + migrationBuilder.DropTable( + "MediaCollectionSummaries"); + + migrationBuilder.AddColumn( + "Poster", + "MediaItems", + "TEXT", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + "Poster", + "MediaItems"); + + migrationBuilder.CreateTable( + "GenericIntegerIds", + table => new + { + Id = table.Column("INTEGER", nullable: false) + }, + constraints: table => { }); + + migrationBuilder.CreateTable( + "MediaCollectionSummaries", + table => new + { + Id = table.Column("INTEGER", nullable: false), + IsSimple = table.Column("INTEGER", nullable: false), + ItemCount = table.Column("INTEGER", nullable: false), + Name = table.Column("TEXT", nullable: true) + }, + constraints: table => { }); + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs b/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs index 252da1d5b..80651b368 100644 --- a/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs +++ b/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs @@ -22,8 +22,6 @@ namespace ErsatzTV.Infrastructure.Migrations { b.Property("Id") .HasColumnType("INTEGER"); - - b.ToTable("GenericIntegerIds"); }); modelBuilder.Entity( @@ -41,8 +39,23 @@ namespace ErsatzTV.Infrastructure.Migrations b.Property("Name") .HasColumnType("TEXT"); + }); + + modelBuilder.Entity( + "ErsatzTV.Core.AggregateModels.MediaItemSummary", + b => + { + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Subtitle") + .HasColumnType("TEXT"); - b.ToTable("MediaCollectionSummaries"); + b.Property("Title") + .HasColumnType("TEXT"); }); modelBuilder.Entity( @@ -205,6 +218,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.Property("Path") .HasColumnType("TEXT"); + b.Property("Poster") + .HasColumnType("TEXT"); + b.HasKey("Id"); b.HasIndex("MediaSourceId"); @@ -631,6 +647,9 @@ namespace ErsatzTV.Infrastructure.Migrations b1.Property("SeasonNumber") .HasColumnType("INTEGER"); + b1.Property("SortTitle") + .HasColumnType("TEXT"); + b1.Property("Subtitle") .HasColumnType("TEXT"); diff --git a/ErsatzTV/Controllers/PostersController.cs b/ErsatzTV/Controllers/PostersController.cs new file mode 100644 index 000000000..9395903ac --- /dev/null +++ b/ErsatzTV/Controllers/PostersController.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; +using ErsatzTV.Application.Images; +using ErsatzTV.Application.Images.Queries; +using ErsatzTV.Core; +using LanguageExt; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers +{ + [ApiController] + [ApiExplorerSettings(IgnoreApi = true)] + public class PostersController : ControllerBase + { + private readonly IMediator _mediator; + + public PostersController(IMediator mediator) => _mediator = mediator; + + [HttpGet("/posters/{fileName}")] + public async Task GetImage(string fileName) + { + Either imageContents = await _mediator.Send(new GetImageContents(fileName)); + return imageContents.Match( + Left: _ => new NotFoundResult(), + Right: r => new FileContentResult(r.Contents, r.MimeType)); + } + } +} diff --git a/ErsatzTV/Pages/LocalMediaSourceEditor.razor b/ErsatzTV/Pages/LocalMediaSourceEditor.razor index 71680811f..db61326b3 100644 --- a/ErsatzTV/Pages/LocalMediaSourceEditor.razor +++ b/ErsatzTV/Pages/LocalMediaSourceEditor.razor @@ -1,7 +1,6 @@ @page "/media/sources/local/add" -@using ErsatzTV.Application -@using ErsatzTV.Application.MediaSources @using ErsatzTV.Application.MediaSources.Commands +@using ErsatzTV.Application.MediaSources @inject NavigationManager NavigationManager @inject ILogger Logger @inject ISnackbar Snackbar @@ -68,7 +67,13 @@ _messageStore.Clear(); if (_editContext.Validate()) { - var command = new CreateLocalMediaSource(_model.Folder, _model.MediaType, _model.Folder); + var command = new CreateLocalMediaSource( + Convert.ToBase64String(Guid.NewGuid().ToByteArray()) + .TrimEnd('=') + .Replace("/", "_") + .Replace("+", "-"), + _model.MediaType, + _model.Folder); Either result = await Mediator.Send(command); await result.Match( Left: error => @@ -85,4 +90,6 @@ } } + + } \ No newline at end of file diff --git a/ErsatzTV/Pages/MediaItems.razor b/ErsatzTV/Pages/MediaItems.razor deleted file mode 100644 index 63bf3e387..000000000 --- a/ErsatzTV/Pages/MediaItems.razor +++ /dev/null @@ -1,17 +0,0 @@ -@page "/media/items" - - - - - - - - - - - - - -@code { - -} \ No newline at end of file diff --git a/ErsatzTV/Pages/MediaMovieItems.razor b/ErsatzTV/Pages/MediaMovieItems.razor new file mode 100644 index 000000000..185814ec3 --- /dev/null +++ b/ErsatzTV/Pages/MediaMovieItems.razor @@ -0,0 +1,8 @@ +@page "/media/movies/items" +@inject IMediator Mediator + + + +@code { + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/MediaOtherItems.razor b/ErsatzTV/Pages/MediaOtherItems.razor new file mode 100644 index 000000000..d944a4f27 --- /dev/null +++ b/ErsatzTV/Pages/MediaOtherItems.razor @@ -0,0 +1,8 @@ +@page "/media/other/items" +@inject IMediator Mediator + + + +@code { + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/MediaTvItems.razor b/ErsatzTV/Pages/MediaTvItems.razor new file mode 100644 index 000000000..69703faf8 --- /dev/null +++ b/ErsatzTV/Pages/MediaTvItems.razor @@ -0,0 +1,8 @@ +@page "/media/tv/items" +@inject IMediator Mediator + + + +@code { + +} \ No newline at end of file diff --git a/ErsatzTV/Services/WorkerService.cs b/ErsatzTV/Services/WorkerService.cs index eb7f3fe31..a7f1bbb19 100644 --- a/ErsatzTV/Services/WorkerService.cs +++ b/ErsatzTV/Services/WorkerService.cs @@ -62,6 +62,7 @@ namespace ErsatzTV.Services RefreshMediaItemMetadata => "metadata", RefreshMediaItemStatistics => "statistics", RefreshMediaItemCollections => "collections", + RefreshMediaItemPoster => "poster", _ => "" }; diff --git a/ErsatzTV/Shared/LocalMediaSources.razor b/ErsatzTV/Shared/LocalMediaSources.razor index 7beb32898..f486ec440 100644 --- a/ErsatzTV/Shared/LocalMediaSources.razor +++ b/ErsatzTV/Shared/LocalMediaSources.razor @@ -42,7 +42,7 @@ var parameters = new DialogParameters { { "EntityType", "media source" }, - { "EntityName", mediaSource.Name }, + { "EntityName", mediaSource.Folder }, { "DetailText", $"This media source contains {count} media items." }, { "DetailHighlight", count.ToString() } }; diff --git a/ErsatzTV/Shared/MainLayout.razor b/ErsatzTV/Shared/MainLayout.razor index c2c521569..c5536e260 100644 --- a/ErsatzTV/Shared/MainLayout.razor +++ b/ErsatzTV/Shared/MainLayout.razor @@ -28,7 +28,9 @@ FFmpeg Media Sources - Media Items + TV Shows + Movies + Other Items Media Collections Schedules @@ -42,7 +44,7 @@ - + @Body diff --git a/ErsatzTV/Shared/MediaCard.razor b/ErsatzTV/Shared/MediaCard.razor new file mode 100644 index 000000000..93706e081 --- /dev/null +++ b/ErsatzTV/Shared/MediaCard.razor @@ -0,0 +1,34 @@ +@using ErsatzTV.Application.MediaItems +
+ + @if (!string.IsNullOrWhiteSpace(Data.Poster)) + { + + } + else + { + + @Placeholder(Data.SortTitle) + + } + + + @Data.Title + + + @Data.Subtitle + +
+ +@code { + + [Parameter] + public AggregateMediaItemViewModel Data { get; set; } + + private string Placeholder(string sortTitle) + { + string first = sortTitle.Substring(0, 1).ToUpperInvariant(); + return int.TryParse(first, out _) ? "#" : first; + } + +} \ No newline at end of file diff --git a/ErsatzTV/Shared/MediaItemTable.razor b/ErsatzTV/Shared/MediaItemTable.razor deleted file mode 100644 index c1cf83b72..000000000 --- a/ErsatzTV/Shared/MediaItemTable.razor +++ /dev/null @@ -1,66 +0,0 @@ -@using ErsatzTV.Application.MediaItems -@using ErsatzTV.Application.MediaItems.Queries -@inject IMediator Mediator - - - - Media Items - - - - - - Source - Title - Count - Duration - - - @context.Source - @context.Title - @context.Count - @context.Duration - - - - - - -@code { - - [Parameter] - public MediaType MediaType { get; set; } - - private IEnumerable _pagedData; - private MudTable _table; - - private int _totalItems; - private string _searchString; - - private async Task> ServerReload(TableState state) - { - List aggregateData = - await Mediator.Send(new GetAggregateMediaItems(MediaType, _searchString)); - - _totalItems = aggregateData.Count; - - _pagedData = aggregateData.Skip(state.Page * state.PageSize).Take(state.PageSize); - return new TableData { TotalItems = _totalItems, Items = _pagedData }; - } - - private async Task OnSearch(string text) - { - _searchString = text; - await _table.ReloadServerData(); - } - - private class MediaItemAggregate - { - public string Source { get; set; } - public string Title { get; set; } - public int Count { get; set; } - public string Duration { get; set; } - } - -} \ No newline at end of file diff --git a/ErsatzTV/Shared/MediaItemsGrid.razor b/ErsatzTV/Shared/MediaItemsGrid.razor new file mode 100644 index 000000000..fb9882c03 --- /dev/null +++ b/ErsatzTV/Shared/MediaItemsGrid.razor @@ -0,0 +1,55 @@ +@using ErsatzTV.Application.MediaItems +@using ErsatzTV.Application.MediaItems.Queries +@inject IMediator Mediator + + + + + + + @Math.Min((_pageNumber - 1) * PageSize + 1, _data.Count)-@Math.Min(_data.Count, _pageNumber * PageSize) of @_data.Count + + + + + + + + @foreach (AggregateMediaItemViewModel item in _data.DataPage) + { + + } + + +@code { + + [Parameter] + public MediaType MediaType { get; set; } + + private int PageSize => 100; + private int _pageNumber = 1; + + private AggregateMediaItemResults _data; + + protected override Task OnParametersSetAsync() => RefreshData(); + + private async Task RefreshData() => + _data = await Mediator.Send(new GetAggregateMediaItems(MediaType, _pageNumber, PageSize)); + + private async Task PrevPage() + { + _pageNumber -= 1; + await RefreshData(); + } + + private async Task NextPage() + { + _pageNumber += 1; + await RefreshData(); + } + +} \ No newline at end of file diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index e4403bdbd..212a89df7 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -6,6 +6,7 @@ using ErsatzTV.Application.Channels.Queries; using ErsatzTV.Core; using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg; +using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Repositories; @@ -15,6 +16,7 @@ using ErsatzTV.Core.Scheduling; using ErsatzTV.Formatters; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data.Repositories; +using ErsatzTV.Infrastructure.Images; using ErsatzTV.Infrastructure.Plex; using ErsatzTV.Serialization; using ErsatzTV.Services; @@ -164,8 +166,10 @@ namespace ErsatzTV services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddHostedService(); services.AddHostedService(); diff --git a/ErsatzTV/_Imports.razor b/ErsatzTV/_Imports.razor index 17af02e6d..fad2bdf07 100644 --- a/ErsatzTV/_Imports.razor +++ b/ErsatzTV/_Imports.razor @@ -17,6 +17,7 @@ @using MudBlazor @using MudBlazor.Dialog @using ErsatzTV +@using ErsatzTV.Application @using ErsatzTV.Core @using ErsatzTV.Core.Domain @using ErsatzTV.Infrastructure.Data diff --git a/ErsatzTV/wwwroot/css/site.css b/ErsatzTV/wwwroot/css/site.css index 5f282702b..b612390cd 100644 --- a/ErsatzTV/wwwroot/css/site.css +++ b/ErsatzTV/wwwroot/css/site.css @@ -1 +1,24 @@ - \ No newline at end of file +.media-card-grid { + display: flex; + flex-direction: row; + flex-wrap: wrap; +} + +.media-card-container { width: 152px; } + +.media-card { + display: flex; + flex-direction: column; + height: 220px; + justify-content: center; + width: 152px; +} + +.media-card-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; +} + +.media-card-poster-placeholder { font-weight: bold; } \ No newline at end of file