feat(525): download external-url logo on channel update
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user