Browse Source

fix: artwork cleanup (#2997)

pull/2998/head
Jason Dove 2 weeks ago committed by GitHub
parent
commit
b3a351167a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      CHANGELOG.md
  2. 52
      ErsatzTV.Application/Artworks/ArtworkMapper.cs
  3. 11
      ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs
  4. 76
      ErsatzTV.Application/MediaCards/Mapper.cs
  5. 38
      ErsatzTV.Application/Movies/Mapper.cs
  6. 41
      ErsatzTV.Application/Television/Mapper.cs
  7. 118
      ErsatzTV.Core.Tests/Jellyfin/JellyfinUrlTests.cs
  8. 56
      ErsatzTV.Core/Emby/EmbyUrl.cs
  9. 78
      ErsatzTV.Core/Jellyfin/JellyfinUrl.cs
  10. 26
      ErsatzTV.Core/Plex/PlexUrl.cs
  11. 2
      ErsatzTV/Controllers/AccountController.cs
  12. 405
      ErsatzTV/Controllers/ArtworkController.cs
  13. 2
      ErsatzTV/Controllers/InternalController.cs
  14. 8
      ErsatzTV/Controllers/IptvController.cs
  15. 2
      ErsatzTV/Controllers/SortController.cs
  16. 19
      ErsatzTV/Extensions/StringExtensions.cs
  17. 23
      ErsatzTV/HexConstraint.cs
  18. 2
      ErsatzTV/Resources/Templates/_episode.sbntxt
  19. 36
      ErsatzTV/Startup.cs

2
CHANGELOG.md

@ -20,6 +20,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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

52
ErsatzTV.Application/Artworks/ArtworkMapper.cs

@ -0,0 +1,52 @@ @@ -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<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> 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<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> 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;
}
}

11
ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs

@ -10,6 +10,7 @@ using ErsatzTV.Core.Interfaces.Metadata; @@ -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<RefreshChannelData> @@ -1092,13 +1093,17 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
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
{

76
ErsatzTV.Application/MediaCards/Mapper.cs

@ -1,9 +1,8 @@ @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -218,18 +217,11 @@ internal static class Mapper
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> 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 @@ -249,58 +241,20 @@ internal static class Mapper
{
Option<SeasonMetadata> maybeSeasonMetadata = episodeMetadata.Episode.Season.SeasonMetadata.HeadOrNone();
return maybeSeasonMetadata.Match(
seasonMetadata => GetPoster(seasonMetadata, maybeJellyfin, maybeEmby),
seasonMetadata => ArtworkMapper.Artwork(seasonMetadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby),
() =>
{
Option<ShowMetadata> 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<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> 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<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> 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<EmbyMediaSource> maybeEmby) =>
ArtworkMapper.Artwork(metadata, ArtworkKind.Thumbnail, maybeJellyfin, maybeEmby);
}

38
ErsatzTV.Application/Movies/Mapper.cs

@ -1,4 +1,5 @@ @@ -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 @@ -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 @@ -55,37 +56,4 @@ internal static class Mapper
.Distinct()
.ToList();
}
private static string Artwork(
Metadata metadata,
ArtworkKind artworkKind,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> 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;
}
}

41
ErsatzTV.Application/Television/Mapper.cs

@ -1,8 +1,6 @@ @@ -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 @@ -69,46 +67,13 @@ internal static class Mapper
Metadata metadata,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby) =>
GetArtwork(metadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby);
ArtworkMapper.Artwork(metadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby);
private static string GetFanArt(
Metadata metadata,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby) =>
GetArtwork(metadata, ArtworkKind.FanArt, maybeJellyfin, maybeEmby);
private static string GetArtwork(
Metadata metadata,
ArtworkKind artworkKind,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> 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<CultureInfo> LanguagesForShow(List<string> languages)
{

118
ErsatzTV.Core.Tests/Jellyfin/JellyfinUrlTests.cs

@ -1,118 +0,0 @@ @@ -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<JellyfinConnection>
{
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<JellyfinConnection>
{
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<JellyfinConnection>
{
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<JellyfinConnection>
{
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<JellyfinConnection>
{
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<JellyfinConnection>
{
new() { Address = address }
}
};
Url url = JellyfinUrl.ForArtwork(Some(mediaSource), artwork);
url.ToString().ShouldBe("https://some.jellyfin.server/jellyfin/Items/2/Images/3?tag=4");
}
}

56
ErsatzTV.Core/Emby/EmbyUrl.cs

@ -1,30 +1,11 @@ @@ -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<EmbyMediaSource> 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 @@ -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 @@ -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));
}

78
ErsatzTV.Core/Jellyfin/JellyfinUrl.cs

@ -1,30 +1,11 @@ @@ -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<JellyfinMediaSource> 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 @@ -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 @@ -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));
}

