diff --git a/CHANGELOG.md b/CHANGELOG.md index a3c182875..fcbbe2d5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix `shuffle_sequence` deleting the instructions between two uses of the same sequence - Fix a sequence that is used two times giving the `custom_title` of the last use to every use - Fix a shuffled sequence with `repeat` making the build run with no end; this stopped all other background work +- Fix bug in XMLTV template for episodes that was breaking thumbnail artwork + - Those with customized `episode.sbntxt` templates will want to make a similar fix ## [26.8.1] - 2026-08-29 ### Security diff --git a/ErsatzTV.Application/Artworks/ArtworkMapper.cs b/ErsatzTV.Application/Artworks/ArtworkMapper.cs new file mode 100644 index 000000000..ae217b919 --- /dev/null +++ b/ErsatzTV.Application/Artworks/ArtworkMapper.cs @@ -0,0 +1,52 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Emby; +using ErsatzTV.Core.Jellyfin; +using ErsatzTV.Core.Plex; +using Flurl; + +namespace ErsatzTV.Application.Artworks; + +public static class ArtworkMapper +{ + public static string Artwork( + Metadata metadata, + ArtworkKind artworkKind, + Option maybeJellyfin, + Option maybeEmby) + { + foreach (var artwork in Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))) + { + return Artwork(artwork, artworkKind, maybeJellyfin, maybeEmby); + } + + return string.Empty; + } + + public static string Artwork( + Artwork artwork, + ArtworkKind artworkKind, + Option maybeJellyfin, + Option maybeEmby) + { + string artworkPath = artwork.Path ?? string.Empty; + + if (artworkPath.StartsWith("plex/", StringComparison.OrdinalIgnoreCase)) + { + Url url = PlexUrl.RelativeProxyForArtwork(artwork.Id); + artworkPath = url; + } + else if (maybeJellyfin.IsSome && artworkPath.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase)) + { + + Url url = JellyfinUrl.RelativeProxyForArtwork(artwork.Id); + artworkPath = url; + } + else if (maybeEmby.IsSome && artworkPath.StartsWith("emby://", StringComparison.OrdinalIgnoreCase)) + { + Url url = EmbyUrl.RelativeProxyForArtwork(artwork.Id); + artworkPath = url; + } + + return artworkPath; + } +} diff --git a/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs b/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs index fb43cb66f..97dfd84c3 100644 --- a/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs @@ -10,6 +10,7 @@ using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Iptv; using ErsatzTV.Core.Jellyfin; +using ErsatzTV.Core.Plex; using ErsatzTV.Core.Streaming; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; @@ -1092,13 +1093,17 @@ public class RefreshChannelDataHandler : IRequestHandler return artworkPath; } - if (artworkPath.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase)) + if (artworkPath.StartsWith("plex/", StringComparison.OrdinalIgnoreCase)) { - artworkPath = JellyfinUrl.PlaceholderProxyForArtwork(artworkPath, artworkKind, height); + artworkPath = PlexUrl.PlaceholderProxyForArtwork(artwork.Id, artworkKind); + } + else if (artworkPath.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase)) + { + artworkPath = JellyfinUrl.PlaceholderProxyForArtwork(artwork.Id, artworkKind); } else if (artworkPath.StartsWith("emby://", StringComparison.OrdinalIgnoreCase)) { - artworkPath = EmbyUrl.PlaceholderProxyForArtwork(artworkPath, artworkKind, height); + artworkPath = EmbyUrl.PlaceholderProxyForArtwork(artwork.Id, artworkKind); } else { diff --git a/ErsatzTV.Application/MediaCards/Mapper.cs b/ErsatzTV.Application/MediaCards/Mapper.cs index 4cae5c80e..b87bb2ca8 100644 --- a/ErsatzTV.Application/MediaCards/Mapper.cs +++ b/ErsatzTV.Application/MediaCards/Mapper.cs @@ -1,9 +1,8 @@ using System.Globalization; +using ErsatzTV.Application.Artworks; using ErsatzTV.Core; using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Emby; using ErsatzTV.Core.Extensions; -using ErsatzTV.Core.Jellyfin; namespace ErsatzTV.Application.MediaCards; @@ -18,7 +17,7 @@ internal static class Mapper showMetadata.Title, showMetadata.Year?.ToString(CultureInfo.InvariantCulture), showMetadata.SortTitle, - GetPoster(showMetadata, maybeJellyfin, maybeEmby), + ArtworkMapper.Artwork(showMetadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby), showMetadata.Show.State); internal static TelevisionSeasonCardViewModel ProjectToViewModel( @@ -32,7 +31,7 @@ internal static class Mapper GetSeasonName(season.SeasonNumber), string.Empty, GetSeasonName(season.SeasonNumber), - season.SeasonMetadata.HeadOrNone().Map(sm => GetPoster(sm, maybeJellyfin, maybeEmby)) + season.SeasonMetadata.HeadOrNone().Map(sm => ArtworkMapper.Artwork(sm, ArtworkKind.Poster, maybeJellyfin, maybeEmby)) .IfNone(string.Empty), season.SeasonNumber == 0 ? "S" : new string(season.SeasonNumber.ToString(CultureInfo.InvariantCulture).Take(20).ToArray()), season.State); @@ -53,7 +52,7 @@ internal static class Mapper showTitle, GetSeasonName(seasonMetadata.Season.SeasonNumber), $"{showTitle}_{seasonMetadata.Season.SeasonNumber:0000}", - GetPoster(seasonMetadata, maybeJellyfin, maybeEmby), + ArtworkMapper.Artwork(seasonMetadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby), seasonMetadata.Season.SeasonNumber == 0 ? "S" : seasonMetadata.Season.SeasonNumber.ToString(CultureInfo.InvariantCulture), @@ -99,7 +98,7 @@ internal static class Mapper movieMetadata.Title, movieMetadata.Year?.ToString(CultureInfo.InvariantCulture), movieMetadata.SortTitle, - GetPoster(movieMetadata, maybeJellyfin, maybeEmby), + ArtworkMapper.Artwork(movieMetadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby), movieMetadata.Movie.State); internal static MusicVideoCardViewModel ProjectToViewModel( @@ -122,7 +121,7 @@ internal static class Mapper string poster = GetThumbnail(otherVideoMetadata, None, None); if (string.IsNullOrWhiteSpace(poster)) { - poster = GetPoster(otherVideoMetadata, None, None); + poster = ArtworkMapper.Artwork(otherVideoMetadata, ArtworkKind.Poster, None, None); } return new OtherVideoCardViewModel( @@ -218,18 +217,11 @@ internal static class Mapper Option maybeJellyfin, Option maybeEmby) { - string artwork = actor.Artwork?.Path ?? string.Empty; - - if (maybeJellyfin.IsSome && artwork.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase)) - { - artwork = JellyfinUrl.RelativeProxyForArtwork(artwork) - .SetQueryParam("fillHeight", 440); - } - else if (maybeEmby.IsSome && artwork.StartsWith("emby://", StringComparison.OrdinalIgnoreCase)) - { - artwork = EmbyUrl.RelativeProxyForArtwork(artwork) - .SetQueryParam("maxHeight", 440); - } + string artwork = ArtworkMapper.Artwork( + actor.Artwork ?? new Artwork(), + ArtworkKind.Poster, + maybeJellyfin, + maybeEmby); return new ActorCardViewModel(actor.Id, actor.Name, actor.Role, artwork, MediaItemState.Normal); } @@ -249,58 +241,20 @@ internal static class Mapper { Option maybeSeasonMetadata = episodeMetadata.Episode.Season.SeasonMetadata.HeadOrNone(); return maybeSeasonMetadata.Match( - seasonMetadata => GetPoster(seasonMetadata, maybeJellyfin, maybeEmby), + seasonMetadata => ArtworkMapper.Artwork(seasonMetadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby), () => { Option maybeShowMetadata = episodeMetadata.Episode.Season.Show.ShowMetadata.HeadOrNone(); return maybeShowMetadata.Match( - showMetadata => GetPoster(showMetadata, maybeJellyfin, maybeEmby), + showMetadata => ArtworkMapper.Artwork(showMetadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby), () => string.Empty); }); } - private static string GetPoster( - Metadata metadata, - Option maybeJellyfin, - Option maybeEmby) - { - string poster = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster)) - .Match(a => a.Path, string.Empty); - - if (maybeJellyfin.IsSome && poster.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase)) - { - poster = JellyfinUrl.RelativeProxyForArtwork(poster) - .SetQueryParam("fillHeight", 440); - } - else if (maybeEmby.IsSome && poster.StartsWith("emby://", StringComparison.OrdinalIgnoreCase)) - { - poster = EmbyUrl.RelativeProxyForArtwork(poster) - .SetQueryParam("maxHeight", 440); - } - - return poster; - } - private static string GetThumbnail( Metadata metadata, Option maybeJellyfin, - Option maybeEmby) - { - string thumb = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail)) - .Match(a => a.Path, string.Empty); - - if (maybeJellyfin.IsSome && thumb.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase)) - { - thumb = JellyfinUrl.RelativeProxyForArtwork(thumb) - .SetQueryParam("fillHeight", 220); - } - else if (maybeEmby.IsSome && thumb.StartsWith("emby://", StringComparison.OrdinalIgnoreCase)) - { - thumb = EmbyUrl.RelativeProxyForArtwork(thumb) - .SetQueryParam("maxHeight", 220); - } - - return thumb; - } + Option maybeEmby) => + ArtworkMapper.Artwork(metadata, ArtworkKind.Thumbnail, maybeJellyfin, maybeEmby); } diff --git a/ErsatzTV.Application/Movies/Mapper.cs b/ErsatzTV.Application/Movies/Mapper.cs index 461b66bb1..14617128d 100644 --- a/ErsatzTV.Application/Movies/Mapper.cs +++ b/ErsatzTV.Application/Movies/Mapper.cs @@ -1,4 +1,5 @@ using System.Globalization; +using ErsatzTV.Application.Artworks; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Emby; using ErsatzTV.Core.Extensions; @@ -36,8 +37,8 @@ internal static class Mapper localPath, movie.State) { - Poster = Artwork(metadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby), - FanArt = Artwork(metadata, ArtworkKind.FanArt, maybeJellyfin, maybeEmby) + Poster = ArtworkMapper.Artwork(metadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby), + FanArt = ArtworkMapper.Artwork(metadata, ArtworkKind.FanArt, maybeJellyfin, maybeEmby) }; } @@ -55,37 +56,4 @@ internal static class Mapper .Distinct() .ToList(); } - - private static string Artwork( - Metadata metadata, - ArtworkKind artworkKind, - Option maybeJellyfin, - Option maybeEmby) - { - string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind)) - .Match(a => a.Path, string.Empty); - - if (maybeJellyfin.IsSome && artwork.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase)) - { - Url url = JellyfinUrl.RelativeProxyForArtwork(artwork); - if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail) - { - url.SetQueryParam("fillHeight", 440); - } - - artwork = url; - } - else if (maybeEmby.IsSome && artwork.StartsWith("emby://", StringComparison.OrdinalIgnoreCase)) - { - Url url = EmbyUrl.RelativeProxyForArtwork(artwork); - if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail) - { - url.SetQueryParam("maxHeight", 440); - } - - artwork = url; - } - - return artwork; - } } diff --git a/ErsatzTV.Application/Television/Mapper.cs b/ErsatzTV.Application/Television/Mapper.cs index b0ff95f61..a8db14adf 100644 --- a/ErsatzTV.Application/Television/Mapper.cs +++ b/ErsatzTV.Application/Television/Mapper.cs @@ -1,8 +1,6 @@ using System.Globalization; +using ErsatzTV.Application.Artworks; using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Emby; -using ErsatzTV.Core.Jellyfin; -using Flurl; namespace ErsatzTV.Application.Television; @@ -69,46 +67,13 @@ internal static class Mapper Metadata metadata, Option maybeJellyfin, Option maybeEmby) => - GetArtwork(metadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby); + ArtworkMapper.Artwork(metadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby); private static string GetFanArt( Metadata metadata, Option maybeJellyfin, Option maybeEmby) => - GetArtwork(metadata, ArtworkKind.FanArt, maybeJellyfin, maybeEmby); - - private static string GetArtwork( - Metadata metadata, - ArtworkKind artworkKind, - Option maybeJellyfin, - Option maybeEmby) - { - string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind)) - .Match(a => a.Path, string.Empty); - - if (maybeJellyfin.IsSome && artwork.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase)) - { - Url url = JellyfinUrl.RelativeProxyForArtwork(artwork); - if (artworkKind == ArtworkKind.Poster) - { - url.SetQueryParam("fillHeight", 440); - } - - artwork = url; - } - else if (maybeEmby.IsSome && artwork.StartsWith("emby://", StringComparison.OrdinalIgnoreCase)) - { - Url url = EmbyUrl.RelativeProxyForArtwork(artwork); - if (artworkKind == ArtworkKind.Poster) - { - url.SetQueryParam("maxHeight", 440); - } - - artwork = url; - } - - return artwork; - } + ArtworkMapper.Artwork(metadata, ArtworkKind.FanArt, maybeJellyfin, maybeEmby); private static List LanguagesForShow(List languages) { diff --git a/ErsatzTV.Core.Tests/Jellyfin/JellyfinUrlTests.cs b/ErsatzTV.Core.Tests/Jellyfin/JellyfinUrlTests.cs deleted file mode 100644 index a13dfa0f6..000000000 --- a/ErsatzTV.Core.Tests/Jellyfin/JellyfinUrlTests.cs +++ /dev/null @@ -1,118 +0,0 @@ -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Jellyfin; -using Flurl; -using NUnit.Framework; -using Shouldly; - -namespace ErsatzTV.Core.Tests.Jellyfin; - -public class JellyfinUrlTests -{ - [Test] - public void Should_Work_Without_Trailing_Slash() - { - var artwork = "jellyfin://Items/2/Images/3?tag=4"; - var address = "https://some.jellyfin.server"; - var mediaSource = new JellyfinMediaSource - { - Connections = new List - { - new() { Address = address } - } - }; - - Url url = JellyfinUrl.ForArtwork(Some(mediaSource), artwork); - - url.ToString().ShouldBe("https://some.jellyfin.server/Items/2/Images/3?tag=4"); - } - - [Test] - public void Should_Work_With_Trailing_Slash() - { - var artwork = "jellyfin://Items/2/Images/3?tag=4"; - var address = "https://some.jellyfin.server/"; - var mediaSource = new JellyfinMediaSource - { - Connections = new List - { - new() { Address = address } - } - }; - - Url url = JellyfinUrl.ForArtwork(Some(mediaSource), artwork); - - url.ToString().ShouldBe("https://some.jellyfin.server/Items/2/Images/3?tag=4"); - } - - [Test] - public void Should_Work_With_Port_Without_Trailing_Slash() - { - var artwork = "jellyfin://Items/2/Images/3?tag=4"; - var address = "https://some.jellyfin.server:1000"; - var mediaSource = new JellyfinMediaSource - { - Connections = new List - { - new() { Address = address } - } - }; - - Url url = JellyfinUrl.ForArtwork(Some(mediaSource), artwork); - - url.ToString().ShouldBe("https://some.jellyfin.server:1000/Items/2/Images/3?tag=4"); - } - - [Test] - public void Should_Work_With_Port_With_Trailing_Slash() - { - var artwork = "jellyfin://Items/2/Images/3?tag=4"; - var address = "https://some.jellyfin.server:1000/"; - var mediaSource = new JellyfinMediaSource - { - Connections = new List - { - new() { Address = address } - } - }; - - Url url = JellyfinUrl.ForArtwork(Some(mediaSource), artwork); - - url.ToString().ShouldBe("https://some.jellyfin.server:1000/Items/2/Images/3?tag=4"); - } - - [Test] - public void Should_Work_With_Path_Prefix_Without_Trailing_Slash() - { - var artwork = "jellyfin://Items/2/Images/3?tag=4"; - var address = "https://some.jellyfin.server/jellyfin"; - var mediaSource = new JellyfinMediaSource - { - Connections = new List - { - new() { Address = address } - } - }; - - Url url = JellyfinUrl.ForArtwork(Some(mediaSource), artwork); - - url.ToString().ShouldBe("https://some.jellyfin.server/jellyfin/Items/2/Images/3?tag=4"); - } - - [Test] - public void Should_Work_With_Path_Prefix_With_Trailing_Slash() - { - var artwork = "jellyfin://Items/2/Images/3?tag=4"; - var address = "https://some.jellyfin.server/jellyfin/"; - var mediaSource = new JellyfinMediaSource - { - Connections = new List - { - new() { Address = address } - } - }; - - Url url = JellyfinUrl.ForArtwork(Some(mediaSource), artwork); - - url.ToString().ShouldBe("https://some.jellyfin.server/jellyfin/Items/2/Images/3?tag=4"); - } -} diff --git a/ErsatzTV.Core/Emby/EmbyUrl.cs b/ErsatzTV.Core/Emby/EmbyUrl.cs index dacd17524..89a8656ef 100644 --- a/ErsatzTV.Core/Emby/EmbyUrl.cs +++ b/ErsatzTV.Core/Emby/EmbyUrl.cs @@ -1,30 +1,11 @@ -using ErsatzTV.Core.Domain; +using System.Globalization; +using ErsatzTV.Core.Domain; using Flurl; namespace ErsatzTV.Core.Emby; public static class EmbyUrl { - public static Url ForArtwork(Option maybeEmby, string artwork) - { - string address = maybeEmby.Map(ms => ms.Connections.HeadOrNone().Map(c => c.Address)) - .Flatten() - .IfNone("emby://"); - - 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(address) - .AppendPathSegment(pathSegment) - .SetQueryParams(query); - } - public static Url ForArtwork(string address, string artwork) { string[] split = artwork.Replace("emby://", string.Empty).Split('?'); @@ -41,17 +22,8 @@ public static class EmbyUrl .SetQueryParams(query); } - public static string PlaceholderProxyForArtwork(string artwork, ArtworkKind artworkKind, int height) + public static string PlaceholderProxyForArtwork(int artworkId, ArtworkKind artworkKind) { - string[] split = artwork.Replace("emby://", string.Empty).Split('?'); - if (split.Length != 2) - { - return artwork; - } - - string pathSegment = split[0]; - QueryParamCollection query = Url.ParseQueryParams(split[1]); - string artworkFolder = artworkKind switch { ArtworkKind.Thumbnail => "thumbnails", @@ -59,26 +31,12 @@ public static class EmbyUrl }; return Url.Parse($"http://not-a-real-host/iptv/artwork/{artworkFolder}/emby") - .AppendPathSegment(pathSegment) - .SetQueryParams(query) - .SetQueryParam("maxHeight", height) + .AppendPathSegment(artworkId.ToString(CultureInfo.InvariantCulture)) .ToString() .Replace("http://not-a-real-host", "{RequestBase}"); } - public static Url RelativeProxyForArtwork(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("emby") - .AppendPathSegment(pathSegment) - .SetQueryParams(query); - } + public static Url RelativeProxyForArtwork(int artworkId) => + Url.Parse("emby") + .AppendPathSegment(artworkId.ToString(CultureInfo.InvariantCulture)); } diff --git a/ErsatzTV.Core/Jellyfin/JellyfinUrl.cs b/ErsatzTV.Core/Jellyfin/JellyfinUrl.cs index 01e18b16a..8c0965677 100644 --- a/ErsatzTV.Core/Jellyfin/JellyfinUrl.cs +++ b/ErsatzTV.Core/Jellyfin/JellyfinUrl.cs @@ -1,30 +1,11 @@ -using ErsatzTV.Core.Domain; +using System.Globalization; +using ErsatzTV.Core.Domain; using Flurl; namespace ErsatzTV.Core.Jellyfin; public static class JellyfinUrl { - public static Url ForArtwork(Option maybeJellyfin, string artwork) - { - string address = maybeJellyfin.Map(ms => ms.Connections.HeadOrNone().Map(c => c.Address)) - .Flatten() - .IfNone("jellyfin://"); - - 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(address) - .AppendPathSegment(pathSegment) - .SetQueryParams(query); - } - public static Url ForArtwork(string address, string artwork) { string[] split = artwork.Replace("jellyfin://", string.Empty).Split('?'); @@ -41,17 +22,8 @@ public static class JellyfinUrl .SetQueryParams(query); } - public static string PlaceholderProxyForArtwork(string artwork, ArtworkKind artworkKind, int height) + public static string PlaceholderProxyForArtwork(int artworkId, ArtworkKind artworkKind) { - string[] split = artwork.Replace("jellyfin://", string.Empty).Split('?'); - if (split.Length != 2) - { - return artwork; - } - - string pathSegment = split[0]; - QueryParamCollection query = Url.ParseQueryParams(split[1]); - string artworkFolder = artworkKind switch { ArtworkKind.Thumbnail => "thumbnails", @@ -59,48 +31,12 @@ public static class JellyfinUrl }; return Url.Parse($"http://not-a-real-host/iptv/artwork/{artworkFolder}/jellyfin") - .AppendPathSegment(pathSegment) - .SetQueryParams(query) - .SetQueryParam("fillHeight", height) + .AppendPathSegment(artworkId.ToString(CultureInfo.InvariantCulture)) .ToString() .Replace("http://not-a-real-host", "{RequestBase}"); } - public static Url ProxyForArtwork(string scheme, string host, string artwork, ArtworkKind artworkKind) - { - string[] split = artwork.Replace("jellyfin://", string.Empty).Split('?'); - if (split.Length != 2) - { - return artwork; - } - - string pathSegment = split[0]; - QueryParamCollection query = Url.ParseQueryParams(split[1]); - - string artworkFolder = artworkKind switch - { - ArtworkKind.Thumbnail => "thumbnails", - _ => "posters" - }; - - return Url.Parse($"{scheme}://{host}/iptv/artwork/{artworkFolder}/jellyfin") - .AppendPathSegment(pathSegment) - .SetQueryParams(query); - } - - public static Url RelativeProxyForArtwork(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("jellyfin") - .AppendPathSegment(pathSegment) - .SetQueryParams(query); - } + public static Url RelativeProxyForArtwork(int artworkId) => + Url.Parse("jellyfin") + .AppendPathSegment(artworkId.ToString(CultureInfo.InvariantCulture)); } diff --git a/ErsatzTV.Core/Plex/PlexUrl.cs b/ErsatzTV.Core/Plex/PlexUrl.cs new file mode 100644 index 000000000..002af00e3 --- /dev/null +++ b/ErsatzTV.Core/Plex/PlexUrl.cs @@ -0,0 +1,26 @@ +using System.Globalization; +using ErsatzTV.Core.Domain; +using Flurl; + +namespace ErsatzTV.Core.Plex; + +public static class PlexUrl +{ + public static string PlaceholderProxyForArtwork(int artworkId, ArtworkKind artworkKind) + { + string artworkFolder = artworkKind switch + { + ArtworkKind.Thumbnail => "thumbnails", + _ => "posters" + }; + + return Url.Parse($"http://not-a-real-host/iptv/artwork/{artworkFolder}/plex") + .AppendPathSegment(artworkId.ToString(CultureInfo.InvariantCulture)) + .ToString() + .Replace("http://not-a-real-host", "{RequestBase}"); + } + + public static Url RelativeProxyForArtwork(int artworkId) => + Url.Parse("plex") + .AppendPathSegment(artworkId.ToString(CultureInfo.InvariantCulture)); +} diff --git a/ErsatzTV/Controllers/AccountController.cs b/ErsatzTV/Controllers/AccountController.cs index 6594cd54d..fff3956d2 100644 --- a/ErsatzTV/Controllers/AccountController.cs +++ b/ErsatzTV/Controllers/AccountController.cs @@ -5,7 +5,7 @@ namespace ErsatzTV.Controllers; [ApiController] public class AccountController : ControllerBase { - private static readonly string[] AuthenticationSchemes = { "oidc", "cookie" }; + private static readonly string[] AuthenticationSchemes = ["oidc", "cookie"]; [HttpPost("account/logout")] public IActionResult Logout() => new SignOutResult(AuthenticationSchemes); diff --git a/ErsatzTV/Controllers/ArtworkController.cs b/ErsatzTV/Controllers/ArtworkController.cs index a3ce07dc5..413bb819d 100644 --- a/ErsatzTV/Controllers/ArtworkController.cs +++ b/ErsatzTV/Controllers/ArtworkController.cs @@ -9,6 +9,7 @@ using ErsatzTV.Core.Emby; using ErsatzTV.Core.Images; using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Jellyfin; +using ErsatzTV.Extensions; using Flurl; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -18,53 +19,51 @@ namespace ErsatzTV.Controllers; [ResponseCache(Duration = 3600)] [ApiController] [ApiExplorerSettings(IgnoreApi = true)] -public class ArtworkController : ControllerBase +public class ArtworkController( + IMediator mediator, + IHttpClientFactory httpClientFactory, + IChannelLogoGenerator channelLogoGenerator) + : ControllerBase { - private readonly IChannelLogoGenerator _channelLogoGenerator; - private readonly IHttpClientFactory _httpClientFactory; - private readonly IMediator _mediator; - - public ArtworkController( - IMediator mediator, - IHttpClientFactory httpClientFactory, - IChannelLogoGenerator channelLogoGenerator) - { - _mediator = mediator; - _httpClientFactory = httpClientFactory; - _channelLogoGenerator = channelLogoGenerator; - } - - [HttpHead("/artwork/{id}")] - [HttpGet("/artwork/{id}")] + [HttpHead("/artwork/{id:int}")] + [HttpGet("/artwork/{id:int}")] // This route redirect to the proper artwork from its Id public async Task RedirectArtwork(int id, CancellationToken cancellationToken) { Either artwork = - await _mediator.Send(new GetArtwork(id), cancellationToken); + await mediator.Send(new GetArtwork(id), cancellationToken); return artwork.Match( Left: _ => new NotFoundResult(), - Right: r => r.ArtworkKind switch + Right: r => { - ArtworkKind.Poster => new RedirectResult("/artwork/posters/" + r.Path), - ArtworkKind.Thumbnail => new RedirectResult("/artwork/thumbnails/" + r.Path), - ArtworkKind.Logo => new RedirectResult("/iptv/logos/" + r.Path), - ArtworkKind.FanArt => new RedirectResult("/artwork/fanart/" + r.Path), - ArtworkKind.Watermark => new RedirectResult("/artwork/watermarks/" + r.Path), - _ => new NotFoundResult() - } - ); + // only redirect local artwork + if (!r.Path.IsHex()) + { + return NotFound(); + } + + return r.ArtworkKind switch + { + ArtworkKind.Poster => new RedirectResult("/artwork/posters/" + r.Path), + ArtworkKind.Thumbnail => new RedirectResult("/artwork/thumbnails/" + r.Path), + ArtworkKind.Logo => new RedirectResult("/iptv/logos/" + r.Path), + ArtworkKind.FanArt => new RedirectResult("/artwork/fanart/" + r.Path), + ArtworkKind.Watermark => new RedirectResult("/artwork/watermarks/" + r.Path), + _ => new NotFoundResult() + }; + }); } - [HttpHead("/iptv/artwork/posters/{fileName}")] - [HttpGet("/iptv/artwork/posters/{fileName}")] - [HttpHead("/iptv/artwork/posters/{fileName}.jpg")] - [HttpGet("/iptv/artwork/posters/{fileName}.jpg")] - [HttpGet("/artwork/posters/{fileName}")] + [HttpHead("/iptv/artwork/posters/{fileName:hex}")] + [HttpGet("/iptv/artwork/posters/{fileName:hex}")] + [HttpHead("/iptv/artwork/posters/{fileName:hex}.jpg")] + [HttpGet("/iptv/artwork/posters/{fileName:hex}.jpg")] + [HttpGet("/artwork/posters/{fileName:hex}")] public async Task GetPoster(string fileName, CancellationToken cancellationToken) { Either cachedImagePath = - await _mediator.Send( + await mediator.Send( new GetCachedImagePath(fileName, ArtworkKind.Poster, string.Empty, 440), cancellationToken); return cachedImagePath.Match( @@ -72,7 +71,7 @@ public class ArtworkController : ControllerBase Right: r => new PhysicalFileResult(r.FileName, r.MimeType)); } - [HttpGet("/artwork/watermarks/{fileName}")] + [HttpGet("/artwork/watermarks/{fileName:hex}")] public async Task GetWatermark( string fileName, [FromQuery] @@ -80,7 +79,7 @@ public class ArtworkController : ControllerBase CancellationToken cancellationToken) { Either cachedImagePath = - await _mediator.Send( + await mediator.Send( new GetCachedImagePath(fileName, ArtworkKind.Watermark, contentType), cancellationToken); return cachedImagePath.Match( @@ -88,85 +87,56 @@ public class ArtworkController : ControllerBase Right: r => new PhysicalFileResult(r.FileName, r.MimeType)); } - [HttpGet("/artwork/fanart/{fileName}")] + [HttpGet("/artwork/fanart/{fileName:hex}")] public async Task GetFanArt(string fileName, CancellationToken cancellationToken) { Either cachedImagePath = - await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.FanArt, string.Empty), cancellationToken); + await mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.FanArt, string.Empty), cancellationToken); return cachedImagePath.Match( Left: _ => new NotFoundResult(), Right: r => new PhysicalFileResult(r.FileName, r.MimeType)); } - [HttpHead("/iptv/artwork/posters/jellyfin/{*path}")] - [HttpGet("/iptv/artwork/posters/jellyfin/{*path}")] - [HttpGet("/artwork/posters/jellyfin/{*path}")] - [HttpHead("/iptv/artwork/thumbnails/jellyfin/{*path}")] - [HttpGet("/iptv/artwork/thumbnails/jellyfin/{*path}")] - [HttpGet("/artwork/thumbnails/jellyfin/{*path}")] - [HttpGet("/artwork/fanart/jellyfin/{*path}")] - public Task GetJellyfin(string path, CancellationToken cancellationToken) - { - if (Request.QueryString.HasValue) - { - path += Request.QueryString.Value; - } - - return GetJellyfinArtwork(path, cancellationToken); - } - - [HttpHead("/iptv/artwork/posters/emby/{*path}")] - [HttpGet("/iptv/artwork/posters/emby/{*path}")] - [HttpGet("/artwork/posters/emby/{*path}")] - [HttpHead("/iptv/artwork/thumbnails/emby/{*path}")] - [HttpGet("/iptv/artwork/thumbnails/emby/{*path}")] - [HttpGet("/artwork/thumbnails/emby/{*path}")] - [HttpGet("/artwork/fanart/emby/{*path}")] - public Task GetEmby(string path, CancellationToken cancellationToken) - { - if (Request.QueryString.HasValue) - { - path += Request.QueryString.Value; - } - - return GetEmbyArtwork(path, cancellationToken); - } - - [HttpHead("/iptv/artwork/posters/plex/{plexMediaSourceId}/{*path}")] - [HttpGet("/iptv/artwork/posters/plex/{plexMediaSourceId}/{*path}")] - [HttpGet("/artwork/posters/plex/{plexMediaSourceId}/{*path}")] - public Task GetPlexPoster(int plexMediaSourceId, string path, CancellationToken cancellationToken) => - GetPlexArtwork( - plexMediaSourceId, - $"photo/:/transcode?url=/{path}&height=440&width=304&minSize=1&upscale=0", - cancellationToken); - - [HttpGet("/artwork/fanart/plex/{plexMediaSourceId}/{*path}")] - public Task GetPlexFanArt(int plexMediaSourceId, string path, CancellationToken cancellationToken) => - GetPlexArtwork(plexMediaSourceId, $"/{path}", cancellationToken); - - [HttpGet("/artwork/thumbnails/plex/{plexMediaSourceId}/{*path}")] - [HttpHead("/iptv/artwork/thumbnails/plex/{plexMediaSourceId}/{*path}")] - [HttpGet("/iptv/artwork/thumbnails/plex/{plexMediaSourceId}/{*path}")] - public Task GetPlexThumbnail( - int plexMediaSourceId, - string path, - CancellationToken cancellationToken) => - GetPlexArtwork( - plexMediaSourceId, - $"photo/:/transcode?url=/{path}&height=220&width=392&minSize=1&upscale=0", - cancellationToken); - - [HttpHead("/iptv/artwork/thumbnails/{fileName}")] - [HttpGet("/iptv/artwork/thumbnails/{fileName}")] - [HttpHead("/iptv/artwork/thumbnails/{fileName}.jpg")] - [HttpGet("/iptv/artwork/thumbnails/{fileName}.jpg")] - [HttpGet("/artwork/thumbnails/{fileName}")] + [HttpHead("/iptv/artwork/posters/plex/{id:int}")] + [HttpGet("/iptv/artwork/posters/plex/{id:int}")] + [HttpGet("/artwork/posters/plex/{id:int}")] + [HttpHead("/iptv/artwork/thumbnails/plex/{id:int}")] + [HttpGet("/iptv/artwork/thumbnails/plex/{id:int}")] + [HttpGet("/artwork/thumbnails/plex/{id:int}")] + [HttpGet("/artwork/fanart/plex/{id:int}")] + public Task GetPlex(int id, CancellationToken cancellationToken) => + GetPlexArtwork(id, cancellationToken); + + [HttpHead("/iptv/artwork/posters/jellyfin/{id:int}")] + [HttpGet("/iptv/artwork/posters/jellyfin/{id:int}")] + [HttpGet("/artwork/posters/jellyfin/{id:int}")] + [HttpHead("/iptv/artwork/thumbnails/jellyfin/{id:int}")] + [HttpGet("/iptv/artwork/thumbnails/jellyfin/{id:int}")] + [HttpGet("/artwork/thumbnails/jellyfin/{id:int}")] + [HttpGet("/artwork/fanart/jellyfin/{id:int}")] + public Task GetJellyfin(int id, CancellationToken cancellationToken) => + GetJellyfinArtwork(id, cancellationToken); + + [HttpHead("/iptv/artwork/posters/emby/{id:int}")] + [HttpGet("/iptv/artwork/posters/emby/{id:int}")] + [HttpGet("/artwork/posters/emby/{id:int}")] + [HttpHead("/iptv/artwork/thumbnails/emby/{id:int}")] + [HttpGet("/iptv/artwork/thumbnails/emby/{id:int}")] + [HttpGet("/artwork/thumbnails/emby/{id:int}")] + [HttpGet("/artwork/fanart/emby/{id:int}")] + public Task GetEmby(int id, CancellationToken cancellationToken) => + GetEmbyArtwork(id, cancellationToken); + + [HttpHead("/iptv/artwork/thumbnails/{fileName:hex}")] + [HttpGet("/iptv/artwork/thumbnails/{fileName:hex}")] + [HttpHead("/iptv/artwork/thumbnails/{fileName:hex}.jpg")] + [HttpGet("/iptv/artwork/thumbnails/{fileName:hex}.jpg")] + [HttpGet("/artwork/thumbnails/{fileName:hex}")] public async Task GetThumbnail(string fileName, CancellationToken cancellationToken) { Either cachedImagePath = - await _mediator.Send( + await mediator.Send( new GetCachedImagePath(fileName, ArtworkKind.Thumbnail, string.Empty, 220), cancellationToken); return cachedImagePath.Match( @@ -174,123 +144,200 @@ public class ArtworkController : ControllerBase Right: r => new PhysicalFileResult(r.FileName, r.MimeType)); } - private async Task GetPlexArtwork( - int plexMediaSourceId, - string transcodePath, - CancellationToken cancellationToken) + private async Task GetPlexArtwork(int id, CancellationToken cancellationToken) { #if DEBUG_NO_SYNC await Task.CompletedTask; return NotFound(); #else - Either connectionParameters = - await _mediator.Send(new GetPlexConnectionParameters(plexMediaSourceId), cancellationToken); + Either artwork = + await mediator.Send(new GetArtwork(id), cancellationToken); - return await connectionParameters.Match>( + return await artwork.Match( Left: _ => new NotFoundResult().AsTask(), - Right: async r => + Right: async art => { - try + // plex/{id}/library/metadata/x/thumb/y + string[] split = (art.Path ?? string.Empty).Split('/'); + if (split.Length < 7 || split[0] != "plex" || !int.TryParse(split[1], out int plexMediaSourceId)) { - HttpClient client = _httpClientFactory.CreateClient(); - HttpContext.Response.RegisterForDispose(client); - client.DefaultRequestHeaders.Add("X-Plex-Token", r.AuthToken); - - var fullPath = new Uri(new Uri(r.Address), transcodePath); - HttpResponseMessage response = await client.GetAsync( - fullPath, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken); - HttpContext.Response.RegisterForDispose(response); - - Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken); - - return new FileStreamResult( - stream, - response.Content.Headers.ContentType?.MediaType ?? "image/jpeg"); + return NotFound(); } - catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + + var path = string.Join('/', split[2..]); + + string transcodePath = art.ArtworkKind switch + { + ArtworkKind.Poster => $"photo/:/transcode?url=/{path}&height=440&width=304&minSize=1&upscale=0", + ArtworkKind.Thumbnail => $"photo/:/transcode?url=/{path}&height=220&width=392&minSize=1&upscale=0", + ArtworkKind.FanArt => $"/{path}", + _ => null + }; + + if (string.IsNullOrWhiteSpace(transcodePath)) { return NotFound(); } + + Either connectionParameters = + await mediator.Send(new GetPlexConnectionParameters(plexMediaSourceId), cancellationToken); + + return await connectionParameters.Match( + Left: _ => new NotFoundResult().AsTask(), + Right: async r => + { + try + { + HttpClient client = httpClientFactory.CreateClient(); + HttpContext.Response.RegisterForDispose(client); + client.DefaultRequestHeaders.Add("X-Plex-Token", r.AuthToken); + + var fullPath = new Uri(new Uri(r.Address), transcodePath); + HttpResponseMessage response = await client.GetAsync( + fullPath, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + HttpContext.Response.RegisterForDispose(response); + + Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken); + + return new FileStreamResult( + stream, + response.Content.Headers.ContentType?.MediaType ?? "image/jpeg"); + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return NotFound(); + } + }); }); #endif } - private async Task GetJellyfinArtwork(string path, CancellationToken cancellationToken) + private async Task GetJellyfinArtwork(int id, CancellationToken cancellationToken) { #if DEBUG_NO_SYNC await Task.CompletedTask; return NotFound(); #else Either connectionParameters = - await _mediator.Send(new GetJellyfinConnectionParameters(), cancellationToken); + await mediator.Send(new GetJellyfinConnectionParameters(), cancellationToken); - return await connectionParameters.Match>( + return await connectionParameters.Match( Left: _ => new NotFoundResult().AsTask(), Right: async vm => { - try - { - HttpClient client = _httpClientFactory.CreateClient(); - HttpContext.Response.RegisterForDispose(client); - - Url fullPath = JellyfinUrl.ForArtwork(vm.Address, path); - HttpResponseMessage response = await client.GetAsync( - fullPath, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken); - HttpContext.Response.RegisterForDispose(response); - - Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken); - - return new FileStreamResult( - stream, - response.Content.Headers.ContentType?.MediaType ?? "image/jpeg"); - } - catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) - { - return NotFound(); - } + Either artwork = + await mediator.Send(new GetArtwork(id), cancellationToken); + + return await artwork.Match( + Left: _ => new NotFoundResult().AsTask(), + Right: async art => + { + try + { + if (!(art.Path ?? string.Empty).StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase)) + { + return NotFound(); + } + + HttpClient client = httpClientFactory.CreateClient(); + HttpContext.Response.RegisterForDispose(client); + + Url fullPath = JellyfinUrl.ForArtwork(vm.Address, art.Path); + string fillHeight = art.ArtworkKind switch + { + ArtworkKind.Poster => "440", + ArtworkKind.Thumbnail => "220", + _ => string.Empty + }; + + if (!string.IsNullOrWhiteSpace(fillHeight)) + { + fullPath.SetQueryParam("fillHeight", fillHeight); + } + + HttpResponseMessage response = await client.GetAsync( + fullPath, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + HttpContext.Response.RegisterForDispose(response); + + Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken); + + return new FileStreamResult( + stream, + response.Content.Headers.ContentType?.MediaType ?? "image/jpeg"); + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return NotFound(); + } + }); }); #endif } - private async Task GetEmbyArtwork(string path, CancellationToken cancellationToken) + private async Task GetEmbyArtwork(int id, CancellationToken cancellationToken) { #if DEBUG_NO_SYNC await Task.CompletedTask; return NotFound(); #else Either connectionParameters = - await _mediator.Send(new GetEmbyConnectionParameters(), cancellationToken); + await mediator.Send(new GetEmbyConnectionParameters(), cancellationToken); - return await connectionParameters.Match>( + return await connectionParameters.Match( Left: _ => new NotFoundResult().AsTask(), Right: async vm => { - try - { - HttpClient client = _httpClientFactory.CreateClient(); - HttpContext.Response.RegisterForDispose(client); - - Url fullPath = EmbyUrl.ForArtwork(vm.Address, path); - HttpResponseMessage response = await client.GetAsync( - fullPath, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken); - HttpContext.Response.RegisterForDispose(response); - - Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken); - - return new FileStreamResult( - stream, - response.Content.Headers.ContentType?.MediaType ?? "image/jpeg"); - } - catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) - { - return NotFound(); - } + Either artwork = + await mediator.Send(new GetArtwork(id), cancellationToken); + + return await artwork.Match( + Left: _ => new NotFoundResult().AsTask(), + Right: async art => + { + try + { + if (!(art.Path ?? string.Empty).StartsWith("emby://", StringComparison.OrdinalIgnoreCase)) + { + return NotFound(); + } + + HttpClient client = httpClientFactory.CreateClient(); + HttpContext.Response.RegisterForDispose(client); + + Url fullPath = EmbyUrl.ForArtwork(vm.Address, art.Path); + string maxHeight = art.ArtworkKind switch + { + ArtworkKind.Poster => "440", + ArtworkKind.Thumbnail => "220", + _ => string.Empty + }; + + if (!string.IsNullOrWhiteSpace(maxHeight)) + { + fullPath.SetQueryParam("maxHeight", maxHeight); + } + + HttpResponseMessage response = await client.GetAsync( + fullPath, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + HttpContext.Response.RegisterForDispose(response); + + Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken); + + return new FileStreamResult( + stream, + response.Content.Headers.ContentType?.MediaType ?? "image/jpeg"); + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return NotFound(); + } + }); }); #endif } @@ -299,7 +346,7 @@ public class ArtworkController : ControllerBase public IActionResult GenerateChannelLogo( string text, // param name = ChannelLogoGenerator.GetRouteQueryParamName CancellationToken cancellationToken) => - _channelLogoGenerator + channelLogoGenerator .GenerateChannelLogo(text, 100, 200, cancellationToken).Match( Left: _ => new RedirectResult("/iptv/images/ersatztv-500.png"), Right: img => File(img, "image/png") diff --git a/ErsatzTV/Controllers/InternalController.cs b/ErsatzTV/Controllers/InternalController.cs index 3f17886e4..8df46e904 100644 --- a/ErsatzTV/Controllers/InternalController.cs +++ b/ErsatzTV/Controllers/InternalController.cs @@ -81,7 +81,7 @@ public class InternalController : StreamingControllerBase return File(Encoding.UTF8.GetBytes(EmptySubtitleDocument("text/x-ssa")), "text/x-ssa"); } - [HttpGet("ffmpeg/remote-stream/{remoteStreamId}")] + [HttpGet("ffmpeg/remote-stream/{remoteStreamId:int}")] public async Task GetRemoteStream( int remoteStreamId, [FromQuery] diff --git a/ErsatzTV/Controllers/IptvController.cs b/ErsatzTV/Controllers/IptvController.cs index bafd14ec4..9a9cf52c0 100644 --- a/ErsatzTV/Controllers/IptvController.cs +++ b/ErsatzTV/Controllers/IptvController.cs @@ -283,10 +283,10 @@ public class IptvController : StreamingControllerBase } } - [HttpHead("iptv/logos/{fileName}")] - [HttpGet("iptv/logos/{fileName}")] - [HttpHead("iptv/logos/{fileName}.jpg")] - [HttpGet("iptv/logos/{fileName}.jpg")] + [HttpHead("iptv/logos/{fileName:hex}")] + [HttpGet("iptv/logos/{fileName:hex}")] + [HttpHead("iptv/logos/{fileName:hex}.jpg")] + [HttpGet("iptv/logos/{fileName:hex}.jpg")] public async Task GetImage(string fileName, [FromQuery] string contentType) { Either cachedImagePath = diff --git a/ErsatzTV/Controllers/SortController.cs b/ErsatzTV/Controllers/SortController.cs index 4ca55e6f8..2421bcbbc 100644 --- a/ErsatzTV/Controllers/SortController.cs +++ b/ErsatzTV/Controllers/SortController.cs @@ -12,7 +12,7 @@ public class SortController : ControllerBase public SortController(IMediator mediator) => _mediator = mediator; - [HttpPost("media/collections/{collectionId}/items")] + [HttpPost("media/collections/{collectionId:int}/items")] public Task SortCollectionItems( int collectionId, [FromForm] diff --git a/ErsatzTV/Extensions/StringExtensions.cs b/ErsatzTV/Extensions/StringExtensions.cs index 921bd4543..e3b68680c 100644 --- a/ErsatzTV/Extensions/StringExtensions.cs +++ b/ErsatzTV/Extensions/StringExtensions.cs @@ -6,6 +6,25 @@ namespace ErsatzTV.Extensions; public static class StringExtensions { + public static bool IsHex(this string maybeHex) + { + if (maybeHex is { Length: >= 2 }) + { + foreach (char c in maybeHex) + { + bool isHex = c is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F'; + if (!isHex) + { + return false; + } + } + + return true; + } + + return false; + } + public static string GetSearchQuery(this string uri) { try diff --git a/ErsatzTV/HexConstraint.cs b/ErsatzTV/HexConstraint.cs new file mode 100644 index 000000000..58d0fb83e --- /dev/null +++ b/ErsatzTV/HexConstraint.cs @@ -0,0 +1,23 @@ +using System.Globalization; +using ErsatzTV.Extensions; + +namespace ErsatzTV; + +public class HexConstraint : IRouteConstraint +{ + public bool Match( + HttpContext httpContext, + IRouter route, + string routeKey, + RouteValueDictionary values, + RouteDirection routeDirection) + { + if (values.TryGetValue(routeKey, out object routeValue)) + { + var stringValue = Convert.ToString(routeValue, CultureInfo.InvariantCulture); + return stringValue.IsHex(); + } + + return false; + } +} diff --git a/ErsatzTV/Resources/Templates/_episode.sbntxt b/ErsatzTV/Resources/Templates/_episode.sbntxt index 22162a37d..b924f7036 100644 --- a/ErsatzTV/Resources/Templates/_episode.sbntxt +++ b/ErsatzTV/Resources/Templates/_episode.sbntxt @@ -48,7 +48,7 @@ The resulting XML will be minified by ErsatzTV - so feel free to keep things nic {{ end }} {{ if episode_has_artwork }} - {{ episode_artwork_url }}" + {{ episode_artwork_url }} {{ end }} {{ if episode_has_thumbnail }} {{ episode_thumbnail_url }} diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 1be86ed7a..494abc589 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -133,6 +133,11 @@ public class Startup options.KnownProxies.Clear(); }); + services.Configure(options => + { + options.ConstraintMap.Add("hex", typeof(HexConstraint)); + }); + services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(FileSystemLayout.DataProtectionFolder)); services.AddOpenApi( @@ -666,7 +671,8 @@ public class Startup app.UseResponseCompression(); app.MapWhen( - ctx => !IsIptvPath(ctx.Request.Path) && !IsInternalPath(ctx.Request.Path) && !IsApiPath(ctx.Request.Path), + ctx => !IsIptvPath(ctx.Request.Path) && !IsInternalPath(ctx.Request.Path) && !IsApiPath(ctx.Request.Path) && + !IsArtworkPath(ctx.Request.Path), blazor => { blazor.UseRouting(); @@ -690,22 +696,24 @@ public class Startup endpoints.MapOpenApi(); } - endpoints.MapScalarApiReference("/docs", options => - { - options.AddDocument( - "scripted-schedule", - "Scripted Schedule", - "openapi/scripted-schedule-tagged.json"); - options.AddDocument("v1", "General", "openapi/v1.json"); - options.HideClientButton = true; - options.DocumentDownloadType = DocumentDownloadType.None; - options.Title = "ErsatzTV API Reference"; - }); + endpoints.MapScalarApiReference( + "/docs", + options => + { + options.AddDocument( + "scripted-schedule", + "Scripted Schedule", + "openapi/scripted-schedule-tagged.json"); + options.AddDocument("v1", "General", "openapi/v1.json"); + options.HideClientButton = true; + options.DocumentDownloadType = DocumentDownloadType.None; + options.Title = "ErsatzTV API Reference"; + }); }); }); app.MapWhen( - ctx => IsIptvPath(ctx.Request.Path) || IsInternalPath(ctx.Request.Path), + ctx => IsIptvPath(ctx.Request.Path) || IsInternalPath(ctx.Request.Path) || IsArtworkPath(ctx.Request.Path), api => { api.UseRouting(); @@ -741,6 +749,8 @@ public class Startup bool IsInternalPath(PathString path) => path.StartsWithSegments("/internal"); + bool IsArtworkPath(PathString path) => path.StartsWithSegments("/artwork"); + // troubleshooting endpoints are requested directly by the browser, so they stay on the blazor // branch and authorize with the ui cookie instead of the api key bool IsApiPath(PathString path) => path.StartsWithSegments("/api") && !IsTroubleshootPath(path);