From f71b6527c03c2268db9334ccd6bb30a9b76543c7 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Mon, 23 Jun 2025 19:43:24 -0500 Subject: [PATCH] allow external channel logo urls (#2067) --- CHANGELOG.md | 4 ++ .../Channels/Commands/CreateChannelHandler.cs | 8 ++- .../Commands/RefreshChannelListHandler.cs | 7 ++- .../Channels/Commands/UpdateChannelHandler.cs | 54 ++++++++++++------- ErsatzTV.Application/Channels/Mapper.cs | 16 ++++-- ErsatzTV.Core/Domain/Metadata/Artwork.cs | 6 +++ ErsatzTV.Core/Iptv/ChannelPlaylist.cs | 14 +++-- ErsatzTV/Pages/ChannelEditor.razor | 19 +++++-- ErsatzTV/Pages/Channels.razor | 2 +- ErsatzTV/Resources/Templates/_channel.sbntxt | 5 +- .../ChannelEditViewModelValidator.cs | 12 ++++- ErsatzTV/ViewModels/ChannelEditViewModel.cs | 5 +- 12 files changed, 113 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dca504486..9476b853d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - e.g. `smart_collection:"one" NOT smart_collection:"two"` - Cycles will be detected and logged, and searches with cycles will not work as expected - Add all `ETV_*` environment variables to Troubleshooting > General info +- Add `External Logo URL` field to channel editor + - Using external (public) logos should fix channel logo display for clients that don't proxy artwork (such as Plex) + - Users who have customized the XMLTV channel template `channel.sbntxt` will need to update their templates again + - This is because the templates require different logic for external URLs vs ETV-hosted URLs ### Changed - Start to make UI minimally responsive (functional on smaller screens) diff --git a/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs index e82c72e26..c02122bde 100644 --- a/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs @@ -51,10 +51,16 @@ public class CreateChannelHandler( var artwork = new List(); if (!string.IsNullOrWhiteSpace(request.Logo)) { + string logo = request.Logo; + if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal)) + { + logo = logo.Replace("iptv/logos/", string.Empty); + } + artwork.Add( new Artwork { - Path = request.Logo, + Path = logo, ArtworkKind = ArtworkKind.Logo, DateAdded = DateTime.UtcNow, DateUpdated = DateTime.UtcNow diff --git a/ErsatzTV.Application/Channels/Commands/RefreshChannelListHandler.cs b/ErsatzTV.Application/Channels/Commands/RefreshChannelListHandler.cs index 6356fbb8d..66d51ea95 100644 --- a/ErsatzTV.Application/Channels/Commands/RefreshChannelListHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/RefreshChannelListHandler.cs @@ -3,6 +3,7 @@ using System.Net; using System.Xml; using Dapper; using ErsatzTV.Core; +using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Iptv; using ErsatzTV.Infrastructure.Data; @@ -77,6 +78,9 @@ public class RefreshChannelListHandler : IRequestHandler await foreach (ChannelResult channel in GetChannels(dbContext).WithCancellation(cancellationToken)) { + bool hasLogo = !string.IsNullOrWhiteSpace(channel.ArtworkPath); + bool hasExternalLogo = hasLogo && Artwork.IsExternalUrl(channel.ArtworkPath); + var data = new { ChannelId = ChannelIdentifier.FromNumber(channel.Number), @@ -84,7 +88,8 @@ public class RefreshChannelListHandler : IRequestHandler ChannelNumber = channel.Number, ChannelName = channel.Name, ChannelCategories = GetCategories(channel.Categories), - ChannelHasArtwork = !string.IsNullOrWhiteSpace(channel.ArtworkPath), + ChannelHasExternalArtwork = hasExternalLogo, + ChannelHasArtwork = hasLogo, ChannelArtworkPath = channel.ArtworkPath, ChannelNameEncoded = WebUtility.UrlEncode(channel.Name) }; diff --git a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs index e7a291943..828a1f4aa 100644 --- a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs @@ -42,30 +42,46 @@ public class UpdateChannelHandler( c.MusicVideoCreditsMode = update.MusicVideoCreditsMode; c.MusicVideoCreditsTemplate = update.MusicVideoCreditsTemplate; c.SongVideoMode = update.SongVideoMode; - c.Artwork ??= new List(); + c.Artwork ??= []; if (!string.IsNullOrWhiteSpace(update.Logo)) { - Option maybeLogo = - Optional(c.Artwork).Flatten().FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo); + string logo = update.Logo; + if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal)) + { + logo = logo.Replace("iptv/logos/", string.Empty); + } - maybeLogo.Match( - artwork => - { - artwork.Path = update.Logo; - artwork.DateUpdated = DateTime.UtcNow; - }, - () => + Option maybeLogo = c.Artwork.Where(a => a.ArtworkKind == ArtworkKind.Logo).HeadOrNone(); + foreach (Artwork artwork in maybeLogo) + { + artwork.Path = logo; + artwork.DateUpdated = DateTime.UtcNow; + } + + if (maybeLogo.IsNone) + { + var artwork = new Artwork { - var artwork = new Artwork - { - Path = update.Logo, - DateAdded = DateTime.UtcNow, - DateUpdated = DateTime.UtcNow, - ArtworkKind = ArtworkKind.Logo - }; - c.Artwork.Add(artwork); - }); + Path = logo, + DateAdded = DateTime.UtcNow, + DateUpdated = DateTime.UtcNow, + ArtworkKind = ArtworkKind.Logo + }; + c.Artwork.Add(artwork); + } + } + else + { + await dbContext.Entry(c) + .Collection(channel => channel.Artwork) + .LoadAsync(); + + foreach (Artwork artwork in c.Artwork.Where(x => x.ArtworkKind is ArtworkKind.Logo).ToList()) + { + c.Artwork.Remove(artwork); + dbContext.Artwork.Remove(artwork); + } } c.ProgressMode = update.ProgressMode; diff --git a/ErsatzTV.Application/Channels/Mapper.cs b/ErsatzTV.Application/Channels/Mapper.cs index a64bb9cab..cfe475b98 100644 --- a/ErsatzTV.Application/Channels/Mapper.cs +++ b/ErsatzTV.Application/Channels/Mapper.cs @@ -42,9 +42,19 @@ internal static class Mapper internal static ResolutionAndBitrateViewModel ProjectToViewModel(Resolution resolution, int bitrate) => new(resolution.Height, resolution.Width, bitrate); - private static string GetLogo(Channel channel) => - Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo)) - .Match(a => a.Path, string.Empty); + private static string GetLogo(Channel channel) + { + Option maybeArtwork = channel.Artwork + .Where(a => a.ArtworkKind == ArtworkKind.Logo) + .HeadOrNone(); + + foreach (Artwork artwork in maybeArtwork) + { + return artwork.IsExternalUrl() ? artwork.Path : $"iptv/logos/{artwork.Path}"; + } + + return string.Empty; + } private static string GetStreamingMode(Channel channel) => channel.StreamingMode switch diff --git a/ErsatzTV.Core/Domain/Metadata/Artwork.cs b/ErsatzTV.Core/Domain/Metadata/Artwork.cs index bd0767407..d9dae6257 100644 --- a/ErsatzTV.Core/Domain/Metadata/Artwork.cs +++ b/ErsatzTV.Core/Domain/Metadata/Artwork.cs @@ -11,4 +11,10 @@ public class Artwork public ArtworkKind ArtworkKind { get; set; } public DateTime DateAdded { get; set; } public DateTime DateUpdated { get; set; } + + public bool IsExternalUrl() => IsExternalUrl(Path); + + public static bool IsExternalUrl(string path) => + Uri.TryCreate(path ?? string.Empty, UriKind.Absolute, out Uri uriResult) + && (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps); } diff --git a/ErsatzTV.Core/Iptv/ChannelPlaylist.cs b/ErsatzTV.Core/Iptv/ChannelPlaylist.cs index d47884db7..f63655c41 100644 --- a/ErsatzTV.Core/Iptv/ChannelPlaylist.cs +++ b/ErsatzTV.Core/Iptv/ChannelPlaylist.cs @@ -60,12 +60,16 @@ public class ChannelPlaylist sb.AppendLine("#KODIPROP:inputstream.ffmpegdirect.open_mode=ffmpeg"); } - string logo = Optional(channel.Artwork).Flatten() + Option maybeArtwork = Optional(channel.Artwork).Flatten() .Filter(a => a.ArtworkKind == ArtworkKind.Logo) - .HeadOrNone() - .Match( - artwork => $"{_scheme}://{_host}{_baseUrl}/iptv/logos/{artwork.Path}.jpg{accessTokenUri}", - () => $"{_scheme}://{_host}{_baseUrl}/iptv/logos/gen?text={channel.WebEncodedName}{accessTokenUriAmp}"); + .HeadOrNone(); + var logo = $"{_scheme}://{_host}{_baseUrl}/iptv/logos/gen?text={channel.WebEncodedName}{accessTokenUriAmp}"; + foreach (Artwork artwork in maybeArtwork) + { + logo = artwork.IsExternalUrl() + ? artwork.Path + : $"{_scheme}://{_host}{_baseUrl}/iptv/logos/{artwork.Path}.jpg{accessTokenUri}"; + } string shortUniqueId = Convert.ToBase64String(channel.UniqueId.ToByteArray()) .TrimEnd('=') diff --git a/ErsatzTV/Pages/ChannelEditor.razor b/ErsatzTV/Pages/ChannelEditor.razor index 176f98a40..9bceccd06 100644 --- a/ErsatzTV/Pages/ChannelEditor.razor +++ b/ErsatzTV/Pages/ChannelEditor.razor @@ -99,9 +99,9 @@ @@ -114,6 +114,7 @@ + @@ -187,7 +188,16 @@ _model.Categories = channelViewModel.Categories; _model.Number = channelViewModel.Number; _model.FFmpegProfileId = channelViewModel.FFmpegProfileId; - _model.Logo = channelViewModel.Logo; + + if (Artwork.IsExternalUrl(channelViewModel.Logo)) + { + _model.ExternalLogoUrl = channelViewModel.Logo; + } + else + { + _model.Logo = channelViewModel.Logo; + } + _model.ProgressMode = channelViewModel.ProgressMode; _model.StreamingMode = channelViewModel.StreamingMode; _model.PreferredAudioLanguageCode = channelViewModel.PreferredAudioLanguageCode; @@ -265,7 +275,8 @@ maybeCacheFileName.Match( relativeFileName => { - _model.Logo = relativeFileName; + _model.Logo = $"iptv/logos/{relativeFileName}"; + _model.ExternalLogoUrl = null; StateHasChanged(); }, error => diff --git a/ErsatzTV/Pages/Channels.razor b/ErsatzTV/Pages/Channels.razor index ac321cb7d..ae4c44b29 100644 --- a/ErsatzTV/Pages/Channels.razor +++ b/ErsatzTV/Pages/Channels.razor @@ -45,7 +45,7 @@ @if (!string.IsNullOrWhiteSpace(context.Logo)) { - + } else { diff --git a/ErsatzTV/Resources/Templates/_channel.sbntxt b/ErsatzTV/Resources/Templates/_channel.sbntxt index 431b0aed9..293f415e5 100644 --- a/ErsatzTV/Resources/Templates/_channel.sbntxt +++ b/ErsatzTV/Resources/Templates/_channel.sbntxt @@ -7,6 +7,7 @@ Available values: - channel_name - channel_categories - channel_has_artwork + - channel_has_external_artwork - channel_artwork_path - channel_name_encoded @@ -25,7 +26,9 @@ The resulting XML will be minified by ErsatzTV - so feel free to keep things nic {{ for category in channel_categories }} {{ category }} {{ end }} - {{ if channel_has_artwork }} + {{ if channel_has_external_artwork }} + + {{ else if channel_has_artwork }} {{ else }} diff --git a/ErsatzTV/Validators/ChannelEditViewModelValidator.cs b/ErsatzTV/Validators/ChannelEditViewModelValidator.cs index 74df18b93..29e4c6ccb 100644 --- a/ErsatzTV/Validators/ChannelEditViewModelValidator.cs +++ b/ErsatzTV/Validators/ChannelEditViewModelValidator.cs @@ -1,5 +1,4 @@ -using System.Globalization; -using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain; using ErsatzTV.ViewModels; using FluentValidation; @@ -15,5 +14,14 @@ public class ChannelEditViewModelValidator : AbstractValidator x.Name).NotEmpty(); RuleFor(x => x.Group).NotEmpty(); RuleFor(x => x.FFmpegProfileId).GreaterThan(0); + + When( + x => !string.IsNullOrWhiteSpace(x.ExternalLogoUrl), + () => + { + RuleFor(x => x.ExternalLogoUrl) + .Must(Artwork.IsExternalUrl) + .WithMessage("External logo url is invalid"); + }); } } diff --git a/ErsatzTV/ViewModels/ChannelEditViewModel.cs b/ErsatzTV/ViewModels/ChannelEditViewModel.cs index 44fa8a6f4..ab2fa5a92 100644 --- a/ErsatzTV/ViewModels/ChannelEditViewModel.cs +++ b/ErsatzTV/ViewModels/ChannelEditViewModel.cs @@ -15,6 +15,7 @@ public class ChannelEditViewModel public string PreferredAudioLanguageCode { get; set; } public string PreferredAudioTitle { get; set; } public string Logo { get; set; } + public string ExternalLogoUrl { get; set; } public ChannelProgressMode ProgressMode { get; set; } public StreamingMode StreamingMode { get; set; } public int? WatermarkId { get; set; } @@ -39,7 +40,7 @@ public class ChannelEditViewModel Group, Categories, FFmpegProfileId, - Logo, + string.IsNullOrWhiteSpace(ExternalLogoUrl) ? Logo : ExternalLogoUrl, PreferredAudioLanguageCode, PreferredAudioTitle, ProgressMode, @@ -59,7 +60,7 @@ public class ChannelEditViewModel Group, Categories, FFmpegProfileId, - Logo, + string.IsNullOrWhiteSpace(ExternalLogoUrl) ? Logo : ExternalLogoUrl, PreferredAudioLanguageCode, PreferredAudioTitle, ProgressMode,