From 4f785d7a61e49d13106e6218b01727811ec1acf0 Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Mon, 1 Mar 2021 05:10:15 -0600 Subject: [PATCH] add plex path replacements --- .../Commands/UpdatePlexPathReplacements.cs | 13 + .../UpdatePlexPathReplacementsHandler.cs | 65 + ErsatzTV.Application/Plex/Mapper.cs | 3 + .../Plex/PlexPathReplacementViewModel.cs | 4 + .../GetPlexPathReplacementsBySourceId.cs | 8 + ...etPlexPathReplacementsBySourceIdHandler.cs | 26 + ...layoutItemProcessByChannelNumberHandler.cs | 47 +- .../Metadata/FallbackMetadataProviderTests.cs | 7 +- .../Domain/MediaSource/PlexMediaSource.cs | 1 + .../Domain/MediaSource/PlexPathReplacement.cs | 11 + ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs | 19 +- .../Metadata/IFallbackMetadataProvider.cs | 6 +- .../Repositories/IMediaSourceRepository.cs | 2 + .../Metadata/FallbackMetadataProvider.cs | 32 +- .../Metadata/LocalMetadataProvider.cs | 42 +- ErsatzTV.Core/Plex/PlexMediaEntry.cs | 20 - ErsatzTV.Core/Plex/PlexMetadataEntry.cs | 19 - ErsatzTV.Core/Plex/PlexPartEntry.cs | 11 - .../PlexMediaSourceConfiguration.cs | 5 + .../PlexPathReplacementConfiguration.cs | 11 + .../Repositories/MediaSourceRepository.cs | 23 + ErsatzTV.Infrastructure/Data/TvContext.cs | 1 + ...001541_Add_PlexPathReplacement.Designer.cs | 1491 +++++++++++++++++ .../20210301001541_Add_PlexPathReplacement.cs | 40 + .../Migrations/TvContextModelSnapshot.cs | 46 +- .../Plex/Models/PlexPartResponse.cs | 1 - .../Plex/PlexServerApiClient.cs | 59 +- ErsatzTV/Pages/PlexLibrariesEditor.razor | 2 +- ErsatzTV/Pages/PlexMediaSources.razor | 70 +- .../Pages/PlexPathReplacementsEditor.razor | 135 ++ ErsatzTV/Startup.cs | 2 +- ...exPathReplacementEditViewModelValidator.cs | 14 + .../PlexPathReplacementEditViewModel.cs | 9 + 33 files changed, 2059 insertions(+), 186 deletions(-) create mode 100644 ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacements.cs create mode 100644 ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacementsHandler.cs create mode 100644 ErsatzTV.Application/Plex/PlexPathReplacementViewModel.cs create mode 100644 ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceId.cs create mode 100644 ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceIdHandler.cs create mode 100644 ErsatzTV.Core/Domain/MediaSource/PlexPathReplacement.cs delete mode 100644 ErsatzTV.Core/Plex/PlexMediaEntry.cs delete mode 100644 ErsatzTV.Core/Plex/PlexMetadataEntry.cs delete mode 100644 ErsatzTV.Core/Plex/PlexPartEntry.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexPathReplacementConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.Designer.cs create mode 100644 ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.cs create mode 100644 ErsatzTV/Pages/PlexPathReplacementsEditor.razor create mode 100644 ErsatzTV/Validators/PlexPathReplacementEditViewModelValidator.cs create mode 100644 ErsatzTV/ViewModels/PlexPathReplacementEditViewModel.cs diff --git a/ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacements.cs b/ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacements.cs new file mode 100644 index 000000000..cb4319e02 --- /dev/null +++ b/ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacements.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using ErsatzTV.Core; +using LanguageExt; + +namespace ErsatzTV.Application.Plex.Commands +{ + public record UpdatePlexPathReplacements + ( + int PlexMediaSourceId, + List PathReplacements) : MediatR.IRequest>; + + public record PlexPathReplacementItem(int Id, string PlexPath, string LocalPath); +} diff --git a/ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacementsHandler.cs b/ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacementsHandler.cs new file mode 100644 index 000000000..35abbcbf0 --- /dev/null +++ b/ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacementsHandler.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Application.Plex.Commands +{ + public class + UpdatePlexPathReplacementsHandler : MediatR.IRequestHandler> + { + private readonly IMediaSourceRepository _mediaSourceRepository; + + public UpdatePlexPathReplacementsHandler(IMediaSourceRepository mediaSourceRepository) => + _mediaSourceRepository = mediaSourceRepository; + + public Task> Handle( + UpdatePlexPathReplacements request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(pms => MergePathReplacements(request, pms)) + .Bind(v => v.ToEitherAsync()); + + private Task MergePathReplacements(UpdatePlexPathReplacements request, PlexMediaSource plexMediaSource) + { + plexMediaSource.PathReplacements ??= new List(); + + var incoming = request.PathReplacements.Map(Project).ToList(); + + var toAdd = incoming.Filter(r => r.Id < 1).ToList(); + var toRemove = plexMediaSource.PathReplacements.Filter(r => incoming.All(pr => pr.Id != r.Id)).ToList(); + var toUpdate = incoming.Except(toAdd).ToList(); + + plexMediaSource.PathReplacements.AddRange(toAdd); + toRemove.ForEach(pr => plexMediaSource.PathReplacements.Remove(pr)); + foreach (PlexPathReplacement pathReplacement in toUpdate) + { + Optional(plexMediaSource.PathReplacements.SingleOrDefault(pr => pr.Id == pathReplacement.Id)) + .IfSome( + pr => + { + pr.PlexPath = pathReplacement.PlexPath; + pr.LocalPath = pathReplacement.LocalPath; + }); + } + + return _mediaSourceRepository.Update(plexMediaSource).ToUnit(); + } + + private static PlexPathReplacement Project(PlexPathReplacementItem vm) => + new() { Id = vm.Id, PlexPath = vm.PlexPath, LocalPath = vm.LocalPath }; + + private Task> Validate(UpdatePlexPathReplacements request) => + PlexMediaSourceMustExist(request); + + private Task> PlexMediaSourceMustExist( + UpdatePlexPathReplacements request) => + _mediaSourceRepository.GetPlex(request.PlexMediaSourceId) + .Map(v => v.ToValidation($"Plex media source {request.PlexMediaSourceId} does not exist.")); + } +} diff --git a/ErsatzTV.Application/Plex/Mapper.cs b/ErsatzTV.Application/Plex/Mapper.cs index 8c80e28f0..c8b2a1d37 100644 --- a/ErsatzTV.Application/Plex/Mapper.cs +++ b/ErsatzTV.Application/Plex/Mapper.cs @@ -14,5 +14,8 @@ namespace ErsatzTV.Application.Plex internal static PlexLibraryViewModel ProjectToViewModel(PlexLibrary library) => new(library.Id, library.Name, library.MediaKind, library.ShouldSyncItems); + + internal static PlexPathReplacementViewModel ProjectToViewModel(PlexPathReplacement pathReplacement) => + new(pathReplacement.Id, pathReplacement.PlexPath, pathReplacement.LocalPath); } } diff --git a/ErsatzTV.Application/Plex/PlexPathReplacementViewModel.cs b/ErsatzTV.Application/Plex/PlexPathReplacementViewModel.cs new file mode 100644 index 000000000..a8809250c --- /dev/null +++ b/ErsatzTV.Application/Plex/PlexPathReplacementViewModel.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Application.Plex +{ + public record PlexPathReplacementViewModel(int Id, string PlexPath, string LocalPath); +} diff --git a/ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceId.cs b/ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceId.cs new file mode 100644 index 000000000..5246d3d08 --- /dev/null +++ b/ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceId.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; +using MediatR; + +namespace ErsatzTV.Application.Plex.Queries +{ + public record GetPlexPathReplacementsBySourceId + (int PlexMediaSourceId) : IRequest>; +} diff --git a/ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceIdHandler.cs b/ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceIdHandler.cs new file mode 100644 index 000000000..d5aa31cd3 --- /dev/null +++ b/ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceIdHandler.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.Plex.Mapper; + +namespace ErsatzTV.Application.Plex.Queries +{ + public class GetPlexPathReplacementsBySourceIdHandler : IRequestHandler> + { + private readonly IMediaSourceRepository _mediaSourceRepository; + + public GetPlexPathReplacementsBySourceIdHandler(IMediaSourceRepository mediaSourceRepository) => + _mediaSourceRepository = mediaSourceRepository; + + public Task> Handle( + GetPlexPathReplacementsBySourceId request, + CancellationToken cancellationToken) => + _mediaSourceRepository.GetPlexPathReplacements(request.PlexMediaSourceId) + .Map(list => list.Map(ProjectToViewModel).ToList()); + } +} diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs index 30f653acb..e3d097969 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs @@ -1,5 +1,8 @@ using System; +using System.Collections.Generic; using System.Diagnostics; +using System.IO; +using System.Linq; using System.Threading.Tasks; using ErsatzTV.Core; using ErsatzTV.Core.Domain; @@ -13,16 +16,19 @@ namespace ErsatzTV.Application.Streaming.Queries GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler { private readonly FFmpegProcessService _ffmpegProcessService; + private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IPlayoutRepository _playoutRepository; public GetPlayoutItemProcessByChannelNumberHandler( IChannelRepository channelRepository, IConfigElementRepository configElementRepository, IPlayoutRepository playoutRepository, + IMediaSourceRepository mediaSourceRepository, FFmpegProcessService ffmpegProcessService) : base(channelRepository, configElementRepository) { _playoutRepository = playoutRepository; + _mediaSourceRepository = mediaSourceRepository; _ffmpegProcessService = ffmpegProcessService; } @@ -33,9 +39,32 @@ namespace ErsatzTV.Application.Streaming.Queries { DateTimeOffset now = DateTimeOffset.Now; Option maybePlayoutItem = await _playoutRepository.GetPlayoutItem(channel.Id, now); - return maybePlayoutItem.Match>( - playoutItem => _ffmpegProcessService.ForPlayoutItem(ffmpegPath, channel, playoutItem, now), - () => + return await maybePlayoutItem.Match>>( + async playoutItem => + { + MediaVersion version = playoutItem.MediaItem switch + { + Movie m => m.MediaVersions.Head(), + Episode e => e.MediaVersions.Head(), + _ => throw new ArgumentOutOfRangeException(nameof(playoutItem)) + }; + + MediaFile file = version.MediaFiles.Head(); + string path = file.Path; + if (playoutItem.MediaItem is PlexMovie plexMovie) + { + path = await GetReplacementPlexPath(plexMovie.LibraryPathId, path); + } + + return _ffmpegProcessService.ForPlayoutItem( + ffmpegPath, + channel, + version, + path, + playoutItem.StartOffset, + now); + }, + async () => { if (channel.FFmpegProfile.Transcode) { @@ -46,5 +75,17 @@ namespace ErsatzTV.Application.Streaming.Queries $"Unable to locate playout item for channel {channel.Number}; offline image is unavailable because transcoding is disabled in ffmpeg profile '{channel.FFmpegProfile.Name}'"); }); } + + private async Task GetReplacementPlexPath(int libraryPathId, string path) + { + List replacements = + await _mediaSourceRepository.GetPlexPathReplacementsByLibraryId(libraryPathId); + // TODO: this might barf mixing platforms (i.e. plex on linux, etv on windows) + Option maybeReplacement = replacements + .SingleOrDefault(r => path.StartsWith(r.PlexPath + Path.DirectorySeparatorChar)); + return maybeReplacement.Match( + replacement => path.Replace(replacement.PlexPath, replacement.LocalPath), + () => path); + } } } diff --git a/ErsatzTV.Core.Tests/Metadata/FallbackMetadataProviderTests.cs b/ErsatzTV.Core.Tests/Metadata/FallbackMetadataProviderTests.cs index dc036508c..7b0ef2e58 100644 --- a/ErsatzTV.Core.Tests/Metadata/FallbackMetadataProviderTests.cs +++ b/ErsatzTV.Core.Tests/Metadata/FallbackMetadataProviderTests.cs @@ -10,6 +10,11 @@ namespace ErsatzTV.Core.Tests.Metadata [TestFixture] public class FallbackMetadataProviderTests { + [SetUp] + public void SetUp() => _fallbackMetadataProvider = new FallbackMetadataProvider(); + + private FallbackMetadataProvider _fallbackMetadataProvider; + [Test] [TestCase("Awesome Show - s01e02.mkv", "Awesome Show", 1, 2)] [TestCase("Awesome Show - S01E02.mkv", "Awesome Show", 1, 2)] @@ -39,7 +44,7 @@ namespace ErsatzTV.Core.Tests.Metadata 2)] public void GetFallbackMetadata_ShouldHandleVariousFormats(string path, string title, int season, int episode) { - (EpisodeMetadata metadata, int episodeNumber) = FallbackMetadataProvider.GetFallbackMetadata( + (EpisodeMetadata metadata, int episodeNumber) = _fallbackMetadataProvider.GetFallbackMetadata( new Episode { LibraryPath = new LibraryPath(), diff --git a/ErsatzTV.Core/Domain/MediaSource/PlexMediaSource.cs b/ErsatzTV.Core/Domain/MediaSource/PlexMediaSource.cs index 4206c93ff..cac53f0e1 100644 --- a/ErsatzTV.Core/Domain/MediaSource/PlexMediaSource.cs +++ b/ErsatzTV.Core/Domain/MediaSource/PlexMediaSource.cs @@ -10,5 +10,6 @@ namespace ErsatzTV.Core.Domain // public bool IsOwned { get; set; } public List Connections { get; set; } + public List PathReplacements { get; set; } } } diff --git a/ErsatzTV.Core/Domain/MediaSource/PlexPathReplacement.cs b/ErsatzTV.Core/Domain/MediaSource/PlexPathReplacement.cs new file mode 100644 index 000000000..f5e5c71ff --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaSource/PlexPathReplacement.cs @@ -0,0 +1,11 @@ +namespace ErsatzTV.Core.Domain +{ + public class PlexPathReplacement + { + public int Id { get; set; } + public string PlexPath { get; set; } + public string LocalPath { get; set; } + public int PlexMediaSourceId { get; set; } + public PlexMediaSource PlexMediaSource { get; set; } + } +} diff --git a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs index 90f45c503..6bbb0405d 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs @@ -15,32 +15,25 @@ namespace ErsatzTV.Core.FFmpeg public Process ForPlayoutItem( string ffmpegPath, Channel channel, - PlayoutItem item, + MediaVersion version, + string path, + DateTimeOffset start, DateTimeOffset now) { - MediaVersion version = item.MediaItem switch - { - Movie m => m.MediaVersions.Head(), - Episode e => e.MediaVersions.Head(), - _ => throw new ArgumentOutOfRangeException(nameof(item)) - }; - FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings( channel.StreamingMode, channel.FFmpegProfile, version, - item.StartOffset, + start, now); - MediaFile file = version.MediaFiles.Head(); - FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath) .WithThreads(playbackSettings.ThreadCount) .WithQuiet() .WithFormatFlags(playbackSettings.FormatFlags) .WithRealtimeOutput(playbackSettings.RealtimeOutput) .WithSeek(playbackSettings.StreamSeek) - .WithInput(file.Path); + .WithInput(path); playbackSettings.ScaledSize.Match( scaledSize => @@ -86,7 +79,7 @@ namespace ErsatzTV.Core.FFmpeg return builder.WithPlaybackArgs(playbackSettings) .WithMetadata(channel) .WithFormat("mpegts") - .WithDuration(item.Start + version.Duration - now) + .WithDuration(start + version.Duration - now) .WithPipe() .Build(); } diff --git a/ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs b/ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs index 52125561c..64121a0b6 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs @@ -1,9 +1,13 @@ -using ErsatzTV.Core.Domain; +using System; +using ErsatzTV.Core.Domain; namespace ErsatzTV.Core.Interfaces.Metadata { public interface IFallbackMetadataProvider { ShowMetadata GetFallbackMetadataForShow(string showFolder); + Tuple GetFallbackMetadata(Episode episode); + MovieMetadata GetFallbackMetadata(Movie movie); + string GetSortTitle(string title); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs index f0b18299c..596a6f67b 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs @@ -12,10 +12,12 @@ namespace ErsatzTV.Core.Interfaces.Repositories Task> GetAll(); Task> GetAllPlex(); Task> GetPlexLibraries(int plexMediaSourceId); + Task> GetPlexPathReplacements(int plexMediaSourceId); Task> GetPlexLibrary(int plexLibraryId); Task> Get(int id); Task> GetPlex(int id); Task> GetPlexByLibraryId(int plexLibraryId); + Task> GetPlexPathReplacementsByLibraryId(int plexLibraryPathId); Task CountMediaItems(int id); Task Update(LocalMediaSource localMediaSource); Task Update(PlexMediaSource plexMediaSource); diff --git a/ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs b/ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs index 27ae51ebf..aa911336d 100644 --- a/ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs +++ b/ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs @@ -17,7 +17,7 @@ namespace ErsatzTV.Core.Metadata return GetTelevisionShowMetadata(fileName, metadata); } - public static Tuple GetFallbackMetadata(Episode episode) + public Tuple GetFallbackMetadata(Episode episode) { string path = episode.MediaVersions.Head().MediaFiles.Head().Path; string fileName = Path.GetFileName(path); @@ -26,7 +26,7 @@ namespace ErsatzTV.Core.Metadata return fileName != null ? GetEpisodeMetadata(fileName, metadata) : Tuple(metadata, 0); } - public static MovieMetadata GetFallbackMetadata(Movie movie) + public MovieMetadata GetFallbackMetadata(Movie movie) { string path = movie.MediaVersions.Head().MediaFiles.Head().Path; string fileName = Path.GetFileName(path); @@ -34,8 +34,28 @@ namespace ErsatzTV.Core.Metadata return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata; } + + public string GetSortTitle(string title) + { + if (string.IsNullOrWhiteSpace(title)) + { + return title; + } + + if (title.StartsWith("the ", StringComparison.OrdinalIgnoreCase)) + { + return title.Substring(4); + } + + if (title.StartsWith("Æ")) + { + return title.Replace("Æ", "E"); + } + + return title; + } - private static Tuple GetEpisodeMetadata(string fileName, EpisodeMetadata metadata) + private Tuple GetEpisodeMetadata(string fileName, EpisodeMetadata metadata) { try { @@ -55,7 +75,7 @@ namespace ErsatzTV.Core.Metadata return Tuple(metadata, 0); } - private static MovieMetadata GetMovieMetadata(string fileName, MovieMetadata metadata) + private MovieMetadata GetMovieMetadata(string fileName, MovieMetadata metadata) { try { @@ -76,9 +96,7 @@ namespace ErsatzTV.Core.Metadata return metadata; } - private static ShowMetadata GetTelevisionShowMetadata( - string fileName, - ShowMetadata metadata) + private ShowMetadata GetTelevisionShowMetadata(string fileName, ShowMetadata metadata) { try { diff --git a/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs b/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs index 0089d519f..3caa326a6 100644 --- a/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs +++ b/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs @@ -51,7 +51,7 @@ namespace ErsatzTV.Core.Metadata () => { ShowMetadata metadata = _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder); - metadata.SortTitle = GetSortTitle(metadata.Title); + metadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(metadata.Title); return metadata; }); } @@ -73,9 +73,9 @@ namespace ErsatzTV.Core.Metadata public Task RefreshFallbackMetadata(MediaItem mediaItem) => mediaItem switch { - Episode e => ApplyMetadataUpdate(e, FallbackMetadataProvider.GetFallbackMetadata(e)) + Episode e => ApplyMetadataUpdate(e, _fallbackMetadataProvider.GetFallbackMetadata(e)) .ToUnit(), - Movie m => ApplyMetadataUpdate(m, FallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(), + Movie m => ApplyMetadataUpdate(m, _fallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(), _ => Task.FromResult(Unit.Default) }; @@ -100,11 +100,11 @@ namespace ErsatzTV.Core.Metadata existing.OriginalTitle = metadata.OriginalTitle; existing.ReleaseDate = metadata.ReleaseDate; existing.Year = metadata.Year; - existing.SortTitle = metadata.SortTitle ?? GetSortTitle(metadata.Title); + existing.SortTitle = metadata.SortTitle ?? _fallbackMetadataProvider.GetSortTitle(metadata.Title); }, () => { - metadata.SortTitle ??= GetSortTitle(metadata.Title); + metadata.SortTitle ??= _fallbackMetadataProvider.GetSortTitle(metadata.Title); episode.EpisodeMetadata = new List { metadata }; }); @@ -126,11 +126,11 @@ namespace ErsatzTV.Core.Metadata existing.OriginalTitle = metadata.OriginalTitle; existing.ReleaseDate = metadata.ReleaseDate; existing.Year = metadata.Year; - existing.SortTitle = metadata.SortTitle ?? GetSortTitle(metadata.Title); + existing.SortTitle = metadata.SortTitle ?? _fallbackMetadataProvider.GetSortTitle(metadata.Title); }, () => { - metadata.SortTitle ??= GetSortTitle(metadata.Title); + metadata.SortTitle ??= _fallbackMetadataProvider.GetSortTitle(metadata.Title); movie.MovieMetadata = new List { metadata }; }); @@ -152,11 +152,11 @@ namespace ErsatzTV.Core.Metadata existing.OriginalTitle = metadata.OriginalTitle; existing.ReleaseDate = metadata.ReleaseDate; existing.Year = metadata.Year; - existing.SortTitle = metadata.SortTitle ?? GetSortTitle(metadata.Title); + existing.SortTitle = metadata.SortTitle ?? _fallbackMetadataProvider.GetSortTitle(metadata.Title); }, () => { - metadata.SortTitle ??= GetSortTitle(metadata.Title); + metadata.SortTitle ??= _fallbackMetadataProvider.GetSortTitle(metadata.Title); show.ShowMetadata = new List { metadata }; }); @@ -247,7 +247,7 @@ namespace ErsatzTV.Core.Metadata catch (Exception ex) { _logger.LogInformation(ex, "Failed to read TV episode nfo metadata from {Path}", nfoFileName); - return FallbackMetadataProvider.GetFallbackMetadata(episode); + return _fallbackMetadataProvider.GetFallbackMetadata(episode); } } @@ -274,7 +274,7 @@ namespace ErsatzTV.Core.Metadata catch (Exception ex) { _logger.LogInformation(ex, "Failed to read Movie nfo metadata from {Path}", nfoFileName); - return FallbackMetadataProvider.GetFallbackMetadata(mediaItem); + return _fallbackMetadataProvider.GetFallbackMetadata(mediaItem); } } @@ -293,26 +293,6 @@ namespace ErsatzTV.Core.Metadata return null; } - private static string GetSortTitle(string title) - { - if (string.IsNullOrWhiteSpace(title)) - { - return title; - } - - if (title.StartsWith("the ", StringComparison.OrdinalIgnoreCase)) - { - return title.Substring(4); - } - - if (title.StartsWith("Æ")) - { - return title.Replace("Æ", "E"); - } - - return title; - } - [XmlRoot("movie")] public class MovieNfo { diff --git a/ErsatzTV.Core/Plex/PlexMediaEntry.cs b/ErsatzTV.Core/Plex/PlexMediaEntry.cs deleted file mode 100644 index cfe062457..000000000 --- a/ErsatzTV.Core/Plex/PlexMediaEntry.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections.Generic; - -namespace ErsatzTV.Core.Plex -{ - public class PlexMediaEntry - { - public int Id { get; set; } - public int Duration { get; set; } - public int Bitrate { get; set; } - public int Width { get; set; } - public int Height { get; set; } - public double AspectRatio { get; set; } - public int AudioChannels { get; set; } - public string AudioCodec { get; set; } - public string VideoCodec { get; set; } - public string Container { get; set; } - public string VideoFrameRate { get; set; } - public List Part { get; set; } - } -} diff --git a/ErsatzTV.Core/Plex/PlexMetadataEntry.cs b/ErsatzTV.Core/Plex/PlexMetadataEntry.cs deleted file mode 100644 index f5089fcee..000000000 --- a/ErsatzTV.Core/Plex/PlexMetadataEntry.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; - -namespace ErsatzTV.Core.Plex -{ - public class PlexMetadataEntry - { - public string Key { get; set; } - public string Title { get; set; } - public string Summary { get; set; } - public int Year { get; set; } - public string Tagline { get; set; } - public string Thumb { get; set; } - public string Art { get; set; } - public string OriginallyAvailableAt { get; set; } - public int AddedAt { get; set; } - public int UpdatedAt { get; set; } - public List Media { get; set; } - } -} diff --git a/ErsatzTV.Core/Plex/PlexPartEntry.cs b/ErsatzTV.Core/Plex/PlexPartEntry.cs deleted file mode 100644 index bd6b90730..000000000 --- a/ErsatzTV.Core/Plex/PlexPartEntry.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace ErsatzTV.Core.Plex -{ - public class PlexPartEntry - { - public int Id { get; set; } - public string Key { get; set; } - public int Duration { get; set; } - public string File { get; set; } - public int Size { get; set; } - } -} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexMediaSourceConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexMediaSourceConfiguration.cs index 990521c73..ed9cc3a06 100644 --- a/ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexMediaSourceConfiguration.cs +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexMediaSourceConfiguration.cs @@ -14,6 +14,11 @@ namespace ErsatzTV.Infrastructure.Data.Configurations .WithOne(c => c.PlexMediaSource) .HasForeignKey(c => c.PlexMediaSourceId) .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(s => s.PathReplacements) + .WithOne(r => r.PlexMediaSource) + .HasForeignKey(r => r.PlexMediaSourceId) + .OnDelete(DeleteBehavior.Cascade); } } } diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexPathReplacementConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexPathReplacementConfiguration.cs new file mode 100644 index 000000000..33e5a8c1c --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexPathReplacementConfiguration.cs @@ -0,0 +1,11 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class PlexPathReplacementConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => builder.ToTable("PlexPathReplacement"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs index 8f328f52b..fe8d51b98 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs @@ -68,6 +68,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .ToListAsync(); } + public Task> GetPlexPathReplacements(int plexMediaSourceId) + { + using TvContext context = _dbContextFactory.CreateDbContext(); + return context.PlexPathReplacements + .Filter(r => r.PlexMediaSourceId == plexMediaSourceId) + .ToListAsync(); + } + public Task> GetPlexLibrary(int plexLibraryId) { using TvContext context = _dbContextFactory.CreateDbContext(); @@ -93,6 +101,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories return context.PlexMediaSources .Include(p => p.Connections) .Include(p => p.Libraries) + .Include(p => p.PathReplacements) .OrderBy(s => s.Id) // https://github.com/dotnet/efcore/issues/22579 .SingleOrDefaultAsync(p => p.Id == id) .Map(Optional); @@ -109,6 +118,20 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .Map(Optional); } + public Task> GetPlexPathReplacementsByLibraryId(int plexLibraryPathId) + { + using TvContext context = _dbContextFactory.CreateDbContext(); + return context.PlexPathReplacements + .FromSqlRaw( + @"select ppr.* from LibraryPath lp + inner join PlexLibrary pl ON pl.Id = lp.LibraryId + inner join Library l ON l.Id = pl.Id + inner join PlexPathReplacement ppr on ppr.PlexMediaSourceId = l.MediaSourceId + where lp.Id = {0}", + plexLibraryPathId) + .ToListAsync(); + } + public Task CountMediaItems(int id) { using TvContext context = _dbContextFactory.CreateDbContext(); diff --git a/ErsatzTV.Infrastructure/Data/TvContext.cs b/ErsatzTV.Infrastructure/Data/TvContext.cs index e77cd5582..ec683cc67 100644 --- a/ErsatzTV.Infrastructure/Data/TvContext.cs +++ b/ErsatzTV.Infrastructure/Data/TvContext.cs @@ -21,6 +21,7 @@ namespace ErsatzTV.Infrastructure.Data public DbSet LocalLibraries { get; set; } public DbSet LibraryPaths { get; set; } public DbSet PlexLibraries { get; set; } + public DbSet PlexPathReplacements { get; set; } public DbSet MediaItems { get; set; } public DbSet MediaVersions { get; set; } public DbSet MediaFiles { get; set; } diff --git a/ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.Designer.cs b/ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.Designer.cs new file mode 100644 index 000000000..58d9cba48 --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.Designer.cs @@ -0,0 +1,1491 @@ +// +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("20210301001541_Add_PlexPathReplacement")] + partial class Add_PlexPathReplacement + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.3"); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Artwork", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ArtworkKind") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("EpisodeMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MovieMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("SeasonMetadataId") + .HasColumnType("INTEGER"); + + b.Property("ShowMetadataId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("EpisodeMetadataId"); + + b.HasIndex("MovieMetadataId"); + + b.HasIndex("SeasonMetadataId"); + + b.HasIndex("ShowMetadataId"); + + b.ToTable("Artwork"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FFmpegProfileId") + .HasColumnType("INTEGER"); + + 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("Channel"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Collection"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.CollectionItem", b => + { + b.Property("CollectionId") + .HasColumnType("INTEGER"); + + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "MediaItemId"); + + b.HasIndex("MediaItemId"); + + b.ToTable("CollectionItem"); + }); + + 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("ConfigElement"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER"); + + b.Property("MetadataKind") + .HasColumnType("INTEGER"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Outline") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.ToTable("EpisodeMetadata"); + }); + + 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("FFmpegProfile"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScan") + .HasColumnType("TEXT"); + + b.Property("MediaKind") + .HasColumnType("INTEGER"); + + b.Property("MediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaSourceId"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryPath"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MediaVersionId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaVersionId"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("MediaFile"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryPathId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryPathId"); + + b.ToTable("MediaItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudioCodec") + .HasColumnType("TEXT"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("DisplayAspectRatio") + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("SampleAspectRatio") + .HasColumnType("TEXT"); + + b.Property("VideoCodec") + .HasColumnType("TEXT"); + + b.Property("VideoScanKind") + .HasColumnType("INTEGER"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.ToTable("MediaVersion"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("MetadataKind") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("INTEGER"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Outline") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + 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("Playout"); + }); + + 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("PlayoutItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CollectionId") + .HasColumnType("INTEGER"); + + b.Property("CollectionType") + .HasColumnType("INTEGER"); + + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CollectionId"); + + b.HasIndex("MediaItemId"); + + b.HasIndex("PlayoutId"); + + b.HasIndex("ProgramScheduleId"); + + b.ToTable("PlayoutProgramScheduleAnchor"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexConnection", 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("PlexConnection"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexPathReplacement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LocalPath") + .HasColumnType("TEXT"); + + b.Property("PlexMediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("PlexPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlexMediaSourceId"); + + b.ToTable("PlexPathReplacement"); + }); + + 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("ProgramSchedule"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CollectionId") + .HasColumnType("INTEGER"); + + b.Property("CollectionType") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CollectionId"); + + b.HasIndex("MediaItemId"); + + b.HasIndex("ProgramScheduleId"); + + b.ToTable("ProgramScheduleItem"); + }); + + 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("Resolution"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SeasonMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("MetadataKind") + .HasColumnType("INTEGER"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Outline") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SeasonId") + .HasColumnType("INTEGER"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId"); + + b.ToTable("SeasonMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("MetadataKind") + .HasColumnType("INTEGER"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Outline") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("INTEGER"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ShowId"); + + b.ToTable("ShowMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalLibrary", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Library"); + + b.ToTable("LocalLibrary"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexLibrary", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Library"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("ShouldSyncItems") + .HasColumnType("INTEGER"); + + b.ToTable("PlexLibrary"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaFile", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaFile"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("PlexId") + .HasColumnType("INTEGER"); + + b.ToTable("PlexMediaFile"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Episode", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.Property("EpisodeNumber") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("INTEGER"); + + b.HasIndex("SeasonId"); + + b.ToTable("Episode"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Movie", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.ToTable("Movie"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("ShowId") + .HasColumnType("INTEGER"); + + b.HasIndex("ShowId"); + + b.ToTable("Season"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Show", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.ToTable("Show"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); + + b.ToTable("LocalMediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); + + b.Property("ClientIdentifier") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("ServerName") + .HasColumnType("TEXT"); + + b.ToTable("PlexMediaSource"); + }); + + 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("ProgramScheduleDurationItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.ToTable("ProgramScheduleFloodItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.ToTable("ProgramScheduleMultipleItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.ToTable("ProgramScheduleOneItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMovie", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Movie"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.ToTable("PlexMovie"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Artwork", b => + { + b.HasOne("ErsatzTV.Core.Domain.Channel", null) + .WithMany("Artwork") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null) + .WithMany("Artwork") + .HasForeignKey("EpisodeMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null) + .WithMany("Artwork") + .HasForeignKey("MovieMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) + .WithMany("Artwork") + .HasForeignKey("SeasonMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null) + .WithMany("Artwork") + .HasForeignKey("ShowMetadataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + 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.CollectionItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.Collection", "Collection") + .WithMany("CollectionItems") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem") + .WithMany("CollectionItems") + .HasForeignKey("MediaItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("MediaItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.Episode", "Episode") + .WithMany("EpisodeMetadata") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Episode"); + }); + + 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.Library", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", "MediaSource") + .WithMany("Libraries") + .HasForeignKey("MediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b => + { + b.HasOne("ErsatzTV.Core.Domain.Library", "Library") + .WithMany("Paths") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaFile", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaVersion", "MediaVersion") + .WithMany("MediaFiles") + .HasForeignKey("MediaVersionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaVersion"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.LibraryPath", "LibraryPath") + .WithMany("MediaItems") + .HasForeignKey("LibraryPathId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LibraryPath"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b => + { + b.HasOne("ErsatzTV.Core.Domain.Episode", null) + .WithMany("MediaVersions") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.Movie", null) + .WithMany("MediaVersions") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.Movie", "Movie") + .WithMany("MovieMetadata") + .HasForeignKey("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("Playout"); + + 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.Collection", "Collection") + .WithMany() + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem") + .WithMany() + .HasForeignKey("MediaItemId") + .OnDelete(DeleteBehavior.Cascade); + + 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.CollectionEnumeratorState", "EnumeratorState", b1 => + { + b1.Property("PlayoutProgramScheduleAnchorId") + .HasColumnType("INTEGER"); + + b1.Property("Index") + .HasColumnType("INTEGER"); + + b1.Property("Seed") + .HasColumnType("INTEGER"); + + b1.HasKey("PlayoutProgramScheduleAnchorId"); + + b1.ToTable("PlayoutProgramScheduleAnchor"); + + b1.WithOwner() + .HasForeignKey("PlayoutProgramScheduleAnchorId"); + }); + + b.Navigation("Collection"); + + b.Navigation("EnumeratorState"); + + b.Navigation("MediaItem"); + + b.Navigation("Playout"); + + b.Navigation("ProgramSchedule"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexConnection", b => + { + b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", "PlexMediaSource") + .WithMany("Connections") + .HasForeignKey("PlexMediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlexMediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexPathReplacement", b => + { + b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", "PlexMediaSource") + .WithMany("PathReplacements") + .HasForeignKey("PlexMediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlexMediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.Collection", "Collection") + .WithMany() + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem") + .WithMany() + .HasForeignKey("MediaItemId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany("Items") + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("MediaItem"); + + b.Navigation("ProgramSchedule"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SeasonMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.Season", "Season") + .WithMany("SeasonMetadata") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.Show", "Show") + .WithMany("ShowMetadata") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Show"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalLibrary", b => + { + b.HasOne("ErsatzTV.Core.Domain.Library", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.LocalLibrary", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexLibrary", b => + { + b.HasOne("ErsatzTV.Core.Domain.Library", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexLibrary", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaFile", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaFile", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexMediaFile", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Episode", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.Episode", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Movie", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.Movie", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.Season", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Show"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Show", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.Show", "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.PlexMovie", b => + { + b.HasOne("ErsatzTV.Core.Domain.Movie", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexMovie", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.Navigation("Artwork"); + + b.Navigation("Playouts"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b => + { + b.Navigation("CollectionItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b => + { + b.Navigation("Artwork"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b => + { + b.Navigation("Paths"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b => + { + b.Navigation("MediaItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + { + b.Navigation("CollectionItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b => + { + b.Navigation("Libraries"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b => + { + b.Navigation("MediaFiles"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + { + b.Navigation("Artwork"); + }); + + 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.SeasonMetadata", b => + { + b.Navigation("Artwork"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b => + { + b.Navigation("Artwork"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Episode", b => + { + b.Navigation("EpisodeMetadata"); + + b.Navigation("MediaVersions"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Movie", b => + { + b.Navigation("MediaVersions"); + + b.Navigation("MovieMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b => + { + b.Navigation("Episodes"); + + b.Navigation("SeasonMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Show", b => + { + b.Navigation("Seasons"); + + b.Navigation("ShowMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.Navigation("Connections"); + + b.Navigation("PathReplacements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.cs b/ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.cs new file mode 100644 index 000000000..646da4a9b --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace ErsatzTV.Infrastructure.Migrations +{ + public partial class Add_PlexPathReplacement : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + "PlexPathReplacement", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PlexPath = table.Column("TEXT", nullable: true), + LocalPath = table.Column("TEXT", nullable: true), + PlexMediaSourceId = table.Column("INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PlexPathReplacement", x => x.Id); + table.ForeignKey( + "FK_PlexPathReplacement_PlexMediaSource_PlexMediaSourceId", + x => x.PlexMediaSourceId, + "PlexMediaSource", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + "IX_PlexPathReplacement_PlexMediaSourceId", + "PlexPathReplacement", + "PlexMediaSourceId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) => + migrationBuilder.DropTable( + "PlexPathReplacement"); + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs b/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs index 19106d621..a1972ca0f 100644 --- a/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs +++ b/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs @@ -596,6 +596,30 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("PlexConnection"); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.PlexPathReplacement", + b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LocalPath") + .HasColumnType("TEXT"); + + b.Property("PlexMediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("PlexPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlexMediaSourceId"); + + b.ToTable("PlexPathReplacement"); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.ProgramSchedule", b => @@ -1266,6 +1290,19 @@ namespace ErsatzTV.Infrastructure.Migrations b.Navigation("PlexMediaSource"); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.PlexPathReplacement", + b => + { + b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", "PlexMediaSource") + .WithMany("PathReplacements") + .HasForeignKey("PlexMediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlexMediaSource"); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.ProgramScheduleItem", b => @@ -1572,7 +1609,14 @@ namespace ErsatzTV.Infrastructure.Migrations b.Navigation("ShowMetadata"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => { b.Navigation("Connections"); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.PlexMediaSource", + b => + { + b.Navigation("Connections"); + + b.Navigation("PathReplacements"); + }); #pragma warning restore 612, 618 } } diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexPartResponse.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexPartResponse.cs index 758de563a..c8a09a21d 100644 --- a/ErsatzTV.Infrastructure/Plex/Models/PlexPartResponse.cs +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexPartResponse.cs @@ -6,6 +6,5 @@ public string Key { get; set; } public int Duration { get; set; } public string File { get; set; } - public int Size { get; set; } } } diff --git a/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs index 23e6be302..0447915bb 100644 --- a/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs +++ b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Plex; using ErsatzTV.Infrastructure.Plex.Models; @@ -15,6 +16,13 @@ namespace ErsatzTV.Infrastructure.Plex { public class PlexServerApiClient : IPlexServerApiClient { + private readonly IFallbackMetadataProvider _fallbackMetadataProvider; + + public PlexServerApiClient(IFallbackMetadataProvider fallbackMetadataProvider) + { + _fallbackMetadataProvider = fallbackMetadataProvider; + } + public async Task>> GetLibraries( PlexConnection connection, PlexServerAuthToken token) @@ -77,49 +85,7 @@ namespace ErsatzTV.Infrastructure.Plex _ => None }; - private static PlexPartEntry Project(PlexPartResponse response) => - new() - { - Id = response.Id, - Key = response.Key, - Duration = response.Duration, - File = response.File, - Size = response.Size - }; - - private static PlexMediaEntry Project(PlexMediaResponse response) => - new() - { - Id = response.Id, - Duration = response.Duration, - Bitrate = response.Bitrate, - Width = response.Width, - Height = response.Height, - AspectRatio = response.AspectRatio, - AudioChannels = response.AudioChannels, - AudioCodec = response.AudioCodec, - VideoCodec = response.VideoCodec, - Container = response.Container, - VideoFrameRate = response.VideoFrameRate, - Part = response.Part.Map(Project).ToList() - }; - - private static PlexMetadataEntry Project(PlexMetadataResponse response) => - new() - { - Key = response.Key, - Title = response.Title, - Summary = response.Summary, - Year = response.Year, - Tagline = response.Tagline, - Thumb = response.Thumb, - Art = response.Art, - AddedAt = response.AddedAt, - UpdatedAt = response.UpdatedAt, - Media = response.Media.Map(Project).ToList() - }; - - private static PlexMovie ProjectToMovie(PlexMetadataResponse response, int mediaSourceId) + private PlexMovie ProjectToMovie(PlexMetadataResponse response, int mediaSourceId) { PlexMediaResponse media = response.Media.Head(); PlexPartResponse part = media.Part.Head(); @@ -129,6 +95,7 @@ namespace ErsatzTV.Infrastructure.Plex var metadata = new MovieMetadata { Title = response.Title, + SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title), Plot = response.Summary, ReleaseDate = DateTime.Parse(response.OriginallyAvailableAt), Year = response.Year, @@ -160,7 +127,7 @@ namespace ErsatzTV.Infrastructure.Plex Height = media.Height, AudioCodec = media.AudioCodec, VideoCodec = media.VideoCodec, - // TODO: aspect ratio + SampleAspectRatio = ConvertToSAR(media.AspectRatio), MediaFiles = new List { new PlexMediaFile @@ -181,5 +148,9 @@ namespace ErsatzTV.Infrastructure.Plex return movie; } + + private static string ConvertToSAR(double aspectRatio) => "1:1"; + // TODO: fix this with more detailed stats pull from plex for each item + // Math.Abs(aspectRatio - 1) < 0.01 ? "1:1" : $"{(int) (aspectRatio * 100)}:100"; } } diff --git a/ErsatzTV/Pages/PlexLibrariesEditor.razor b/ErsatzTV/Pages/PlexLibrariesEditor.razor index 361f39d4b..ce40519b0 100644 --- a/ErsatzTV/Pages/PlexLibrariesEditor.razor +++ b/ErsatzTV/Pages/PlexLibrariesEditor.razor @@ -8,7 +8,7 @@ @inject ISnackbar Snackbar - + @_source.Name Libraries diff --git a/ErsatzTV/Pages/PlexMediaSources.razor b/ErsatzTV/Pages/PlexMediaSources.razor index cd496c59f..3b098c21f 100644 --- a/ErsatzTV/Pages/PlexMediaSources.razor +++ b/ErsatzTV/Pages/PlexMediaSources.razor @@ -8,38 +8,44 @@ @inject ILogger Logger @inject IJSRuntime JsRuntime - - - Plex Media Sources - - - - - - - - Name - Address - - - - @context.Name - @context.Address - - - - Edit Libraries - - @* *@ - @* Edit Path Replacements *@ - @* *@ - - - - - - Add Plex Media Source - + + + + Plex Media Sources + + + + + + + + Name + Address + + + + @context.Name + @context.Address + +
+ + + + + + + + +
+
+
+
+ + Add Plex Media Source + +
@code { private List _mediaSources; diff --git a/ErsatzTV/Pages/PlexPathReplacementsEditor.razor b/ErsatzTV/Pages/PlexPathReplacementsEditor.razor new file mode 100644 index 000000000..325471ae6 --- /dev/null +++ b/ErsatzTV/Pages/PlexPathReplacementsEditor.razor @@ -0,0 +1,135 @@ +@page "/media/sources/plex/{Id:int}/paths" +@using ErsatzTV.Application.Plex +@using ErsatzTV.Application.Plex.Commands +@using ErsatzTV.Application.Plex.Queries +@inject NavigationManager NavigationManager +@inject ILogger Logger +@inject ISnackbar Snackbar +@inject IMediator Mediator + + + + + @_source.Name Path Replacements + + + + + + + + Plex Path + Local Path + + + + + + @context.PlexPath + + + + + @context.LocalPath + + + + + + + + + + + + Add Path Replacement + + + Save Changes + + + @if (_selectedItem is not null) + { +
+ + + + + + + + + +
+ } +
+ +@code { + + [Parameter] + public int Id { get; set; } + + private PlexMediaSourceViewModel _source; + private List _pathReplacements; + + private PlexPathReplacementEditViewModel _selectedItem; + + protected override Task OnParametersSetAsync() => LoadData(); + + private async Task LoadData() + { + Option maybeSource = await Mediator.Send(new GetPlexMediaSourceById(Id)); + await maybeSource.Match( + async source => + { + _source = source; + _pathReplacements = await Mediator.Send(new GetPlexPathReplacementsBySourceId(Id)) + .Map(list => list.Map(ProjectToEditViewModel).ToList()); + }, + () => + { + NavigationManager.NavigateTo("404"); + return Task.CompletedTask; + }); + } + + private PlexPathReplacementEditViewModel ProjectToEditViewModel(PlexPathReplacementViewModel item) => + new() { Id = item.Id, PlexPath = item.PlexPath, LocalPath = item.LocalPath }; + + private void AddPathReplacement() + { + var item = new PlexPathReplacementEditViewModel(); + _pathReplacements.Add(item); + _selectedItem = item; + } + + private void RemovePathReplacement(PlexPathReplacementEditViewModel item) + { + _selectedItem = null; + _pathReplacements.Remove(item); + } + + private async Task SaveChanges() + { + var items = _pathReplacements + .Map(item => new PlexPathReplacementItem(item.Id, item.PlexPath, item.LocalPath)) + .ToList(); + + Seq errorMessages = await Mediator.Send(new UpdatePlexPathReplacements(Id, items)).Map(e => e.LeftToSeq()); + + errorMessages.HeadOrNone().Match( + error => + { + Snackbar.Add($"Unexpected error saving path replacements: {error.Value}", Severity.Error); + Logger.LogError("Unexpected error saving path replacements: {Error}", error.Value); + }, + () => NavigationManager.NavigateTo("/media/plex")); + } + +} \ No newline at end of file diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 5b1306a45..a25ab333b 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -178,7 +178,6 @@ namespace ErsatzTV services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); // TODO: does this need to be singleton? - services.AddSingleton(); services.AddSingleton(); AddChannel(services); AddChannel(services); @@ -206,6 +205,7 @@ namespace ErsatzTV services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddHostedService(); services.AddHostedService(); diff --git a/ErsatzTV/Validators/PlexPathReplacementEditViewModelValidator.cs b/ErsatzTV/Validators/PlexPathReplacementEditViewModelValidator.cs new file mode 100644 index 000000000..0b84aa0ea --- /dev/null +++ b/ErsatzTV/Validators/PlexPathReplacementEditViewModelValidator.cs @@ -0,0 +1,14 @@ +using ErsatzTV.ViewModels; +using FluentValidation; + +namespace ErsatzTV.Validators +{ + public class PlexPathReplacementEditViewModelValidator : AbstractValidator + { + public PlexPathReplacementEditViewModelValidator() + { + RuleFor(vm => vm.PlexPath).NotEmpty(); + RuleFor(vm => vm.LocalPath).NotEmpty(); + } + } +} diff --git a/ErsatzTV/ViewModels/PlexPathReplacementEditViewModel.cs b/ErsatzTV/ViewModels/PlexPathReplacementEditViewModel.cs new file mode 100644 index 000000000..66214781b --- /dev/null +++ b/ErsatzTV/ViewModels/PlexPathReplacementEditViewModel.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.ViewModels +{ + public class PlexPathReplacementEditViewModel + { + public int Id { get; set; } + public string PlexPath { get; set; } + public string LocalPath { get; set; } + } +}