allow external channel logo urls (#2067)

This commit is contained in:
Jason Dove
2025-06-24 00:43:24 +00:00
committed by GitHub
parent 20d2fe71cd
commit f71b6527c0
12 changed files with 113 additions and 39 deletions
+4
View File
@@ -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)
@@ -51,10 +51,16 @@ public class CreateChannelHandler(
var artwork = new List<Artwork>();
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
@@ -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<RefreshChannelList>
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<RefreshChannelList>
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)
};
@@ -42,30 +42,46 @@ public class UpdateChannelHandler(
c.MusicVideoCreditsMode = update.MusicVideoCreditsMode;
c.MusicVideoCreditsTemplate = update.MusicVideoCreditsTemplate;
c.SongVideoMode = update.SongVideoMode;
c.Artwork ??= new List<Artwork>();
c.Artwork ??= [];
if (!string.IsNullOrWhiteSpace(update.Logo))
{
Option<Artwork> 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 =>
Option<Artwork> 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
{
artwork.Path = update.Logo;
artwork.DateUpdated = DateTime.UtcNow;
},
() =>
{
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;
+13 -3
View File
@@ -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<Artwork> 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
+6
View File
@@ -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);
}
+9 -5
View File
@@ -60,12 +60,16 @@ public class ChannelPlaylist
sb.AppendLine("#KODIPROP:inputstream.ffmpegdirect.open_mode=ffmpeg");
}
string logo = Optional(channel.Artwork).Flatten()
Option<Artwork> 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('=')
+15 -4
View File
@@ -99,9 +99,9 @@
<MudGrid Class="mt-3" Style="align-items: center" Justify="Justify.Center">
<MudItem xs="6">
<InputFile id="fileInput" OnChange="UploadLogo" hidden/>
@if (!string.IsNullOrWhiteSpace(_model.Logo))
@if (!string.IsNullOrWhiteSpace(_model.Logo) || !string.IsNullOrWhiteSpace(_model.ExternalLogoUrl))
{
<MudElement HtmlTag="img" src="@($"iptv/logos/{_model.Logo}")" Style="max-height: 50px"/>
<MudElement HtmlTag="img" src="@(string.IsNullOrWhiteSpace(_model.ExternalLogoUrl) ? _model.Logo : _model.ExternalLogoUrl)" Style="max-height: 50px"/>
}
</MudItem>
<MudItem xs="6">
@@ -114,6 +114,7 @@
</MudButton>
</MudItem>
</MudGrid>
<MudTextField Label="External Logo URL" @bind-Value="_model.ExternalLogoUrl" For="@(() => _model.ExternalLogoUrl)"/>
<MudSelect Class="mt-3" Label="Watermark" @bind-Value="_model.WatermarkId" For="@(() => _model.WatermarkId)"
Disabled="@(_model.StreamingMode == StreamingMode.HttpLiveStreamingDirect)"
Clearable="true">
@@ -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 =>
+1 -1
View File
@@ -45,7 +45,7 @@
<MudTd DataLabel="Logo">
@if (!string.IsNullOrWhiteSpace(context.Logo))
{
<MudElement HtmlTag="img" src="@($"iptv/logos/{context.Logo}")" Style="max-height: 50px"/>
<MudElement HtmlTag="img" src="@context.Logo" Style="max-height: 50px"/>
}
else
{
+4 -1
View File
@@ -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 lang="en">{{ category }}</category>
{{ end }}
{{ if channel_has_artwork }}
{{ if channel_has_external_artwork }}
<icon src="{{ channel_artwork_path }}" />
{{ else if channel_has_artwork }}
<icon src="{RequestBase}/iptv/logos/{{ channel_artwork_path }}.jpg{AccessTokenUri}" />
{{ else }}
<icon src="{RequestBase}/iptv/logos/gen{AccessTokenUri}&text={{ channel_name_encoded }}" />
@@ -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<ChannelEditViewMo
RuleFor(x => 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");
});
}
}
+3 -2
View File
@@ -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,