26
ErsatzTV.Core/Plex/PlexUrl.cs

@ -0,0 +1,26 @@ @@ -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));
}

2
ErsatzTV/Controllers/AccountController.cs

@ -5,7 +5,7 @@ namespace ErsatzTV.Controllers; @@ -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);

405
ErsatzTV/Controllers/ArtworkController.cs

@ -9,6 +9,7 @@ using ErsatzTV.Core.Emby; @@ -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; @@ -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<IActionResult> RedirectArtwork(int id, CancellationToken cancellationToken)
{
Either<BaseError, Artwork> artwork =
await _mediator.Send(new GetArtwork(id), cancellationToken);
await mediator.Send(new GetArtwork(id), cancellationToken);
return artwork.Match<IActionResult>(
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<IActionResult> GetPoster(string fileName, CancellationToken cancellationToken)
{
Either<BaseError, CachedImagePathViewModel> cachedImagePath =
await _mediator.Send(
await mediator.Send(
new GetCachedImagePath(fileName, ArtworkKind.Poster, string.Empty, 440),
cancellationToken);
return cachedImagePath.Match<IActionResult>(
@ -72,7 +71,7 @@ public class ArtworkController : ControllerBase @@ -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<IActionResult> GetWatermark(
string fileName,
[FromQuery]
@ -80,7 +79,7 @@ public class ArtworkController : ControllerBase @@ -80,7 +79,7 @@ public class ArtworkController : ControllerBase
CancellationToken cancellationToken)
{
Either<BaseError, CachedImagePathViewModel> cachedImagePath =
await _mediator.Send(
await mediator.Send(
new GetCachedImagePath(fileName, ArtworkKind.Watermark, contentType),
cancellationToken);
return cachedImagePath.Match<IActionResult>(
@ -88,85 +87,56 @@ public class ArtworkController : ControllerBase @@ -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<IActionResult> GetFanArt(string fileName, CancellationToken cancellationToken)
{
Either<BaseError, CachedImagePathViewModel> 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<IActionResult>(
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> GetThumbnail(string fileName, CancellationToken cancellationToken)
{
Either<BaseError, CachedImagePathViewModel> cachedImagePath =
await _mediator.Send(
await mediator.Send(
new GetCachedImagePath(fileName, ArtworkKind.Thumbnail, string.Empty, 220),
cancellationToken);
return cachedImagePath.Match<IActionResult>(
@ -174,123 +144,200 @@ public class ArtworkController : ControllerBase @@ -174,123 +144,200 @@ public class ArtworkController : ControllerBase
Right: r => new PhysicalFileResult(r.FileName, r.MimeType));
}
private async Task<IActionResult> GetPlexArtwork(
int plexMediaSourceId,
string transcodePath,
CancellationToken cancellationToken)
private async Task<IActionResult> GetPlexArtwork(int id, CancellationToken cancellationToken)
{
#if DEBUG_NO_SYNC
await Task.CompletedTask;
return NotFound();
#else
Either<BaseError, PlexConnectionParametersViewModel> connectionParameters =
await _mediator.Send(new GetPlexConnectionParameters(plexMediaSourceId), cancellationToken);
Either<BaseError, Artwork> artwork =
await mediator.Send(new GetArtwork(id), cancellationToken);
return await connectionParameters.Match<Task<IActionResult>>(
return await artwork.Match(
Left: _ => new NotFoundResult().AsTask<IActionResult>(),
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<BaseError, PlexConnectionParametersViewModel> connectionParameters =
await mediator.Send(new GetPlexConnectionParameters(plexMediaSourceId), cancellationToken);
return await connectionParameters.Match(
Left: _ => new NotFoundResult().AsTask<IActionResult>(),
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<IActionResult> GetJellyfinArtwork(string path, CancellationToken cancellationToken)
private async Task<IActionResult> GetJellyfinArtwork(int id, CancellationToken cancellationToken)
{
#if DEBUG_NO_SYNC
await Task.CompletedTask;
return NotFound();
#else
Either<BaseError, JellyfinConnectionParametersViewModel> connectionParameters =
await _mediator.Send(new GetJellyfinConnectionParameters(), cancellationToken);
await mediator.Send(new GetJellyfinConnectionParameters(), cancellationToken);
return await connectionParameters.Match<Task<IActionResult>>(
return await connectionParameters.Match(
Left: _ => new NotFoundResult().AsTask<IActionResult>(),
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<BaseError, Artwork> artwork =
await mediator.Send(new GetArtwork(id), cancellationToken);
return await artwork.Match(
Left: _ => new NotFoundResult().AsTask<IActionResult>(),
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<IActionResult> GetEmbyArtwork(string path, CancellationToken cancellationToken)
private async Task<IActionResult> GetEmbyArtwork(int id, CancellationToken cancellationToken)
{
#if DEBUG_NO_SYNC
await Task.CompletedTask;
return NotFound();
#else
Either<BaseError, EmbyConnectionParametersViewModel> connectionParameters =
await _mediator.Send(new GetEmbyConnectionParameters(), cancellationToken);
await mediator.Send(new GetEmbyConnectionParameters(), cancellationToken);
return await connectionParameters.Match<Task<IActionResult>>(
return await connectionParameters.Match(
Left: _ => new NotFoundResult().AsTask<IActionResult>(),
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<BaseError, Artwork> artwork =
await mediator.Send(new GetArtwork(id), cancellationToken);
return await artwork.Match(
Left: _ => new NotFoundResult().AsTask<IActionResult>(),
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 @@ -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<IActionResult>(
Left: _ => new RedirectResult("/iptv/images/ersatztv-500.png"),
Right: img => File(img, "image/png")

2
ErsatzTV/Controllers/InternalController.cs

@ -81,7 +81,7 @@ public class InternalController : StreamingControllerBase @@ -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<IActionResult> GetRemoteStream(
int remoteStreamId,
[FromQuery]

8
ErsatzTV/Controllers/IptvController.cs

@ -283,10 +283,10 @@ public class IptvController : StreamingControllerBase @@ -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<IActionResult> GetImage(string fileName, [FromQuery] string contentType)
{
Either<BaseError, CachedImagePathViewModel> cachedImagePath =

2
ErsatzTV/Controllers/SortController.cs

@ -12,7 +12,7 @@ public class SortController : ControllerBase @@ -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]

19
ErsatzTV/Extensions/StringExtensions.cs

@ -6,6 +6,25 @@ namespace ErsatzTV.Extensions; @@ -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

23
ErsatzTV/HexConstraint.cs

@ -0,0 +1,23 @@ @@ -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;
}
}

2
ErsatzTV/Resources/Templates/_episode.sbntxt

@ -48,7 +48,7 @@ The resulting XML will be minified by ErsatzTV - so feel free to keep things nic @@ -48,7 +48,7 @@ The resulting XML will be minified by ErsatzTV - so feel free to keep things nic
{{ end }}
{{ if episode_has_artwork }}
<icon src="{{ episode_artwork_url }}" />
<image type="poster" size="3">{{ episode_artwork_url }}"</image>
<image type="poster" size="3">{{ episode_artwork_url }}</image>
{{ end }}
{{ if episode_has_thumbnail }}
<image type="still" orient="L" size="3">{{ episode_thumbnail_url }}</image>

36
ErsatzTV/Startup.cs

@ -133,6 +133,11 @@ public class Startup @@ -133,6 +133,11 @@ public class Startup
options.KnownProxies.Clear();
});
services.Configure<RouteOptions>(options =>
{
options.ConstraintMap.Add("hex", typeof(HexConstraint));
});
services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(FileSystemLayout.DataProtectionFolder));
services.AddOpenApi(
@ -666,7 +671,8 @@ public class Startup @@ -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 @@ -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 @@ -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);

Loading…
Cancel
Save