From ed1f88edf44543be14887160fda8c421dc698206 Mon Sep 17 00:00:00 2001 From: Tim Moore Date: Thu, 22 Jan 2026 19:44:39 +0000 Subject: [PATCH 1/3] Fetch image list with IAsyncEnumerable and populate on map as images are fetched --- .github/copilot-instructions.md | 12 +++++++ ImageMapper.slnx | 1 + README.md | 15 +++++---- .../Controllers/ImagesController.cs | 8 ++--- src/ImageMapper.Api/Services/IImageService.cs | 2 +- src/ImageMapper.Api/Services/ImageService.cs | 12 +++---- .../Client/ImageItemFetcher.cs | 6 ++-- .../Components/Pages/Home.razor | 31 ++++++------------- .../Components/Pages/Home.razor.cs | 29 +++++++++++------ 9 files changed, 61 insertions(+), 55 deletions(-) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..3367826 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,12 @@ +# Copilot Instructions + +## General Guidelines +- First general instruction +- Second general instruction + +## Code Style +- Use specific formatting rules +- Follow naming conventions + +## Async Programming +- When methods return `IAsyncEnumerable`, use the `[EnumeratorCancellation]` attribute from `System.Runtime.CompilerServices` for the `CancellationToken` parameter to properly propagate cancellation through the async enumerable chain. \ No newline at end of file diff --git a/ImageMapper.slnx b/ImageMapper.slnx index f041c06..f31c8a2 100644 --- a/ImageMapper.slnx +++ b/ImageMapper.slnx @@ -1,6 +1,7 @@ + diff --git a/README.md b/README.md index 0ceb3b6..bbdb3cf 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,17 @@ [![Build](https://github.com/tjmoore/image-mapper/actions/workflows/build.yml/badge.svg)](https://github.com/tjmoore/image-mapper/actions/workflows/build.yml) -ImageMapper is a server hosted application that processes images from a configured server folder, -extracts GPS metadata, and in a web application generates an interactive map displaying the locations of these images +ImageMapper is a server hosted application that processes images from a configured server folder and maps them based on geotagged metadata within the images. This application is built using .NET and leverages the Leaflet.js library for map rendering +Initial development has partly been an experiment in using AI tools to assist in code generation, architecture design, and unit test creation. +With strict human review and modification to ensure quality and correctness. + ## Dependencies - .NET 10 (likely will work with .NET 8+) +- [Aspire](https://aspire.dev/) - [MetadataExtractor](https://github.com/drewnoakes/metadata-extractor-dotnet) - [Leaflet.js](https://leafletjs.com/) - [openstreetmap.org](https://www.openstreetmap.org/) @@ -60,12 +63,12 @@ The folder for images can be configured via `appsettings.json` file in ImageMapp ## TODO -- Further unit tests -- Optimise image processing for large number of images and folders +- Restore cluster grouping of markers when dynamically loading images +- Show progress when loading images +- Potential for optimising Leaflet rendering, only render markers in view etc? Would need back-end to cache image locations and support querying by bounding box? - Abstract file enumeration and loading to allow varied sources not just a file folder -- Caching (minimally in-memory/redis etc, future database caching of metadata. With detection of image file changes) +- Caching - Configure map tile provider options - UI improvements, filtering etc -- Optimise Leaflet rendering from back-end images especially to handle large numbers of images - Error handling and logging improvements - Container support diff --git a/src/ImageMapper.Api/Controllers/ImagesController.cs b/src/ImageMapper.Api/Controllers/ImagesController.cs index 6749d02..ac878e2 100644 --- a/src/ImageMapper.Api/Controllers/ImagesController.cs +++ b/src/ImageMapper.Api/Controllers/ImagesController.cs @@ -12,13 +12,9 @@ public class ImagesController(IImageService svc) : ControllerBase private readonly IImageService _svc = svc; [HttpGet] - public async Task> Get(CancellationToken ct) + public IAsyncEnumerable Get(CancellationToken ct) { - var images = await _svc.GetImagesAsync(ct); - - Log.Debug("GET /api/images - Retrieved {Count} images", images.Count()); - - return images; + return _svc.GetImagesAsync(ct); } [HttpGet("raw/{**relativePath}")] diff --git a/src/ImageMapper.Api/Services/IImageService.cs b/src/ImageMapper.Api/Services/IImageService.cs index aa13b3a..b11d2ba 100644 --- a/src/ImageMapper.Api/Services/IImageService.cs +++ b/src/ImageMapper.Api/Services/IImageService.cs @@ -4,6 +4,6 @@ namespace ImageMapper.Api.Services; public interface IImageService { - Task> GetImagesAsync(CancellationToken ct = default); + IAsyncEnumerable GetImagesAsync(CancellationToken ct = default); Task GetImageBytesAsync(string relativePath, CancellationToken ct = default); } diff --git a/src/ImageMapper.Api/Services/ImageService.cs b/src/ImageMapper.Api/Services/ImageService.cs index 776bd14..47c1934 100644 --- a/src/ImageMapper.Api/Services/ImageService.cs +++ b/src/ImageMapper.Api/Services/ImageService.cs @@ -2,6 +2,7 @@ using MetadataExtractor; using MetadataExtractor.Formats.Exif; using Serilog; +using System.Runtime.CompilerServices; namespace ImageMapper.Api.Services; @@ -18,19 +19,16 @@ public ImageService(IConfiguration config) Log.Information("ImageService initialized with ImageFolder: {ImageFolder}", _imagesRoot); } - public async Task> GetImagesAsync(CancellationToken ct = default) + public async IAsyncEnumerable GetImagesAsync([EnumeratorCancellation] CancellationToken ct = default) { if (!System.IO.Directory.Exists(_imagesRoot)) - return []; - - // TODO: this will need to be optimised for large file stores and possibly return async enumerable + yield break; var extensions = new[] { ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".nef" }; var files = System.IO.Directory.EnumerateFiles(_imagesRoot, "*.*", SearchOption.AllDirectories) .Where(f => extensions.Contains(Path.GetExtension(f).ToLowerInvariant())); - var list = new List(); foreach (string f in files) { ct.ThrowIfCancellationRequested(); @@ -57,10 +55,8 @@ public async Task> GetImagesAsync(CancellationToken ct = { Log.Warning("Failed to read metadata for file: {File}", f); } - list.Add(info); + yield return info; } - - return await Task.FromResult(list); } public async Task GetImageBytesAsync(string relativePath, CancellationToken ct = default) diff --git a/src/ImageMapper.Web/Client/ImageItemFetcher.cs b/src/ImageMapper.Web/Client/ImageItemFetcher.cs index 6eb6fdd..0daaeb7 100644 --- a/src/ImageMapper.Web/Client/ImageItemFetcher.cs +++ b/src/ImageMapper.Web/Client/ImageItemFetcher.cs @@ -5,12 +5,12 @@ namespace ImageMapper.Web.Client public class ImageItemFetcher(HttpClient httpClient) { /// - /// Fetch list of available images with metadata + /// Fetch list of available images with metadata, streamed as async enumerable /// /// - public async Task> Fetch(CancellationToken ct) + public IAsyncEnumerable Fetch(CancellationToken ct = default) { - return (await httpClient.GetFromJsonAsync>("/api/images", ct)) ?? []; + return httpClient.GetFromJsonAsAsyncEnumerable("/api/images", ct); } diff --git a/src/ImageMapper.Web/Components/Pages/Home.razor b/src/ImageMapper.Web/Components/Pages/Home.razor index aaa56f7..cf6d2b8 100644 --- a/src/ImageMapper.Web/Components/Pages/Home.razor +++ b/src/ImageMapper.Web/Components/Pages/Home.razor @@ -9,33 +9,22 @@
\ No newline at end of file diff --git a/src/ImageMapper.Web/Components/Pages/Home.razor.cs b/src/ImageMapper.Web/Components/Pages/Home.razor.cs index 7a1fa83..e3dc646 100644 --- a/src/ImageMapper.Web/Components/Pages/Home.razor.cs +++ b/src/ImageMapper.Web/Components/Pages/Home.razor.cs @@ -5,23 +5,32 @@ namespace ImageMapper.Web.Components.Pages { public partial class Home { - private IEnumerable images = []; private readonly CancellationTokenSource cts = new(); protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { - // Any additional JS interop can be done here if needed - images = await imageFetcher.Fetch(cts.Token); + // Initialize the map once + await JS.InvokeVoidAsync("initClusterMap", cts.Token); - await JS.InvokeVoidAsync("initClusterMap", - cts.Token, - images.Select(i => new - { - i.FileName, i.Latitude, i.Longitude, - Url = $"/api/images/raw/{Uri.EscapeDataString(i.RelativePath)}" - })); + // Add markers as images arrive + await foreach (ImageInfo? image in imageFetcher.Fetch(cts.Token)) + { + if (image == null) + continue; + + await JS.InvokeVoidAsync("addMarkerToMap", + new + { + image.FileName, + image.Latitude, + image.Longitude, + Url = $"/api/images/raw/{Uri.EscapeDataString(image.RelativePath)}" + }); + + StateHasChanged(); + } } } From 77a632dc338dcff64490965cfa2a1951f2ac2b8a Mon Sep 17 00:00:00 2001 From: Tim Moore Date: Thu, 22 Jan 2026 19:45:09 +0000 Subject: [PATCH 2/3] Add unit tests for image list fetch --- .../ImageMapper.Tests.csproj | 1 + src/ImageMapper.Tests/ImagesApiTest.cs | 114 ++++++++++++++++-- src/ImageMapper.Tests/IntegrationTest.cs | 99 +++++++++++++++ 3 files changed, 207 insertions(+), 7 deletions(-) diff --git a/src/ImageMapper.Tests/ImageMapper.Tests.csproj b/src/ImageMapper.Tests/ImageMapper.Tests.csproj index 22f02e1..0d31192 100644 --- a/src/ImageMapper.Tests/ImageMapper.Tests.csproj +++ b/src/ImageMapper.Tests/ImageMapper.Tests.csproj @@ -25,6 +25,7 @@ + diff --git a/src/ImageMapper.Tests/ImagesApiTest.cs b/src/ImageMapper.Tests/ImagesApiTest.cs index 3387c97..09b801e 100644 --- a/src/ImageMapper.Tests/ImagesApiTest.cs +++ b/src/ImageMapper.Tests/ImagesApiTest.cs @@ -1,4 +1,5 @@ using ImageMapper.Api.Services; +using ImageMapper.Models; using Microsoft.Extensions.Configuration; namespace ImageMapper.Tests @@ -99,7 +100,7 @@ public async Task GetImageBytesAsyncReturnsNullForNonExistentFile() [Test] [TestCase("subfolder/nested-image.jpg")] [TestCase("subfolder/deep/deep-image.png")] - public async Task GetImageBytesAsyncReturnsValidImageBytesFromSubfolders(string relativePath) + public async Task GetImageBytesAsyncReturnsNestedImageBytes(string relativePath) { // Arrange var config = new ConfigurationBuilder() @@ -116,10 +117,7 @@ public async Task GetImageBytesAsyncReturnsValidImageBytesFromSubfolders(string } [Test] - [TestCase("subfolder/nonexistent.jpg")] - [TestCase("subfolder/deep/missing-image.png")] - [TestCase("nonexistent-folder/image.jpg")] - public async Task GetImageBytesAsyncReturnsNullForNonExistentFilesInSubfolders(string relativePath) + public async Task GetImagesAsyncReturnsAllImagesFromDirectory() { // Arrange var config = new ConfigurationBuilder() @@ -128,10 +126,112 @@ public async Task GetImageBytesAsyncReturnsNullForNonExistentFilesInSubfolders(s var service = new ImageService(config); // Act - var bytes = await service.GetImageBytesAsync(relativePath); + var images = new List(); + await foreach (var image in service.GetImagesAsync()) + { + images.Add(image); + } // Assert - Assert.That(bytes, Is.Null); + Assert.That(images, Has.Count.EqualTo(3)); + Assert.That(images.Select(i => i.FileName), Does.Contain("test-image.jpg")); + Assert.That(images.Select(i => i.FileName), Does.Contain("nested-image.jpg")); + Assert.That(images.Select(i => i.FileName), Does.Contain("deep-image.png")); + } + + [Test] + public async Task GetImagesAsyncReturnsCorrectRelativePaths() + { + // Arrange + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "ImageFolder", _testImagesDirectory } }) + .Build(); + var service = new ImageService(config); + + // Act + var images = new List(); + await foreach (var image in service.GetImagesAsync()) + { + images.Add(image); + } + + // Assert + Assert.That(images.Select(i => i.RelativePath), Does.Contain("test-image.jpg")); + Assert.That(images.Select(i => i.RelativePath), Does.Contain("subfolder/nested-image.jpg")); + Assert.That(images.Select(i => i.RelativePath), Does.Contain("subfolder/deep/deep-image.png")); + } + + [Test] + public async Task GetImagesAsyncReturnsEmptyListForNonExistentDirectory() + { + // Arrange + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "ImageFolder", Path.Combine(_testImagesDirectory, "nonexistent") } }) + .Build(); + var service = new ImageService(config); + + // Act + var images = new List(); + await foreach (var image in service.GetImagesAsync()) + { + images.Add(image); + } + + // Assert + Assert.That(images, Is.Empty); + } + + [Test] + public async Task GetImagesAsyncIsCancellable() + { + // Arrange + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "ImageFolder", _testImagesDirectory } }) + .Build(); + var service = new ImageService(config); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + var ex = Assert.ThrowsAsync( + async () => + { + await foreach (var image in service.GetImagesAsync(cts.Token)) + { + // This should throw due to cancellation + } + }); + + Assert.That(ex, Is.Not.Null); + } + + [Test] + public async Task GetImagesAsyncFiltersOnlyImageExtensions() + { + // Arrange + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "ImageFolder", _testImagesDirectory } }) + .Build(); + + // Create a non-image file + var nonImagePath = Path.Combine(_testImagesDirectory, "readme.txt"); + File.WriteAllText(nonImagePath, "This is not an image"); + + var service = new ImageService(config); + + // Act + var images = new List(); + await foreach (var image in service.GetImagesAsync()) + { + images.Add(image); + } + + // Assert + Assert.That(images, Has.Count.EqualTo(3)); // Still only 3 images, not 4 + Assert.That(images.Select(i => i.FileName), Does.Not.Contain("readme.txt")); + + // Cleanup + File.Delete(nonImagePath); } } } \ No newline at end of file diff --git a/src/ImageMapper.Tests/IntegrationTest.cs b/src/ImageMapper.Tests/IntegrationTest.cs index 02fc851..b20b396 100644 --- a/src/ImageMapper.Tests/IntegrationTest.cs +++ b/src/ImageMapper.Tests/IntegrationTest.cs @@ -1,4 +1,6 @@ using Aspire.Hosting; +using ImageMapper.Models; +using ImageMapper.Web.Client; using Microsoft.Extensions.Logging; using Serilog; @@ -8,6 +10,7 @@ public class IntegrationTest { private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); private string _testImagesDirectory = null!; + private string _testSubdirectory = null!; [OneTimeSetUp] public void OneTimeSetUp() @@ -18,6 +21,13 @@ public void OneTimeSetUp() // Create a test image file var testImagePath = Path.Combine(_testImagesDirectory, "test-image.jpg"); File.WriteAllBytes(testImagePath, [0xFF, 0xD8, 0xFF, 0xE0]); // JPEG magic bytes + + // Create a subdirectory with test images + _testSubdirectory = Path.Combine(_testImagesDirectory, "subfolder"); + Directory.CreateDirectory(_testSubdirectory); + + var subImagePath = Path.Combine(_testSubdirectory, "nested-image.png"); + File.WriteAllBytes(subImagePath, [0x89, 0x50, 0x4E, 0x47]); // PNG magic bytes } [OneTimeTearDown] @@ -75,6 +85,95 @@ public async Task GetWebResourceRootReturnsOkStatusCode() Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); } + [Test] + public async Task GetImagesListReturnsAllImages() + { + // Arrange + using var cts = new CancellationTokenSource(DefaultTimeout); + var cancellationToken = cts.Token; + + await using var app = await BuildAndStartAppAsync(cancellationToken, _testImagesDirectory); + using var httpClient = app.CreateHttpClient("imagemapper-api"); + await app.ResourceNotifications.WaitForResourceHealthyAsync("imagemapper-web", cancellationToken).WaitAsync(DefaultTimeout, cancellationToken); + + var fetcher = new ImageItemFetcher(httpClient); + + // Act + var images = new List(); + await foreach (var image in fetcher.Fetch(cancellationToken)) + { + if (image != null) + images.Add(image); + } + + // Assert + Assert.That(images, Has.Count.EqualTo(2)); + Assert.That(images.Select(i => i.FileName), Does.Contain("test-image.jpg")); + Assert.That(images.Select(i => i.FileName), Does.Contain("nested-image.png")); + } + + [Test] + public async Task GetImagesListReturnsCorrectRelativePaths() + { + // Arrange + using var cts = new CancellationTokenSource(DefaultTimeout); + var cancellationToken = cts.Token; + + await using var app = await BuildAndStartAppAsync(cancellationToken, _testImagesDirectory); + using var httpClient = app.CreateHttpClient("imagemapper-api"); + await app.ResourceNotifications.WaitForResourceHealthyAsync("imagemapper-web", cancellationToken).WaitAsync(DefaultTimeout, cancellationToken); + + var fetcher = new ImageItemFetcher(httpClient); + + // Act + var images = new List(); + await foreach (var image in fetcher.Fetch(cancellationToken)) + { + if (image != null) + images.Add(image); + } + + // Assert + Assert.That(images.Select(i => i.RelativePath), Does.Contain("test-image.jpg")); + Assert.That(images.Select(i => i.RelativePath), Does.Contain("subfolder/nested-image.png")); + } + + [Test] + public async Task GetImagesListReturnsEmptyWhenNoImagesExist() + { + // Arrange + using var cts = new CancellationTokenSource(DefaultTimeout); + var cancellationToken = cts.Token; + + var emptyDirectory = Path.Combine(TestContext.CurrentContext.TestDirectory, $"empty-test-{Guid.NewGuid()}"); + Directory.CreateDirectory(emptyDirectory); + + try + { + await using var app = await BuildAndStartAppAsync(cancellationToken, emptyDirectory); + using var httpClient = app.CreateHttpClient("imagemapper-api"); + await app.ResourceNotifications.WaitForResourceHealthyAsync("imagemapper-web", cancellationToken).WaitAsync(DefaultTimeout, cancellationToken); + + var fetcher = new ImageItemFetcher(httpClient); + + // Act + var images = new List(); + await foreach (var image in fetcher.Fetch(cancellationToken)) + { + if (image != null) + images.Add(image); + } + + // Assert + Assert.That(images, Is.Empty); + } + finally + { + if (Directory.Exists(emptyDirectory)) + Directory.Delete(emptyDirectory, recursive: true); + } + } + [Test] public async Task GetRawImageReturnsExistingFile() { From 144c64a65f855ce94f80c550c29e1572bd4d1c92 Mon Sep 17 00:00:00 2001 From: Tim Moore Date: Thu, 22 Jan 2026 19:54:10 +0000 Subject: [PATCH 3/3] Restore tests that were accidentally removed --- src/ImageMapper.Tests/ImagesApiTest.cs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/ImageMapper.Tests/ImagesApiTest.cs b/src/ImageMapper.Tests/ImagesApiTest.cs index 09b801e..b2161ff 100644 --- a/src/ImageMapper.Tests/ImagesApiTest.cs +++ b/src/ImageMapper.Tests/ImagesApiTest.cs @@ -100,7 +100,7 @@ public async Task GetImageBytesAsyncReturnsNullForNonExistentFile() [Test] [TestCase("subfolder/nested-image.jpg")] [TestCase("subfolder/deep/deep-image.png")] - public async Task GetImageBytesAsyncReturnsNestedImageBytes(string relativePath) + public async Task GetImageBytesAsyncReturnsValidImageBytesFromSubfolders(string relativePath) { // Arrange var config = new ConfigurationBuilder() @@ -116,6 +116,25 @@ public async Task GetImageBytesAsyncReturnsNestedImageBytes(string relativePath) Assert.That(bytes, Has.Length.GreaterThan(0)); } + [Test] + [TestCase("subfolder/nonexistent.jpg")] + [TestCase("subfolder/deep/missing-image.png")] + [TestCase("nonexistent-folder/image.jpg")] + public async Task GetImageBytesAsyncReturnsNullForNonExistentFilesInSubfolders(string relativePath) + { + // Arrange + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "ImageFolder", _testImagesDirectory } }) + .Build(); + var service = new ImageService(config); + + // Act + var bytes = await service.GetImageBytesAsync(relativePath); + + // Assert + Assert.That(bytes, Is.Null); + } + [Test] public async Task GetImagesAsyncReturnsAllImagesFromDirectory() {