diff --git a/Controllers/RadarController.cs b/Controllers/RadarController.cs
index abb490b..4e8f8d9 100644
--- a/Controllers/RadarController.cs
+++ b/Controllers/RadarController.cs
@@ -19,10 +19,10 @@ public class RadarController : ControllerBase
}
///
- /// Get radar screenshot for a location. Returns cached screenshot if available, or triggers background update.
+ /// Get radar data for a location. Returns JSON with all frames and their URLs. Triggers background update if cache is stale.
///
[HttpGet("{suburb}/{state}")]
- public async Task GetScreenshot(string suburb, string state, CancellationToken cancellationToken = default)
+ public async Task> GetRadar(string suburb, string state, CancellationToken cancellationToken = default)
{
try
{
@@ -45,32 +45,69 @@ public class RadarController : ControllerBase
}
});
- // Return cached screenshot if it exists (no waiting)
- var result = await _bomRadarService.GetCachedScreenshotAsync(suburb, state, cancellationToken);
+ // Return cached radar data if available (no waiting)
+ var result = await _bomRadarService.GetCachedRadarAsync(suburb, state, cancellationToken);
- if (result == null)
+ if (result == null || result.Frames.Count == 0)
{
return NotFound(new {
- error = "Screenshot not found in cache. Cache update has been triggered in background. Please retry in a few moments.",
+ error = "Screenshots not found in cache. Cache update has been triggered in background. Please retry in a few moments.",
retryAfter = 30, // seconds
refreshEndpoint = $"/api/radar/{suburb}/{state}/refresh"
});
}
- // Return the image file
- if (System.IO.File.Exists(result.ImagePath))
+ // Generate URLs for frames if not already set
+ if (result.Frames.Any(f => string.IsNullOrEmpty(f.ImageUrl)))
{
- var imageBytes = await System.IO.File.ReadAllBytesAsync(result.ImagePath, cancellationToken);
- var contentType = "image/png";
- return File(imageBytes, contentType, Path.GetFileName(result.ImagePath));
+ foreach (var frame in result.Frames)
+ {
+ frame.ImageUrl = $"/api/radar/{Uri.EscapeDataString(suburb)}/{Uri.EscapeDataString(state)}/frame/{frame.FrameIndex}";
+ }
}
-
- return NotFound(new { error = "Screenshot file not found" });
+
+ return Ok(result);
}
catch (Exception ex)
{
- _logger.LogError(ex, "Error getting cached radar screenshot for suburb: {Suburb}, state: {State}", suburb, state);
- return StatusCode(500, new { error = "An error occurred while getting the radar screenshot", message = ex.Message });
+ _logger.LogError(ex, "Error getting cached radar screenshots for suburb: {Suburb}, state: {State}", suburb, state);
+ return StatusCode(500, new { error = "An error occurred while getting the radar screenshots", message = ex.Message });
+ }
+ }
+
+ ///
+ /// Get a specific frame image for a location.
+ ///
+ [HttpGet("{suburb}/{state}/frame/{frameIndex}")]
+ public async Task GetFrame(string suburb, string state, int frameIndex, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var validationError = ValidationHelper.ValidateLocation(suburb, state);
+ if (validationError != null)
+ {
+ return BadRequest(new { error = validationError });
+ }
+
+ if (frameIndex < 0 || frameIndex > 6)
+ {
+ return BadRequest(new { error = "Frame index must be between 0 and 6" });
+ }
+
+ var frame = await _bomRadarService.GetCachedFrameAsync(suburb, state, frameIndex, cancellationToken);
+
+ if (frame == null || !System.IO.File.Exists(frame.ImagePath))
+ {
+ return NotFound(new { error = $"Frame {frameIndex} not found for {suburb}, {state}" });
+ }
+
+ var imageBytes = await System.IO.File.ReadAllBytesAsync(frame.ImagePath, cancellationToken);
+ return File(imageBytes, "image/png", $"frame_{frameIndex}.png");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting frame {FrameIndex} for suburb: {Suburb}, state: {State}", frameIndex, suburb, state);
+ return StatusCode(500, new { error = "An error occurred while getting the frame", message = ex.Message });
}
}
diff --git a/Models/FrameMetadata.cs b/Models/FrameMetadata.cs
new file mode 100644
index 0000000..b8d5e0e
--- /dev/null
+++ b/Models/FrameMetadata.cs
@@ -0,0 +1,18 @@
+namespace BomLocalService.Models;
+
+///
+/// Metadata for a single radar frame, stored alongside the frame image.
+///
+public class FrameMetadata
+{
+ ///
+ /// Frame index (0-6).
+ ///
+ public int FrameIndex { get; set; }
+
+ ///
+ /// Number of minutes ago this frame represents (40, 35, 30, 25, 20, 15, 10).
+ ///
+ public int MinutesAgo { get; set; }
+}
+
diff --git a/Models/RadarFrame.cs b/Models/RadarFrame.cs
new file mode 100644
index 0000000..8d91965
--- /dev/null
+++ b/Models/RadarFrame.cs
@@ -0,0 +1,33 @@
+namespace BomLocalService.Models;
+
+///
+/// Represents a single frame from the radar slideshow (0-6).
+///
+public class RadarFrame
+{
+ ///
+ /// Frame index (0-6), where 0 is the oldest frame (40 minutes ago) and 6 is the newest (10 minutes ago).
+ ///
+ public int FrameIndex { get; set; }
+
+ ///
+ /// Full file system path to the frame image file (server-side only).
+ /// Format: "{CacheDirectory}/{LocationKey}_{Timestamp}/frame_{FrameIndex}.png"
+ /// Example: "/app/cache/Pomona_QLD_20251207_000906/frame_0.png"
+ ///
+ public string ImagePath { get; set; } = string.Empty;
+
+ ///
+ /// URL endpoint to retrieve this frame image.
+ /// Format: "/api/radar/{Suburb}/{State}/frame/{FrameIndex}"
+ /// Example: "/api/radar/Pomona/QLD/frame/0"
+ ///
+ public string ImageUrl { get; set; } = string.Empty;
+
+ ///
+ /// Number of minutes ago this frame represents (40, 35, 30, 25, 20, 15, 10).
+ /// Frame 0 = 40 minutes ago, Frame 6 = 10 minutes ago.
+ ///
+ public int MinutesAgo { get; set; }
+}
+
diff --git a/Models/RadarScreenshotResponse.cs b/Models/RadarResponse.cs
similarity index 81%
rename from Models/RadarScreenshotResponse.cs
rename to Models/RadarResponse.cs
index 043d31c..b4e05f2 100644
--- a/Models/RadarScreenshotResponse.cs
+++ b/Models/RadarResponse.cs
@@ -1,18 +1,16 @@
namespace BomLocalService.Models;
///
-/// Response model containing the radar screenshot image path and associated metadata.
+/// Response model containing all radar screenshot frames and associated metadata.
/// This is the primary response returned when requesting a radar screenshot for a location.
///
-public class RadarScreenshotResponse
+public class RadarResponse
{
///
- /// The full file system path to the cached PNG screenshot image file.
- /// This is the path on the server where the screenshot is stored.
- /// Format: "{CacheDirectory}/{Suburb}_{State}_{Timestamp}.png"
- /// Example: "/app/cache/Pomona_QLD_20251207_000906.png"
+ /// List of all captured frames (typically 7 frames: 0-6).
+ /// Frame 0 is oldest (40 minutes ago), Frame 6 is newest (10 minutes ago).
///
- public string ImagePath { get; set; } = string.Empty;
+ public List Frames { get; set; } = new();
///
/// The UTC timestamp when the screenshot file was last written/modified on disk.
diff --git a/Models/ScreenshotCropConfig.cs b/Models/ScreenshotCropConfig.cs
new file mode 100644
index 0000000..8882bfb
--- /dev/null
+++ b/Models/ScreenshotCropConfig.cs
@@ -0,0 +1,33 @@
+namespace BomLocalService.Models;
+
+///
+/// Configuration for cropping screenshots to avoid map controls and overlays.
+/// All values are relative to the map container's bounding box.
+///
+public class ScreenshotCropConfig
+{
+ ///
+ /// X offset in pixels from the left edge of the map container.
+ /// Default: 0 (start at container's left edge).
+ ///
+ public int X { get; set; } = 0;
+
+ ///
+ /// Y offset in pixels from the top edge of the map container.
+ /// Default: 0 (start at container's top edge).
+ ///
+ public int Y { get; set; } = 0;
+
+ ///
+ /// Width of the crop area in pixels.
+ /// If null, uses the full container width minus X offset.
+ ///
+ public int? Width { get; set; }
+
+ ///
+ /// Height of the crop area in pixels.
+ /// If null, uses the full container height minus Y offset.
+ ///
+ public int? Height { get; set; }
+}
+
diff --git a/Services/BomRadarService.cs b/Services/BomRadarService.cs
index d20232e..d2b51cf 100644
--- a/Services/BomRadarService.cs
+++ b/Services/BomRadarService.cs
@@ -30,24 +30,48 @@ public class BomRadarService : IBomRadarService, IDisposable
_cacheExpirationMinutes = configuration.GetValue("CacheExpirationMinutes", 15.5);
}
- public async Task GetCachedScreenshotAsync(string suburb, string state, CancellationToken cancellationToken = default)
+ public async Task GetCachedRadarAsync(string suburb, string state, CancellationToken cancellationToken = default)
{
- var (cachedPath, cachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, cancellationToken);
+ var (cacheFolderPath, cachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, cancellationToken);
- if (string.IsNullOrEmpty(cachedPath) || !File.Exists(cachedPath))
+ if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath))
{
return null;
}
- return ResponseBuilder.CreateRadarScreenshotResponse(cachedPath, cachedMetadata);
+ var frames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken);
+ if (frames == null || frames.Count == 0)
+ {
+ return null;
+ }
+
+ return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, cachedMetadata, suburb, state);
+ }
+
+ public async Task?> GetCachedFramesAsync(string suburb, string state, CancellationToken cancellationToken = default)
+ {
+ var frames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken);
+
+ // Generate URLs for frames
+ foreach (var frame in frames)
+ {
+ frame.ImageUrl = $"/api/radar/{Uri.EscapeDataString(suburb)}/{Uri.EscapeDataString(state)}/frame/{frame.FrameIndex}";
+ }
+
+ return frames.Count > 0 ? frames : null;
+ }
+
+ public async Task GetCachedFrameAsync(string suburb, string state, int frameIndex, CancellationToken cancellationToken = default)
+ {
+ return await _cacheService.GetCachedFrameAsync(suburb, state, frameIndex, cancellationToken);
}
public async Task TriggerCacheUpdateAsync(string suburb, string state, CancellationToken cancellationToken = default)
{
var status = new CacheUpdateStatus();
- var (cachedPath, cachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, cancellationToken);
+ var (cacheFolderPath, cachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, cancellationToken);
- status.CacheExists = !string.IsNullOrEmpty(cachedPath) && File.Exists(cachedPath);
+ status.CacheExists = !string.IsNullOrEmpty(cacheFolderPath) && Directory.Exists(cacheFolderPath);
if (cachedMetadata != null)
{
@@ -98,58 +122,61 @@ public class BomRadarService : IBomRadarService, IDisposable
return status;
}
- private async Task FetchAndCacheScreenshotAsync(string suburb, string state, CancellationToken cancellationToken = default)
+ private async Task FetchAndCacheScreenshotAsync(string suburb, string state, CancellationToken cancellationToken = default)
{
_logger.LogInformation("Getting radar screenshot for suburb: {Suburb}, state: {State}", suburb, state);
// Check cache FIRST, before acquiring semaphore (cached requests shouldn't block)
- var (cachedPath, cachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, cancellationToken);
+ var (cacheFolderPath, cachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, cancellationToken);
- if (!string.IsNullOrEmpty(cachedPath) && File.Exists(cachedPath) && cachedMetadata != null)
+ if (!string.IsNullOrEmpty(cacheFolderPath) && Directory.Exists(cacheFolderPath) && cachedMetadata != null)
{
var isValid = _cacheService.IsCacheValid(cachedMetadata);
if (isValid)
{
- _logger.LogInformation("Returning valid cached screenshot for {Suburb}, {State} (no semaphore needed)", suburb, state);
- return ResponseBuilder.CreateRadarScreenshotResponse(cachedPath, cachedMetadata);
+ _logger.LogInformation("Returning valid cached screenshots for {Suburb}, {State} (no semaphore needed)", suburb, state);
+ var frames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken);
+ return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, cachedMetadata, suburb, state);
}
else
{
var nextUpdate = cachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes);
var timeUntilExpiry = nextUpdate - DateTime.UtcNow;
- _logger.LogInformation("Cached screenshot exists but is stale (observation time: {ObservationTime}, expired {TimeAgo} ago), fetching new one",
+ _logger.LogInformation("Cached screenshots exist but are stale (observation time: {ObservationTime}, expired {TimeAgo} ago), fetching new ones",
cachedMetadata.ObservationTime, -timeUntilExpiry);
}
}
- else if (!string.IsNullOrEmpty(cachedPath) && File.Exists(cachedPath))
+ else if (!string.IsNullOrEmpty(cacheFolderPath) && Directory.Exists(cacheFolderPath))
{
- _logger.LogWarning("Cached screenshot found but no metadata file exists, fetching new one");
+ _logger.LogWarning("Cached folder found but no metadata file exists, fetching new screenshots");
}
else
{
- _logger.LogInformation("No cached screenshot found for {Suburb}, {State}, fetching new one", suburb, state);
+ _logger.LogInformation("No cached screenshots found for {Suburb}, {State}, fetching new ones", suburb, state);
}
// Only acquire semaphore if we need to fetch new screenshot
var semaphore = _browserService.GetSemaphore();
await semaphore.WaitAsync(cancellationToken);
- // Generate request ID for debugging
- var requestId = $"{DateTime.UtcNow:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}";
- var debugFolder = _debugService.CreateRequestFolder(requestId);
-
IBrowserContext? context = null;
+ string? debugFolder = null;
+ string? requestId = null;
try
{
// Double-check cache after acquiring semaphore (another request might have just created it)
- var (recheckCachedPath, recheckCachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, cancellationToken);
- if (!string.IsNullOrEmpty(recheckCachedPath) && File.Exists(recheckCachedPath) && recheckCachedMetadata != null && _cacheService.IsCacheValid(recheckCachedMetadata))
+ var (recheckCacheFolderPath, recheckCachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, cancellationToken);
+ if (!string.IsNullOrEmpty(recheckCacheFolderPath) && Directory.Exists(recheckCacheFolderPath) && recheckCachedMetadata != null && _cacheService.IsCacheValid(recheckCachedMetadata))
{
- _logger.LogInformation("Cache became valid while waiting for semaphore, returning cached screenshot");
- return ResponseBuilder.CreateRadarScreenshotResponse(recheckCachedPath, recheckCachedMetadata);
+ _logger.LogInformation("Cache became valid while waiting for semaphore, returning cached screenshots");
+ var recheckFrames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken);
+ return ResponseBuilder.CreateRadarResponse(recheckCacheFolderPath, recheckFrames, recheckCachedMetadata, suburb, state);
}
- // Need to capture new screenshot
+ // Need to capture new screenshot - create debug folder only now
+ requestId = $"{DateTime.UtcNow:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}";
+ debugFolder = _debugService.CreateRequestFolder(requestId);
+
context = await _browserService.CreateContextAsync();
var (page, consoleMessages, networkRequests) = await _browserService.CreatePageWithDebugAsync(context, requestId);
@@ -176,11 +203,29 @@ public class BomRadarService : IBomRadarService, IDisposable
}
catch (Exception ex)
{
- _logger.LogError(ex, "Error getting radar screenshot for suburb: {Suburb}, state: {State} (RequestId: {RequestId})", suburb, state, requestId);
+ _logger.LogError(ex, "Error getting radar screenshot for suburb: {Suburb}, state: {State} (RequestId: {RequestId})", suburb, state, requestId ?? "unknown");
throw;
}
finally
{
+ // Clean up empty debug folder if we returned early without scraping
+ if (!string.IsNullOrEmpty(debugFolder) && Directory.Exists(debugFolder))
+ {
+ try
+ {
+ var files = Directory.GetFiles(debugFolder, "*", SearchOption.AllDirectories);
+ if (files.Length == 0)
+ {
+ Directory.Delete(debugFolder, recursive: true);
+ _logger.LogDebug("Removed empty debug folder: {DebugFolder}", debugFolder);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to clean up empty debug folder: {DebugFolder}", debugFolder);
+ }
+ }
+
semaphore.Release();
}
}
diff --git a/Services/CacheCleanupService.cs b/Services/CacheCleanupService.cs
index 9486b01..2b2d2e8 100644
--- a/Services/CacheCleanupService.cs
+++ b/Services/CacheCleanupService.cs
@@ -52,54 +52,47 @@ public class CacheCleanupService : BackgroundService
try
{
- // Get all PNG files in cache directory
- var pngFiles = Directory.GetFiles(_cacheDirectory, "*.png", SearchOption.TopDirectoryOnly);
+ // Get all cache folders
+ var folders = Directory.GetDirectories(_cacheDirectory);
- foreach (var file in pngFiles)
+ foreach (var folder in folders)
{
if (cancellationToken.IsCancellationRequested)
break;
try
{
- var fileInfo = new FileInfo(file);
- var fileTime = fileInfo.LastWriteTimeUtc;
+ var folderInfo = new DirectoryInfo(folder);
+ var folderTime = folderInfo.LastWriteTimeUtc;
- // Delete if file is older than retention period
- if (fileTime < cutoffTime)
+ // Delete if folder is older than retention period
+ if (folderTime < cutoffTime)
{
- var fileSize = fileInfo.Length;
- File.Delete(file);
+ var folderSize = GetFolderSize(folder);
+ Directory.Delete(folder, recursive: true);
deletedCount++;
- totalSize += fileSize;
+ totalSize += folderSize;
- // Also delete associated metadata file if it exists
- var metadataFile = FilePathHelper.GetMetadataFilePath(file);
- if (File.Exists(metadataFile))
- {
- File.Delete(metadataFile);
- }
-
- _logger.LogDebug("Deleted old cache file: {File} (age: {Age})",
- Path.GetFileName(file), DateTime.UtcNow - fileTime);
+ _logger.LogDebug("Deleted old cache folder: {Folder} (age: {Age})",
+ Path.GetFileName(folder), DateTime.UtcNow - folderTime);
}
}
catch (Exception ex)
{
- _logger.LogWarning(ex, "Failed to delete cache file: {File}", file);
+ _logger.LogWarning(ex, "Failed to delete cache folder: {Folder}", folder);
}
}
if (deletedCount > 0)
{
var sizeInMB = totalSize / (1024.0 * 1024.0);
- _logger.LogInformation("Cache cleanup completed. Deleted {Count} files ({Size:F2} MB)",
+ _logger.LogInformation("Cache cleanup completed. Deleted {Count} folders ({Size:F2} MB)",
deletedCount, sizeInMB);
}
}
catch (Exception ex)
{
- _logger.LogError(ex, "Error during cache file enumeration");
+ _logger.LogError(ex, "Error during cache folder enumeration");
}
// Also clean up old debug directories
@@ -137,5 +130,27 @@ public class CacheCleanupService : BackgroundService
return Task.CompletedTask;
}
+
+ ///
+ /// Calculates the total size of a folder and all its contents
+ ///
+ private long GetFolderSize(string folderPath)
+ {
+ long size = 0;
+ try
+ {
+ var files = Directory.GetFiles(folderPath, "*", SearchOption.AllDirectories);
+ foreach (var file in files)
+ {
+ var fileInfo = new FileInfo(file);
+ size += fileInfo.Length;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Error calculating folder size for: {Folder}", folderPath);
+ }
+ return size;
+ }
}
diff --git a/Services/CacheManagementService.cs b/Services/CacheManagementService.cs
index d00800e..be7607f 100644
--- a/Services/CacheManagementService.cs
+++ b/Services/CacheManagementService.cs
@@ -174,16 +174,18 @@ public class CacheManagementService : BackgroundService
try
{
- // Get locations from existing cache files
+ // Get locations from existing cache folders
var cacheDirectory = FilePathHelper.GetCacheDirectory(_configuration);
if (Directory.Exists(cacheDirectory))
{
- var pngFiles = Directory.GetFiles(cacheDirectory, "*.png", SearchOption.TopDirectoryOnly);
+ // Get all folders (not files)
+ var folders = Directory.GetDirectories(cacheDirectory);
var seen = new HashSet();
- foreach (var file in pngFiles)
+ foreach (var folder in folders)
{
- var location = LocationHelper.ParseLocationFromFilename(file);
+ var folderName = Path.GetFileName(folder);
+ var location = LocationHelper.ParseLocationFromFilename(folderName);
if (location.HasValue)
{
var locationKey = LocationHelper.GetLocationKey(location.Value.suburb, location.Value.state);
diff --git a/Services/CacheService.cs b/Services/CacheService.cs
index a4cc9ac..b9db08d 100644
--- a/Services/CacheService.cs
+++ b/Services/CacheService.cs
@@ -22,67 +22,202 @@ public class CacheService : ICacheService
}
///
- /// Gets the cached screenshot path and metadata for a location
+ /// Gets the cached screenshot folder path and metadata for a location
///
- public async Task<(string? screenshotPath, LastUpdatedInfo? metadata)> GetCachedScreenshotWithMetadataAsync(
+ public async Task<(string? cacheFolderPath, LastUpdatedInfo? metadata)> GetCachedScreenshotWithMetadataAsync(
string suburb,
string state,
CancellationToken cancellationToken = default)
{
- var pattern = FilePathHelper.GetCacheFilePattern(suburb, state);
- _logger.LogDebug("Looking for cached files matching pattern: {Pattern} in directory: {Directory}", pattern, _cacheDirectory);
+ var pattern = FilePathHelper.GetCacheFolderPattern(suburb, state);
+ _logger.LogDebug("Looking for cached folders matching pattern: {Pattern} in directory: {Directory}", pattern, _cacheDirectory);
- var files = Directory.GetFiles(_cacheDirectory, pattern)
+ var folders = Directory.GetDirectories(_cacheDirectory, pattern)
.OrderByDescending(f =>
{
- // Try to extract timestamp from filename first
- var timestamp = LocationHelper.ParseTimestampFromFilename(f);
+ // Try to extract timestamp from folder name
+ var folderName = Path.GetFileName(f);
+ var timestamp = LocationHelper.ParseTimestampFromFilename(folderName);
if (timestamp.HasValue)
{
return timestamp.Value;
}
- // Fallback to file write time
- return File.GetLastWriteTime(f);
+ // Fallback to folder creation time
+ return Directory.GetCreationTime(f);
})
.ToList();
- var screenshotPath = files.FirstOrDefault();
- if (string.IsNullOrEmpty(screenshotPath) || !File.Exists(screenshotPath))
+ var cacheFolderPath = folders.FirstOrDefault();
+ if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath))
{
return (null, null);
}
- var metadata = await LoadMetadataAsync(screenshotPath, cancellationToken);
- return (screenshotPath, metadata);
+ var metadata = await LoadMetadataAsync(cacheFolderPath, cancellationToken);
+ return (cacheFolderPath, metadata);
+ }
+
+ ///
+ /// Gets all cached frames for a location
+ ///
+ public async Task> GetCachedFramesAsync(
+ string suburb,
+ string state,
+ CancellationToken cancellationToken = default)
+ {
+ var (cacheFolderPath, _) = await GetCachedScreenshotWithMetadataAsync(suburb, state, cancellationToken);
+
+ if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath))
+ {
+ return new List();
+ }
+
+ var frames = new List();
+
+ // Load frame metadata from frames.json if available
+ var framesMetadata = await LoadFramesMetadataAsync(cacheFolderPath, cancellationToken);
+ var metadataDict = framesMetadata.ToDictionary(f => f.FrameIndex, f => f.MinutesAgo);
+
+ // Load frames from folder (frame_0.png through frame_6.png)
+ for (int i = 0; i < 7; i++)
+ {
+ var framePath = FilePathHelper.GetFrameFilePath(cacheFolderPath, i);
+ if (File.Exists(framePath))
+ {
+ // Use stored minutesAgo if available, otherwise default
+ var minutesAgo = metadataDict.ContainsKey(i)
+ ? metadataDict[i]
+ : 40 - (i * 5);
+
+ frames.Add(new RadarFrame
+ {
+ FrameIndex = i,
+ ImagePath = framePath,
+ MinutesAgo = minutesAgo
+ });
+ }
+ }
+
+ return frames;
+ }
+
+ ///
+ /// Gets a specific cached frame for a location
+ ///
+ public async Task GetCachedFrameAsync(
+ string suburb,
+ string state,
+ int frameIndex,
+ CancellationToken cancellationToken = default)
+ {
+ if (frameIndex < 0 || frameIndex > 6)
+ {
+ return null;
+ }
+
+ var (cacheFolderPath, _) = await GetCachedScreenshotWithMetadataAsync(suburb, state, cancellationToken);
+
+ if (string.IsNullOrEmpty(cacheFolderPath))
+ {
+ return null;
+ }
+
+ var framePath = FilePathHelper.GetFrameFilePath(cacheFolderPath, frameIndex);
+ if (!File.Exists(framePath))
+ {
+ return null;
+ }
+
+ // Load frame metadata to get accurate minutesAgo
+ var framesMetadata = await LoadFramesMetadataAsync(cacheFolderPath, cancellationToken);
+ var frameMetadata = framesMetadata.FirstOrDefault(f => f.FrameIndex == frameIndex);
+ var minutesAgo = frameMetadata != null
+ ? frameMetadata.MinutesAgo
+ : 40 - (frameIndex * 5);
+
+ return new RadarFrame
+ {
+ FrameIndex = frameIndex,
+ ImagePath = framePath,
+ MinutesAgo = minutesAgo
+ };
}
///
- /// Saves metadata alongside a screenshot
+ /// Saves metadata in a cache folder
///
- public async Task SaveMetadataAsync(string screenshotPath, LastUpdatedInfo metadata, CancellationToken cancellationToken = default)
+ public async Task SaveMetadataAsync(string cacheFolderPath, LastUpdatedInfo metadata, CancellationToken cancellationToken = default)
{
try
{
- var metadataPath = FilePathHelper.GetMetadataFilePath(screenshotPath);
+ var metadataPath = FilePathHelper.GetMetadataFilePath(cacheFolderPath);
var json = JsonSerializer.Serialize(metadata, new JsonSerializerOptions { WriteIndented = true });
// Use CancellationToken.None for file writes to avoid cancellation issues
- // The screenshot is already saved, metadata save is best-effort
+ // The frames are already saved, metadata save is best-effort
await File.WriteAllTextAsync(metadataPath, json, CancellationToken.None);
_logger.LogDebug("Saved metadata to: {Path}", metadataPath);
}
catch (Exception ex)
{
- // Log but don't fail - screenshot is already saved
- _logger.LogWarning(ex, "Failed to save metadata for screenshot: {Path}", screenshotPath);
+ // Log but don't fail - frames are already saved
+ _logger.LogWarning(ex, "Failed to save metadata for folder: {Path}", cacheFolderPath);
+ }
+ }
+
+ ///
+ /// Saves frame metadata to frames.json
+ ///
+ public async Task SaveFramesMetadataAsync(string cacheFolderPath, List frames, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var framesMetadata = frames.Select(f => new FrameMetadata
+ {
+ FrameIndex = f.FrameIndex,
+ MinutesAgo = f.MinutesAgo
+ }).ToList();
+
+ var framesPath = Path.Combine(cacheFolderPath, "frames.json");
+ var json = JsonSerializer.Serialize(framesMetadata, new JsonSerializerOptions { WriteIndented = true });
+ await File.WriteAllTextAsync(framesPath, json, CancellationToken.None);
+ _logger.LogDebug("Saved frames metadata to: {Path}", framesPath);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to save frames metadata for folder: {Path}", cacheFolderPath);
+ }
+ }
+
+ ///
+ /// Loads frame metadata from frames.json
+ ///
+ private async Task> LoadFramesMetadataAsync(string cacheFolderPath, CancellationToken cancellationToken = default)
+ {
+ var framesPath = Path.Combine(cacheFolderPath, "frames.json");
+ if (!File.Exists(framesPath))
+ {
+ return new List();
+ }
+
+ try
+ {
+ var json = await File.ReadAllTextAsync(framesPath, cancellationToken);
+ var framesMetadata = JsonSerializer.Deserialize>(json);
+ return framesMetadata ?? new List();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to load frames metadata from: {Path}", framesPath);
+ return new List();
}
}
///
- /// Loads metadata for a screenshot
+ /// Loads metadata from a cache folder
///
- public async Task LoadMetadataAsync(string screenshotPath, CancellationToken cancellationToken = default)
+ private async Task LoadMetadataAsync(string cacheFolderPath, CancellationToken cancellationToken = default)
{
- var metadataPath = FilePathHelper.GetMetadataFilePath(screenshotPath);
+ var metadataPath = FilePathHelper.GetMetadataFilePath(cacheFolderPath);
if (!File.Exists(metadataPath))
{
return null;
@@ -117,59 +252,47 @@ public class CacheService : ICacheService
}
///
- /// Gets the path to the cached screenshot for a location (simple version, ordered by creation time)
+ /// Gets the path to the cached screenshot for a location (returns first frame path for backward compatibility)
///
- public Task GetCachedScreenshotPathAsync(string suburb, string state, CancellationToken cancellationToken = default)
+ public async Task GetCachedScreenshotPathAsync(string suburb, string state, CancellationToken cancellationToken = default)
{
- var pattern = FilePathHelper.GetCacheFilePattern(suburb, state);
- var files = Directory.GetFiles(_cacheDirectory, pattern)
- .OrderByDescending(f => File.GetCreationTime(f))
- .ToList();
-
- return Task.FromResult(files.FirstOrDefault() ?? string.Empty);
+ var frames = await GetCachedFramesAsync(suburb, state, cancellationToken);
+ return frames.FirstOrDefault()?.ImagePath ?? string.Empty;
}
///
- /// Deletes all cached files for a location
+ /// Deletes all cached folders for a location
///
public Task DeleteCachedLocationAsync(string suburb, string state, CancellationToken cancellationToken = default)
{
- var pattern = FilePathHelper.GetCacheFilePattern(suburb, state);
+ var pattern = FilePathHelper.GetCacheFolderPattern(suburb, state);
var deleted = false;
try
{
- // Delete all PNG files for this location
- var pngFiles = Directory.GetFiles(_cacheDirectory, pattern);
- foreach (var pngFile in pngFiles)
+ // Delete all folders matching the pattern
+ var folders = Directory.GetDirectories(_cacheDirectory, pattern);
+ foreach (var folder in folders)
{
try
{
- File.Delete(pngFile);
- _logger.LogInformation("Deleted cached screenshot: {File}", pngFile);
+ Directory.Delete(folder, recursive: true);
+ _logger.LogInformation("Deleted cached folder: {Folder}", folder);
deleted = true;
-
- // Also delete associated metadata JSON file
- var metadataFile = FilePathHelper.GetMetadataFilePath(pngFile);
- if (File.Exists(metadataFile))
- {
- File.Delete(metadataFile);
- _logger.LogInformation("Deleted metadata file: {File}", metadataFile);
- }
}
catch (Exception ex)
{
- _logger.LogWarning(ex, "Failed to delete file: {File}", pngFile);
+ _logger.LogWarning(ex, "Failed to delete folder: {Folder}", folder);
}
}
if (deleted)
{
- _logger.LogInformation("Deleted all cached files for location: {Suburb}, {State}", suburb, state);
+ _logger.LogInformation("Deleted all cached folders for location: {Suburb}, {State}", suburb, state);
}
else
{
- _logger.LogDebug("No cached files found to delete for location: {Suburb}, {State}", suburb, state);
+ _logger.LogDebug("No cached folders found to delete for location: {Suburb}, {State}", suburb, state);
}
}
catch (Exception ex)
diff --git a/Services/Interfaces/IBomRadarService.cs b/Services/Interfaces/IBomRadarService.cs
index 8724a73..cf7df4c 100644
--- a/Services/Interfaces/IBomRadarService.cs
+++ b/Services/Interfaces/IBomRadarService.cs
@@ -9,14 +9,14 @@ namespace BomLocalService.Services.Interfaces;
public interface IBomRadarService
{
///
- /// Gets a cached radar screenshot for a location.
- /// Returns the screenshot response if available in cache, otherwise returns null.
+ /// Gets cached radar data for a location.
+ /// Returns the radar response with all frames if available in cache, otherwise returns null.
///
/// The suburb name (e.g., "Pomona", "Brisbane")
/// The Australian state abbreviation (e.g., "QLD", "NSW", "VIC")
/// Cancellation token to cancel the operation
- /// Radar screenshot response with image path and metadata, or null if not cached
- Task GetCachedScreenshotAsync(string suburb, string state, CancellationToken cancellationToken = default);
+ /// Radar response with frames and metadata, or null if not cached
+ Task GetCachedRadarAsync(string suburb, string state, CancellationToken cancellationToken = default);
///
/// Triggers a cache update for a location.
@@ -49,12 +49,31 @@ public interface IBomRadarService
Task GetCachedScreenshotPathAsync(string suburb, string state, CancellationToken cancellationToken = default);
///
- /// Deletes all cached files (screenshot and metadata) for a location.
+ /// Deletes all cached folders (containing frames and metadata) for a location.
///
/// The suburb name (e.g., "Pomona", "Brisbane")
/// The Australian state abbreviation (e.g., "QLD", "NSW", "VIC")
/// Cancellation token to cancel the operation
- /// True if files were deleted, false if no cached files existed
+ /// True if folders were deleted, false if no cached folders existed
Task DeleteCachedLocationAsync(string suburb, string state, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets all cached frames for a location.
+ ///
+ /// The suburb name (e.g., "Pomona", "Brisbane")
+ /// The Australian state abbreviation (e.g., "QLD", "NSW", "VIC")
+ /// Cancellation token to cancel the operation
+ /// List of cached frames with URLs, or null if no frames exist
+ Task?> GetCachedFramesAsync(string suburb, string state, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets a specific cached frame for a location.
+ ///
+ /// The suburb name (e.g., "Pomona", "Brisbane")
+ /// The Australian state abbreviation (e.g., "QLD", "NSW", "VIC")
+ /// Frame index (0-6)
+ /// Cancellation token to cancel the operation
+ /// The cached frame, or null if not found
+ Task GetCachedFrameAsync(string suburb, string state, int frameIndex, CancellationToken cancellationToken = default);
}
diff --git a/Services/Interfaces/ICacheService.cs b/Services/Interfaces/ICacheService.cs
index ea17e37..6a119bb 100644
--- a/Services/Interfaces/ICacheService.cs
+++ b/Services/Interfaces/ICacheService.cs
@@ -9,26 +9,60 @@ namespace BomLocalService.Services.Interfaces;
public interface ICacheService
{
///
- /// Gets the cached screenshot file path and associated metadata for a location.
- /// Searches for PNG files matching the location pattern and loads the corresponding metadata JSON file.
+ /// Gets the cached screenshot folder path and associated metadata for a location.
+ /// Searches for cache folders matching the location pattern and loads the corresponding metadata JSON file.
///
/// The suburb name (e.g., "Pomona", "Brisbane")
/// The Australian state abbreviation (e.g., "QLD", "NSW", "VIC")
/// Cancellation token to cancel the operation
- /// Tuple containing the screenshot file path and metadata, or (null, null) if not found
- Task<(string? screenshotPath, LastUpdatedInfo? metadata)> GetCachedScreenshotWithMetadataAsync(
+ /// Tuple containing the cache folder path and metadata, or (null, null) if not found
+ Task<(string? cacheFolderPath, LastUpdatedInfo? metadata)> GetCachedScreenshotWithMetadataAsync(
string suburb,
string state,
CancellationToken cancellationToken = default);
///
- /// Saves metadata JSON file alongside a screenshot file.
- /// Creates a JSON file with the same base name as the screenshot but with .json extension.
+ /// Gets all cached frames for a location.
///
- /// Full path to the screenshot PNG file
+ /// The suburb name (e.g., "Pomona", "Brisbane")
+ /// The Australian state abbreviation (e.g., "QLD", "NSW", "VIC")
+ /// Cancellation token to cancel the operation
+ /// List of cached frames, or empty list if not found
+ Task> GetCachedFramesAsync(
+ string suburb,
+ string state,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets a specific cached frame for a location.
+ ///
+ /// The suburb name (e.g., "Pomona", "Brisbane")
+ /// The Australian state abbreviation (e.g., "QLD", "NSW", "VIC")
+ /// Frame index (0-6)
+ /// Cancellation token to cancel the operation
+ /// The cached frame, or null if not found
+ Task GetCachedFrameAsync(
+ string suburb,
+ string state,
+ int frameIndex,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Saves metadata JSON file in a cache folder.
+ /// Creates a metadata.json file in the specified cache folder.
+ ///
+ /// Full path to the cache folder
/// Metadata to save (observation time, forecast time, weather station, distance)
/// Cancellation token to cancel the operation
- Task SaveMetadataAsync(string screenshotPath, LastUpdatedInfo metadata, CancellationToken cancellationToken = default);
+ Task SaveMetadataAsync(string cacheFolderPath, LastUpdatedInfo metadata, CancellationToken cancellationToken = default);
+
+ ///
+ /// Saves frame metadata (frame index and minutes ago) to a frames.json file in the cache folder.
+ ///
+ /// Full path to the cache folder
+ /// List of frames with their metadata
+ /// Cancellation token to cancel the operation
+ Task SaveFramesMetadataAsync(string cacheFolderPath, List frames, CancellationToken cancellationToken = default);
///
/// Checks if cached metadata is still valid (not expired).
@@ -50,12 +84,12 @@ public interface ICacheService
Task GetCachedScreenshotPathAsync(string suburb, string state, CancellationToken cancellationToken = default);
///
- /// Deletes all cached files (PNG screenshots and JSON metadata) for a location.
+ /// Deletes all cached folders (containing frames and metadata) for a location.
///
/// The suburb name (e.g., "Pomona", "Brisbane")
/// The Australian state abbreviation (e.g., "QLD", "NSW", "VIC")
/// Cancellation token to cancel the operation
- /// True if any files were deleted, false if no cached files existed
+ /// True if any folders were deleted, false if no cached folders existed
Task DeleteCachedLocationAsync(string suburb, string state, CancellationToken cancellationToken = default);
///
diff --git a/Services/Interfaces/IScrapingService.cs b/Services/Interfaces/IScrapingService.cs
index a6d1316..a027b08 100644
--- a/Services/Interfaces/IScrapingService.cs
+++ b/Services/Interfaces/IScrapingService.cs
@@ -22,7 +22,7 @@ public interface IScrapingService
/// List to capture network request information (for debugging)
/// Cancellation token to cancel the operation
/// Radar screenshot response with image path and metadata
- Task ScrapeRadarScreenshotAsync(
+ Task ScrapeRadarScreenshotAsync(
string suburb,
string state,
string debugFolder,
diff --git a/Services/ScrapingService.cs b/Services/ScrapingService.cs
index 3180ff0..c3e582f 100644
--- a/Services/ScrapingService.cs
+++ b/Services/ScrapingService.cs
@@ -14,6 +14,7 @@ public class ScrapingService : IScrapingService
private readonly IDebugService _debugService;
private readonly int _dynamicContentWaitMs;
private readonly int _tileRenderWaitMs;
+ private readonly ScreenshotCropConfig _cropConfig;
// Selector constants
private static readonly string[] SearchButtonSelectors = new[]
@@ -53,12 +54,25 @@ public class ScrapingService : IScrapingService
_debugService = debugService;
_dynamicContentWaitMs = configuration.GetValue("Screenshot:DynamicContentWaitMs", 2000);
_tileRenderWaitMs = configuration.GetValue("Screenshot:TileRenderWaitMs", 5000);
+
+ // Load crop configuration
+ var cropSection = configuration.GetSection("Screenshot:Crop");
+ _cropConfig = new ScreenshotCropConfig
+ {
+ X = cropSection.GetValue("X", 0),
+ Y = cropSection.GetValue("Y", 0),
+ Width = cropSection.GetValue("Width"),
+ Height = cropSection.GetValue("Height")
+ };
+
+ _logger.LogInformation("Screenshot crop config: X={X}, Y={Y}, Width={Width}, Height={Height}",
+ _cropConfig.X, _cropConfig.Y, _cropConfig.Width, _cropConfig.Height);
}
///
/// Scrapes the BOM website to get a radar screenshot for a location
///
- public async Task ScrapeRadarScreenshotAsync(
+ public async Task ScrapeRadarScreenshotAsync(
string suburb,
string state,
string debugFolder,
@@ -315,53 +329,208 @@ public class ScrapingService : IScrapingService
await _debugService.SaveStepDebugAsync(debugFolder, 7, "weather_map_ready", page, consoleMessages, networkRequests, cancellationToken);
- // Step 8: Extract last updated information
- var lastUpdatedInfo = await _timeParsingService.ExtractLastUpdatedInfoAsync(page);
+ // Step 8: Ensure radar is paused before capturing frames
+ _logger.LogInformation("Checking if radar loop is paused");
+ var playPauseButton = page.Locator("button[data-testid='bom-time-scrub-play-pause']").First;
+ await playPauseButton.WaitForAsync(new LocatorWaitForOptions { Timeout = 5000 });
- // Step 9: Find the map container and take screenshot of just the map area
- _logger.LogInformation("Taking screenshot of map area");
+ // Check if button shows "Play" (paused) or "Pause" (playing)
+ var buttonLabel = await playPauseButton.Locator(".bom-scrub-action__label").TextContentAsync();
+ if (buttonLabel?.Trim().Equals("Pause", StringComparison.OrdinalIgnoreCase) == true)
+ {
+ _logger.LogInformation("Radar is playing, pausing it");
+ await playPauseButton.ClickAsync();
+ // Wait for pause to take effect
+ await page.WaitForTimeoutAsync(500);
+
+ // Verify it's now paused
+ buttonLabel = await playPauseButton.Locator(".bom-scrub-action__label").TextContentAsync();
+ if (buttonLabel?.Trim().Equals("Play", StringComparison.OrdinalIgnoreCase) != true)
+ {
+ _logger.LogWarning("Radar may not be paused after click, continuing anyway");
+ }
+ }
+ else
+ {
+ _logger.LogInformation("Radar is already paused");
+ }
+
+ await _debugService.SaveStepDebugAsync(debugFolder, 8, "radar_paused", page, consoleMessages, networkRequests, cancellationToken);
+
+ // Step 9: Click on first frame segment to ensure we start at frame 0
+ _logger.LogInformation("Resetting to first frame (frame 0)");
+ try
+ {
+ var firstFrameSegment = page.Locator("[data-testid='bom-scrub-segment'][data-id='0']").First;
+ await firstFrameSegment.WaitForAsync(new LocatorWaitForOptions { Timeout = 5000 });
+ await firstFrameSegment.ClickAsync();
+ // Wait for frame to update
+ await page.WaitForTimeoutAsync(1000);
+ _logger.LogInformation("Successfully clicked frame 0 segment");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to click first frame segment, continuing anyway");
+ }
+
+ await _debugService.SaveStepDebugAsync(debugFolder, 9, "frame_0_selected", page, consoleMessages, networkRequests, cancellationToken);
+
+ // Step 10: Verify scrubber is at position 0 before capturing first frame
+ _logger.LogInformation("Verifying scrubber is at position 0");
+ try
+ {
+ await page.WaitForFunctionAsync(@"() => {
+ const thumb = document.querySelector('[data-testid=""bom-scrub-thumb""]');
+ if (!thumb) return false;
+ const left = parseFloat(thumb.style.left) || 0;
+ // Position 0 should be at 0% or very close to it (within 5%)
+ return left <= 5;
+ }", new PageWaitForFunctionOptions { Timeout = 5000 });
+
+ // Also verify the active segment has data-id="0"
+ var activeSegment = await page.EvaluateAsync(@"() => {
+ const segments = Array.from(document.querySelectorAll('[data-testid=""bom-scrub-segment""]'));
+ const activeSegment = segments.find(s => {
+ const style = window.getComputedStyle(s);
+ return style.backgroundColor !== 'rgb(148, 148, 148)' && style.backgroundColor !== 'rgb(148, 148, 148)';
+ });
+ return activeSegment && activeSegment.getAttribute('data-id') === '0';
+ }");
+
+ if (activeSegment)
+ {
+ _logger.LogInformation("Scrubber confirmed at position 0");
+ }
+ else
+ {
+ _logger.LogWarning("Could not confirm scrubber is at position 0, but continuing");
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to verify scrubber position, continuing anyway");
+ }
+
+ await _debugService.SaveStepDebugAsync(debugFolder, 10, "scrubber_at_position_0", page, consoleMessages, networkRequests, cancellationToken);
+
+ // Step 11: Extract metadata and frame information
+ _logger.LogInformation("Extracting metadata and frame information");
+ var lastUpdatedInfo = await _timeParsingService.ExtractLastUpdatedInfoAsync(page);
+ var frameInfo = await ExtractFrameInfoAsync(page);
+
+ // Step 12: Get map container and calculate bounding box once
+ _logger.LogInformation("Preparing map container for screenshot");
var mapContainer = page.Locator(".esri-view-surface").First;
await mapContainer.WaitForAsync(new LocatorWaitForOptions { Timeout = 10000 });
-
- // Ensure the map container is visible and has dimensions before taking screenshot
+
+ // Ensure the map container is visible and has dimensions
await page.WaitForFunctionAsync(@"() => {
const container = document.querySelector('.esri-view-surface');
return container && container.offsetWidth > 0 && container.offsetHeight > 0;
}", new PageWaitForFunctionOptions { Timeout = 10000 });
- // Get bounding box of map container
var boundingBox = await mapContainer.BoundingBoxAsync();
if (boundingBox == null)
{
throw new Exception("Could not determine map container bounds");
}
+
+ // Convert BoundingBox to Clip for crop calculation
+ var containerClip = new Clip
+ {
+ X = boundingBox.X,
+ Y = boundingBox.Y,
+ Width = boundingBox.Width,
+ Height = boundingBox.Height
+ };
- // Generate cache filename based on suburb, state and timestamp
+ // Step 13: Create cache folder
var locationKey = LocationHelper.GetLocationKey(suburb, state);
var safeLocationKey = LocationHelper.SanitizeFileName(locationKey);
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
- var cacheFileName = $"{safeLocationKey}_{timestamp}.png";
- var cacheFilePath = Path.Combine(_cacheService.GetCacheDirectory(), cacheFileName);
+ var cacheFolderPath = FilePathHelper.GetCacheFolderPath(_cacheService.GetCacheDirectory(), suburb, state, timestamp);
+ Directory.CreateDirectory(cacheFolderPath);
- // Take screenshot of the map area only
- await page.ScreenshotAsync(new PageScreenshotOptions
+ _logger.LogInformation("Created cache folder: {Path}", cacheFolderPath);
+
+ // Step 14-20: Capture all 7 frames
+ var frames = new List();
+ var stepForwardButton = page.Locator("button[data-testid='bom-scrub-utils__right__step-forward']").First;
+
+ for (int frameIndex = 0; frameIndex < 7; frameIndex++)
{
- Path = cacheFilePath,
- Clip = new Clip
+ _logger.LogInformation("Capturing frame {FrameIndex} of 7", frameIndex);
+
+ // Wait for map to stabilize (tiles to load for current frame)
+ await page.WaitForTimeoutAsync(_tileRenderWaitMs);
+
+ // Extract actual minutes ago from the display label (e.g., "17 minutes ago" -> 17)
+ var minutesAgo = await ExtractMinutesAgoFromDisplayAsync(page);
+ if (minutesAgo == null)
{
- X = boundingBox.X,
- Y = boundingBox.Y,
- Width = boundingBox.Width,
- Height = boundingBox.Height
+ // Fallback to calculated value if extraction fails
+ var (_, defaultMinutesAgo) = frameInfo[frameIndex];
+ minutesAgo = defaultMinutesAgo;
+ _logger.LogWarning("Failed to extract minutes from display label for frame {FrameIndex}, using default: {MinutesAgo}", frameIndex, minutesAgo);
}
- });
+
+ // Take screenshot with crop configuration
+ var framePath = FilePathHelper.GetFrameFilePath(cacheFolderPath, frameIndex);
+ await CaptureMapScreenshotAsync(page, mapContainer, framePath, containerClip);
+
+ frames.Add(new RadarFrame
+ {
+ FrameIndex = frameIndex,
+ ImagePath = framePath,
+ MinutesAgo = minutesAgo.Value
+ });
+
+ _logger.LogInformation("Frame {FrameIndex} saved: {Path} ({MinutesAgo} minutes ago)",
+ frameIndex, framePath, minutesAgo.Value);
+
+ // Save debug screenshot BEFORE clicking step forward
+ await _debugService.SaveStepDebugAsync(debugFolder, 14 + frameIndex, $"frame_{frameIndex}_captured", page, consoleMessages, networkRequests, cancellationToken);
+
+ // If not the last frame, click step forward to prepare for next frame
+ if (frameIndex < 6)
+ {
+ // Wait for any modal overlays to disappear before clicking
+ try
+ {
+ await page.WaitForFunctionAsync(@"() => {
+ const overlay = document.querySelector('.bom-modal-overlay--after-open');
+ return !overlay || overlay.style.display === 'none';
+ }", new PageWaitForFunctionOptions { Timeout = 5000 });
+ }
+ catch
+ {
+ // If overlay doesn't disappear, try to dismiss it by clicking outside or pressing Escape
+ try
+ {
+ await page.Keyboard.PressAsync("Escape");
+ await page.WaitForTimeoutAsync(500);
+ }
+ catch
+ {
+ // Ignore if Escape doesn't work
+ }
+ }
+
+ // Use force click to bypass any remaining overlays
+ await stepForwardButton.ClickAsync(new LocatorClickOptions { Force = true });
+ // Wait for map to update to next frame
+ await page.WaitForTimeoutAsync(1000); // Wait for frame transition
+ }
+ }
- _logger.LogInformation("Screenshot saved to: {Path}", cacheFilePath);
+ _logger.LogInformation("All 7 frames captured successfully");
- // Save metadata alongside the screenshot
- await _cacheService.SaveMetadataAsync(cacheFilePath, lastUpdatedInfo, cancellationToken);
+ // Step 21: Save metadata and frame information
+ await _cacheService.SaveMetadataAsync(cacheFolderPath, lastUpdatedInfo, cancellationToken);
+ await _cacheService.SaveFramesMetadataAsync(cacheFolderPath, frames, cancellationToken);
- return ResponseBuilder.CreateRadarScreenshotResponse(cacheFilePath, lastUpdatedInfo);
+ // Step 22: Return response with all frames
+ return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, lastUpdatedInfo, suburb, state);
}
catch (Exception ex)
{
@@ -371,5 +540,161 @@ public class ScrapingService : IScrapingService
throw;
}
}
+
+ ///
+ /// Calculates the crop area for screenshot based on configuration
+ ///
+ private Clip CalculateCropArea(Clip containerClip)
+ {
+ // Start with container's position plus offset
+ var x = containerClip.X + _cropConfig.X;
+ var y = containerClip.Y + _cropConfig.Y;
+
+ // Calculate width (use configured or remaining width minus right offset)
+ var rightOffset = 200;
+ var width = _cropConfig.Width ?? (containerClip.Width - _cropConfig.X - rightOffset);
+
+ // Calculate height (use configured or remaining height)
+ var height = _cropConfig.Height ?? (containerClip.Height - _cropConfig.Y);
+
+ // Validate bounds
+ if (x < containerClip.X || y < containerClip.Y)
+ {
+ _logger.LogWarning("Crop offset is outside container bounds, using container bounds");
+ x = containerClip.X;
+ y = containerClip.Y;
+ }
+
+ var maxWidth = containerClip.Width - (x - containerClip.X);
+ var maxHeight = containerClip.Height - (y - containerClip.Y);
+
+ if (width > maxWidth)
+ {
+ _logger.LogWarning("Crop width exceeds container bounds, adjusting from {Requested} to {Max}", width, maxWidth);
+ width = maxWidth;
+ }
+
+ if (height > maxHeight)
+ {
+ _logger.LogWarning("Crop height exceeds container bounds, adjusting from {Requested} to {Max}", height, maxHeight);
+ height = maxHeight;
+ }
+
+ if (width <= 0 || height <= 0)
+ {
+ throw new Exception($"Invalid crop dimensions: {width}x{height}");
+ }
+
+ _logger.LogDebug("Crop area calculated: X={X}, Y={Y}, Width={Width}, Height={Height} (container: {ContainerX}, {ContainerY}, {ContainerWidth}x{ContainerHeight})",
+ x, y, width, height, containerClip.X, containerClip.Y, containerClip.Width, containerClip.Height);
+
+ return new Clip
+ {
+ X = x,
+ Y = y,
+ Width = width,
+ Height = height
+ };
+ }
+
+ ///
+ /// Extracts frame information from timeline segments
+ ///
+ private async Task> ExtractFrameInfoAsync(IPage page)
+ {
+ try
+ {
+ var frameInfo = await page.EvaluateAsync