diff --git a/.gitignore b/.gitignore index 4a6afa6..f776852 100644 --- a/.gitignore +++ b/.gitignore @@ -354,3 +354,6 @@ MigrationBackup/ # JetBrains IDEA files .idea/ + +# Serena MCP files +.serena/ diff --git a/Directory.Build.props b/Directory.Build.props index e92a1c5..7f7c68f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -10,6 +10,7 @@ 9999 True True + high $(NoWarn) @@ -28,7 +29,7 @@ - 1.1.1 + 1.2.0 diff --git a/Directory.Packages.props b/Directory.Packages.props index 676b3ee..fff288d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,6 +19,7 @@ + diff --git a/README.md b/README.md index b56be9e..bd748d5 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ Each package provides HTTP clients for accessing external data sources with stro |---------------------|-----------------------------------------------------------------------------------------------------------------------------| | **RTSW Client** | NOAA real-time solar wind feeds — 1-minute magnetometer and plasma JSON data with `active` and `source` metadata | | **KP-Index Client** | Geomagnetic activity indices — nowcast and forecast data (3-day and 27-day) | +| **WSA-ENLIL Client** | Solar wind forecast animation — downloads WSA-ENLIL model frames and encodes them as MP4 (H.264) via FFmpeg | | **ACE Client** | Legacy text feeds (deprecated; use RTSW instead — see [Integrations #3](https://github.com/Aurora-Science-Hub/Integrations/issues/3)) | See [detailed documentation](src/NoaaClient/README.md) for usage examples and API reference. @@ -107,6 +108,7 @@ This repository provides production-ready HTTP clients for external space weathe - [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) or later - IDE: [Visual Studio 2025+](https://visualstudio.microsoft.com/), [Rider 2025+](https://www.jetbrains.com/rider/), or [VS Code](https://code.visualstudio.com/) +- **FFmpeg** — required for WSA-ENLIL animation encoding (see [installation instructions](#ffmpeg-installation)) ### Building from Source @@ -125,6 +127,35 @@ dotnet build dotnet test ``` +### FFmpeg Installation + +The **WSA-ENLIL animation** feature requires FFmpeg to encode forecast frames into MP4 video. Install it for your platform: + +**Windows:** +```powershell +winget install ffmpeg +``` + +**macOS:** +```bash +brew install ffmpeg +``` + +**Linux (Ubuntu/Debian):** +```bash +sudo apt-get install ffmpeg +``` + +**Linux (Fedora/RHEL):** +```bash +sudo dnf install ffmpeg-free +``` + +Verify the installation: +```bash +ffmpeg -version +``` + ## Code Style The solution uses [EditorConfig](.editorconfig) based on [Azure SDK .NET](https://github.com/Azure/azure-sdk-for-net/blob/main/.editorconfig) to maintain consistent code style across all packages. diff --git a/samples/NoaaClientSample/Program.cs b/samples/NoaaClientSample/Program.cs index b698f3c..86629ae 100644 --- a/samples/NoaaClientSample/Program.cs +++ b/samples/NoaaClientSample/Program.cs @@ -2,6 +2,7 @@ using AuroraScienceHub.Integrations.NoaaClient; using AuroraScienceHub.Integrations.NoaaClient.Ace; +using AuroraScienceHub.Integrations.NoaaClient.WsaEnlil; using AuroraScienceHub.Integrations.NoaaClient.KpIndex; using AuroraScienceHub.Integrations.NoaaClient.Rtsw; using AuroraScienceHub.Integrations.Samples.NoaaClientSample; @@ -20,6 +21,7 @@ var aceClient = host.Services.GetRequiredService(); var kpIndexClient = host.Services.GetRequiredService(); var rtswClient = host.Services.GetRequiredService(); +var wsaEnlilClient = host.Services.GetRequiredService(); // Display header AnsiConsole.Write(new FigletText("NOAA Client").Color(Color.Blue)); @@ -40,6 +42,9 @@ "KP Index 27-Day Forecast", "KP Index 3-Day Forecast", "KP Index Nowcast", + "WSA-ENLIL Animation", + "WSA-ENLIL Animation (small, 320px)", + "WSA-ENLIL Last Frame Time", new string('-', 30), "Execute All Requests", "Exit")); @@ -99,6 +104,28 @@ await AnsiConsole.Status() var data = await kpIndexClient.GetKpIndexNowcastAsync(CancellationToken.None); OutputFormatter.DisplayKpNowcast(data, 10); }), + "WSA-ENLIL Animation" => FetchAndDisplay(async () => + { + await using var stream = await wsaEnlilClient.GetEnlilAnimationAsync( + maxWidth: 480, cancellationToken: CancellationToken.None); + await SaveAnimationAsync(stream, "enlil_animation.mp4"); + }), + "WSA-ENLIL Animation (small, 320px)" => FetchAndDisplay(async () => + { + await using var stream = await wsaEnlilClient.GetEnlilAnimationAsync( + maxWidth: 320, cancellationToken: CancellationToken.None); + await SaveAnimationAsync(stream, "enlil_animation_small.mp4"); + }), + "WSA-ENLIL Last Frame Time" => FetchAndDisplay(async () => + { + var lastFrameTime = await wsaEnlilClient.GetLastFrameTimeAsync( + CancellationToken.None); + if (lastFrameTime is null) + AnsiConsole.MarkupLine("[yellow]No data available.[/]"); + else + AnsiConsole.MarkupLine( + $"[green]Last frame time:[/] {lastFrameTime:yyyy-MM-dd HH:mm:ss} UTC"); + }), "Execute All Requests" => ExecuteAllRequests(), _ => Task.CompletedTask }); @@ -148,6 +175,12 @@ async Task ExecuteAllRequests() { var data = await kpIndexClient.GetKpIndexNowcastAsync(CancellationToken.None); OutputFormatter.DisplayKpNowcast(data, 5); + }), + ("WSA-ENLIL Animation", async () => + { + await using var stream = await wsaEnlilClient.GetEnlilAnimationAsync( + maxWidth: 480, cancellationToken: CancellationToken.None); + await SaveAnimationAsync(stream, "enlil_animation.mp4"); }) }; @@ -185,4 +218,21 @@ await AnsiConsole.Progress() _ => "Extreme" }; +// Saves MP4 animation stream to a temp file and displays the result +static async Task SaveAnimationAsync(Stream stream, string fileName) +{ + if (stream.Length == 0) + { + AnsiConsole.MarkupLine("[red]No animation data returned.[/]"); + return; + } + + var outputPath = Path.Combine(Path.GetTempPath(), fileName); + await using var fileStream = File.Create(outputPath); + await stream.CopyToAsync(fileStream); + + AnsiConsole.MarkupLine($"[green]Animation saved:[/] {outputPath}"); + AnsiConsole.MarkupLine($"[dim]Size: {stream.Length / 1024} KB | Format: MP4[/]"); +} + diff --git a/src/NoaaClient/NoaaClient.csproj b/src/NoaaClient/NoaaClient.csproj index ed48fd3..2f3462d 100644 --- a/src/NoaaClient/NoaaClient.csproj +++ b/src/NoaaClient/NoaaClient.csproj @@ -10,6 +10,7 @@ + diff --git a/src/NoaaClient/README.md b/src/NoaaClient/README.md index 423c5b6..1789b96 100644 --- a/src/NoaaClient/README.md +++ b/src/NoaaClient/README.md @@ -49,6 +49,28 @@ var forecast3Day = await kpIndexClient.GetKpIndex3DayForecastAsync(cancellationT var forecast27Day = await kpIndexClient.GetKpIndex27DayForecastAsync(cancellationToken); ``` +## WSA-ENLIL Client + +Generates MP4 (H.264) animations from WSA-ENLIL solar wind forecast model frames provided by NOAA SWPC. + +**Prerequisites:** FFmpeg must be installed on the system. See [FFmpeg Installation](../../README.md#ffmpeg-installation) for platform-specific instructions. + +```csharp +// Download and encode WSA-ENLIL animation frames as MP4 video +await using var stream = await wsaEnlilClient.GetEnlilAnimationAsync( + maxWidth: 480, + cancellationToken: cancellationToken); + +// Save to file +await using var fileStream = File.Create("enlil_animation.mp4"); +await stream.CopyToAsync(fileStream); +``` + +- **maxWidth** — maximum output width in pixels; frames are scaled proportionally (default: 480) +- **Return value** — `MemoryStream` containing the complete MP4 video; caller is responsible for disposal +- Frame rate: 20 fps (50 ms per frame) +- Encoder: H.264 (libx264), CRF 23, pixel format yuv420p + ## Deprecated API `IAceClient` is obsolete and scheduled for removal ([#3](https://github.com/Aurora-Science-Hub/Integrations/issues/3)). Use `IRtswClient` for magnetometer and solar wind plasma data. diff --git a/src/NoaaClient/ServiceCollectionExtensions.cs b/src/NoaaClient/ServiceCollectionExtensions.cs index 127aac5..bab307a 100644 --- a/src/NoaaClient/ServiceCollectionExtensions.cs +++ b/src/NoaaClient/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using AuroraScienceHub.Integrations.NoaaClient.Ace; +using AuroraScienceHub.Integrations.NoaaClient.WsaEnlil; using AuroraScienceHub.Integrations.NoaaClient.KpIndex; using AuroraScienceHub.Integrations.NoaaClient.Rtsw; using Microsoft.Extensions.DependencyInjection; @@ -26,6 +27,7 @@ public static IServiceCollection AddNoaaClients(this IServiceCollection services #pragma warning restore CS0618 services.AddHttpClient(); services.AddHttpClient(); + services.AddHttpClient(); return services; } diff --git a/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs b/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs new file mode 100644 index 0000000..93d96a2 --- /dev/null +++ b/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs @@ -0,0 +1,38 @@ +namespace AuroraScienceHub.Integrations.NoaaClient.WsaEnlil; + +/// +/// Client for creating WSA-ENLIL solar wind forecast animations from NOAA SWPC imagery. +/// +/// +/// Requires FFmpeg to be installed on the system and available in PATH. +/// See README for platform-specific installation instructions. +/// +public interface IWsaEnlilClient +{ + /// + /// Downloads the WSA-ENLIL animation manifest and all frames, assembling them into an optimized MP4 (H.264) video. + /// + /// Maximum output width in pixels. Frames are resized proportionally. Default 480. + /// Cancellation token. + /// A stream containing the MP4 video data. The caller is responsible for disposing this stream. + /// + /// The returned stream is a MemoryStream containing the complete MP4 data. + /// Typical usage: await using var stream = await GetEnlilAnimationAsync(cancellationToken: cancellationToken); + /// + Task GetEnlilAnimationAsync( + int maxWidth = 480, + CancellationToken cancellationToken = default); + + /// + /// Returns the timestamp of the last frame in the WSA-ENLIL animation, without downloading frames or encoding video. + /// + /// Cancellation token. + /// + /// The timestamp extracted from the last frame's URL, or if the manifest is empty or unavailable. + /// + /// + /// Fetches only the manifest JSON (~1 KB). Use this to check whether the animation has been updated + /// before calling . + /// + Task GetLastFrameTimeAsync(CancellationToken cancellationToken = default); +} diff --git a/src/NoaaClient/WsaEnlil/Responses/WsaEnlilManifestEntry.cs b/src/NoaaClient/WsaEnlil/Responses/WsaEnlilManifestEntry.cs new file mode 100644 index 0000000..0b68114 --- /dev/null +++ b/src/NoaaClient/WsaEnlil/Responses/WsaEnlilManifestEntry.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace AuroraScienceHub.Integrations.NoaaClient.WsaEnlil.Responses; + +/// +/// A single frame entry in the WSA-ENLIL animation manifest. +/// +/// Relative frame URL, e.g. "/images/animations/enlil/enlil_com2_58426_20250118T120000.jpg". +public sealed record WsaEnlilManifestEntry( + [property: JsonPropertyName("url")] string Url +); diff --git a/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs new file mode 100644 index 0000000..cf3e249 --- /dev/null +++ b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs @@ -0,0 +1,148 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using AuroraScienceHub.Framework.Http; +using AuroraScienceHub.Integrations.NoaaClient.WsaEnlil.Responses; +using FFMpegCore; +using FFMpegCore.Pipes; +using Microsoft.Extensions.Options; + +namespace AuroraScienceHub.Integrations.NoaaClient.WsaEnlil; + +internal sealed partial class WsaEnlilClient : IWsaEnlilClient +{ + private const string ManifestPath = "products/animations/enlil.json"; + private const int Fps = 20; + private const int Crf = 23; // H.264 quality (0 = lossless, 51 = worst) + private const string TempDirPrefix = "enlil_"; + private const string FrameFileFormat = "frame_{0:D4}.jpg"; + private const string FrameSearchPattern = "frame_%04d.jpg"; + private const int OutputStreamCapacity = 5 * 1024 * 1024; // 5 MB initial buffer + + private readonly HttpClient _httpClient; + private readonly Uri _baseUrl; + + [GeneratedRegex(@"(\d{8}T\d{6})\.jpg$")] + private static partial Regex FrameTimestampRegex(); + + public WsaEnlilClient( + HttpClient httpClient, + IOptions options) + { + _httpClient = httpClient; + _baseUrl = options.Value.RequiredServerUrl; + } + + public async Task GetEnlilAnimationAsync( + int maxWidth, + CancellationToken cancellationToken) + { + if (maxWidth <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxWidth), "Max width must be greater than zero."); + } + + var manifest = await FetchManifestAsync(cancellationToken).ConfigureAwait(false); + if (manifest is null || manifest.Count == 0) + { + return new MemoryStream(); + } + + string? tempDir = null; + try + { + tempDir = Directory.CreateTempSubdirectory(TempDirPrefix).FullName; + await DownloadFramesAsync(manifest, tempDir, cancellationToken).ConfigureAwait(false); + return await EncodeVideoAsync(tempDir, maxWidth, cancellationToken).ConfigureAwait(false); + } + finally + { + if (tempDir is not null) + { + try { Directory.Delete(tempDir, recursive: true); } + catch { /* best-effort cleanup */ } + } + } + } + + public async Task GetLastFrameTimeAsync(CancellationToken cancellationToken) + { + var manifest = await FetchManifestAsync(cancellationToken).ConfigureAwait(false); + if (manifest is null || manifest.Count == 0) + { + return null; + } + + var lastUrl = manifest.Last().Url; + var match = FrameTimestampRegex().Match(lastUrl); + if (!match.Success) + { + return null; + } + + return DateTime.ParseExact( + match.Groups[1].Value, + "yyyyMMddTHHmmss", + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal); + } + + private async Task?> FetchManifestAsync( + CancellationToken cancellationToken) + { + var manifestUrl = new Uri(_baseUrl, ManifestPath); + return await _httpClient + .GetFromJsonOrDefaultAsync>(manifestUrl, cancellationToken) + .ConfigureAwait(false); + } + + private async Task DownloadFramesAsync( + IReadOnlyCollection manifest, + string tempDir, + CancellationToken cancellationToken) + { + var index = 0; + foreach (var entry in manifest) + { + cancellationToken.ThrowIfCancellationRequested(); + + var frameUrl = new Uri(_baseUrl, entry.Url); + await using var sourceStream = await _httpClient + .GetStreamAsync(frameUrl, cancellationToken) + .ConfigureAwait(false); + + var framePath = Path.Combine(tempDir, string.Format(FrameFileFormat, index)); + await using var fileStream = File.Create(framePath); + await sourceStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + + index++; + } + } + + private static async Task EncodeVideoAsync( + string tempDir, + int maxWidth, + CancellationToken cancellationToken) + { + var outputStream = new MemoryStream(OutputStreamCapacity); + var inputPattern = Path.Combine(tempDir, FrameSearchPattern); + + await FFMpegArguments + .FromFileInput(inputPattern, verifyExists: false, + inputOptions => inputOptions + .WithCustomArgument($"-framerate {Fps}")) + .OutputToPipe(new StreamPipeSink(outputStream), + outputOptions => outputOptions + .WithCustomArgument($"-vf scale={maxWidth}:-2") + .WithVideoCodec("libx264") + .WithCustomArgument($"-crf {Crf}") + .WithCustomArgument("-pix_fmt yuv420p") + .WithCustomArgument("-movflags +frag_keyframe+empty_moov") + .ForceFormat("mp4")) + .CancellableThrough(cancellationToken) + .ProcessAsynchronously() + .ConfigureAwait(false); + + outputStream.Position = 0; + return outputStream; + } +} diff --git a/tests/UnitTests/NoaaClient/WsaEnlil/WsaEnlilClientTests.cs b/tests/UnitTests/NoaaClient/WsaEnlil/WsaEnlilClientTests.cs new file mode 100644 index 0000000..7735106 --- /dev/null +++ b/tests/UnitTests/NoaaClient/WsaEnlil/WsaEnlilClientTests.cs @@ -0,0 +1,254 @@ +using System.Net; +using AuroraScienceHub.Integrations.NoaaClient; +using AuroraScienceHub.Integrations.NoaaClient.WsaEnlil; +using Microsoft.Extensions.Options; +using Shouldly; + +namespace AuroraScienceHub.Integrations.UnitTests.NoaaClient.WsaEnlil; + +/// +/// Unit tests for . +/// +/// +/// Tests cover the HTTP/download pipeline. ffmpeg encoding is an integration concern +/// and is not covered by unit tests. +/// +public sealed class WsaEnlilClientTests +{ + private static readonly Uri BaseUrl = new("https://noaa.test"); + + [Fact(DisplayName = "Returns empty stream when NOAA manifest is an empty JSON array")] + public async Task GetEnlilAnimationAsync_WhenManifestIsEmpty_ReturnsEmptyStream() + { + // Arrange + var sut = CreateSut("[]"); + + // Act + await using var result = await sut.GetEnlilAnimationAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + result.Length.ShouldBe(0); + } + + [Fact(DisplayName = "Returns empty stream when NOAA manifest deserializes to null")] + public async Task GetEnlilAnimationAsync_WhenManifestIsNull_ReturnsEmptyStream() + { + // Arrange + var sut = CreateSut("null"); + + // Act + await using var result = await sut.GetEnlilAnimationAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + result.Length.ShouldBe(0); + } + + [Theory(DisplayName = "Throws ArgumentOutOfRangeException when maxWidth is zero or negative")] + [InlineData(0)] + [InlineData(-1)] + public async Task GetEnlilAnimationAsync_WhenMaxWidthIsInvalid_ThrowsArgumentOutOfRangeException(int maxWidth) + { + // Arrange + var sut = CreateSut("[]"); + + // Act & Assert + await Should.ThrowAsync( + async () => await sut.GetEnlilAnimationAsync( + maxWidth: maxWidth, + cancellationToken: TestContext.Current.CancellationToken)); + } + + [Fact(DisplayName = "GetLastFrameTime returns null when manifest is empty")] + public async Task GetLastFrameTimeAsync_WhenManifestIsEmpty_ReturnsNull() + { + // Arrange + var sut = CreateSut("[]"); + + // Act + var result = await sut.GetLastFrameTimeAsync( + TestContext.Current.CancellationToken); + + // Assert + result.ShouldBeNull(); + } + + [Fact(DisplayName = "GetLastFrameTime returns null when manifest is null")] + public async Task GetLastFrameTimeAsync_WhenManifestIsNull_ReturnsNull() + { + // Arrange + var sut = CreateSut("null"); + + // Act + var result = await sut.GetLastFrameTimeAsync( + TestContext.Current.CancellationToken); + + // Assert + result.ShouldBeNull(); + } + + [Fact(DisplayName = "GetLastFrameTime extracts timestamp from last frame URL")] + public async Task GetLastFrameTimeAsync_ReturnsTimestampFromLastFrameUrl() + { + // Arrange + var manifestJson = """ + [ + {"url":"/images/animations/enlil/frame_20250117T120000.jpg"}, + {"url":"/images/animations/enlil/frame_20250118T060000.jpg"}, + {"url":"/images/animations/enlil/frame_20250118T120000.jpg"} + ] + """; + var sut = CreateSut(manifestJson); + + // Act + var result = await sut.GetLastFrameTimeAsync( + TestContext.Current.CancellationToken); + + // Assert + result.ShouldBe(new DateTime(2025, 1, 18, 12, 0, 0, DateTimeKind.Utc)); + } + + [Fact(DisplayName = "GetLastFrameTime returns null when URL has no timestamp")] + public async Task GetLastFrameTimeAsync_WhenUrlHasNoTimestamp_ReturnsNull() + { + // Arrange + var manifestJson = """ + [ + {"url":"/images/animations/enlil/no_timestamp_here.jpg"} + ] + """; + var sut = CreateSut(manifestJson); + + // Act + var result = await sut.GetLastFrameTimeAsync( + TestContext.Current.CancellationToken); + + // Assert + result.ShouldBeNull(); + } + + [Fact(DisplayName = "Throws OperationCanceledException when token is pre-cancelled")] + public async Task GetEnlilAnimationAsync_WhenTokenIsCancelled_ThrowsOperationCanceledException() + { + // Arrange + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + var sut = CreateSut("[]"); + + // Act & Assert + await Should.ThrowAsync( + async () => await sut.GetEnlilAnimationAsync(cancellationToken: cts.Token)); + } + + [Fact(DisplayName = "Throws when token is cancelled mid-download")] + public async Task GetEnlilAnimationAsync_WhenCancelledDuringDownload_ThrowsOperationCanceledException() + { + // Arrange + using var cts = new CancellationTokenSource(); + + var manifestJson = """ + [ + {"url":"/images/animations/enlil/frame1.jpg"}, + {"url":"/images/animations/enlil/frame2.jpg"}, + {"url":"/images/animations/enlil/frame3.jpg"} + ] + """; + + var handler = new CancellingHandler(manifestJson, cancelAfterRequests: 2, cts); + using var httpClient = new HttpClient(handler); + var options = Options.Create(new NoaaClientOptions { ServerUrl = BaseUrl }); + var sut = new WsaEnlilClient(httpClient, options); + + // Act & Assert + await Should.ThrowAsync( + async () => await sut.GetEnlilAnimationAsync(maxWidth: 420, cancellationToken: cts.Token)); + } + + private static IWsaEnlilClient CreateSut(string manifestJson) + { + var handler = new TestHttpMessageHandler(CreateJsonResponse(manifestJson)); + var httpClient = new HttpClient(handler); + var options = Options.Create(new NoaaClientOptions { ServerUrl = BaseUrl }); + return new WsaEnlilClient(httpClient, options); + } + + private static HttpResponseMessage CreateJsonResponse(string json) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }; + } + + /// + /// Simple test double that returns pre-configured responses. + /// + private sealed class TestHttpMessageHandler : HttpMessageHandler + { + private readonly Queue _responses; + + public TestHttpMessageHandler(params HttpResponseMessage[] responses) + { + _responses = new Queue(responses); + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(_responses.Count > 0 + ? _responses.Dequeue() + : new HttpResponseMessage(HttpStatusCode.NotFound)); + } + } + + /// + /// Handler that cancels the token after a configured number of requests. + /// + private sealed class CancellingHandler : HttpMessageHandler + { + private readonly string _manifestJson; + private readonly int _cancelAfterRequests; + private readonly CancellationTokenSource _cts; + private int _requestCount; + + public CancellingHandler(string manifestJson, int cancelAfterRequests, CancellationTokenSource cts) + { + _manifestJson = manifestJson; + _cancelAfterRequests = cancelAfterRequests; + _cts = cts; + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var count = Interlocked.Increment(ref _requestCount); + if (count > _cancelAfterRequests) + { + await _cts.CancelAsync(); + // Give the cancellation a moment to propagate + await Task.Delay(50, CancellationToken.None); + cancellationToken.ThrowIfCancellationRequested(); + } + + // Simulate network latency so cancellation can be observed + await Task.Delay(100, CancellationToken.None); + + return count == 1 + ? new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(_manifestJson, System.Text.Encoding.UTF8, "application/json") + } + : new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent("fake-jpeg-data"u8.ToArray()) + }; + } + } +}