mirror of https://github.com/ErsatzTV/ErsatzTV.git
24 changed files with 833 additions and 293 deletions
@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
|
||||
using ErsatzTV.FFmpeg; |
||||
|
||||
namespace ErsatzTV.Application.Streaming; |
||||
|
||||
public record GetGraphicsCanvasStream( |
||||
string ChannelNumber, |
||||
int PlayoutItemId, |
||||
TimeSpan Offset, |
||||
TimeSpan Duration, |
||||
FrameRate FrameRate) : IRequest<Option<Stream>>; |
||||
@ -0,0 +1,261 @@
@@ -0,0 +1,261 @@
|
||||
using System.IO.Pipelines; |
||||
using CliWrap; |
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Extensions; |
||||
using ErsatzTV.Core.FFmpeg; |
||||
using ErsatzTV.Core.Interfaces.FFmpeg; |
||||
using ErsatzTV.Core.Interfaces.Streaming; |
||||
using ErsatzTV.Core.Interfaces.Troubleshooting; |
||||
using ErsatzTV.FFmpeg; |
||||
using ErsatzTV.Infrastructure.Data; |
||||
using ErsatzTV.Infrastructure.Extensions; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using Microsoft.Extensions.Logging; |
||||
|
||||
namespace ErsatzTV.Application.Streaming; |
||||
|
||||
public class GetGraphicsCanvasStreamHandler( |
||||
IDbContextFactory<TvContext> dbContextFactory, |
||||
IWatermarkSelector watermarkSelector, |
||||
IGraphicsElementSelector graphicsElementSelector, |
||||
IGraphicsEngineContextFactory graphicsEngineContextFactory, |
||||
IGraphicsEngine graphicsEngine, |
||||
IFFmpegSegmenterService ffmpegSegmenterService, |
||||
ITroubleshootingPlayoutItemStore troubleshootingPlayoutItemStore, |
||||
ILogger<GetGraphicsCanvasStreamHandler> logger) |
||||
: IRequestHandler<GetGraphicsCanvasStream, Option<Stream>> |
||||
{ |
||||
public async Task<Option<Stream>> Handle(GetGraphicsCanvasStream request, CancellationToken cancellationToken) |
||||
{ |
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); |
||||
|
||||
Option<string> maybeFFmpegPath = await dbContext.ConfigElements |
||||
.GetValue<string>(ConfigElementKey.FFmpegPath, cancellationToken); |
||||
if (maybeFFmpegPath.IsNone) |
||||
{ |
||||
logger.LogWarning("Unable to stream graphics canvas; ffmpeg path is not configured"); |
||||
return None; |
||||
} |
||||
|
||||
Option<CanvasItem> maybeCanvasItem = request.ChannelNumber == FileSystemLayout.TranscodeTroubleshootingChannel |
||||
? troubleshootingPlayoutItemStore.Current().Map(t => new CanvasItem(t.Channel, t.PlayoutItem, None)) |
||||
: await LoadCanvasItem(dbContext, request, cancellationToken); |
||||
|
||||
foreach ((Channel channel, PlayoutItem playoutItem, Option<ChannelWatermark> maybeGlobalWatermark) in maybeCanvasItem) |
||||
{ |
||||
DateTimeOffset contentStart = playoutItem.StartOffset; |
||||
DateTimeOffset now = contentStart + request.Offset; |
||||
DateTimeOffset finish = playoutItem.FinishOffset < now + request.Duration |
||||
? playoutItem.FinishOffset |
||||
: now + request.Duration; |
||||
|
||||
// a stale offset past the item's finish still gets the requested duration so
|
||||
// next's ffmpeg never sees an empty stream
|
||||
if (finish <= now) |
||||
{ |
||||
logger.LogWarning( |
||||
"Graphics canvas request for playout item {PlayoutItemId} at offset {Offset} is past item finish {Finish}", |
||||
playoutItem.Id, |
||||
request.Offset, |
||||
playoutItem.FinishOffset); |
||||
|
||||
finish = now + request.Duration; |
||||
} |
||||
|
||||
List<WatermarkOptions> watermarks = watermarkSelector.SelectWatermarks( |
||||
maybeGlobalWatermark, |
||||
channel, |
||||
playoutItem, |
||||
now, |
||||
shouldLogMessages: false); |
||||
|
||||
List<PlayoutItemGraphicsElement> graphicsElements = graphicsElementSelector.SelectGraphicsElements( |
||||
channel, |
||||
playoutItem, |
||||
now, |
||||
shouldLogMessages: false); |
||||
|
||||
DateTimeOffset channelStartTime = contentStart; |
||||
if (ffmpegSegmenterService.TryGetWorker(request.ChannelNumber, out IHlsSessionWorker worker)) |
||||
{ |
||||
channelStartTime = worker.GetModel().StartedAt; |
||||
} |
||||
|
||||
MediaVersion headVersion = playoutItem.MediaItem.GetHeadVersion(); |
||||
// the engine derives content_total_seconds as Seek + ContentTotalDuration, so this is
|
||||
// the time remaining in the item (legacy passes finish - now), not the media duration
|
||||
TimeSpan contentTotalDuration = playoutItem.FinishOffset > now |
||||
? playoutItem.FinishOffset - now |
||||
: finish - now; |
||||
|
||||
Option<GraphicsEngineContext> maybeContext = await graphicsEngineContextFactory.Create( |
||||
channel, |
||||
playoutItem.MediaItem, |
||||
headVersion, |
||||
watermarks, |
||||
graphicsElements, |
||||
request.FrameRate, |
||||
channelStartTime, |
||||
contentStart, |
||||
playoutItem.FinishOffset, |
||||
request.Offset, |
||||
finish - now, |
||||
contentTotalDuration, |
||||
cancellationToken); |
||||
|
||||
// nothing selected (channel edited since sync): stream transparent frames rather
|
||||
// than an error, which would kill next's ffmpeg for the whole chunk
|
||||
GraphicsEngineContext context = maybeContext.IfNone( |
||||
() => new GraphicsEngineContext( |
||||
channel.Number, |
||||
playoutItem.MediaItem, |
||||
Elements: [], |
||||
TemplateVariables: [], |
||||
channel.FFmpegProfile.Resolution, |
||||
channel.FFmpegProfile.Resolution, |
||||
request.FrameRate, |
||||
channelStartTime, |
||||
contentStart, |
||||
playoutItem.FinishOffset, |
||||
request.Offset, |
||||
finish - now, |
||||
contentTotalDuration)); |
||||
|
||||
logger.LogDebug( |
||||
"Streaming graphics canvas for channel {ChannelNumber} item {PlayoutItemId}: offset {Offset}, duration {Duration}, rate {FrameRate}, elements {ElementCount}", |
||||
channel.Number, |
||||
playoutItem.Id, |
||||
request.Offset, |
||||
finish - now, |
||||
request.FrameRate.RFrameRate, |
||||
context.Elements.Count); |
||||
|
||||
return StartCanvas( |
||||
await maybeFFmpegPath.IfNoneAsync(string.Empty), |
||||
channel.FFmpegProfile.Resolution, |
||||
request.FrameRate, |
||||
context, |
||||
cancellationToken); |
||||
} |
||||
|
||||
logger.LogWarning( |
||||
"Unable to locate playout item {PlayoutItemId} for graphics canvas on channel {ChannelNumber}", |
||||
request.PlayoutItemId, |
||||
request.ChannelNumber); |
||||
|
||||
return None; |
||||
} |
||||
|
||||
private sealed record CanvasItem(Channel Channel, PlayoutItem PlayoutItem, Option<ChannelWatermark> GlobalWatermark); |
||||
|
||||
private static async Task<Option<CanvasItem>> LoadCanvasItem( |
||||
TvContext dbContext, |
||||
GetGraphicsCanvasStream request, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
Option<Channel> maybeChannel = await dbContext.Channels |
||||
.AsNoTracking() |
||||
.Include(c => c.FFmpegProfile) |
||||
.ThenInclude(p => p.Resolution) |
||||
.Include(c => c.Watermark) |
||||
.Include(c => c.Artwork) |
||||
.Include(c => c.MirrorSourceChannel) |
||||
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber, cancellationToken); |
||||
|
||||
foreach (Channel channel in maybeChannel) |
||||
{ |
||||
// mirror channels play the source channel's items shifted by the playout offset,
|
||||
// matching what SyncNextPlayoutHandler wrote for next
|
||||
TimeSpan playoutOffset = TimeSpan.Zero; |
||||
string sourceChannelNumber = channel.Number; |
||||
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel is not null) |
||||
{ |
||||
sourceChannelNumber = channel.MirrorSourceChannel.Number; |
||||
playoutOffset = channel.PlayoutOffset ?? TimeSpan.Zero; |
||||
} |
||||
|
||||
Option<PlayoutItem> maybePlayoutItem = await dbContext.PlayoutItems |
||||
.AsNoTracking() |
||||
.Where(i => i.Id == request.PlayoutItemId && i.Playout.Channel.Number == sourceChannelNumber) |
||||
.IncludeForNextPlayout() |
||||
.AsSplitQuery() |
||||
.SingleOrDefaultAsync(cancellationToken) |
||||
.Map(Optional); |
||||
|
||||
foreach (PlayoutItem playoutItem in maybePlayoutItem) |
||||
{ |
||||
playoutItem.Start += playoutOffset; |
||||
playoutItem.Finish += playoutOffset; |
||||
|
||||
Option<ChannelWatermark> maybeGlobalWatermark = await dbContext.ConfigElements |
||||
.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken) |
||||
.BindT(watermarkId => dbContext.ChannelWatermarks |
||||
.SelectOneAsync(w => w.Id, w => w.Id == watermarkId, cancellationToken)); |
||||
|
||||
return new CanvasItem(channel, playoutItem, maybeGlobalWatermark); |
||||
} |
||||
} |
||||
|
||||
return None; |
||||
} |
||||
|
||||
private Stream StartCanvas( |
||||
string ffmpegPath, |
||||
Resolution resolution, |
||||
FrameRate frameRate, |
||||
GraphicsEngineContext context, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
// for process counter
|
||||
var ffmpegProcess = new FFmpegProcess(); |
||||
|
||||
var cts = new CancellationTokenSource(); |
||||
|
||||
// do not use 'using' here; the token needs to live longer than this method scope
|
||||
var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken); |
||||
|
||||
var enginePipe = new Pipe(); |
||||
var outputPipe = new Pipe(); |
||||
|
||||
// fire and forget graphics engine task
|
||||
_ = graphicsEngine.Run(context, enginePipe.Writer, linkedCts.Token); |
||||
|
||||
string[] arguments = |
||||
[ |
||||
"-hide_banner", "-nostats", "-loglevel", "error", |
||||
"-f", "rawvideo", |
||||
"-pix_fmt", "bgra", |
||||
"-video_size", $"{resolution.Width}x{resolution.Height}", |
||||
"-framerate", frameRate.RFrameRate, |
||||
"-i", "pipe:0", |
||||
"-c:v", "ffv1", "-level", "3", "-slices", "16", "-slicecrc", "0", |
||||
"-pix_fmt", "bgra", |
||||
"-f", "nut", "pipe:1" |
||||
]; |
||||
|
||||
CommandTask<CommandResult> task = Cli.Wrap(ffmpegPath) |
||||
.WithArguments(arguments) |
||||
.WithStandardInputPipe(PipeSource.FromStream(enginePipe.Reader.AsStream())) |
||||
.WithStandardOutputPipe(PipeTarget.ToStream(outputPipe.Writer.AsStream())) |
||||
.WithStandardErrorPipe(PipeTarget.ToDelegate(line => logger.LogWarning("Graphics canvas ffmpeg: {Line}", line))) |
||||
.WithValidation(CommandResultValidation.None) |
||||
.ExecuteAsync(linkedCts.Token); |
||||
|
||||
// ensure cleanup happens when ffmpeg exits (either naturally or via cancellation);
|
||||
// cancelling stops the engine if ffmpeg exited first
|
||||
_ = task.Task.ContinueWith( |
||||
(t, _) => |
||||
{ |
||||
outputPipe.Writer.Complete(t.Exception); |
||||
cts.Cancel(); |
||||
ffmpegProcess.Dispose(); |
||||
linkedCts.Dispose(); |
||||
cts.Dispose(); |
||||
}, |
||||
null, |
||||
TaskScheduler.Default); |
||||
|
||||
return outputPipe.Reader.AsStream(); |
||||
} |
||||
} |
||||
@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.FFmpeg; |
||||
using ErsatzTV.FFmpeg; |
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Streaming; |
||||
|
||||
public interface IGraphicsEngineContextFactory |
||||
{ |
||||
/// <summary>
|
||||
/// Builds a fully loaded engine context, or None when nothing would be rendered.
|
||||
/// </summary>
|
||||
Task<Option<GraphicsEngineContext>> Create( |
||||
Channel channel, |
||||
MediaItem mediaItem, |
||||
MediaVersion videoVersion, |
||||
List<WatermarkOptions> watermarks, |
||||
List<PlayoutItemGraphicsElement> elements, |
||||
FrameRate frameRate, |
||||
DateTimeOffset channelStartTime, |
||||
DateTimeOffset contentStartTime, |
||||
DateTimeOffset contentFinishTime, |
||||
TimeSpan seek, |
||||
TimeSpan duration, |
||||
TimeSpan contentTotalDuration, |
||||
CancellationToken cancellationToken); |
||||
} |
||||
@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Troubleshooting; |
||||
|
||||
public record TroubleshootingPlayoutItem(Channel Channel, PlayoutItem PlayoutItem); |
||||
|
||||
/// <summary>
|
||||
/// Media item troubleshooting builds a synthetic playout item that never reaches the database;
|
||||
/// this holds it so the graphics canvas endpoint can serve the troubleshooting channel.
|
||||
/// </summary>
|
||||
public interface ITroubleshootingPlayoutItemStore |
||||
{ |
||||
void Store(Channel channel, PlayoutItem playoutItem); |
||||
Option<TroubleshootingPlayoutItem> Current(); |
||||
void Clear(); |
||||
} |
||||
@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Interfaces.Troubleshooting; |
||||
|
||||
namespace ErsatzTV.Core.Troubleshooting; |
||||
|
||||
public class TroubleshootingPlayoutItemStore : ITroubleshootingPlayoutItemStore |
||||
{ |
||||
private volatile TroubleshootingPlayoutItem _current; |
||||
|
||||
public void Store(Channel channel, PlayoutItem playoutItem) => _current = new TroubleshootingPlayoutItem(channel, playoutItem); |
||||
|
||||
public Option<TroubleshootingPlayoutItem> Current() => Optional(_current); |
||||
|
||||
public void Clear() => _current = null; |
||||
} |
||||
@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.FFmpeg; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using ErsatzTV.Core.Metadata; |
||||
using ErsatzTV.FFmpeg; |
||||
using ErsatzTV.Infrastructure.Streaming.Graphics; |
||||
using Microsoft.Extensions.Logging.Abstractions; |
||||
using NSubstitute; |
||||
using NUnit.Framework; |
||||
using Shouldly; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Tests.Streaming; |
||||
|
||||
[TestFixture] |
||||
public class GraphicsCanvasContextTests |
||||
{ |
||||
[TestCase(0, 44)] |
||||
[TestCase(44, 44)] |
||||
[TestCase(1760, 40)] |
||||
public async Task Should_keep_scheduled_stop_independent_of_chunk(int offsetSeconds, int durationSeconds) |
||||
{ |
||||
var loader = new GraphicsElementLoader(null!, null!, Substitute.For<ITemplateDataRepository>(), |
||||
NullLogger<GraphicsElementLoader>.Instance); |
||||
var factory = new GraphicsEngineContextFactory(loader); |
||||
var start = new DateTimeOffset(2026, 9, 10, 12, 0, 0, TimeSpan.Zero); |
||||
DateTimeOffset finish = start.AddMinutes(30); |
||||
var resolution = new Resolution { Width = 1920, Height = 1080 }; |
||||
var channel = new Channel(Guid.NewGuid()) |
||||
{ |
||||
Number = "1", |
||||
FFmpegProfile = new FFmpegProfile { Resolution = resolution, ScalingBehavior = ScalingBehavior.Stretch } |
||||
}; |
||||
|
||||
var result = await factory.Create( |
||||
channel, new Movie(), new MediaVersion(), |
||||
[new WatermarkOptions(new ChannelWatermark(), "watermark.png", LanguageExt.Option<int>.None)], |
||||
[], new FrameRate("24000/1001"), start, start, finish, |
||||
TimeSpan.FromSeconds(offsetSeconds), TimeSpan.FromSeconds(durationSeconds), |
||||
TimeSpan.FromMinutes(30), CancellationToken.None); |
||||
|
||||
result.IsSome.ShouldBeTrue(); |
||||
foreach (var context in result) |
||||
{ |
||||
context.TemplateVariables[MediaItemTemplateDataKey.Stop].ShouldBe(finish); |
||||
context.TemplateVariables[MediaItemTemplateDataKey.StreamSeek].ShouldBe(TimeSpan.FromSeconds(offsetSeconds)); |
||||
context.Duration.ShouldBe(TimeSpan.FromSeconds(durationSeconds)); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.FFmpeg; |
||||
using ErsatzTV.Core.Interfaces.Streaming; |
||||
using ErsatzTV.FFmpeg; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Streaming.Graphics; |
||||
|
||||
public class GraphicsEngineContextFactory(IGraphicsElementLoader graphicsElementLoader) |
||||
: IGraphicsEngineContextFactory |
||||
{ |
||||
public async Task<Option<GraphicsEngineContext>> Create( |
||||
Channel channel, |
||||
MediaItem mediaItem, |
||||
MediaVersion videoVersion, |
||||
List<WatermarkOptions> watermarks, |
||||
List<PlayoutItemGraphicsElement> elements, |
||||
FrameRate frameRate, |
||||
DateTimeOffset channelStartTime, |
||||
DateTimeOffset contentStartTime, |
||||
DateTimeOffset contentFinishTime, |
||||
TimeSpan seek, |
||||
TimeSpan duration, |
||||
TimeSpan contentTotalDuration, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
if (watermarks.Count == 0 && elements.Count == 0) |
||||
{ |
||||
return None; |
||||
} |
||||
|
||||
List<GraphicsElementContext> elementContexts = [.. watermarks.Select(wm => new WatermarkElementContext(wm))]; |
||||
|
||||
var context = new GraphicsEngineContext( |
||||
channel.Number, |
||||
mediaItem, |
||||
elementContexts, |
||||
TemplateVariables: [], |
||||
SquarePixelFrameSize(channel.FFmpegProfile, videoVersion), |
||||
channel.FFmpegProfile.Resolution, |
||||
frameRate, |
||||
channelStartTime, |
||||
contentStartTime, |
||||
contentFinishTime, |
||||
seek, |
||||
duration, |
||||
contentTotalDuration); |
||||
|
||||
context = await graphicsElementLoader.LoadAll(context, elements, cancellationToken); |
||||
|
||||
return context?.Elements?.Count > 0 ? Some(context) : None; |
||||
} |
||||
|
||||
// must match the content box ffmpeg produces: stretch and crop both fill the frame,
|
||||
// scale-and-pad leaves the square-pixel scaled size inside the padded frame
|
||||
private static Resolution SquarePixelFrameSize(FFmpegProfile profile, MediaVersion videoVersion) |
||||
{ |
||||
if (profile.ScalingBehavior is ScalingBehavior.Stretch or ScalingBehavior.Crop) |
||||
{ |
||||
return new Resolution { Width = profile.Resolution.Width, Height = profile.Resolution.Height }; |
||||
} |
||||
|
||||
var videoStream = new VideoStream( |
||||
0, |
||||
string.Empty, |
||||
string.Empty, |
||||
None, |
||||
ColorParams.Unknown, |
||||
new FrameSize(videoVersion.Width, videoVersion.Height), |
||||
videoVersion.SampleAspectRatio, |
||||
videoVersion.DisplayAspectRatio, |
||||
None, |
||||
StillImage: false, |
||||
ScanKind.Progressive); |
||||
|
||||
FrameSize scaledSize = videoStream.SquarePixelFrameSize( |
||||
new FrameSize(profile.Resolution.Width, profile.Resolution.Height)); |
||||
|
||||
return new Resolution { Width = scaledSize.Width, Height = scaledSize.Height }; |
||||
} |
||||
} |
||||
@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
|
||||
using System.Globalization; |
||||
using ErsatzTV.Controllers; |
||||
using ErsatzTV.Core.Security; |
||||
using Microsoft.AspNetCore.Http; |
||||
using Microsoft.AspNetCore.Mvc; |
||||
using Microsoft.Extensions.Logging.Abstractions; |
||||
using NUnit.Framework; |
||||
using Shouldly; |
||||
|
||||
namespace ErsatzTV.Tests.Controllers; |
||||
|
||||
[TestFixture] |
||||
public class GraphicsCanvasHeaderTests |
||||
{ |
||||
[TestCase("x-etv-frame-rate", "garbage")] |
||||
[TestCase("x-etv-frame-rate", "25/0")] |
||||
[TestCase("x-etv-frame-rate", "0/1")] |
||||
[TestCase("x-etv-frame-rate", "-25/1")] |
||||
[TestCase("x-etv-frame-rate", "25/1/1")] |
||||
[TestCase("x-etv-frame-rate", "NaN")] |
||||
[TestCase("x-etv-frame-rate", "Infinity")] |
||||
[TestCase("x-etv-frame-rate", "0")] |
||||
[TestCase("x-etv-frame-rate", "")] |
||||
[TestCase("x-etv-offset-ms", "-1")] |
||||
[TestCase("x-etv-offset-ms", "922337203685478")] |
||||
[TestCase("x-etv-duration-ms", "922337203685478")] |
||||
[TestCase("x-etv-duration-ms", "0")] |
||||
[TestCase("x-etv-channel", "2")] |
||||
public async Task Should_reject_invalid_headers_before_dispatch(string header, string value) |
||||
{ |
||||
// These requests must fail before any service is called.
|
||||
var controller = new InternalController(null!, null!, null!, null!, null!, |
||||
NullLogger<InternalController>.Instance) |
||||
{ |
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } |
||||
}; |
||||
controller.Request.Headers["x-etv-offset-ms"] = "0"; |
||||
controller.Request.Headers["x-etv-duration-ms"] = "44000"; |
||||
controller.Request.Headers["x-etv-frame-rate"] = "24000/1001"; |
||||
controller.Request.Headers["x-etv-channel"] = "1"; |
||||
controller.Request.Headers[header] = value; |
||||
|
||||
DateTimeOffset expires = DateTimeOffset.UtcNow.AddMinutes(5); |
||||
string signature = InternalUrlSigner.Sign(expires, "graphics", "1", "123"); |
||||
IActionResult result = await controller.GetGraphicsCanvas( |
||||
"1", 123, |
||||
expires.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture), signature); |
||||
|
||||
result.ShouldBeOfType<BadRequestResult>(); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue