diff --git a/Controllers/CacheController.cs b/Controllers/CacheController.cs new file mode 100644 index 0000000..26579c1 --- /dev/null +++ b/Controllers/CacheController.cs @@ -0,0 +1,112 @@ +using BomLocalService.Models; +using BomLocalService.Services.Interfaces; +using BomLocalService.Utilities; +using Microsoft.AspNetCore.Mvc; + +namespace BomLocalService.Controllers; + +[ApiController] +[Route("api/cache")] +public class CacheController : ControllerBase +{ + private readonly ICacheService _cacheService; + private readonly IBomRadarService _bomRadarService; + private readonly ILogger _logger; + + public CacheController( + ICacheService cacheService, + IBomRadarService bomRadarService, + ILogger logger) + { + _cacheService = cacheService; + _bomRadarService = bomRadarService; + _logger = logger; + } + + /// + /// Get information about the available cache range for a location (oldest and newest cache folders). + /// Helps clients understand what historical data is available before requesting extended timespans. + /// + [HttpGet("{suburb}/{state}/range")] + public async Task> GetCacheRange(string suburb, string state, CancellationToken cancellationToken = default) + { + try + { + var validationError = ValidationHelper.ValidateLocation(suburb, state); + if (validationError != null) + { + return BadRequest(new { error = validationError }); + } + + var result = await _bomRadarService.GetCacheRangeAsync(suburb, state, cancellationToken); + + if (result.TotalCacheFolders == 0) + { + return NotFound(new { error = "No cached data found for this location." }); + } + + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting cache range for suburb: {Suburb}, state: {State}", suburb, state); + return StatusCode(500, new { error = "An error occurred while getting cache range", message = ex.Message }); + } + } + + /// + /// Manually trigger a cache refresh for a location. Returns status of the update operation. + /// + [HttpPost("{suburb}/{state}/refresh")] + public async Task> RefreshCache(string suburb, string state, CancellationToken cancellationToken = default) + { + try + { + var validationError = ValidationHelper.ValidateLocation(suburb, state); + if (validationError != null) + { + return BadRequest(new { error = validationError }); + } + + var status = await _bomRadarService.TriggerCacheUpdateAsync(suburb, state, cancellationToken); + return Ok(status); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error triggering cache update for suburb: {Suburb}, state: {State}", suburb, state); + return StatusCode(500, new { error = "An error occurred while triggering cache update", message = ex.Message }); + } + } + + /// + /// Delete cached data for a location. + /// + [HttpDelete("{suburb}/{state}")] + public async Task DeleteCache(string suburb, string state, CancellationToken cancellationToken = default) + { + try + { + var validationError = ValidationHelper.ValidateLocation(suburb, state); + if (validationError != null) + { + return BadRequest(new { error = validationError }); + } + + var deleted = await _bomRadarService.DeleteCachedLocationAsync(suburb, state, cancellationToken); + + if (deleted) + { + return Ok(new { message = $"Cache deleted for {suburb}, {state}" }); + } + else + { + return NotFound(new { error = $"No cached data found for {suburb}, {state}" }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting cached location for suburb: {Suburb}, state: {State}", suburb, state); + return StatusCode(500, new { error = "An error occurred while deleting cached location", message = ex.Message }); + } + } +} diff --git a/Controllers/RadarController.cs b/Controllers/RadarController.cs index 4e8f8d9..c428ff4 100644 --- a/Controllers/RadarController.cs +++ b/Controllers/RadarController.cs @@ -10,11 +10,19 @@ namespace BomLocalService.Controllers; public class RadarController : ControllerBase { private readonly IBomRadarService _bomRadarService; + private readonly ICacheService _cacheService; + private readonly IConfiguration _configuration; private readonly ILogger _logger; - public RadarController(IBomRadarService bomRadarService, ILogger logger) + public RadarController( + IBomRadarService bomRadarService, + ICacheService cacheService, + IConfiguration configuration, + ILogger logger) { _bomRadarService = bomRadarService; + _cacheService = cacheService; + _configuration = configuration; _logger = logger; } @@ -50,10 +58,28 @@ public class RadarController : ControllerBase if (result == null || result.Frames.Count == 0) { + // Get cache status to include in 404 response + // Use cache service directly since this is cache management, not radar-specific + var cacheExpirationMinutes = (int)_configuration.GetValue("CacheExpirationMinutes", 15.5); + var cacheManagementCheckIntervalMinutes = _configuration.GetValue("CacheManagement:CheckIntervalMinutes", 5); + var cacheStatus = await _cacheService.GetCacheStatusAsync( + suburb, + state, + CachedDataType.Radar, + cacheExpirationMinutes, + cacheManagementCheckIntervalMinutes, + cancellationToken); + return NotFound(new { 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" + refreshEndpoint = $"/api/cache/{suburb}/{state}/refresh", + updateTriggered = cacheStatus.UpdateTriggered, + cacheExists = cacheStatus.CacheExists, + cacheIsValid = cacheStatus.CacheIsValid, + cacheExpiresAt = cacheStatus.CacheExpiresAt, + nextUpdateTime = cacheStatus.NextUpdateTime, + message = cacheStatus.Message }); } @@ -89,12 +115,18 @@ public class RadarController : ControllerBase return BadRequest(new { error = validationError }); } - if (frameIndex < 0 || frameIndex > 6) - { - return BadRequest(new { error = "Frame index must be between 0 and 6" }); - } + RadarFrame? frame; - var frame = await _bomRadarService.GetCachedFrameAsync(suburb, state, frameIndex, cancellationToken); + // Support cacheFolder query parameter for historical data + var cacheFolder = Request.Query["cacheFolder"].FirstOrDefault(); + if (!string.IsNullOrEmpty(cacheFolder)) + { + frame = await _bomRadarService.GetFrameFromCacheFolderAsync(suburb, state, cacheFolder, frameIndex, cancellationToken); + } + else + { + frame = await _bomRadarService.GetCachedFrameAsync(suburb, state, frameIndex, cancellationToken); + } if (frame == null || !System.IO.File.Exists(frame.ImagePath)) { @@ -129,7 +161,7 @@ public class RadarController : ControllerBase if (result == null) { - return NotFound(new { error = "No cached data found. Use POST /api/radar/{suburb}/{state}/refresh to trigger cache update." }); + return NotFound(new { error = "No cached data found. Use POST /api/cache/{suburb}/{state}/refresh to trigger cache update." }); } return Ok(result); @@ -142,10 +174,16 @@ public class RadarController : ControllerBase } /// - /// Manually trigger a cache refresh for a location. Returns status of the update operation. + /// Get historical radar data (frames from multiple cache folders) between specified timestamps. + /// Radar frames can be joined across cache folders because they represent a historical time series. /// - [HttpPost("{suburb}/{state}/refresh")] - public async Task> RefreshCache(string suburb, string state, CancellationToken cancellationToken = default) + [HttpGet("{suburb}/{state}/timeseries")] + public async Task> GetRadarTimeSeries( + string suburb, + string state, + [FromQuery] string? startTime = null, + [FromQuery] string? endTime = null, + CancellationToken cancellationToken = default) { try { @@ -155,45 +193,45 @@ public class RadarController : ControllerBase return BadRequest(new { error = validationError }); } - var status = await _bomRadarService.TriggerCacheUpdateAsync(suburb, state, cancellationToken); - return Ok(status); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error triggering cache update for suburb: {Suburb}, state: {State}", suburb, state); - return StatusCode(500, new { error = "An error occurred while triggering cache update", message = ex.Message }); - } - } - - /// - /// Delete cached data for a location. - /// - [HttpDelete("{suburb}/{state}")] - public async Task DeleteCache(string suburb, string state, CancellationToken cancellationToken = default) - { - try - { - var validationError = ValidationHelper.ValidateLocation(suburb, state); - if (validationError != null) - { - return BadRequest(new { error = validationError }); - } - - var deleted = await _bomRadarService.DeleteCachedLocationAsync(suburb, state, cancellationToken); + DateTime? start = null; + DateTime? end = null; - if (deleted) + if (!string.IsNullOrEmpty(startTime)) { - return Ok(new { message = $"Cache deleted for {suburb}, {state}" }); + if (!DateTime.TryParse(startTime, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsedStart)) + { + return BadRequest(new { error = "Invalid startTime format. Use ISO 8601 format (e.g., 2025-12-07T00:00:00Z)" }); + } + start = parsedStart.ToUniversalTime(); } - else + + if (!string.IsNullOrEmpty(endTime)) { - return NotFound(new { error = $"No cached data found for {suburb}, {state}" }); + if (!DateTime.TryParse(endTime, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsedEnd)) + { + return BadRequest(new { error = "Invalid endTime format. Use ISO 8601 format (e.g., 2025-12-07T12:00:00Z)" }); + } + end = parsedEnd.ToUniversalTime(); } + + if (start.HasValue && end.HasValue && start.Value > end.Value) + { + return BadRequest(new { error = "startTime must be before or equal to endTime" }); + } + + var result = await _bomRadarService.GetRadarTimeSeriesAsync(suburb, state, start, end, cancellationToken); + + if (result.CacheFolders.Count == 0) + { + return NotFound(new { error = "No historical data found for the specified time range." }); + } + + return Ok(result); } catch (Exception ex) { - _logger.LogError(ex, "Error deleting cached location for suburb: {Suburb}, state: {State}", suburb, state); - return StatusCode(500, new { error = "An error occurred while deleting cached location", message = ex.Message }); + _logger.LogError(ex, "Error getting radar time series for suburb: {Suburb}, state: {State}", suburb, state); + return StatusCode(500, new { error = "An error occurred while getting radar time series", message = ex.Message }); } } } diff --git a/Models/CacheFolder.cs b/Models/CacheFolder.cs new file mode 100644 index 0000000..d65a7ba --- /dev/null +++ b/Models/CacheFolder.cs @@ -0,0 +1,33 @@ +namespace BomLocalService.Models; + +/// +/// Information about a cache folder containing cached data for a location. +/// Each cache folder represents one capture session and can contain multiple data types. +/// +public class CacheFolder +{ + /// + /// The folder name (e.g., "Pomona_QLD_20251207_000906"). + /// + public string FolderName { get; set; } = string.Empty; + + /// + /// The UTC timestamp when this cache folder was created (extracted from folder name). + /// + public DateTime CacheTimestamp { get; set; } + + /// + /// The observation time from the metadata (when the weather data was captured by BOM). + /// + public DateTime ObservationTime { get; set; } + + /// + /// List of data types available in this cache folder. + /// + public List AvailableDataTypes { get; set; } = new(); + + /// + /// Whether this cache folder is complete and ready to use. + /// + public bool IsComplete { get; set; } +} diff --git a/Models/CacheRange.cs b/Models/CacheRange.cs new file mode 100644 index 0000000..5317ae5 --- /dev/null +++ b/Models/CacheRange.cs @@ -0,0 +1,29 @@ +namespace BomLocalService.Models; + +/// +/// Information about the available cache range for a location. +/// Provides oldest and newest cache folder information to help clients understand available historical data. +/// +public class CacheRange +{ + /// + /// Information about the oldest available cache folder. + /// + public CacheFolder? OldestCache { get; set; } + + /// + /// Information about the newest available cache folder. + /// + public CacheFolder? NewestCache { get; set; } + + /// + /// Total number of complete cache folders available. + /// + public int TotalCacheFolders { get; set; } + + /// + /// The time span between oldest and newest cache (in minutes). + /// Null if there are fewer than 2 cache folders. + /// + public int? TimeSpanMinutes { get; set; } +} diff --git a/Models/CacheUpdateStatus.cs b/Models/CacheUpdateStatus.cs index 77e90cc..c938ae6 100644 --- a/Models/CacheUpdateStatus.cs +++ b/Models/CacheUpdateStatus.cs @@ -50,6 +50,7 @@ public class CacheUpdateStatus /// - "Cache is valid, no update needed" - Cache exists and is still fresh /// - "Cache is stale, update triggered" - Cache exists but expired, update initiated /// - "No cache exists, update triggered" - No cache found, update initiated + /// - "Cache update already in progress" - An update is currently running /// public string? Message { get; set; } } diff --git a/Models/CachedDataType.cs b/Models/CachedDataType.cs new file mode 100644 index 0000000..6db489e --- /dev/null +++ b/Models/CachedDataType.cs @@ -0,0 +1,16 @@ +namespace BomLocalService.Models; + +/// +/// Types of cached data available from BOM weather pages. +/// +public enum CachedDataType +{ + /// + /// Rain radar images showing historical precipitation (40 min ago → 10 min ago). + /// Frames represent a time series and can be joined across cache folders for extended historical slideshows. + /// + Radar + // Future data types (not yet implemented): + // Temperature - forecast images showing current conditions and projected temperatures for the week ahead + // Wind - forecast images showing current conditions and projected wind patterns for the week ahead +} diff --git a/Models/CachedFrame.cs b/Models/CachedFrame.cs new file mode 100644 index 0000000..5303340 --- /dev/null +++ b/Models/CachedFrame.cs @@ -0,0 +1,28 @@ +namespace BomLocalService.Models; + +/// +/// Base model representing a single cached frame image. +/// Extended by type-specific frame models (RadarFrame, etc.). +/// +public class CachedFrame +{ + /// + /// Frame index within the frameset (0-based). + /// + public int FrameIndex { get; set; } + + /// + /// Full file system path to the frame image file (server-side only). + /// + public string ImagePath { get; set; } = string.Empty; + + /// + /// URL endpoint to retrieve this frame image. + /// + public string ImageUrl { get; set; } = string.Empty; + + /// + /// The data type this frame belongs to. + /// + public CachedDataType DataType { get; set; } +} diff --git a/Models/RadarFrame.cs b/Models/RadarFrame.cs index 8d91965..8982d0a 100644 --- a/Models/RadarFrame.cs +++ b/Models/RadarFrame.cs @@ -1,33 +1,29 @@ namespace BomLocalService.Models; /// -/// Represents a single frame from the radar slideshow (0-6). +/// Represents a single radar frame (historical precipitation data). +/// Frame 0 is oldest (40 minutes ago), Frame 6 is newest (10 minutes ago). +/// Radar frames can be joined across cache folders to create extended historical slideshows. /// -public class RadarFrame +public class RadarFrame : CachedFrame { - /// - /// 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. + /// This is relative to the cache folder's observation time. /// public int MinutesAgo { get; set; } + + /// + /// The absolute UTC observation time for this frame. + /// Calculated as: ObservationTime - MinutesAgo. + /// This is set when frames are joined across cache folders in timeseries responses. + /// + public DateTime? AbsoluteObservationTime { get; set; } + + public RadarFrame() + { + DataType = CachedDataType.Radar; + } } diff --git a/Models/RadarTimeSeriesResponse.cs b/Models/RadarTimeSeriesResponse.cs new file mode 100644 index 0000000..e299f16 --- /dev/null +++ b/Models/RadarTimeSeriesResponse.cs @@ -0,0 +1,58 @@ +namespace BomLocalService.Models; + +/// +/// Response containing radar frames from multiple cache folders (historical data). +/// Used when requesting radar data across a time range rather than just the most recent frames. +/// Radar frames can be joined across cache folders because they represent a historical time series. +/// +public class RadarTimeSeriesResponse +{ + /// + /// List of cache folders, each containing radar frames from a single capture session. + /// Ordered chronologically (oldest first). + /// + public List CacheFolders { get; set; } = new(); + + /// + /// The start time of the requested range (UTC). + /// + public DateTime? StartTime { get; set; } + + /// + /// The end time of the requested range (UTC). + /// + public DateTime? EndTime { get; set; } + + /// + /// Total number of frames across all cache folders. + /// + public int TotalFrames { get; set; } +} + +/// +/// A cache folder with its associated radar frames. +/// Represents all frames from a single radar capture session (from the radar subfolder). +/// +public class RadarCacheFolderFrames +{ + /// + /// The cache folder name this frameset came from. + /// + public string CacheFolderName { get; set; } = string.Empty; + + /// + /// The timestamp when this cache folder was created (UTC). + /// + public DateTime CacheTimestamp { get; set; } + + /// + /// The observation time from metadata (UTC). + /// + public DateTime ObservationTime { get; set; } + + /// + /// All radar frames from this cache folder's radar subfolder (typically 7 frames, 0-6). + /// Frame URLs are already generated and ready to use. + /// + public List Frames { get; set; } = new(); +} diff --git a/Services/BomRadarService.cs b/Services/BomRadarService.cs index e83e160..8b91fb2 100644 --- a/Services/BomRadarService.cs +++ b/Services/BomRadarService.cs @@ -2,7 +2,6 @@ using BomLocalService.Models; using BomLocalService.Services.Interfaces; using BomLocalService.Utilities; using Microsoft.Playwright; -using System.Collections.Concurrent; namespace BomLocalService.Services; @@ -13,9 +12,9 @@ public class BomRadarService : IBomRadarService, IDisposable private readonly IBrowserService _browserService; private readonly IScrapingService _scrapingService; private readonly IDebugService _debugService; + private readonly IConfiguration _configuration; private readonly double _cacheExpirationMinutes; private readonly int _cacheManagementCheckIntervalMinutes; - private readonly ConcurrentDictionary _activeCacheFolders = new(); // locationKey -> cacheFolderPath public BomRadarService( ILogger logger, @@ -30,6 +29,7 @@ public class BomRadarService : IBomRadarService, IDisposable _browserService = browserService; _scrapingService = scrapingService; _debugService = debugService; + _configuration = configuration; _cacheExpirationMinutes = configuration.GetValue("CacheExpirationMinutes", 15.5); _cacheManagementCheckIntervalMinutes = configuration.GetValue("CacheManagement:CheckIntervalMinutes", 5); } @@ -37,8 +37,8 @@ public class BomRadarService : IBomRadarService, IDisposable public async Task GetCachedRadarAsync(string suburb, string state, CancellationToken cancellationToken = default) { var locationKey = LocationHelper.GetLocationKey(suburb, state); - var excludeFolder = _activeCacheFolders.TryGetValue(locationKey, out var activeFolder) ? activeFolder : null; - var (cacheFolderPath, cachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, excludeFolder, cancellationToken); + var excludeFolder = _cacheService.GetActiveCacheFolder(locationKey); + var (cacheFolderPath, cachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, excludeFolder, cancellationToken); if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath)) { @@ -54,7 +54,7 @@ public class BomRadarService : IBomRadarService, IDisposable // Determine cache state var isValid = cachedMetadata != null && _cacheService.IsCacheValid(cachedMetadata); var cacheExpiresAt = cachedMetadata != null ? cachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes) : (DateTime?)null; - var isUpdating = _activeCacheFolders.ContainsKey(locationKey); + var isUpdating = _cacheService.IsLocationUpdating(locationKey); return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating, _cacheManagementCheckIntervalMinutes); } @@ -83,9 +83,9 @@ public class BomRadarService : IBomRadarService, IDisposable var locationKey = LocationHelper.GetLocationKey(suburb, state); // Check if an update is already in progress - var isAlreadyUpdating = _activeCacheFolders.ContainsKey(locationKey); - var excludeFolder = _activeCacheFolders.TryGetValue(locationKey, out var activeFolder) ? activeFolder : null; - var (cacheFolderPath, cachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, excludeFolder, cancellationToken); + var isAlreadyUpdating = _cacheService.IsLocationUpdating(locationKey); + var excludeFolder = _cacheService.GetActiveCacheFolder(locationKey); + var (cacheFolderPath, cachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, excludeFolder, cancellationToken); if (isAlreadyUpdating) { @@ -170,14 +170,14 @@ public class BomRadarService : IBomRadarService, IDisposable // Check cache FIRST, before acquiring semaphore (cached requests shouldn't block) var locationKey = LocationHelper.GetLocationKey(suburb, state); - var excludeFolder = _activeCacheFolders.TryGetValue(locationKey, out var activeFolder) ? activeFolder : null; - var isUpdating = !string.IsNullOrEmpty(activeFolder); - var (cacheFolderPath, cachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, excludeFolder, cancellationToken); + var excludeFolder = _cacheService.GetActiveCacheFolder(locationKey); + var isUpdating = _cacheService.IsLocationUpdating(locationKey); + var (cacheFolderPath, cachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, excludeFolder, cancellationToken); // If there's an active update in progress, return existing cache (if available) with IsUpdating=true if (isUpdating) { - if (!string.IsNullOrEmpty(cacheFolderPath) && Directory.Exists(cacheFolderPath) && CacheHelper.IsCacheFolderComplete(cacheFolderPath)) + if (!string.IsNullOrEmpty(cacheFolderPath) && Directory.Exists(cacheFolderPath) && CacheHelper.IsCacheFolderComplete(cacheFolderPath, _configuration)) { _logger.LogInformation("Cache update in progress for {Suburb}, {State}, returning existing cached data", suburb, state); var frames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken); @@ -192,7 +192,7 @@ public class BomRadarService : IBomRadarService, IDisposable } } - if (!string.IsNullOrEmpty(cacheFolderPath) && Directory.Exists(cacheFolderPath) && cachedMetadata != null && CacheHelper.IsCacheFolderComplete(cacheFolderPath)) + if (!string.IsNullOrEmpty(cacheFolderPath) && Directory.Exists(cacheFolderPath) && cachedMetadata != null && CacheHelper.IsCacheFolderComplete(cacheFolderPath, _configuration)) { var isValid = _cacheService.IsCacheValid(cachedMetadata); if (isValid) @@ -231,39 +231,21 @@ public class BomRadarService : IBomRadarService, IDisposable { // Create cache folder and track it before double-checking var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); - newCacheFolderPath = FilePathHelper.GetCacheFolderPath(_cacheService.GetCacheDirectory(), suburb, state, timestamp); - Directory.CreateDirectory(newCacheFolderPath); + newCacheFolderPath = _cacheService.CreateCacheFolder(suburb, state, timestamp); // Track this folder as being written to - _activeCacheFolders[locationKey] = newCacheFolderPath; - _logger.LogDebug("Tracking cache folder being written to: {Folder} for {Location}", newCacheFolderPath, locationKey); + _cacheService.SetActiveCacheFolder(locationKey, newCacheFolderPath); // Double-check cache after acquiring semaphore (another request might have just created it) // Exclude the folder we're about to write to - var (recheckCacheFolderPath, recheckCachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, newCacheFolderPath, cancellationToken); + var (recheckCacheFolderPath, recheckCachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, newCacheFolderPath, cancellationToken); if (!string.IsNullOrEmpty(recheckCacheFolderPath) && Directory.Exists(recheckCacheFolderPath) && recheckCachedMetadata != null && _cacheService.IsCacheValid(recheckCachedMetadata)) { // Remove from tracking since we're not using this folder - _activeCacheFolders.TryRemove(locationKey, out _); + _cacheService.ClearActiveCacheFolder(locationKey); // Clean up the empty folder we created since we're not using it - if (!string.IsNullOrEmpty(newCacheFolderPath) && Directory.Exists(newCacheFolderPath)) - { - try - { - // Check if folder is empty (only . and ..) - var files = Directory.GetFiles(newCacheFolderPath); - if (files.Length == 0) - { - Directory.Delete(newCacheFolderPath, recursive: true); - _logger.LogDebug("Cleaned up empty cache folder that was created but not used: {Folder}", newCacheFolderPath); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to clean up empty cache folder: {Folder}", newCacheFolderPath); - } - } + _cacheService.TryDeleteEmptyCacheFolder(newCacheFolderPath); _logger.LogInformation("Cache became valid while waiting for semaphore, returning cached screenshots"); var recheckFrames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken); @@ -291,8 +273,7 @@ public class BomRadarService : IBomRadarService, IDisposable cancellationToken); // Remove from active tracking once complete - _activeCacheFolders.TryRemove(locationKey, out _); - _logger.LogDebug("Cache folder complete, removed from active tracking: {Folder}", newCacheFolderPath); + _cacheService.ClearActiveCacheFolder(locationKey); // Update result with cache state result.IsUpdating = false; @@ -320,23 +301,12 @@ public class BomRadarService : IBomRadarService, IDisposable finally { // Always remove from active tracking, even on error - _activeCacheFolders.TryRemove(locationKey, out _); + _cacheService.ClearActiveCacheFolder(locationKey); // Clean up incomplete cache folder if error occurred - if (!string.IsNullOrEmpty(newCacheFolderPath) && Directory.Exists(newCacheFolderPath)) + if (!string.IsNullOrEmpty(newCacheFolderPath) && _cacheService.TryDeleteIncompleteCacheFolder(newCacheFolderPath)) { - try - { - if (!CacheHelper.IsCacheFolderComplete(newCacheFolderPath)) - { - Directory.Delete(newCacheFolderPath, recursive: true); - _logger.LogWarning("Cleaned up incomplete cache folder due to error: {Folder}", newCacheFolderPath); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to clean up incomplete cache folder: {Folder}", newCacheFolderPath); - } + _logger.LogWarning("Cleaned up incomplete cache folder due to error: {Folder}", newCacheFolderPath); } // Clean up empty debug folder if we returned early without scraping @@ -365,8 +335,8 @@ public class BomRadarService : IBomRadarService, IDisposable { // Return cached metadata if available, otherwise return null var locationKey = LocationHelper.GetLocationKey(suburb, state); - var excludeFolder = _activeCacheFolders.TryGetValue(locationKey, out var activeFolder) ? activeFolder : null; - var (_, metadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, excludeFolder, cancellationToken); + var excludeFolder = _cacheService.GetActiveCacheFolder(locationKey); + var (_, metadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, excludeFolder, cancellationToken); if (metadata != null) { @@ -388,6 +358,253 @@ public class BomRadarService : IBomRadarService, IDisposable return _cacheService.DeleteCachedLocationAsync(suburb, state, cancellationToken); } + public async Task GetCacheRangeAsync(string suburb, string state, CancellationToken cancellationToken = default) + { + var allFolders = await _cacheService.GetAllCacheFoldersAsync(suburb, state, cancellationToken); + + if (allFolders.Count == 0) + { + return new CacheRange + { + TotalCacheFolders = 0 + }; + } + + var oldest = allFolders.First(); + var newest = allFolders.Last(); + + var timeSpanMinutes = allFolders.Count >= 2 + ? (int?)(newest.CacheTimestamp - oldest.CacheTimestamp).TotalMinutes + : null; + + return new CacheRange + { + OldestCache = oldest, + NewestCache = newest, + TotalCacheFolders = allFolders.Count, + TimeSpanMinutes = timeSpanMinutes + }; + } + + public async Task GetRadarTimeSeriesAsync( + string suburb, + string state, + DateTime? startTime, + DateTime? endTime, + CancellationToken cancellationToken = default) + { + var allFolders = await _cacheService.GetAllCacheFoldersAsync(suburb, state, cancellationToken); + + // Filter by time range if provided + var filteredFolders = allFolders.AsEnumerable(); + if (startTime.HasValue) + { + filteredFolders = filteredFolders.Where(f => f.CacheTimestamp >= startTime.Value); + } + if (endTime.HasValue) + { + filteredFolders = filteredFolders.Where(f => f.CacheTimestamp <= endTime.Value); + } + + var result = new List(); + var encodedSuburb = Uri.EscapeDataString(suburb); + var encodedState = Uri.EscapeDataString(state); + + // Process folders in reverse chronological order (newest first) so we keep frames from most recent cache folders + // Use a HashSet to track unique absolute observation times we've already seen + var seenAbsoluteTimes = new HashSet(); + + foreach (var folderInfo in filteredFolders.OrderByDescending(f => f.CacheTimestamp)) + { + // Load frames from this folder's radar subfolder + var cachedFrames = await _cacheService.GetFramesFromCacheFolderAsync( + suburb, + state, + folderInfo.FolderName, + CachedDataType.Radar, + cancellationToken); + + var radarFrames = cachedFrames.Cast() + .OrderBy(f => f.FrameIndex) // Ensure frames are sorted by index + .ToList(); + + if (radarFrames.Count > 0) + { + // Generate URLs and calculate absolute observation times for each frame + var uniqueFrames = new List(); + + foreach (var frame in radarFrames) + { + frame.ImageUrl = $"/api/radar/{encodedSuburb}/{encodedState}/frame/{frame.FrameIndex}?cacheFolder={Uri.EscapeDataString(folderInfo.FolderName)}"; + + // Calculate absolute observation time for this frame + // minutesAgo is relative to the cache folder's observation time + // Validate that ObservationTime is reasonable (not default/min value) + if (folderInfo.ObservationTime > DateTime.MinValue.AddYears(1) && frame.MinutesAgo >= 0) + { + frame.AbsoluteObservationTime = folderInfo.ObservationTime.AddMinutes(-frame.MinutesAgo); + + // Only include frames with unique absolute observation times + // Since we process folders newest-first, this ensures we keep frames from the most recent cache folder + if (frame.AbsoluteObservationTime.HasValue && !seenAbsoluteTimes.Contains(frame.AbsoluteObservationTime.Value)) + { + seenAbsoluteTimes.Add(frame.AbsoluteObservationTime.Value); + uniqueFrames.Add(frame); + } + } + else + { + _logger.LogWarning("Skipping frame {FrameIndex} from folder {FolderName}: Invalid ObservationTime ({ObservationTime}) or MinutesAgo ({MinutesAgo})", + frame.FrameIndex, folderInfo.FolderName, folderInfo.ObservationTime, frame.MinutesAgo); + } + } + + // Only add folder to result if it has unique frames + if (uniqueFrames.Count > 0) + { + // Sort frames within folder by absolute observation time (oldest first) for slideshow + uniqueFrames = uniqueFrames + .OrderBy(f => f.AbsoluteObservationTime ?? DateTime.MaxValue) + .ToList(); + + result.Add(new RadarCacheFolderFrames + { + CacheFolderName = folderInfo.FolderName, + CacheTimestamp = folderInfo.CacheTimestamp, + ObservationTime = folderInfo.ObservationTime, + Frames = uniqueFrames + }); + } + } + } + + // Flatten all frames, sort by absolute observation time, and filter out frames that are too close together + // Frames should be at least 4 minutes apart, and must be in strict chronological order + var allFrames = result.SelectMany(r => r.Frames.Select(f => new { Folder = r, Frame = f })) + .Where(x => x.Frame.AbsoluteObservationTime.HasValue) + .OrderBy(x => x.Frame.AbsoluteObservationTime!.Value) + .ToList(); + + // Filter out frames that are too close together (< 4 minutes) or out of order + // Track which absolute times passed the filter + var acceptedAbsoluteTimes = new HashSet(); + DateTime? lastAbsoluteTime = null; + const int minMinutesBetweenFrames = 4; + + foreach (var item in allFrames) + { + var absoluteTime = item.Frame.AbsoluteObservationTime!.Value; + + // Skip if this frame is older than the previous one (shouldn't happen after sorting, but safety check) + if (lastAbsoluteTime.HasValue && absoluteTime < lastAbsoluteTime.Value) + { + _logger.LogWarning("Skipping frame {FrameIndex} from folder {FolderName}: absolute time {AbsoluteTime} is older than previous {PreviousTime}", + item.Frame.FrameIndex, item.Folder.CacheFolderName, absoluteTime, lastAbsoluteTime.Value); + continue; + } + + // Skip if this frame is too close to the previous one (< 4 minutes) + if (lastAbsoluteTime.HasValue) + { + var timeDiff = absoluteTime - lastAbsoluteTime.Value; + if (timeDiff.TotalMinutes < minMinutesBetweenFrames) + { + _logger.LogDebug("Skipping frame {FrameIndex} from folder {FolderName}: only {Minutes} minutes after previous frame (minimum {MinMinutes})", + item.Frame.FrameIndex, item.Folder.CacheFolderName, timeDiff.TotalMinutes, minMinutesBetweenFrames); + continue; + } + } + + acceptedAbsoluteTimes.Add(absoluteTime); + lastAbsoluteTime = absoluteTime; + } + + // Prune frames from original folder structure - only keep frames that passed the filter + // This preserves the original folder order and structure + foreach (var folder in result) + { + folder.Frames = folder.Frames + .Where(f => f.AbsoluteObservationTime.HasValue && acceptedAbsoluteTimes.Contains(f.AbsoluteObservationTime.Value)) + .OrderBy(f => f.AbsoluteObservationTime!.Value) // Sort within folder by absolute time + .ToList(); + } + + // Remove empty folders and sort folders to ensure chronological order when client flattens + // To guarantee chronological order when iterating folder-by-folder, we need to ensure + // that the LAST frame of folder N is before the FIRST frame of folder N+1 + // Since we've already deduplicated and filtered, folders should not have overlapping frames + // Sort by earliest frame - if folders overlap, the deduplication should have prevented it + result = result + .Where(r => r.Frames.Count > 0) // Only include folders that still have frames after filtering + .OrderBy(r => r.Frames + .Where(f => f.AbsoluteObservationTime.HasValue) + .Select(f => f.AbsoluteObservationTime!.Value) + .DefaultIfEmpty(DateTime.MaxValue) + .Min()) // Sort by earliest frame + .ToList(); + + // Validate that folders don't overlap (last frame of N < first frame of N+1) + for (int i = 0; i < result.Count - 1; i++) + { + var currentFolder = result[i]; + var nextFolder = result[i + 1]; + + var currentLast = currentFolder.Frames + .Where(f => f.AbsoluteObservationTime.HasValue) + .Select(f => f.AbsoluteObservationTime!.Value) + .DefaultIfEmpty(DateTime.MinValue) + .Max(); + + var nextFirst = nextFolder.Frames + .Where(f => f.AbsoluteObservationTime.HasValue) + .Select(f => f.AbsoluteObservationTime!.Value) + .DefaultIfEmpty(DateTime.MaxValue) + .Min(); + + if (currentLast >= nextFirst) + { + _logger.LogWarning("Folder overlap detected: {CurrentFolder} ends at {CurrentLast}, {NextFolder} starts at {NextFirst}. This may cause out-of-order frames when client flattens.", + currentFolder.CacheFolderName, currentLast, nextFolder.CacheFolderName, nextFirst); + } + } + + var totalFrames = result.Sum(cf => cf.Frames.Count); + + return new RadarTimeSeriesResponse + { + CacheFolders = result, + StartTime = startTime, + EndTime = endTime, + TotalFrames = totalFrames + }; + } + + public async Task GetFrameFromCacheFolderAsync( + string suburb, + string state, + string cacheFolderName, + int frameIndex, + CancellationToken cancellationToken = default) + { + var cachedFrames = await _cacheService.GetFramesFromCacheFolderAsync( + suburb, + state, + cacheFolderName, + CachedDataType.Radar, + cancellationToken); + + var radarFrame = cachedFrames.Cast().FirstOrDefault(f => f.FrameIndex == frameIndex); + + if (radarFrame != null) + { + // Generate URL + radarFrame.ImageUrl = $"/api/radar/{Uri.EscapeDataString(suburb)}/{Uri.EscapeDataString(state)}/frame/{frameIndex}?cacheFolder={Uri.EscapeDataString(cacheFolderName)}"; + } + + return radarFrame; + } + + public void Dispose() { _browserService?.Dispose(); diff --git a/Services/CacheService.cs b/Services/CacheService.cs index d6afc4b..9aa4236 100644 --- a/Services/CacheService.cs +++ b/Services/CacheService.cs @@ -1,6 +1,7 @@ using BomLocalService.Models; using BomLocalService.Services.Interfaces; using BomLocalService.Utilities; +using System.Collections.Concurrent; using System.Text.Json; namespace BomLocalService.Services; @@ -10,10 +11,13 @@ public class CacheService : ICacheService private readonly ILogger _logger; private readonly string _cacheDirectory; private readonly double _cacheExpirationMinutes; + private readonly IConfiguration _configuration; + private readonly ConcurrentDictionary _activeCacheFolders = new(); // locationKey -> cacheFolderPath public CacheService(ILogger logger, IConfiguration configuration) { _logger = logger; + _configuration = configuration; _cacheDirectory = FilePathHelper.GetCacheDirectory(configuration); _cacheExpirationMinutes = configuration.GetValue("CacheExpirationMinutes", 15.5); @@ -22,11 +26,12 @@ public class CacheService : ICacheService } /// - /// Gets the cached screenshot folder path and metadata for a location + /// Gets the cached screenshot folder path and metadata for a location and data type /// public async Task<(string? cacheFolderPath, LastUpdatedInfo? metadata)> GetCachedScreenshotWithMetadataAsync( string suburb, string state, + CachedDataType dataType, string? excludeFolder = null, CancellationToken cancellationToken = default) { @@ -48,7 +53,7 @@ public class CacheService : ICacheService }) .ToList(); - // Find the first complete cache folder (has all 7 frames + metadata) + // Find the first complete cache folder for the specified data type foreach (var folder in folders) { if (!Directory.Exists(folder)) @@ -61,10 +66,10 @@ public class CacheService : ICacheService continue; } - // Check if folder is complete: must have all 7 frames, metadata.json, and frames.json - if (!CacheHelper.IsCacheFolderComplete(folder)) + // Check if folder is complete for the specified data type + if (!CacheHelper.IsCacheFolderCompleteForDataType(folder, dataType, _configuration)) { - _logger.LogDebug("Skipping incomplete cache folder: {Folder}", folder); + _logger.LogDebug("Skipping incomplete cache folder for {DataType}: {Folder}", dataType, folder); continue; // Skip incomplete folders (currently being written to) } @@ -85,40 +90,15 @@ public class CacheService : ICacheService string state, CancellationToken cancellationToken = default) { - var (cacheFolderPath, _) = await GetCachedScreenshotWithMetadataAsync(suburb, state, null, cancellationToken); + var (cacheFolderPath, _) = await GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, null, 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; + var cachedFrames = await GetFramesFromCacheFolderAsync(suburb, state, Path.GetFileName(cacheFolderPath), CachedDataType.Radar, cancellationToken); + return cachedFrames.Cast().ToList(); } /// @@ -130,26 +110,27 @@ public class CacheService : ICacheService int frameIndex, CancellationToken cancellationToken = default) { - if (frameIndex < 0 || frameIndex > 6) + var frameCount = CacheHelper.GetFrameCountForDataType(_configuration, CachedDataType.Radar); + if (frameIndex < 0 || frameIndex >= frameCount) { return null; } - var (cacheFolderPath, _) = await GetCachedScreenshotWithMetadataAsync(suburb, state, null, cancellationToken); + var (cacheFolderPath, _) = await GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, null, cancellationToken); if (string.IsNullOrEmpty(cacheFolderPath)) { return null; } - var framePath = FilePathHelper.GetFrameFilePath(cacheFolderPath, frameIndex); + var framePath = FilePathHelper.GetFrameFilePath(cacheFolderPath, CachedDataType.Radar, frameIndex); if (!File.Exists(framePath)) { return null; } // Load frame metadata to get accurate minutesAgo - var framesMetadata = await LoadFramesMetadataAsync(cacheFolderPath, cancellationToken); + var framesMetadata = await LoadFramesMetadataAsync(cacheFolderPath, CachedDataType.Radar, cancellationToken); var frameMetadata = framesMetadata.FirstOrDefault(f => f.FrameIndex == frameIndex); var minutesAgo = frameMetadata != null ? frameMetadata.MinutesAgo @@ -185,9 +166,9 @@ public class CacheService : ICacheService } /// - /// Saves frame metadata to frames.json + /// Saves frame metadata to frames.json in the data type subfolder /// - public async Task SaveFramesMetadataAsync(string cacheFolderPath, List frames, CancellationToken cancellationToken = default) + public async Task SaveFramesMetadataAsync(string cacheFolderPath, CachedDataType dataType, List frames, CancellationToken cancellationToken = default) { try { @@ -197,7 +178,13 @@ public class CacheService : ICacheService MinutesAgo = f.MinutesAgo }).ToList(); - var framesPath = Path.Combine(cacheFolderPath, "frames.json"); + var framesPath = FilePathHelper.GetFramesMetadataFilePath(cacheFolderPath, dataType); + var framesDir = Path.GetDirectoryName(framesPath); + if (!string.IsNullOrEmpty(framesDir) && !Directory.Exists(framesDir)) + { + Directory.CreateDirectory(framesDir); + } + var json = JsonSerializer.Serialize(framesMetadata, new JsonSerializerOptions { WriteIndented = true }); await File.WriteAllTextAsync(framesPath, json, CancellationToken.None); _logger.LogDebug("Saved frames metadata to: {Path}", framesPath); @@ -209,11 +196,11 @@ public class CacheService : ICacheService } /// - /// Loads frame metadata from frames.json + /// Loads frame metadata from frames.json in the data type subfolder /// - private async Task> LoadFramesMetadataAsync(string cacheFolderPath, CancellationToken cancellationToken = default) + private async Task> LoadFramesMetadataAsync(string cacheFolderPath, CachedDataType dataType, CancellationToken cancellationToken = default) { - var framesPath = Path.Combine(cacheFolderPath, "frames.json"); + var framesPath = FilePathHelper.GetFramesMetadataFilePath(cacheFolderPath, dataType); if (!File.Exists(framesPath)) { return new List(); @@ -324,6 +311,120 @@ public class CacheService : ICacheService return Task.FromResult(deleted); } + /// + /// Gets all complete cache folders for a location, ordered by timestamp (oldest first) + /// + public async Task> GetAllCacheFoldersAsync( + string suburb, + string state, + CancellationToken cancellationToken = default) + { + var pattern = FilePathHelper.GetCacheFolderPattern(suburb, state); + var folders = Directory.GetDirectories(_cacheDirectory, pattern) + .Select(f => + { + var folderName = Path.GetFileName(f); + var timestamp = LocationHelper.ParseTimestampFromFilename(folderName); + return new { Folder = f, FolderName = folderName, Timestamp = timestamp }; + }) + .Where(x => x.Timestamp.HasValue) + .Select(x => new { x.Folder, x.FolderName, Timestamp = x.Timestamp!.Value }) + .OrderBy(x => x.Timestamp) // Oldest first + .ToList(); + + var result = new List(); + + foreach (var folder in folders) + { + if (!Directory.Exists(folder.Folder)) + continue; + + var metadata = await LoadMetadataAsync(folder.Folder, cancellationToken); + if (metadata == null) + continue; + + // Check which data types are available in this folder + var availableDataTypes = new List(); + if (CacheHelper.IsCacheFolderCompleteForDataType(folder.Folder, CachedDataType.Radar, _configuration)) + { + availableDataTypes.Add(CachedDataType.Radar); + } + + if (availableDataTypes.Count > 0) + { + result.Add(new CacheFolder + { + FolderName = folder.FolderName, + CacheTimestamp = folder.Timestamp, + ObservationTime = metadata.ObservationTime, + AvailableDataTypes = availableDataTypes, + IsComplete = true + }); + } + } + + return result; + } + + /// + /// Gets frames from a specific cache folder and data type + /// + public async Task> GetFramesFromCacheFolderAsync( + string suburb, + string state, + string cacheFolderName, + CachedDataType dataType, + CancellationToken cancellationToken = default) + { + var folderPath = Path.Combine(_cacheDirectory, cacheFolderName); + + if (!Directory.Exists(folderPath)) + { + return new List(); + } + + var frameCount = CacheHelper.GetFrameCountForDataType(_configuration, dataType); + var frames = new List(); + + // Load frame metadata from frames.json if available + var framesMetadata = await LoadFramesMetadataAsync(folderPath, dataType, cancellationToken); + var metadataDict = framesMetadata.ToDictionary(f => f.FrameIndex, f => f.MinutesAgo); + + // Load frames from data type subfolder + for (int i = 0; i < frameCount; i++) + { + var framePath = FilePathHelper.GetFrameFilePath(folderPath, dataType, i); + if (File.Exists(framePath)) + { + if (dataType == CachedDataType.Radar) + { + var minutesAgo = metadataDict.ContainsKey(i) + ? metadataDict[i] + : 40 - (i * 5); + + frames.Add(new RadarFrame + { + FrameIndex = i, + ImagePath = framePath, + MinutesAgo = minutesAgo + }); + } + else + { + // For future data types, create base CachedFrame + frames.Add(new CachedFrame + { + FrameIndex = i, + ImagePath = framePath, + DataType = dataType + }); + } + } + } + + return frames; + } + /// /// Gets the cache directory path /// @@ -352,8 +453,8 @@ public class CacheService : ICacheService { try { - // Check if folder is incomplete - if (!CacheHelper.IsCacheFolderComplete(folder)) + // Check if folder is incomplete (check for radar data type) + if (!CacheHelper.IsCacheFolderCompleteForDataType(folder, CachedDataType.Radar, _configuration)) { _logger.LogInformation("Found incomplete cache folder from previous session, deleting: {Folder}", Path.GetFileName(folder)); Directory.Delete(folder, recursive: true); @@ -382,5 +483,147 @@ public class CacheService : ICacheService return deletedCount; } + + public bool IsLocationUpdating(string locationKey) + { + return _activeCacheFolders.ContainsKey(locationKey); + } + + public string? GetActiveCacheFolder(string locationKey) + { + return _activeCacheFolders.TryGetValue(locationKey, out var folder) ? folder : null; + } + + public void SetActiveCacheFolder(string locationKey, string cacheFolderPath) + { + _activeCacheFolders[locationKey] = cacheFolderPath; + _logger.LogDebug("Tracking active cache folder: {Folder} for location: {Location}", cacheFolderPath, locationKey); + } + + public void ClearActiveCacheFolder(string locationKey) + { + if (_activeCacheFolders.TryRemove(locationKey, out var folder)) + { + _logger.LogDebug("Cleared active cache folder tracking: {Folder} for location: {Location}", folder, locationKey); + } + } + + public string CreateCacheFolder(string suburb, string state, string timestamp) + { + var cacheFolderPath = FilePathHelper.GetCacheFolderPath(_cacheDirectory, suburb, state, timestamp); + Directory.CreateDirectory(cacheFolderPath); + + // Create radar subfolder structure (for future: create other data type folders as needed) + var radarFolder = FilePathHelper.GetDataTypeFolderPath(cacheFolderPath, CachedDataType.Radar); + Directory.CreateDirectory(radarFolder); + + _logger.LogDebug("Created cache folder: {Folder}", cacheFolderPath); + return cacheFolderPath; + } + + public bool TryDeleteIncompleteCacheFolder(string cacheFolderPath) + { + if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath)) + { + return false; + } + + try + { + // Check if folder is incomplete for radar data type + if (!CacheHelper.IsCacheFolderCompleteForDataType(cacheFolderPath, CachedDataType.Radar, _configuration)) + { + Directory.Delete(cacheFolderPath, recursive: true); + _logger.LogDebug("Deleted incomplete cache folder: {Folder}", cacheFolderPath); + return true; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete incomplete cache folder: {Folder}", cacheFolderPath); + } + + return false; + } + + public bool TryDeleteEmptyCacheFolder(string cacheFolderPath) + { + if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath)) + { + return false; + } + + try + { + var files = Directory.GetFiles(cacheFolderPath, "*", SearchOption.AllDirectories); + if (files.Length == 0) + { + Directory.Delete(cacheFolderPath, recursive: true); + _logger.LogDebug("Deleted empty cache folder: {Folder}", cacheFolderPath); + return true; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete empty cache folder: {Folder}", cacheFolderPath); + } + + return false; + } + + public async Task GetCacheStatusAsync( + string suburb, + string state, + CachedDataType dataType, + int cacheExpirationMinutes, + int cacheManagementCheckIntervalMinutes, + CancellationToken cancellationToken = default) + { + var status = new CacheUpdateStatus(); + var locationKey = LocationHelper.GetLocationKey(suburb, state); + + // Check if an update is already in progress + var isUpdating = IsLocationUpdating(locationKey); + var excludeFolder = GetActiveCacheFolder(locationKey); + var (cacheFolderPath, cachedMetadata) = await GetCachedScreenshotWithMetadataAsync(suburb, state, dataType, excludeFolder, cancellationToken); + + status.CacheExists = !string.IsNullOrEmpty(cacheFolderPath) && Directory.Exists(cacheFolderPath); + + if (cachedMetadata != null) + { + status.CacheIsValid = IsCacheValid(cachedMetadata); + status.CacheExpiresAt = cachedMetadata.ObservationTime.AddMinutes(cacheExpirationMinutes); + } + else + { + status.CacheIsValid = false; + } + + // Set update status - if an update is in progress, UpdateTriggered would be false but Message indicates it + // For GetCacheStatusAsync, we don't trigger updates, but we can indicate if one is in progress + status.UpdateTriggered = false; // This method doesn't trigger updates + if (isUpdating) + { + status.Message = "Cache update already in progress"; + // Update in progress - estimate completion in ~2 minutes + status.NextUpdateTime = DateTime.UtcNow.AddMinutes(2); + } + else if (status.CacheIsValid && status.CacheExpiresAt.HasValue) + { + // Cache is valid - next update will be when cache expires + status.Message = "Cache is valid, no update needed"; + status.NextUpdateTime = status.CacheExpiresAt.Value; + } + else + { + // Cache is invalid/missing - next update would be after background service check + status.Message = status.CacheExists ? "Cache is stale" : "No cache exists"; + var now = DateTime.UtcNow; + var minutesUntilNextCheck = cacheManagementCheckIntervalMinutes - (now.Minute % cacheManagementCheckIntervalMinutes); + status.NextUpdateTime = now.AddMinutes(minutesUntilNextCheck); + } + + return status; + } } diff --git a/Services/Interfaces/IBomRadarService.cs b/Services/Interfaces/IBomRadarService.cs index cf7df4c..c56d22f 100644 --- a/Services/Interfaces/IBomRadarService.cs +++ b/Services/Interfaces/IBomRadarService.cs @@ -75,5 +75,31 @@ public interface IBomRadarService /// 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); + + /// + /// Gets information about the available cache range for a location (oldest and newest cache folders). + /// + Task GetCacheRangeAsync(string suburb, string state, CancellationToken cancellationToken = default); + + /// + /// Gets historical radar data (frames from multiple cache folders) between specified timestamps. + /// Radar frames can be joined across cache folders because they represent a historical time series. + /// + Task GetRadarTimeSeriesAsync( + string suburb, + string state, + DateTime? startTime, + DateTime? endTime, + CancellationToken cancellationToken = default); + + /// + /// Gets a specific radar frame from a specific cache folder. + /// + Task GetFrameFromCacheFolderAsync( + string suburb, + string state, + string cacheFolderName, + int frameIndex, + CancellationToken cancellationToken = default); } diff --git a/Services/Interfaces/ICacheService.cs b/Services/Interfaces/ICacheService.cs index 2d8f720..e7bb0fb 100644 --- a/Services/Interfaces/ICacheService.cs +++ b/Services/Interfaces/ICacheService.cs @@ -9,20 +9,51 @@ namespace BomLocalService.Services.Interfaces; public interface ICacheService { /// - /// Gets the cached screenshot folder path and associated metadata for a location. + /// Gets the cached screenshot folder path and associated metadata for a location and data type. /// 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") + /// The type of cached data to retrieve /// Optional folder path to exclude from search (e.g., folder currently being written to) /// Cancellation token to cancel the operation /// Tuple containing the cache folder path and metadata, or (null, null) if not found Task<(string? cacheFolderPath, LastUpdatedInfo? metadata)> GetCachedScreenshotWithMetadataAsync( string suburb, string state, + CachedDataType dataType, string? excludeFolder = null, CancellationToken cancellationToken = default); + /// + /// Gets all complete cache folders for a location, ordered by timestamp (oldest first). + /// Returns basic folder information without loading all frame data. + /// + /// The suburb name (e.g., "Pomona", "Brisbane") + /// The Australian state abbreviation (e.g., "QLD", "NSW", "VIC") + /// Cancellation token to cancel the operation + /// List of cache folders with available data types + Task> GetAllCacheFoldersAsync( + string suburb, + string state, + CancellationToken cancellationToken = default); + + /// + /// Gets frames from a specific cache folder and data type. + /// + /// The suburb name (e.g., "Pomona", "Brisbane") + /// The Australian state abbreviation (e.g., "QLD", "NSW", "VIC") + /// The cache folder name + /// The type of cached data to retrieve + /// Cancellation token to cancel the operation + /// List of cached frames, or empty list if not found + Task> GetFramesFromCacheFolderAsync( + string suburb, + string state, + string cacheFolderName, + CachedDataType dataType, + CancellationToken cancellationToken = default); + /// /// Gets all cached frames for a location. /// @@ -59,12 +90,13 @@ public interface ICacheService 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. + /// Saves frame metadata (frame index and minutes ago) to a frames.json file in the data type subfolder. /// /// Full path to the cache folder + /// The type of cached data /// List of frames with their metadata /// Cancellation token to cancel the operation - Task SaveFramesMetadataAsync(string cacheFolderPath, List frames, CancellationToken cancellationToken = default); + Task SaveFramesMetadataAsync(string cacheFolderPath, CachedDataType dataType, List frames, CancellationToken cancellationToken = default); /// /// Checks if cached metadata is still valid (not expired). @@ -107,5 +139,61 @@ public interface ICacheService /// /// The number of incomplete folders that were deleted int CleanupIncompleteCacheFolders(); + + /// + /// Checks if a location is currently being updated (has an active cache folder being written to). + /// + bool IsLocationUpdating(string locationKey); + + /// + /// Gets the active cache folder path for a location (if one is being written to). + /// + string? GetActiveCacheFolder(string locationKey); + + /// + /// Sets the active cache folder for a location (indicates a cache update is in progress). + /// + void SetActiveCacheFolder(string locationKey, string cacheFolderPath); + + /// + /// Clears the active cache folder for a location (indicates cache update is complete). + /// + void ClearActiveCacheFolder(string locationKey); + + /// + /// Creates a new cache folder for a location and timestamp. + /// Creates the folder structure and ensures data type subfolders are ready. + /// + string CreateCacheFolder(string suburb, string state, string timestamp); + + /// + /// Deletes an incomplete cache folder (cleanup on error). + /// Only deletes if the folder is incomplete (missing required files). + /// + bool TryDeleteIncompleteCacheFolder(string cacheFolderPath); + + /// + /// Deletes an empty cache folder. + /// + bool TryDeleteEmptyCacheFolder(string cacheFolderPath); + + /// + /// Gets cache status information for a location and data type without triggering an update. + /// Returns information about whether cache exists, is valid, is updating, etc. + /// + /// The suburb name (e.g., "Pomona", "Brisbane") + /// The Australian state abbreviation (e.g., "QLD", "NSW", "VIC") + /// The type of cached data to check + /// The number of minutes after observation time that cache expires (will be cast to int) + /// The interval in minutes that the background cache management service checks for updates + /// Cancellation token to cancel the operation + /// Cache status information + Task GetCacheStatusAsync( + string suburb, + string state, + CachedDataType dataType, + int cacheExpirationMinutes, + int cacheManagementCheckIntervalMinutes, + CancellationToken cancellationToken = default); } diff --git a/Services/ScrapingService.cs b/Services/ScrapingService.cs index 0a3bb88..a83128e 100644 --- a/Services/ScrapingService.cs +++ b/Services/ScrapingService.cs @@ -450,6 +450,7 @@ public class ScrapingService : IScrapingService // Step 15-21: Capture all 7 frames var frames = new List(); var stepForwardButton = page.Locator("button[data-testid='bom-scrub-utils__right__step-forward']").First; + int? previousMinutesAgo = null; for (int frameIndex = 0; frameIndex < 7; frameIndex++) { @@ -468,8 +469,32 @@ public class ScrapingService : IScrapingService _logger.LogWarning("Failed to extract minutes from display label for frame {FrameIndex}, using default: {MinutesAgo}", frameIndex, minutesAgo); } + // Validate that minutesAgo is different from previous frame (unless it's frame 0) + if (frameIndex > 0 && previousMinutesAgo.HasValue && minutesAgo == previousMinutesAgo.Value) + { + _logger.LogWarning("Frame {FrameIndex} has same minutesAgo ({MinutesAgo}) as previous frame. Waiting for display to update...", frameIndex, minutesAgo); + // Wait for display label to change + await WaitForDisplayLabelToChangeAsync(page, previousMinutesAgo.Value); + // Re-extract after waiting + minutesAgo = await ExtractMinutesAgoFromDisplayAsync(page); + if (minutesAgo == null || minutesAgo == previousMinutesAgo.Value) + { + // Still same or failed - use calculated fallback + var (_, defaultMinutesAgo) = frameInfo[frameIndex]; + minutesAgo = defaultMinutesAgo; + _logger.LogWarning("Display label did not update for frame {FrameIndex}, using calculated default: {MinutesAgo}", frameIndex, minutesAgo); + } + } + // Take screenshot with crop configuration - var framePath = FilePathHelper.GetFrameFilePath(cacheFolderPath, frameIndex); + // Ensure radar subfolder exists + var radarFolder = FilePathHelper.GetDataTypeFolderPath(cacheFolderPath, CachedDataType.Radar); + if (!Directory.Exists(radarFolder)) + { + Directory.CreateDirectory(radarFolder); + } + + var framePath = FilePathHelper.GetFrameFilePath(cacheFolderPath, CachedDataType.Radar, frameIndex); await CaptureMapScreenshotAsync(page, mapContainer, framePath, containerClip); frames.Add(new RadarFrame @@ -479,6 +504,8 @@ public class ScrapingService : IScrapingService MinutesAgo = minutesAgo.Value }); + previousMinutesAgo = minutesAgo; + _logger.LogInformation("Frame {FrameIndex} saved: {Path} ({MinutesAgo} minutes ago)", frameIndex, framePath, minutesAgo.Value); @@ -488,32 +515,25 @@ public class ScrapingService : IScrapingService // 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 - } - } + // Dismiss any modal overlays (BOM, reCAPTCHA, feedback forms) before clicking + await DismissModalOverlaysAsync(page); + + // Get current minutesAgo before clicking (to detect change) + var currentMinutesAgo = await ExtractMinutesAgoFromDisplayAsync(page); // 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 + + // Wait for display label to actually change (not just a fixed timeout) + if (currentMinutesAgo.HasValue) + { + await WaitForDisplayLabelToChangeAsync(page, currentMinutesAgo.Value); + } + else + { + // Fallback to fixed wait if we can't detect change + await page.WaitForTimeoutAsync(1000); + } } } @@ -521,7 +541,7 @@ public class ScrapingService : IScrapingService // Step 22: Save metadata and frame information await _cacheService.SaveMetadataAsync(cacheFolderPath, lastUpdatedInfo, cancellationToken); - await _cacheService.SaveFramesMetadataAsync(cacheFolderPath, frames, cancellationToken); + await _cacheService.SaveFramesMetadataAsync(cacheFolderPath, CachedDataType.Radar, frames, cancellationToken); // Step 23: Return response with all frames // Note: ScrapingService doesn't have access to cache management check interval @@ -675,6 +695,139 @@ public class ScrapingService : IScrapingService } } + /// + /// Waits for the display label to change from the current minutesAgo value + /// + private async Task WaitForDisplayLabelToChangeAsync(IPage page, int currentMinutesAgo, int maxWaitMs = 5000) + { + try + { + var startTime = DateTime.UtcNow; + while ((DateTime.UtcNow - startTime).TotalMilliseconds < maxWaitMs) + { + var newMinutesAgo = await ExtractMinutesAgoFromDisplayAsync(page); + if (newMinutesAgo.HasValue && newMinutesAgo.Value != currentMinutesAgo) + { + // Display has changed + return; + } + await page.WaitForTimeoutAsync(200); // Check every 200ms + } + _logger.LogDebug("Display label did not change from {CurrentMinutesAgo} within {MaxWaitMs}ms", currentMinutesAgo, maxWaitMs); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error waiting for display label to change"); + } + } + + /// + /// Dismisses any modal overlays (BOM modals, reCAPTCHA, feedback forms) that might block the radar + /// Uses a single efficient check and minimal delays + /// + private async Task DismissModalOverlaysAsync(IPage page) + { + try + { + // Single check for all modal types (BOM, reCAPTCHA, feedback forms) + var hasModal = await page.EvaluateAsync(@"() => { + // Check BOM modal overlay + const bomOverlay = document.querySelector('.bom-modal-overlay--after-open'); + if (bomOverlay && bomOverlay.style.display !== 'none') { + return true; + } + + // Check reCAPTCHA modals (large visible elements) + const recaptchaSelectors = [ + '.g-recaptcha', + '#g-recaptcha', + '.rc-anchor-container' + ]; + for (const selector of recaptchaSelectors) { + const el = document.querySelector(selector); + if (el) { + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + if (style.display !== 'none' && style.visibility !== 'hidden' && + rect.width > 200 && rect.height > 200) { + return true; + } + } + } + + // Check feedback forms with reCAPTCHA + const allForms = document.querySelectorAll('form'); + for (const form of allForms) { + const action = form.getAttribute('action') || ''; + const id = form.getAttribute('id') || ''; + if (action.includes('feedback') || id.includes('feedback')) { + const style = window.getComputedStyle(form); + const rect = form.getBoundingClientRect(); + if (style.display !== 'none' && rect.width > 200 && rect.height > 200) { + const text = form.textContent || ''; + if (text.includes('reCAPTCHA') || text.includes('recaptcha') || text.includes('Tell us why')) { + return true; + } + } + } + } + + return false; + }"); + + if (!hasModal) + { + return; // No modal, exit quickly + } + + _logger.LogDebug("Modal overlay detected, dismissing"); + + // Single Escape key press (works for most modals) + await page.Keyboard.PressAsync("Escape"); + await page.WaitForTimeoutAsync(300); // Minimal delay + + // If still visible, try clicking outside (on map) + var stillVisible = await page.EvaluateAsync(@"() => { + const bomOverlay = document.querySelector('.bom-modal-overlay--after-open'); + if (bomOverlay && bomOverlay.style.display !== 'none') return true; + + // Check feedback forms + const allForms = document.querySelectorAll('form'); + for (const form of allForms) { + const action = form.getAttribute('action') || ''; + const id = form.getAttribute('id') || ''; + if (action.includes('feedback') || id.includes('feedback')) { + const style = window.getComputedStyle(form); + const rect = form.getBoundingClientRect(); + if (style.display !== 'none' && rect.width > 200 && rect.height > 200) { + return true; + } + } + } + return false; + }"); + + if (stillVisible) + { + // Click on map to dismiss (only if Escape didn't work) + try + { + var mapContainer = page.Locator(".esri-view-surface").First; + await mapContainer.ClickAsync(new LocatorClickOptions { Force = true }); + await page.WaitForTimeoutAsync(200); // Minimal delay + } + catch + { + // Ignore if click fails + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error dismissing modal overlay, continuing"); + } + } + /// /// Captures map screenshot with crop configuration /// @@ -721,6 +874,9 @@ public class ScrapingService : IScrapingService // Continue if network idle timeout - fonts may already be loaded } + // Dismiss any modal overlays (BOM, reCAPTCHA, feedback forms) before taking screenshot + await DismissModalOverlaysAsync(page); + // Take high-quality screenshot with explicit PNG format and disabled animations await page.ScreenshotAsync(new PageScreenshotOptions { diff --git a/Utilities/CacheHelper.cs b/Utilities/CacheHelper.cs index 4775bbf..23b9460 100644 --- a/Utilities/CacheHelper.cs +++ b/Utilities/CacheHelper.cs @@ -1,3 +1,6 @@ +using BomLocalService.Models; +using Microsoft.Extensions.Configuration; + namespace BomLocalService.Utilities; /// @@ -6,40 +9,43 @@ namespace BomLocalService.Utilities; public static class CacheHelper { /// - /// Checks if a cache folder is complete (has all required files). - /// A complete cache folder must have: - /// - All 7 frame images (frame_0.png through frame_6.png) - /// - metadata.json file - /// - frames.json file + /// Gets the configured frame count for a data type from configuration. /// - /// The path to the cache folder to check - /// True if the folder is complete, false otherwise - public static bool IsCacheFolderComplete(string cacheFolderPath) + public static int GetFrameCountForDataType(IConfiguration configuration, CachedDataType dataType) + { + var dataTypeName = dataType.ToString(); + var frameCount = configuration.GetValue($"CachedDataTypes:{dataTypeName}:FrameCount", 7); + return frameCount; + } + + /// + /// Checks if a cache folder has complete data for a specific data type. + /// + public static bool IsCacheFolderCompleteForDataType(string cacheFolderPath, CachedDataType dataType, IConfiguration configuration) { if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath)) { return false; } - // Check for all 7 frames - for (int i = 0; i < 7; i++) + var dataTypeFolder = FilePathHelper.GetDataTypeFolderPath(cacheFolderPath, dataType); + if (!Directory.Exists(dataTypeFolder)) { - var framePath = FilePathHelper.GetFrameFilePath(cacheFolderPath, i); + return false; + } + + var expectedFrameCount = GetFrameCountForDataType(configuration, dataType); + for (int i = 0; i < expectedFrameCount; i++) + { + var framePath = FilePathHelper.GetFrameFilePath(cacheFolderPath, dataType, i); if (!File.Exists(framePath)) { return false; } } - // Check for metadata.json - var metadataPath = FilePathHelper.GetMetadataFilePath(cacheFolderPath); - if (!File.Exists(metadataPath)) - { - return false; - } - - // Check for frames.json - var framesMetadataPath = FilePathHelper.GetFramesMetadataFilePath(cacheFolderPath); + // Check for frames.json in data type folder + var framesMetadataPath = FilePathHelper.GetFramesMetadataFilePath(cacheFolderPath, dataType); if (!File.Exists(framesMetadataPath)) { return false; @@ -47,5 +53,13 @@ public static class CacheHelper return true; } + + /// + /// Checks if a cache folder is complete (has radar data - for backward compatibility). + /// + public static bool IsCacheFolderComplete(string cacheFolderPath, IConfiguration configuration) + { + return IsCacheFolderCompleteForDataType(cacheFolderPath, CachedDataType.Radar, configuration); + } } diff --git a/Utilities/FilePathHelper.cs b/Utilities/FilePathHelper.cs index 0eded19..c43813f 100644 --- a/Utilities/FilePathHelper.cs +++ b/Utilities/FilePathHelper.cs @@ -1,3 +1,5 @@ +using BomLocalService.Models; + namespace BomLocalService.Utilities; public static class FilePathHelper @@ -51,12 +53,34 @@ public static class FilePathHelper } /// - /// Gets the frame file path within a cache folder. - /// Format: "{CacheFolderPath}/frame_{FrameIndex}.png" + /// Gets the data type subfolder path within a cache folder. + /// Format: "{CacheFolderPath}/{DataType}" + /// Example: "/app/cache/Pomona_QLD_20251207_000906/radar" /// + public static string GetDataTypeFolderPath(string cacheFolderPath, CachedDataType dataType) + { + var dataTypeFolder = dataType.ToString().ToLowerInvariant(); + return Path.Combine(cacheFolderPath, dataTypeFolder); + } + + /// + /// Gets the frame file path within a data type subfolder. + /// Format: "{CacheFolderPath}/{DataType}/frame_{FrameIndex}.png" + /// + public static string GetFrameFilePath(string cacheFolderPath, CachedDataType dataType, int frameIndex) + { + var dataTypeFolder = GetDataTypeFolderPath(cacheFolderPath, dataType); + return Path.Combine(dataTypeFolder, $"frame_{frameIndex}.png"); + } + + /// + /// Gets the frame file path within a cache folder (legacy - defaults to radar). + /// Format: "{CacheFolderPath}/radar/frame_{FrameIndex}.png" + /// + [Obsolete("Use GetFrameFilePath with dataType parameter")] public static string GetFrameFilePath(string cacheFolderPath, int frameIndex) { - return Path.Combine(cacheFolderPath, $"frame_{frameIndex}.png"); + return GetFrameFilePath(cacheFolderPath, CachedDataType.Radar, frameIndex); } /// @@ -90,12 +114,23 @@ public static class FilePathHelper } /// - /// Gets the frames metadata file path within a cache folder. - /// Format: "{CacheFolderPath}/frames.json" + /// Gets the frames metadata file path within a data type subfolder. + /// Format: "{CacheFolderPath}/{DataType}/frames.json" /// + public static string GetFramesMetadataFilePath(string cacheFolderPath, CachedDataType dataType) + { + var dataTypeFolder = GetDataTypeFolderPath(cacheFolderPath, dataType); + return Path.Combine(dataTypeFolder, "frames.json"); + } + + /// + /// Gets the frames metadata file path within a cache folder (legacy - defaults to radar). + /// Format: "{CacheFolderPath}/radar/frames.json" + /// + [Obsolete("Use GetFramesMetadataFilePath with dataType parameter")] public static string GetFramesMetadataFilePath(string cacheFolderPath) { - return Path.Combine(cacheFolderPath, "frames.json"); + return GetFramesMetadataFilePath(cacheFolderPath, CachedDataType.Radar); } } diff --git a/Views/RadarTest/Index.cshtml b/Views/RadarTest/Index.cshtml index ff6e61a..3034efa 100644 --- a/Views/RadarTest/Index.cshtml +++ b/Views/RadarTest/Index.cshtml @@ -22,10 +22,16 @@ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; - padding: 20px; + padding: 10px; color: #333; } + @@media (min-width: 768px) { + body { + padding: 20px; + } + } + .container { max-width: 1400px; margin: 0 auto; @@ -38,38 +44,131 @@ .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; - padding: 30px; + padding: 20px; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 15px; + } + + @@media (min-width: 768px) { + .header { + padding: 30px; + gap: 20px; + } + } + + .header-content { + flex: 1; text-align: center; + min-width: 0; } .header h1 { - font-size: 2.5em; - margin-bottom: 10px; + font-size: 1.5em; + margin-bottom: 8px; font-weight: 700; + word-wrap: break-word; + } + + @@media (min-width: 768px) { + .header h1 { + font-size: 2.5em; + margin-bottom: 10px; + } } .header .subtitle { - font-size: 1.1em; + font-size: 0.9em; opacity: 0.9; } - .content { - display: grid; - grid-template-columns: 2fr 1fr; - gap: 20px; - padding: 30px; + @@media (min-width: 768px) { + .header .subtitle { + font-size: 1.1em; + } } - @@media (max-width: 968px) { + .header-actions { + display: flex; + gap: 10px; + align-items: center; + } + + .settings-header-btn { + background: rgba(255, 255, 255, 0.2); + border: 2px solid rgba(255, 255, 255, 0.3); + color: white; + padding: 10px 16px; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + font-size: 0.9em; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 6px; + min-height: 44px; /* Touch target size */ + } + + @@media (min-width: 768px) { + .settings-header-btn { + padding: 12px 24px; + font-size: 1em; + gap: 8px; + } + } + + .settings-header-btn:hover { + background: rgba(255, 255, 255, 0.3); + border-color: rgba(255, 255, 255, 0.5); + transform: translateY(-2px); + } + + .content { + display: flex; + flex-direction: column; + gap: 15px; + padding: 15px; + } + + @@media (min-width: 768px) { .content { - grid-template-columns: 1fr; + gap: 20px; + padding: 30px; + } + } + + .info-section { + display: grid; + grid-template-columns: 1fr; + gap: 12px; + } + + @@media (min-width: 480px) { + .info-section { + grid-template-columns: repeat(2, 1fr); + } + } + + @@media (min-width: 768px) { + .info-section { + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 15px; } } .radar-section { background: #f8f9fa; border-radius: 12px; - padding: 20px; + padding: 15px; + } + + @@media (min-width: 768px) { + .radar-section { + padding: 20px; + } } .radar-image-container { @@ -78,9 +177,16 @@ background: #000; border-radius: 8px; overflow: hidden; - margin-bottom: 20px; + margin-bottom: 15px; aspect-ratio: 16/9; - min-height: 400px; /* Prevent layout shift */ + min-height: 250px; /* Smaller on mobile */ + } + + @@media (min-width: 768px) { + .radar-image-container { + margin-bottom: 20px; + min-height: 400px; + } } .radar-image { @@ -105,11 +211,18 @@ .frame-controls { display: flex; - gap: 10px; + gap: 8px; align-items: center; justify-content: center; flex-wrap: wrap; - margin-bottom: 20px; + margin-bottom: 15px; + } + + @@media (min-width: 768px) { + .frame-controls { + gap: 10px; + margin-bottom: 20px; + } } .frame-btn { @@ -143,21 +256,47 @@ .play-controls { display: flex; - gap: 10px; + gap: 8px; justify-content: center; - margin-bottom: 20px; + margin-bottom: 15px; + flex-wrap: wrap; + } + + @@media (min-width: 768px) { + .play-controls { + gap: 10px; + margin-bottom: 20px; + } } .play-btn { - padding: 12px 30px; + padding: 12px 20px; border: none; background: #667eea; color: white; border-radius: 8px; cursor: pointer; font-weight: 600; - font-size: 1em; + font-size: 0.9em; transition: all 0.2s; + min-height: 44px; /* Touch target size */ + flex: 1; + min-width: 100px; + white-space: nowrap; + } + + @@media (min-width: 480px) { + .play-btn { + flex: 0 1 auto; + min-width: auto; + } + } + + @@media (min-width: 768px) { + .play-btn { + padding: 12px 30px; + font-size: 1em; + } } .play-btn:hover { @@ -174,29 +313,54 @@ .info-section { background: #f8f9fa; border-radius: 12px; - padding: 20px; + padding: 15px; + } + + @@media (min-width: 768px) { + .info-section { + padding: 20px; + } } .info-card { background: white; border-radius: 8px; - padding: 15px; - margin-bottom: 15px; + padding: 12px; border-left: 4px solid #667eea; } + @@media (min-width: 768px) { + .info-card { + padding: 15px; + } + } + .info-card h3 { - font-size: 0.9em; + font-size: 0.8em; text-transform: uppercase; color: #666; - margin-bottom: 8px; + margin-bottom: 6px; letter-spacing: 0.5px; } + @@media (min-width: 768px) { + .info-card h3 { + font-size: 0.9em; + margin-bottom: 8px; + } + } + .info-card .value { - font-size: 1.2em; + font-size: 1em; font-weight: 600; color: #333; + word-wrap: break-word; + } + + @@media (min-width: 768px) { + .info-card .value { + font-size: 1.2em; + } } .status-badge { @@ -271,39 +435,308 @@ font-size: 0.9em; margin-top: 10px; } + + /* Frame Slider Controls */ + .frame-slider-container { + width: 100%; + max-width: 100%; + padding: 15px; + background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%); + border-radius: 12px; + border: 2px solid #e0e0e0; + box-shadow: 0 4px 16px rgba(0,0,0,0.08); + box-sizing: border-box; + margin-bottom: 15px; + } + + @@media (min-width: 768px) { + .frame-slider-container { + padding: 24px; + border-radius: 16px; + margin-bottom: 20px; + } + } + + .frame-slider-wrapper { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 12px; + width: 100%; + flex-wrap: wrap; + } + + @@media (min-width: 480px) { + .frame-slider-wrapper { + gap: 6px; + flex-wrap: nowrap; + } + } + + @@media (min-width: 768px) { + .frame-slider-wrapper { + gap: 12px; + margin-bottom: 20px; + } + } + + .frame-nav-btn { + padding: 10px 16px; + border: 2px solid #667eea; + background: white; + color: #667eea; + border-radius: 10px; + cursor: pointer; + font-weight: 700; + font-size: 0.9em; + transition: all 0.2s; + min-width: 50px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + } + + .frame-nav-btn:hover:not(:disabled) { + background: #667eea; + color: white; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); + } + + .frame-nav-btn:active:not(:disabled) { + transform: translateY(0); + } + + .frame-nav-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + background: #f0f0f0; + border-color: #d0d0d0; + color: #999; + } + + .frame-slider { + flex: 1; + min-width: 120px; + height: 10px; + border-radius: 5px; + background: #e0e0e0; + outline: none; + -webkit-appearance: none; + max-width: 100%; + cursor: pointer; + transition: background 0.2s; + width: 100%; + order: 3; /* Put slider in middle on mobile when wrapped */ + } + + @@media (min-width: 480px) { + .frame-slider { + min-width: 100px; + order: 0; /* Reset order for larger screens */ + } + } + + @@media (min-width: 768px) { + .frame-slider { + min-width: 0; + } + } + + .frame-slider:hover { + background: #d0d0d0; + } + + /* Webkit (Chrome, Safari, Edge) */ + .frame-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 24px; + height: 24px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + cursor: pointer; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4); + transition: all 0.2s; + border: 3px solid white; + } + + .frame-slider::-webkit-slider-thumb:hover { + transform: scale(1.15); + box-shadow: 0 4px 16px rgba(102, 126, 234, 0.6); + } + + .frame-slider::-webkit-slider-thumb:active { + transform: scale(1.05); + } + + /* Track fill for webkit */ + .frame-slider::-webkit-slider-runnable-track { + height: 10px; + border-radius: 5px; + background: linear-gradient(to right, #667eea 0%, #764ba2 100%); + } + + /* Firefox */ + .frame-slider::-moz-range-thumb { + width: 24px; + height: 24px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + cursor: pointer; + border: 3px solid white; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4); + transition: all 0.2s; + } + + .frame-slider::-moz-range-thumb:hover { + transform: scale(1.15); + box-shadow: 0 4px 16px rgba(102, 126, 234, 0.6); + } + + .frame-slider::-moz-range-thumb:active { + transform: scale(1.05); + } + + .frame-slider::-moz-range-track { + height: 10px; + border-radius: 5px; + background: #e0e0e0; + } + + .frame-slider::-moz-range-progress { + height: 10px; + border-radius: 5px; + background: linear-gradient(to right, #667eea 0%, #764ba2 100%); + } + + .frame-info-display { + display: grid; + grid-template-columns: 1fr; + gap: 12px; + padding-top: 12px; + border-top: 1px solid #e0e0e0; + } + + @@media (min-width: 480px) { + .frame-info-display { + grid-template-columns: repeat(2, 1fr); + gap: 16px; + padding-top: 16px; + } + } + + @@media (min-width: 768px) { + .frame-info-display { + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + } + } + + .frame-info-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.85em; + } + + @@media (min-width: 768px) { + .frame-info-item { + gap: 8px; + font-size: 0.95em; + } + } + + .frame-info-label { + color: #667eea; + font-weight: 600; + font-size: 0.8em; + text-transform: uppercase; + letter-spacing: 0.5px; + min-width: 60px; + } + + @@media (min-width: 768px) { + .frame-info-label { + font-size: 0.9em; + min-width: 70px; + } + } + + .frame-info-value { + color: #333; + font-weight: 700; + font-size: 0.95em; + } + + @@media (min-width: 768px) { + .frame-info-value { + font-size: 1.1em; + } + } + + .frame-jump-input { + width: 70px; + padding: 10px 12px; + border: 2px solid #667eea; + border-radius: 8px; + font-size: 0.9em; + text-align: center; + font-weight: 700; + color: #333; + background: white; + transition: all 0.2s; + min-height: 44px; /* Touch target size */ + box-sizing: border-box; + } + + @@media (min-width: 768px) { + .frame-jump-input { + padding: 8px 12px; + font-size: 1em; + min-height: auto; + } + } + } + + .frame-jump-input:focus { + outline: none; + border-color: #764ba2; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15); + background: #f8f9ff; + } + + .frame-jump-input::-webkit-inner-spin-button, + .frame-jump-input::-webkit-outer-spin-button { + opacity: 1; + height: 20px; + }
-

🌧️ BOM Radar Test

-
@suburb, @state
+
+

🌧️ BOM Radar Test

+
@suburb, @state
+
+
+ +
-
-
-
Loading radar data...
- -
- -
- -
- - - -
- -
-
- +
-

Cache Status

+

Cache & Update Status

Checking...
+
@@ -333,12 +766,6 @@
-
-
-

Update Status

-
-
-
-
-

Auto Refresh

@@ -348,42 +775,112 @@
- -
-

Settings

- +
+ + +
+
+
Loading radar data...
+
+ +
+ +
+ + + +
+ +
-