allow external channel logo urls (#2067)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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('=')
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user