feat(525): download external-url logo on channel update

This commit is contained in:
2026-07-21 12:00:01 +02:00
parent 3bde23808b
commit 8dbd5a0e6d
3 changed files with 107 additions and 24 deletions
@@ -6,6 +6,7 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -19,7 +20,8 @@ namespace ErsatzTV.Application.Channels;
public class UpdateChannelHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
ISearchTargets searchTargets)
ISearchTargets searchTargets,
IRemoteLogoCacher remoteLogoCacher)
: IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>>
{
public async Task<Either<BaseError, ChannelViewModel>> Handle(
@@ -39,17 +41,45 @@ public class UpdateChannelHandler(
{
Validation<BaseError, Channel> validation =
await Validate(dbContext, request, channel, cancellationToken);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
return await validation.Match(
Succ: async c =>
{
Either<BaseError, string> resolvedLogo = await ResolveLogoPath(request, cancellationToken);
return await resolvedLogo.Match(
Right: async logoPath => Right<BaseError, ChannelViewModel>(
await ApplyUpdateRequest(dbContext, c, request, logoPath, cancellationToken)),
Left: e => Task.FromResult(Left<BaseError, ChannelViewModel>(e)));
},
Fail: errors => Task.FromResult(Left<BaseError, ChannelViewModel>(errors.Head)));
},
None: () => Task.FromResult(
Left<BaseError, ChannelViewModel>(
new NotFoundError($"Channel {request.ChannelId} does not exist."))));
}
// Resolve the incoming logo path into a value safe to persist. An external http(s) URL is
// downloaded and cached (a cacher Left fails the whole save); an empty path (logo removal) or an
// already-local/cached path passes through unchanged. (ersatztv#525)
private async Task<Either<BaseError, string>> ResolveLogoPath(
UpdateChannel request,
CancellationToken cancellationToken)
{
string path = request.Logo?.Path ?? string.Empty;
if (!Artwork.IsExternalUrl(path))
{
return path;
}
Either<BaseError, string> cached = await remoteLogoCacher.CacheFromUrl(new Uri(path), cancellationToken);
return cached;
}
private async Task<ChannelViewModel> ApplyUpdateRequest(
TvContext dbContext,
Channel c,
UpdateChannel update,
string resolvedLogoPath,
CancellationToken cancellationToken)
{
bool hasEpgChange = c.PlayoutSource != update.PlayoutSource || c.ShowInEpg != update.ShowInEpg;
@@ -76,9 +106,9 @@ public class UpdateChannelHandler(
c.ShowInEpg = update.IsEnabled && update.ShowInEpg;
c.Artwork ??= [];
if (!string.IsNullOrWhiteSpace(update.Logo?.Path))
if (!string.IsNullOrWhiteSpace(resolvedLogoPath))
{
string logo = update.Logo.Path;
string logo = resolvedLogoPath;
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
{
logo = logo.Replace("iptv/logos/", string.Empty);
@@ -6,15 +6,17 @@ using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class UpdateChannelHandlerTests : ChannelHandlerTestBase
{
private UpdateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets);
private UpdateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets, RemoteLogoCacher);
[Test]
public async Task Should_Return_NotFoundError_When_Channel_Missing()
@@ -186,6 +188,53 @@ public class UpdateChannelHandlerTests : ChannelHandlerTestBase
channel.PlayoutOffset.ShouldBeNull();
}
[Test]
public async Task Should_Download_External_Url_Logo_And_Store_Cache_Name()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
RemoteLogoCacher.CacheFromUrl(Arg.Any<Uri>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, string>("cachedhash"));
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", logoPath: "https://example.com/logo.png"),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext db = Db.CreateContext();
Artwork logo = db.Channels.Include(c => c.Artwork).Single(c => c.Id == channel.Id)
.Artwork.Single(a => a.ArtworkKind == ArtworkKind.Logo);
logo.Path.ShouldBe("cachedhash");
logo.IsExternalUrl().ShouldBeFalse();
}
[Test]
public async Task Should_Fail_The_Save_When_The_Logo_Download_Fails()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
RemoteLogoCacher.CacheFromUrl(Arg.Any<Uri>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, string>(BaseError.New("Could not download logo: host timed out")));
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", logoPath: "https://example.com/logo.png"),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
LeftOf(result).Value.ShouldContain("Could not download logo");
}
[Test]
public async Task Should_Not_Call_The_Cacher_For_An_Uploaded_Logo_Path()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", logoPath: "iptv/logos/deadbeef"),
CancellationToken.None);
await RemoteLogoCacher.DidNotReceive().CacheFromUrl(Arg.Any<Uri>(), Arg.Any<CancellationToken>());
}
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
}
@@ -3,6 +3,7 @@ using ErsatzTV.Application;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using NSubstitute;
@@ -16,6 +17,7 @@ public abstract class ChannelHandlerTestBase
protected InMemoryTvContext Db = null!;
protected ChannelWriter<IBackgroundServiceRequest> Worker = null!;
protected ISearchTargets SearchTargets = null!;
protected IRemoteLogoCacher RemoteLogoCacher = null!;
[SetUp]
public async Task BaseSetUp()
@@ -23,6 +25,7 @@ public abstract class ChannelHandlerTestBase
Db = await InMemoryTvContext.CreateAsync();
Worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
SearchTargets = Substitute.For<ISearchTargets>();
RemoteLogoCacher = Substitute.For<IRemoteLogoCacher>();
}
[TearDown]
@@ -35,7 +38,7 @@ public abstract class ChannelHandlerTestBase
await context.SaveChangesAsync();
}
protected async Task SeedChannel(
protected async Task<DomainChannel> SeedChannel(
int id,
string number,
string name = "Test",
@@ -43,25 +46,26 @@ public abstract class ChannelHandlerTestBase
string group = "ErsatzTV")
{
await using TvContext context = Db.CreateContext();
context.Channels.Add(
new DomainChannel(Guid.NewGuid())
{
Id = id,
Number = number,
Name = name,
Group = group,
Categories = string.Empty,
FFmpegProfileId = ffmpegProfileId,
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PreferredSubtitleLanguageCode = string.Empty,
MusicVideoCreditsTemplate = string.Empty,
StreamingMode = StreamingMode.TransportStreamHybrid,
PlayoutSource = ChannelPlayoutSource.Generated,
PlayoutMode = ChannelPlayoutMode.Continuous
});
var channel = new DomainChannel(Guid.NewGuid())
{
Id = id,
Number = number,
Name = name,
Group = group,
Categories = string.Empty,
FFmpegProfileId = ffmpegProfileId,
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PreferredSubtitleLanguageCode = string.Empty,
MusicVideoCreditsTemplate = string.Empty,
StreamingMode = StreamingMode.TransportStreamHybrid,
PlayoutSource = ChannelPlayoutSource.Generated,
PlayoutMode = ChannelPlayoutMode.Continuous
};
context.Channels.Add(channel);
await context.SaveChangesAsync();
return channel;
}
protected async Task SeedPlayout(int id, int channelId)