Fix frame deduplication and chronological ordering in time series endpoint

- Fix scraping to wait for display label to change after step forward, preventing duplicate minutesAgo values within same folder
- Add hashset-based deduplication by AbsoluteObservationTime across cache folders
- Filter frames that are too close together (< 4 minutes apart)
- Preserve original folder structure while pruning filtered frames
- Sort folders by earliest frame time to maintain chronological order
- Add validation to detect folder overlaps that could cause out-of-order frames
This commit is contained in:
2025-12-08 01:20:09 +10:00
parent d7bdbdaebb
commit 2d9d45d430
18 changed files with 2734 additions and 366 deletions
+112
View File
@@ -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<CacheController> _logger;
public CacheController(
ICacheService cacheService,
IBomRadarService bomRadarService,
ILogger<CacheController> logger)
{
_cacheService = cacheService;
_bomRadarService = bomRadarService;
_logger = logger;
}
/// <summary>
/// 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.
/// </summary>
[HttpGet("{suburb}/{state}/range")]
public async Task<ActionResult<CacheRange>> 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 });
}
}
/// <summary>
/// Manually trigger a cache refresh for a location. Returns status of the update operation.
/// </summary>
[HttpPost("{suburb}/{state}/refresh")]
public async Task<ActionResult<CacheUpdateStatus>> 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 });
}
}
/// <summary>
/// Delete cached data for a location.
/// </summary>
[HttpDelete("{suburb}/{state}")]
public async Task<ActionResult> 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 });
}
}
}
+80 -42
View File
@@ -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<RadarController> _logger;
public RadarController(IBomRadarService bomRadarService, ILogger<RadarController> logger)
public RadarController(
IBomRadarService bomRadarService,
ICacheService cacheService,
IConfiguration configuration,
ILogger<RadarController> 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<double>("CacheExpirationMinutes", 15.5);
var cacheManagementCheckIntervalMinutes = _configuration.GetValue<int>("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
}
/// <summary>
/// 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.
/// </summary>
[HttpPost("{suburb}/{state}/refresh")]
public async Task<ActionResult<CacheUpdateStatus>> RefreshCache(string suburb, string state, CancellationToken cancellationToken = default)
[HttpGet("{suburb}/{state}/timeseries")]
public async Task<ActionResult<RadarTimeSeriesResponse>> 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 });
}
}
/// <summary>
/// Delete cached data for a location.
/// </summary>
[HttpDelete("{suburb}/{state}")]
public async Task<ActionResult> 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 });
}
}
}
+33
View File
@@ -0,0 +1,33 @@
namespace BomLocalService.Models;
/// <summary>
/// Information about a cache folder containing cached data for a location.
/// Each cache folder represents one capture session and can contain multiple data types.
/// </summary>
public class CacheFolder
{
/// <summary>
/// The folder name (e.g., "Pomona_QLD_20251207_000906").
/// </summary>
public string FolderName { get; set; } = string.Empty;
/// <summary>
/// The UTC timestamp when this cache folder was created (extracted from folder name).
/// </summary>
public DateTime CacheTimestamp { get; set; }
/// <summary>
/// The observation time from the metadata (when the weather data was captured by BOM).
/// </summary>
public DateTime ObservationTime { get; set; }
/// <summary>
/// List of data types available in this cache folder.
/// </summary>
public List<CachedDataType> AvailableDataTypes { get; set; } = new();
/// <summary>
/// Whether this cache folder is complete and ready to use.
/// </summary>
public bool IsComplete { get; set; }
}
+29
View File
@@ -0,0 +1,29 @@
namespace BomLocalService.Models;
/// <summary>
/// Information about the available cache range for a location.
/// Provides oldest and newest cache folder information to help clients understand available historical data.
/// </summary>
public class CacheRange
{
/// <summary>
/// Information about the oldest available cache folder.
/// </summary>
public CacheFolder? OldestCache { get; set; }
/// <summary>
/// Information about the newest available cache folder.
/// </summary>
public CacheFolder? NewestCache { get; set; }
/// <summary>
/// Total number of complete cache folders available.
/// </summary>
public int TotalCacheFolders { get; set; }
/// <summary>
/// The time span between oldest and newest cache (in minutes).
/// Null if there are fewer than 2 cache folders.
/// </summary>
public int? TimeSpanMinutes { get; set; }
}
+1
View File
@@ -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
/// </summary>
public string? Message { get; set; }
}
+16
View File
@@ -0,0 +1,16 @@
namespace BomLocalService.Models;
/// <summary>
/// Types of cached data available from BOM weather pages.
/// </summary>
public enum CachedDataType
{
/// <summary>
/// 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.
/// </summary>
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
}
+28
View File
@@ -0,0 +1,28 @@
namespace BomLocalService.Models;
/// <summary>
/// Base model representing a single cached frame image.
/// Extended by type-specific frame models (RadarFrame, etc.).
/// </summary>
public class CachedFrame
{
/// <summary>
/// Frame index within the frameset (0-based).
/// </summary>
public int FrameIndex { get; set; }
/// <summary>
/// Full file system path to the frame image file (server-side only).
/// </summary>
public string ImagePath { get; set; } = string.Empty;
/// <summary>
/// URL endpoint to retrieve this frame image.
/// </summary>
public string ImageUrl { get; set; } = string.Empty;
/// <summary>
/// The data type this frame belongs to.
/// </summary>
public CachedDataType DataType { get; set; }
}
+17 -21
View File
@@ -1,33 +1,29 @@
namespace BomLocalService.Models;
/// <summary>
/// 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.
/// </summary>
public class RadarFrame
public class RadarFrame : CachedFrame
{
/// <summary>
/// Frame index (0-6), where 0 is the oldest frame (40 minutes ago) and 6 is the newest (10 minutes ago).
/// </summary>
public int FrameIndex { get; set; }
/// <summary>
/// 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"
/// </summary>
public string ImagePath { get; set; } = string.Empty;
/// <summary>
/// URL endpoint to retrieve this frame image.
/// Format: "/api/radar/{Suburb}/{State}/frame/{FrameIndex}"
/// Example: "/api/radar/Pomona/QLD/frame/0"
/// </summary>
public string ImageUrl { get; set; } = string.Empty;
/// <summary>
/// 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.
/// </summary>
public int MinutesAgo { get; set; }
/// <summary>
/// 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.
/// </summary>
public DateTime? AbsoluteObservationTime { get; set; }
public RadarFrame()
{
DataType = CachedDataType.Radar;
}
}
+58
View File
@@ -0,0 +1,58 @@
namespace BomLocalService.Models;
/// <summary>
/// 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.
/// </summary>
public class RadarTimeSeriesResponse
{
/// <summary>
/// List of cache folders, each containing radar frames from a single capture session.
/// Ordered chronologically (oldest first).
/// </summary>
public List<RadarCacheFolderFrames> CacheFolders { get; set; } = new();
/// <summary>
/// The start time of the requested range (UTC).
/// </summary>
public DateTime? StartTime { get; set; }
/// <summary>
/// The end time of the requested range (UTC).
/// </summary>
public DateTime? EndTime { get; set; }
/// <summary>
/// Total number of frames across all cache folders.
/// </summary>
public int TotalFrames { get; set; }
}
/// <summary>
/// A cache folder with its associated radar frames.
/// Represents all frames from a single radar capture session (from the radar subfolder).
/// </summary>
public class RadarCacheFolderFrames
{
/// <summary>
/// The cache folder name this frameset came from.
/// </summary>
public string CacheFolderName { get; set; } = string.Empty;
/// <summary>
/// The timestamp when this cache folder was created (UTC).
/// </summary>
public DateTime CacheTimestamp { get; set; }
/// <summary>
/// The observation time from metadata (UTC).
/// </summary>
public DateTime ObservationTime { get; set; }
/// <summary>
/// All radar frames from this cache folder's radar subfolder (typically 7 frames, 0-6).
/// Frame URLs are already generated and ready to use.
/// </summary>
public List<RadarFrame> Frames { get; set; } = new();
}
+271 -54
View File
@@ -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<string, string> _activeCacheFolders = new(); // locationKey -> cacheFolderPath
public BomRadarService(
ILogger<BomRadarService> logger,
@@ -30,6 +29,7 @@ public class BomRadarService : IBomRadarService, IDisposable
_browserService = browserService;
_scrapingService = scrapingService;
_debugService = debugService;
_configuration = configuration;
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 15.5);
_cacheManagementCheckIntervalMinutes = configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
}
@@ -37,8 +37,8 @@ public class BomRadarService : IBomRadarService, IDisposable
public async Task<RadarResponse?> 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<CacheRange> 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<RadarTimeSeriesResponse> 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<RadarCacheFolderFrames>();
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<DateTime>();
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<RadarFrame>()
.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<RadarFrame>();
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>();
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<RadarFrame?> 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<RadarFrame>().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();
+288 -45
View File
@@ -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<CacheService> _logger;
private readonly string _cacheDirectory;
private readonly double _cacheExpirationMinutes;
private readonly IConfiguration _configuration;
private readonly ConcurrentDictionary<string, string> _activeCacheFolders = new(); // locationKey -> cacheFolderPath
public CacheService(ILogger<CacheService> logger, IConfiguration configuration)
{
_logger = logger;
_configuration = configuration;
_cacheDirectory = FilePathHelper.GetCacheDirectory(configuration);
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 15.5);
@@ -22,11 +26,12 @@ public class CacheService : ICacheService
}
/// <summary>
/// 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
/// </summary>
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<RadarFrame>();
}
var frames = new List<RadarFrame>();
// 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<RadarFrame>().ToList();
}
/// <summary>
@@ -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
}
/// <summary>
/// Saves frame metadata to frames.json
/// Saves frame metadata to frames.json in the data type subfolder
/// </summary>
public async Task SaveFramesMetadataAsync(string cacheFolderPath, List<RadarFrame> frames, CancellationToken cancellationToken = default)
public async Task SaveFramesMetadataAsync(string cacheFolderPath, CachedDataType dataType, List<RadarFrame> 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
}
/// <summary>
/// Loads frame metadata from frames.json
/// Loads frame metadata from frames.json in the data type subfolder
/// </summary>
private async Task<List<FrameMetadata>> LoadFramesMetadataAsync(string cacheFolderPath, CancellationToken cancellationToken = default)
private async Task<List<FrameMetadata>> 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<FrameMetadata>();
@@ -324,6 +311,120 @@ public class CacheService : ICacheService
return Task.FromResult(deleted);
}
/// <summary>
/// Gets all complete cache folders for a location, ordered by timestamp (oldest first)
/// </summary>
public async Task<List<CacheFolder>> 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<CacheFolder>();
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<CachedDataType>();
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;
}
/// <summary>
/// Gets frames from a specific cache folder and data type
/// </summary>
public async Task<List<CachedFrame>> 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<CachedFrame>();
}
var frameCount = CacheHelper.GetFrameCountForDataType(_configuration, dataType);
var frames = new List<CachedFrame>();
// 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;
}
/// <summary>
/// Gets the cache directory path
/// </summary>
@@ -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<CacheUpdateStatus> 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;
}
}
+26
View File
@@ -75,5 +75,31 @@ public interface IBomRadarService
/// <param name="cancellationToken">Cancellation token to cancel the operation</param>
/// <returns>The cached frame, or null if not found</returns>
Task<RadarFrame?> GetCachedFrameAsync(string suburb, string state, int frameIndex, CancellationToken cancellationToken = default);
/// <summary>
/// Gets information about the available cache range for a location (oldest and newest cache folders).
/// </summary>
Task<CacheRange> GetCacheRangeAsync(string suburb, string state, CancellationToken cancellationToken = default);
/// <summary>
/// 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.
/// </summary>
Task<RadarTimeSeriesResponse> GetRadarTimeSeriesAsync(
string suburb,
string state,
DateTime? startTime,
DateTime? endTime,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a specific radar frame from a specific cache folder.
/// </summary>
Task<RadarFrame?> GetFrameFromCacheFolderAsync(
string suburb,
string state,
string cacheFolderName,
int frameIndex,
CancellationToken cancellationToken = default);
}
+91 -3
View File
@@ -9,20 +9,51 @@ namespace BomLocalService.Services.Interfaces;
public interface ICacheService
{
/// <summary>
/// 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.
/// </summary>
/// <param name="suburb">The suburb name (e.g., "Pomona", "Brisbane")</param>
/// <param name="state">The Australian state abbreviation (e.g., "QLD", "NSW", "VIC")</param>
/// <param name="dataType">The type of cached data to retrieve</param>
/// <param name="excludeFolder">Optional folder path to exclude from search (e.g., folder currently being written to)</param>
/// <param name="cancellationToken">Cancellation token to cancel the operation</param>
/// <returns>Tuple containing the cache folder path and metadata, or (null, null) if not found</returns>
Task<(string? cacheFolderPath, LastUpdatedInfo? metadata)> GetCachedScreenshotWithMetadataAsync(
string suburb,
string state,
CachedDataType dataType,
string? excludeFolder = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all complete cache folders for a location, ordered by timestamp (oldest first).
/// Returns basic folder information without loading all frame data.
/// </summary>
/// <param name="suburb">The suburb name (e.g., "Pomona", "Brisbane")</param>
/// <param name="state">The Australian state abbreviation (e.g., "QLD", "NSW", "VIC")</param>
/// <param name="cancellationToken">Cancellation token to cancel the operation</param>
/// <returns>List of cache folders with available data types</returns>
Task<List<CacheFolder>> GetAllCacheFoldersAsync(
string suburb,
string state,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets frames from a specific cache folder and data type.
/// </summary>
/// <param name="suburb">The suburb name (e.g., "Pomona", "Brisbane")</param>
/// <param name="state">The Australian state abbreviation (e.g., "QLD", "NSW", "VIC")</param>
/// <param name="cacheFolderName">The cache folder name</param>
/// <param name="dataType">The type of cached data to retrieve</param>
/// <param name="cancellationToken">Cancellation token to cancel the operation</param>
/// <returns>List of cached frames, or empty list if not found</returns>
Task<List<CachedFrame>> GetFramesFromCacheFolderAsync(
string suburb,
string state,
string cacheFolderName,
CachedDataType dataType,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all cached frames for a location.
/// </summary>
@@ -59,12 +90,13 @@ public interface ICacheService
Task SaveMetadataAsync(string cacheFolderPath, LastUpdatedInfo metadata, CancellationToken cancellationToken = default);
/// <summary>
/// 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.
/// </summary>
/// <param name="cacheFolderPath">Full path to the cache folder</param>
/// <param name="dataType">The type of cached data</param>
/// <param name="frames">List of frames with their metadata</param>
/// <param name="cancellationToken">Cancellation token to cancel the operation</param>
Task SaveFramesMetadataAsync(string cacheFolderPath, List<RadarFrame> frames, CancellationToken cancellationToken = default);
Task SaveFramesMetadataAsync(string cacheFolderPath, CachedDataType dataType, List<RadarFrame> frames, CancellationToken cancellationToken = default);
/// <summary>
/// Checks if cached metadata is still valid (not expired).
@@ -107,5 +139,61 @@ public interface ICacheService
/// </summary>
/// <returns>The number of incomplete folders that were deleted</returns>
int CleanupIncompleteCacheFolders();
/// <summary>
/// Checks if a location is currently being updated (has an active cache folder being written to).
/// </summary>
bool IsLocationUpdating(string locationKey);
/// <summary>
/// Gets the active cache folder path for a location (if one is being written to).
/// </summary>
string? GetActiveCacheFolder(string locationKey);
/// <summary>
/// Sets the active cache folder for a location (indicates a cache update is in progress).
/// </summary>
void SetActiveCacheFolder(string locationKey, string cacheFolderPath);
/// <summary>
/// Clears the active cache folder for a location (indicates cache update is complete).
/// </summary>
void ClearActiveCacheFolder(string locationKey);
/// <summary>
/// Creates a new cache folder for a location and timestamp.
/// Creates the folder structure and ensures data type subfolders are ready.
/// </summary>
string CreateCacheFolder(string suburb, string state, string timestamp);
/// <summary>
/// Deletes an incomplete cache folder (cleanup on error).
/// Only deletes if the folder is incomplete (missing required files).
/// </summary>
bool TryDeleteIncompleteCacheFolder(string cacheFolderPath);
/// <summary>
/// Deletes an empty cache folder.
/// </summary>
bool TryDeleteEmptyCacheFolder(string cacheFolderPath);
/// <summary>
/// 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.
/// </summary>
/// <param name="suburb">The suburb name (e.g., "Pomona", "Brisbane")</param>
/// <param name="state">The Australian state abbreviation (e.g., "QLD", "NSW", "VIC")</param>
/// <param name="dataType">The type of cached data to check</param>
/// <param name="cacheExpirationMinutes">The number of minutes after observation time that cache expires (will be cast to int)</param>
/// <param name="cacheManagementCheckIntervalMinutes">The interval in minutes that the background cache management service checks for updates</param>
/// <param name="cancellationToken">Cancellation token to cancel the operation</param>
/// <returns>Cache status information</returns>
Task<CacheUpdateStatus> GetCacheStatusAsync(
string suburb,
string state,
CachedDataType dataType,
int cacheExpirationMinutes,
int cacheManagementCheckIntervalMinutes,
CancellationToken cancellationToken = default);
}
+181 -25
View File
@@ -450,6 +450,7 @@ public class ScrapingService : IScrapingService
// Step 15-21: Capture all 7 frames
var frames = new List<RadarFrame>();
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
}
}
/// <summary>
/// Waits for the display label to change from the current minutesAgo value
/// </summary>
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");
}
}
/// <summary>
/// Dismisses any modal overlays (BOM modals, reCAPTCHA, feedback forms) that might block the radar
/// Uses a single efficient check and minimal delays
/// </summary>
private async Task DismissModalOverlaysAsync(IPage page)
{
try
{
// Single check for all modal types (BOM, reCAPTCHA, feedback forms)
var hasModal = await page.EvaluateAsync<bool>(@"() => {
// 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<bool>(@"() => {
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");
}
}
/// <summary>
/// Captures map screenshot with crop configuration
/// </summary>
@@ -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
{
+34 -20
View File
@@ -1,3 +1,6 @@
using BomLocalService.Models;
using Microsoft.Extensions.Configuration;
namespace BomLocalService.Utilities;
/// <summary>
@@ -6,40 +9,43 @@ namespace BomLocalService.Utilities;
public static class CacheHelper
{
/// <summary>
/// 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.
/// </summary>
/// <param name="cacheFolderPath">The path to the cache folder to check</param>
/// <returns>True if the folder is complete, false otherwise</returns>
public static bool IsCacheFolderComplete(string cacheFolderPath)
public static int GetFrameCountForDataType(IConfiguration configuration, CachedDataType dataType)
{
var dataTypeName = dataType.ToString();
var frameCount = configuration.GetValue<int>($"CachedDataTypes:{dataTypeName}:FrameCount", 7);
return frameCount;
}
/// <summary>
/// Checks if a cache folder has complete data for a specific data type.
/// </summary>
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;
}
/// <summary>
/// Checks if a cache folder is complete (has radar data - for backward compatibility).
/// </summary>
public static bool IsCacheFolderComplete(string cacheFolderPath, IConfiguration configuration)
{
return IsCacheFolderCompleteForDataType(cacheFolderPath, CachedDataType.Radar, configuration);
}
}
+41 -6
View File
@@ -1,3 +1,5 @@
using BomLocalService.Models;
namespace BomLocalService.Utilities;
public static class FilePathHelper
@@ -51,12 +53,34 @@ public static class FilePathHelper
}
/// <summary>
/// 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"
/// </summary>
public static string GetDataTypeFolderPath(string cacheFolderPath, CachedDataType dataType)
{
var dataTypeFolder = dataType.ToString().ToLowerInvariant();
return Path.Combine(cacheFolderPath, dataTypeFolder);
}
/// <summary>
/// Gets the frame file path within a data type subfolder.
/// Format: "{CacheFolderPath}/{DataType}/frame_{FrameIndex}.png"
/// </summary>
public static string GetFrameFilePath(string cacheFolderPath, CachedDataType dataType, int frameIndex)
{
var dataTypeFolder = GetDataTypeFolderPath(cacheFolderPath, dataType);
return Path.Combine(dataTypeFolder, $"frame_{frameIndex}.png");
}
/// <summary>
/// Gets the frame file path within a cache folder (legacy - defaults to radar).
/// Format: "{CacheFolderPath}/radar/frame_{FrameIndex}.png"
/// </summary>
[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);
}
/// <summary>
@@ -90,12 +114,23 @@ public static class FilePathHelper
}
/// <summary>
/// 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"
/// </summary>
public static string GetFramesMetadataFilePath(string cacheFolderPath, CachedDataType dataType)
{
var dataTypeFolder = GetDataTypeFolderPath(cacheFolderPath, dataType);
return Path.Combine(dataTypeFolder, "frames.json");
}
/// <summary>
/// Gets the frames metadata file path within a cache folder (legacy - defaults to radar).
/// Format: "{CacheFolderPath}/radar/frames.json"
/// </summary>
[Obsolete("Use GetFramesMetadataFilePath with dataType parameter")]
public static string GetFramesMetadataFilePath(string cacheFolderPath)
{
return Path.Combine(cacheFolderPath, "frames.json");
return GetFramesMetadataFilePath(cacheFolderPath, CachedDataType.Radar);
}
}
+1422 -149
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -32,5 +32,10 @@
"Enabled": false,
"WaitMs": 2000
},
"Timezone": "Australia/Brisbane"
"Timezone": "Australia/Brisbane",
"CachedDataTypes": {
"Radar": {
"FrameCount": 7
}
}
}