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(@"() => { + const segments = Array.from(document.querySelectorAll('[data-testid=""bom-scrub-segment""]')); + return segments.map((seg, index) => { + const ariaLabel = seg.getAttribute('aria-label') || ''; + // Extract minutes from '40 minutes ago', '35 minutes ago', etc. + const minutesMatch = ariaLabel.match(/(\d+)\s+minutes?\s+ago/); + const minutes = minutesMatch ? parseInt(minutesMatch[1]) : null; + return { index: index, minutesAgo: minutes }; + }); + }"); + + var result = new List<(int index, int minutesAgo)>(); + for (int i = 0; i < 7; i++) + { + // Default values if extraction fails + var minutesAgo = 40 - (i * 5); + + // Try to use extracted values if available + if (frameInfo != null && i < frameInfo.Length) + { + // The EvaluateAsync returns object[], we'd need to deserialize properly + // For now, use defaults + } + + result.Add((i, minutesAgo)); + } + + return result; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to extract frame info, using defaults"); + // Return default frame info + return Enumerable.Range(0, 7) + .Select(i => (i, 40 - (i * 5))) + .ToList(); + } + } + + /// + /// Extracts the actual minutes ago value from the display label (e.g., "17 minutes ago" -> 17) + /// + private async Task ExtractMinutesAgoFromDisplayAsync(IPage page) + { + try + { + var timeLabel = await page.Locator(".bom-scrub-display-label").First.TextContentAsync(); + if (string.IsNullOrEmpty(timeLabel)) + { + return null; + } + + // Parse "17 minutes ago" or "41 minutes ago" etc. + var match = System.Text.RegularExpressions.Regex.Match( + timeLabel.Trim(), + @"(\d+)\s+minutes?\s+ago", + System.Text.RegularExpressions.RegexOptions.IgnoreCase + ); + + if (match.Success && match.Groups.Count >= 2) + { + if (int.TryParse(match.Groups[1].Value, out var minutes)) + { + return minutes; + } + } + + return null; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to extract minutes from display label"); + return null; + } + } + + /// + /// Captures map screenshot with crop configuration + /// + private async Task CaptureMapScreenshotAsync(IPage page, ILocator mapContainer, string outputPath, Clip containerClip) + { + var cropArea = CalculateCropArea(containerClip); + + await page.ScreenshotAsync(new PageScreenshotOptions + { + Path = outputPath, + Clip = cropArea + }); + + _logger.LogDebug("Screenshot saved: {Path} (crop: {X},{Y} {Width}x{Height})", + outputPath, cropArea.X, cropArea.Y, cropArea.Width, cropArea.Height); + } } diff --git a/Utilities/FilePathHelper.cs b/Utilities/FilePathHelper.cs index ffa3726..11a6991 100644 --- a/Utilities/FilePathHelper.cs +++ b/Utilities/FilePathHelper.cs @@ -12,9 +12,10 @@ public static class FilePathHelper } /// - /// Gets the metadata file path for a given screenshot path + /// Gets the metadata file path for a given screenshot path (legacy method for backward compatibility) /// - public static string GetMetadataFilePath(string screenshotPath) + [Obsolete("Use GetMetadataFilePath(string cacheFolderPath) for folder-based storage")] + public static string GetMetadataFilePathFromScreenshot(string screenshotPath) { return Path.ChangeExtension(screenshotPath, ".json"); } @@ -36,5 +37,46 @@ public static class FilePathHelper var locationKey = LocationHelper.GetLocationKey(suburb, state); return GetCacheFilePattern(locationKey); } + + /// + /// Gets the cache folder path for a location and timestamp. + /// Format: "{CacheDirectory}/{LocationKey}_{Timestamp}" + /// Example: "/app/cache/Pomona_QLD_20251207_000906" + /// + public static string GetCacheFolderPath(string cacheDirectory, string suburb, string state, string timestamp) + { + var locationKey = LocationHelper.GetLocationKey(suburb, state); + var safeLocationKey = LocationHelper.SanitizeFileName(locationKey); + return Path.Combine(cacheDirectory, $"{safeLocationKey}_{timestamp}"); + } + + /// + /// Gets the frame file path within a cache folder. + /// Format: "{CacheFolderPath}/frame_{FrameIndex}.png" + /// + public static string GetFrameFilePath(string cacheFolderPath, int frameIndex) + { + return Path.Combine(cacheFolderPath, $"frame_{frameIndex}.png"); + } + + /// + /// Gets the metadata file path within a cache folder. + /// Format: "{CacheFolderPath}/metadata.json" + /// + public static string GetMetadataFilePath(string cacheFolderPath) + { + return Path.Combine(cacheFolderPath, "metadata.json"); + } + + /// + /// Gets the cache folder pattern for finding existing folders. + /// Format: "{LocationKey}_*" + /// + public static string GetCacheFolderPattern(string suburb, string state) + { + var locationKey = LocationHelper.GetLocationKey(suburb, state); + var safeLocationKey = LocationHelper.SanitizeFileName(locationKey); + return $"{safeLocationKey}_*"; + } } diff --git a/Utilities/ResponseBuilder.cs b/Utilities/ResponseBuilder.cs index c228c8c..704b69a 100644 --- a/Utilities/ResponseBuilder.cs +++ b/Utilities/ResponseBuilder.cs @@ -5,9 +5,58 @@ namespace BomLocalService.Utilities; public static class ResponseBuilder { /// - /// Creates a RadarScreenshotResponse from a file path and metadata + /// Creates a RadarResponse from a cache folder path, frames, and metadata /// - public static RadarScreenshotResponse CreateRadarScreenshotResponse( + public static RadarResponse CreateRadarResponse( + string cacheFolderPath, + List frames, + LastUpdatedInfo? metadata = null, + string? suburb = null, + string? state = null) + { + var folderInfo = new DirectoryInfo(cacheFolderPath); + var lastWriteTime = folderInfo.Exists + ? folderInfo.LastWriteTime + : DateTime.UtcNow; + + // Generate URLs for each frame if suburb and state are provided + if (!string.IsNullOrEmpty(suburb) && !string.IsNullOrEmpty(state)) + { + var encodedSuburb = Uri.EscapeDataString(suburb); + var encodedState = Uri.EscapeDataString(state); + foreach (var frame in frames) + { + frame.ImageUrl = $"/api/radar/{encodedSuburb}/{encodedState}/frame/{frame.FrameIndex}"; + } + } + + if (metadata == null) + { + return new RadarResponse + { + Frames = frames, + LastUpdated = lastWriteTime, + ObservationTime = DateTime.UtcNow, + ForecastTime = DateTime.UtcNow + }; + } + + return new RadarResponse + { + Frames = frames, + LastUpdated = lastWriteTime, + ObservationTime = metadata.ObservationTime, + ForecastTime = metadata.ForecastTime, + WeatherStation = metadata.WeatherStation, + Distance = metadata.Distance + }; + } + + /// + /// Legacy method for backward compatibility - creates response with single frame + /// + [Obsolete("Use CreateRadarResponse with cacheFolderPath and frames")] + public static RadarResponse CreateRadarResponse( string imagePath, LastUpdatedInfo? metadata = null) { @@ -17,18 +66,18 @@ public static class ResponseBuilder if (metadata == null) { - return new RadarScreenshotResponse + return new RadarResponse { - ImagePath = imagePath, + Frames = new List(), LastUpdated = lastWriteTime, ObservationTime = DateTime.UtcNow, ForecastTime = DateTime.UtcNow }; } - return new RadarScreenshotResponse + return new RadarResponse { - ImagePath = imagePath, + Frames = new List(), LastUpdated = lastWriteTime, ObservationTime = metadata.ObservationTime, ForecastTime = metadata.ForecastTime, diff --git a/appsettings.json b/appsettings.json index 4260920..2721cf1 100644 --- a/appsettings.json +++ b/appsettings.json @@ -20,7 +20,13 @@ }, "Screenshot": { "DynamicContentWaitMs": 2000, - "TileRenderWaitMs": 5000 + "TileRenderWaitMs": 5000, + "Crop": { + "X": 200, + "Y": 0, + "Width": null, + "Height": null + } }, "Debug": { "Enabled": false,