From c9e20e28dfe532eb9d93cf66e1891e9d95fc2714 Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Tue, 25 May 2021 15:15:15 -0500 Subject: [PATCH] proxy jellyfin and emby artwork for xmltv (#210) * fix xmltv artwork for jf and emby * proxy jellyfin and emby artwork for xmltv --- .../Emby/EmbyConnectionParametersViewModel.cs | 4 + .../Queries/GetEmbyConnectionParameters.cs | 8 ++ .../GetEmbyConnectionParametersHandler.cs | 73 ++++++++++++++++++ .../JellyfinConnectionParametersViewModel.cs | 4 + .../GetJellyfinConnectionParameters.cs | 8 ++ .../GetJellyfinConnectionParametersHandler.cs | 73 ++++++++++++++++++ ErsatzTV.Application/MediaCards/Mapper.cs | 6 +- ErsatzTV.Application/Movies/Mapper.cs | 2 +- ...layoutItemProcessByChannelNumberHandler.cs | 2 +- ErsatzTV.Application/Television/Mapper.cs | 2 +- .../FFmpegPlaybackSettingsCalculatorTests.cs | 5 +- ErsatzTV.Core/Emby/EmbyUrl.cs | 34 +++++++- ErsatzTV.Core/Iptv/ChannelGuide.cs | 32 ++++++-- .../JellyfinPathReplacementService.cs | 3 +- ErsatzTV.Core/Jellyfin/JellyfinUrl.cs | 34 +++++++- ErsatzTV/Controllers/ArtworkController.cs | 77 +++++++++++++++++++ 16 files changed, 348 insertions(+), 19 deletions(-) create mode 100644 ErsatzTV.Application/Emby/EmbyConnectionParametersViewModel.cs create mode 100644 ErsatzTV.Application/Emby/Queries/GetEmbyConnectionParameters.cs create mode 100644 ErsatzTV.Application/Emby/Queries/GetEmbyConnectionParametersHandler.cs create mode 100644 ErsatzTV.Application/Jellyfin/JellyfinConnectionParametersViewModel.cs create mode 100644 ErsatzTV.Application/Jellyfin/Queries/GetJellyfinConnectionParameters.cs create mode 100644 ErsatzTV.Application/Jellyfin/Queries/GetJellyfinConnectionParametersHandler.cs diff --git a/ErsatzTV.Application/Emby/EmbyConnectionParametersViewModel.cs b/ErsatzTV.Application/Emby/EmbyConnectionParametersViewModel.cs new file mode 100644 index 000000000..579662dfd --- /dev/null +++ b/ErsatzTV.Application/Emby/EmbyConnectionParametersViewModel.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Application.Emby +{ + public record EmbyConnectionParametersViewModel(string Address); +} diff --git a/ErsatzTV.Application/Emby/Queries/GetEmbyConnectionParameters.cs b/ErsatzTV.Application/Emby/Queries/GetEmbyConnectionParameters.cs new file mode 100644 index 000000000..6cc1f22fa --- /dev/null +++ b/ErsatzTV.Application/Emby/Queries/GetEmbyConnectionParameters.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Emby.Queries +{ + public record GetEmbyConnectionParameters : IRequest>; +} diff --git a/ErsatzTV.Application/Emby/Queries/GetEmbyConnectionParametersHandler.cs b/ErsatzTV.Application/Emby/Queries/GetEmbyConnectionParametersHandler.cs new file mode 100644 index 000000000..c67315287 --- /dev/null +++ b/ErsatzTV.Application/Emby/Queries/GetEmbyConnectionParametersHandler.cs @@ -0,0 +1,73 @@ +using System; +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 MediatR; +using Microsoft.Extensions.Caching.Memory; + +namespace ErsatzTV.Application.Emby.Queries +{ + public class GetEmbyConnectionParametersHandler : IRequestHandler> + { + private readonly IMediaSourceRepository _mediaSourceRepository; + private readonly IMemoryCache _memoryCache; + + public GetEmbyConnectionParametersHandler( + IMemoryCache memoryCache, + IMediaSourceRepository mediaSourceRepository) + { + _memoryCache = memoryCache; + _mediaSourceRepository = mediaSourceRepository; + } + + public async Task> Handle( + GetEmbyConnectionParameters request, + CancellationToken cancellationToken) + { + if (_memoryCache.TryGetValue(request, out EmbyConnectionParametersViewModel parameters)) + { + return parameters; + } + + Either maybeParameters = + await Validate() + .MapT(cp => new EmbyConnectionParametersViewModel(cp.ActiveConnection.Address)) + .Map(v => v.ToEither()); + + return maybeParameters.Match( + p => + { + _memoryCache.Set(request, p, TimeSpan.FromHours(1)); + return maybeParameters; + }, + error => error); + } + + private Task> Validate() => + EmbyMediaSourceMustExist() + .BindT(MediaSourceMustHaveActiveConnection); + + private Task> EmbyMediaSourceMustExist() => + _mediaSourceRepository.GetAllEmby().Map(list => list.HeadOrNone()) + .Map( + v => v.ToValidation( + "Emby media source does not exist.")); + + private Validation MediaSourceMustHaveActiveConnection( + EmbyMediaSource embyMediaSource) + { + Option maybeConnection = embyMediaSource.Connections.FirstOrDefault(); + return maybeConnection.Map(connection => new ConnectionParameters(embyMediaSource, connection)) + .ToValidation("Emby media source requires an active connection"); + } + + private record ConnectionParameters( + EmbyMediaSource EmbyMediaSource, + EmbyConnection ActiveConnection); + } +} diff --git a/ErsatzTV.Application/Jellyfin/JellyfinConnectionParametersViewModel.cs b/ErsatzTV.Application/Jellyfin/JellyfinConnectionParametersViewModel.cs new file mode 100644 index 000000000..2ad489515 --- /dev/null +++ b/ErsatzTV.Application/Jellyfin/JellyfinConnectionParametersViewModel.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Application.Jellyfin +{ + public record JellyfinConnectionParametersViewModel(string Address); +} diff --git a/ErsatzTV.Application/Jellyfin/Queries/GetJellyfinConnectionParameters.cs b/ErsatzTV.Application/Jellyfin/Queries/GetJellyfinConnectionParameters.cs new file mode 100644 index 000000000..b3666e6dc --- /dev/null +++ b/ErsatzTV.Application/Jellyfin/Queries/GetJellyfinConnectionParameters.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Jellyfin.Queries +{ + public record GetJellyfinConnectionParameters : IRequest>; +} diff --git a/ErsatzTV.Application/Jellyfin/Queries/GetJellyfinConnectionParametersHandler.cs b/ErsatzTV.Application/Jellyfin/Queries/GetJellyfinConnectionParametersHandler.cs new file mode 100644 index 000000000..23f359575 --- /dev/null +++ b/ErsatzTV.Application/Jellyfin/Queries/GetJellyfinConnectionParametersHandler.cs @@ -0,0 +1,73 @@ +using System; +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 MediatR; +using Microsoft.Extensions.Caching.Memory; + +namespace ErsatzTV.Application.Jellyfin.Queries +{ + public class GetJellyfinConnectionParametersHandler : IRequestHandler> + { + private readonly IMediaSourceRepository _mediaSourceRepository; + private readonly IMemoryCache _memoryCache; + + public GetJellyfinConnectionParametersHandler( + IMemoryCache memoryCache, + IMediaSourceRepository mediaSourceRepository) + { + _memoryCache = memoryCache; + _mediaSourceRepository = mediaSourceRepository; + } + + public async Task> Handle( + GetJellyfinConnectionParameters request, + CancellationToken cancellationToken) + { + if (_memoryCache.TryGetValue(request, out JellyfinConnectionParametersViewModel parameters)) + { + return parameters; + } + + Either maybeParameters = + await Validate() + .MapT(cp => new JellyfinConnectionParametersViewModel(cp.ActiveConnection.Address)) + .Map(v => v.ToEither()); + + return maybeParameters.Match( + p => + { + _memoryCache.Set(request, p, TimeSpan.FromHours(1)); + return maybeParameters; + }, + error => error); + } + + private Task> Validate() => + JellyfinMediaSourceMustExist() + .BindT(MediaSourceMustHaveActiveConnection); + + private Task> JellyfinMediaSourceMustExist() => + _mediaSourceRepository.GetAllJellyfin().Map(list => list.HeadOrNone()) + .Map( + v => v.ToValidation( + "Jellyfin media source does not exist.")); + + private Validation MediaSourceMustHaveActiveConnection( + JellyfinMediaSource jellyfinMediaSource) + { + Option maybeConnection = jellyfinMediaSource.Connections.FirstOrDefault(); + return maybeConnection.Map(connection => new ConnectionParameters(jellyfinMediaSource, connection)) + .ToValidation("Jellyfin media source requires an active connection"); + } + + private record ConnectionParameters( + JellyfinMediaSource JellyfinMediaSource, + JellyfinConnection ActiveConnection); + } +} diff --git a/ErsatzTV.Application/MediaCards/Mapper.cs b/ErsatzTV.Application/MediaCards/Mapper.cs index 6371e2fd4..b26f4e419 100644 --- a/ErsatzTV.Application/MediaCards/Mapper.cs +++ b/ErsatzTV.Application/MediaCards/Mapper.cs @@ -122,7 +122,7 @@ namespace ErsatzTV.Application.MediaCards else if (maybeEmby.IsSome && artwork.StartsWith("emby://")) { artwork = EmbyUrl.ForArtwork(maybeEmby, artwork) - .SetQueryParam("fillHeight", 440); + .SetQueryParam("maxHeight", 440); } return new ActorCardViewModel(actor.Id, actor.Name, actor.Role, artwork); @@ -152,7 +152,7 @@ namespace ErsatzTV.Application.MediaCards else if (maybeEmby.IsSome && poster.StartsWith("emby://")) { poster = EmbyUrl.ForArtwork(maybeEmby, poster) - .SetQueryParam("fillHeight", 440); + .SetQueryParam("maxHeight", 440); } return poster; @@ -174,7 +174,7 @@ namespace ErsatzTV.Application.MediaCards else if (maybeEmby.IsSome && thumb.StartsWith("emby://")) { thumb = EmbyUrl.ForArtwork(maybeEmby, thumb) - .SetQueryParam("fillHeight", 220); + .SetQueryParam("maxHeight", 220); } return thumb; diff --git a/ErsatzTV.Application/Movies/Mapper.cs b/ErsatzTV.Application/Movies/Mapper.cs index 60f0e5be6..1ac0e33f0 100644 --- a/ErsatzTV.Application/Movies/Mapper.cs +++ b/ErsatzTV.Application/Movies/Mapper.cs @@ -76,7 +76,7 @@ namespace ErsatzTV.Application.Movies Url url = EmbyUrl.ForArtwork(maybeEmby, artwork); if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail) { - url.SetQueryParam("fillHeight", 440); + url.SetQueryParam("maxHeight", 440); } artwork = url; diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs index 612ead67b..87830cbbd 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs @@ -19,9 +19,9 @@ namespace ErsatzTV.Application.Streaming.Queries GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler { private readonly IConfigElementRepository _configElementRepository; + private readonly IEmbyPathReplacementService _embyPathReplacementService; private readonly FFmpegProcessService _ffmpegProcessService; private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService; - private readonly IEmbyPathReplacementService _embyPathReplacementService; private readonly ILocalFileSystem _localFileSystem; private readonly IPlayoutRepository _playoutRepository; private readonly IPlexPathReplacementService _plexPathReplacementService; diff --git a/ErsatzTV.Application/Television/Mapper.cs b/ErsatzTV.Application/Television/Mapper.cs index 929bc3f39..3539d64d1 100644 --- a/ErsatzTV.Application/Television/Mapper.cs +++ b/ErsatzTV.Application/Television/Mapper.cs @@ -104,7 +104,7 @@ namespace ErsatzTV.Application.Television Url url = EmbyUrl.ForArtwork(maybeEmby, artwork); if (artworkKind == ArtworkKind.Poster) { - url.SetQueryParam("fillHeight", 440); + url.SetQueryParam("maxHeight", 440); } artwork = url; diff --git a/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsCalculatorTests.cs b/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsCalculatorTests.cs index 2ae148cc2..c595abd82 100644 --- a/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsCalculatorTests.cs +++ b/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsCalculatorTests.cs @@ -466,9 +466,10 @@ namespace ErsatzTV.Core.Tests.FFmpeg actual.PadToDesiredResolution.Should().BeFalse(); actual.VideoCodec.Should().Be("copy"); } - + [Test] - public void Should_SetCorrectVideoCodec_When_ContentIsCorrectSize_And_CorrectCodec_And_Framerate_ForTransportStream() + public void + Should_SetCorrectVideoCodec_When_ContentIsCorrectSize_And_CorrectCodec_And_Framerate_ForTransportStream() { var ffmpegProfile = new FFmpegProfile { diff --git a/ErsatzTV.Core/Emby/EmbyUrl.cs b/ErsatzTV.Core/Emby/EmbyUrl.cs index 9e7907141..f5c9ab6dc 100644 --- a/ErsatzTV.Core/Emby/EmbyUrl.cs +++ b/ErsatzTV.Core/Emby/EmbyUrl.cs @@ -21,11 +21,41 @@ namespace ErsatzTV.Core.Emby string pathSegment = split[0]; QueryParamCollection query = Url.ParseQueryParams(split[1]); - Url x = Url.Parse(address) + return Url.Parse(address) .AppendPathSegment(pathSegment) .SetQueryParams(query); + } + + public static Url ForArtwork(string address, string artwork) + { + string[] split = artwork.Replace("emby://", string.Empty).Split('?'); + if (split.Length != 2) + { + return artwork; + } + + string pathSegment = split[0]; + QueryParamCollection query = Url.ParseQueryParams(split[1]); - return x; + return Url.Parse(address) + .AppendPathSegment(pathSegment) + .SetQueryParams(query); + } + + public static Url ProxyForArtwork(string scheme, string host, string artwork) + { + string[] split = artwork.Replace("emby://", string.Empty).Split('?'); + if (split.Length != 2) + { + return artwork; + } + + string pathSegment = split[0]; + QueryParamCollection query = Url.ParseQueryParams(split[1]); + + return Url.Parse($"{scheme}://{host}/iptv/artwork/posters/emby") + .AppendPathSegment(pathSegment) + .SetQueryParams(query); } } } diff --git a/ErsatzTV.Core/Iptv/ChannelGuide.cs b/ErsatzTV.Core/Iptv/ChannelGuide.cs index 7a23cd2e1..92565072f 100644 --- a/ErsatzTV.Core/Iptv/ChannelGuide.cs +++ b/ErsatzTV.Core/Iptv/ChannelGuide.cs @@ -4,6 +4,8 @@ using System.Linq; using System.Text; using System.Xml; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Emby; +using ErsatzTV.Core.Jellyfin; using LanguageExt; using LanguageExt.UnsafeValueAccess; using static LanguageExt.Prelude; @@ -108,9 +110,7 @@ namespace ErsatzTV.Core.Iptv string poster = Optional(metadata.Artwork).Flatten() .Filter(a => a.ArtworkKind == ArtworkKind.Poster) .HeadOrNone() - .Match( - artwork => $"{_scheme}://{_host}/iptv/artwork/posters/{artwork.Path}", - () => string.Empty); + .Match(GetPoster, () => string.Empty); if (!string.IsNullOrWhiteSpace(poster)) { @@ -147,9 +147,7 @@ namespace ErsatzTV.Core.Iptv string poster = Optional(metadata.Artwork).Flatten() .Filter(a => a.ArtworkKind == ArtworkKind.Poster) .HeadOrNone() - .Match( - artwork => $"{_scheme}://{_host}/iptv/artwork/posters/{artwork.Path}", - () => string.Empty); + .Match(GetPoster, () => string.Empty); if (!string.IsNullOrWhiteSpace(poster)) { @@ -208,6 +206,28 @@ namespace ErsatzTV.Core.Iptv return Encoding.UTF8.GetString(ms.ToArray()); } + private string GetPoster(Artwork artwork) + { + string poster = artwork.Path; + + if (poster.StartsWith("jellyfin://")) + { + poster = JellyfinUrl.ProxyForArtwork(_scheme, _host, poster) + .SetQueryParam("fillHeight", 440); + } + else if (poster.StartsWith("emby://")) + { + poster = EmbyUrl.ProxyForArtwork(_scheme, _host, poster) + .SetQueryParam("maxHeight", 440); + } + else + { + poster = $"{_scheme}://{_host}/iptv/artwork/posters/{artwork.Path}"; + } + + return poster; + } + private static string GetTitle(PlayoutItem playoutItem) { if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle)) diff --git a/ErsatzTV.Core/Jellyfin/JellyfinPathReplacementService.cs b/ErsatzTV.Core/Jellyfin/JellyfinPathReplacementService.cs index 0108e99b5..8a9eefb03 100644 --- a/ErsatzTV.Core/Jellyfin/JellyfinPathReplacementService.cs +++ b/ErsatzTV.Core/Jellyfin/JellyfinPathReplacementService.cs @@ -56,7 +56,8 @@ namespace ErsatzTV.Core.Jellyfin replacement => { string finalPath = path.Replace(replacement.JellyfinPath, replacement.LocalPath); - if (IsWindows(replacement.JellyfinMediaSource, path) && !_runtimeInfo.IsOSPlatform(OSPlatform.Windows)) + if (IsWindows(replacement.JellyfinMediaSource, path) && + !_runtimeInfo.IsOSPlatform(OSPlatform.Windows)) { finalPath = finalPath.Replace(@"\", @"/"); } diff --git a/ErsatzTV.Core/Jellyfin/JellyfinUrl.cs b/ErsatzTV.Core/Jellyfin/JellyfinUrl.cs index 09c439331..002c91a46 100644 --- a/ErsatzTV.Core/Jellyfin/JellyfinUrl.cs +++ b/ErsatzTV.Core/Jellyfin/JellyfinUrl.cs @@ -21,11 +21,41 @@ namespace ErsatzTV.Core.Jellyfin string pathSegment = split[0]; QueryParamCollection query = Url.ParseQueryParams(split[1]); - Url x = Url.Parse(address) + return Url.Parse(address) .AppendPathSegment(pathSegment) .SetQueryParams(query); + } + + public static Url ForArtwork(string address, string artwork) + { + string[] split = artwork.Replace("jellyfin://", string.Empty).Split('?'); + if (split.Length != 2) + { + return artwork; + } + + string pathSegment = split[0]; + QueryParamCollection query = Url.ParseQueryParams(split[1]); - return x; + return Url.Parse(address) + .AppendPathSegment(pathSegment) + .SetQueryParams(query); + } + + public static Url ProxyForArtwork(string scheme, string host, string artwork) + { + string[] split = artwork.Replace("jellyfin://", string.Empty).Split('?'); + if (split.Length != 2) + { + return artwork; + } + + string pathSegment = split[0]; + QueryParamCollection query = Url.ParseQueryParams(split[1]); + + return Url.Parse($"{scheme}://{host}/iptv/artwork/posters/jellyfin") + .AppendPathSegment(pathSegment) + .SetQueryParams(query); } } } diff --git a/ErsatzTV/Controllers/ArtworkController.cs b/ErsatzTV/Controllers/ArtworkController.cs index 743cb1b45..834c21d66 100644 --- a/ErsatzTV/Controllers/ArtworkController.cs +++ b/ErsatzTV/Controllers/ArtworkController.cs @@ -2,12 +2,19 @@ using System.IO; using System.Net.Http; using System.Threading.Tasks; +using ErsatzTV.Application.Emby; +using ErsatzTV.Application.Emby.Queries; using ErsatzTV.Application.Images; using ErsatzTV.Application.Images.Queries; +using ErsatzTV.Application.Jellyfin; +using ErsatzTV.Application.Jellyfin.Queries; using ErsatzTV.Application.Plex; using ErsatzTV.Application.Plex.Queries; using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Emby; +using ErsatzTV.Core.Jellyfin; +using Flurl; using LanguageExt; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -49,6 +56,30 @@ namespace ErsatzTV.Controllers Right: r => new FileContentResult(r.Contents, r.MimeType)); } + [HttpGet("/iptv/artwork/posters/jellyfin/{*path}")] + [HttpGet("/artwork/posters/jellyfin/{*path}")] + public Task GetJellyfinPoster(string path) + { + if (Request.QueryString.HasValue) + { + path += Request.QueryString.Value; + } + + return GetJellyfinArtwork(path); + } + + [HttpGet("/iptv/artwork/posters/emby/{*path}")] + [HttpGet("/artwork/posters/emby/{*path}")] + public Task GetEmbyPoster(string path) + { + if (Request.QueryString.HasValue) + { + path += Request.QueryString.Value; + } + + return GetEmbyArtwork(path); + } + [HttpGet("/iptv/artwork/posters/plex/{plexMediaSourceId}/{*path}")] [HttpGet("/artwork/posters/plex/{plexMediaSourceId}/{*path}")] public Task GetPlexPoster(int plexMediaSourceId, string path) => @@ -101,5 +132,51 @@ namespace ErsatzTV.Controllers response.Content.Headers.ContentType?.MediaType ?? "image/jpeg"); }); } + + private async Task GetJellyfinArtwork(string path) + { + Either connectionParameters = + await _mediator.Send(new GetJellyfinConnectionParameters()); + + return await connectionParameters.Match>( + Left: _ => new NotFoundResult().AsTask(), + Right: async vm => + { + HttpClient client = _httpClientFactory.CreateClient(); + + Url fullPath = JellyfinUrl.ForArtwork(vm.Address, path); + HttpResponseMessage response = await client.GetAsync( + fullPath, + HttpCompletionOption.ResponseHeadersRead); + Stream stream = await response.Content.ReadAsStreamAsync(); + + return new FileStreamResult( + stream, + response.Content.Headers.ContentType?.MediaType ?? "image/jpeg"); + }); + } + + private async Task GetEmbyArtwork(string path) + { + Either connectionParameters = + await _mediator.Send(new GetEmbyConnectionParameters()); + + return await connectionParameters.Match>( + Left: _ => new NotFoundResult().AsTask(), + Right: async vm => + { + HttpClient client = _httpClientFactory.CreateClient(); + + Url fullPath = EmbyUrl.ForArtwork(vm.Address, path); + HttpResponseMessage response = await client.GetAsync( + fullPath, + HttpCompletionOption.ResponseHeadersRead); + Stream stream = await response.Content.ReadAsStreamAsync(); + + return new FileStreamResult( + stream, + response.Content.Headers.ContentType?.MediaType ?? "image/jpeg"); + }); + } } }