mirror of
https://github.com/alexhopeoconnor/bom-local-service.git
synced 2026-10-04 03:28:11 +10:00
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:
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,11 +10,19 @@ namespace BomLocalService.Controllers;
|
|||||||
public class RadarController : ControllerBase
|
public class RadarController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IBomRadarService _bomRadarService;
|
private readonly IBomRadarService _bomRadarService;
|
||||||
|
private readonly ICacheService _cacheService;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
private readonly ILogger<RadarController> _logger;
|
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;
|
_bomRadarService = bomRadarService;
|
||||||
|
_cacheService = cacheService;
|
||||||
|
_configuration = configuration;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,10 +58,28 @@ public class RadarController : ControllerBase
|
|||||||
|
|
||||||
if (result == null || result.Frames.Count == 0)
|
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 {
|
return NotFound(new {
|
||||||
error = "Screenshots not found in cache. Cache update has been triggered in background. Please retry in a few moments.",
|
error = "Screenshots not found in cache. Cache update has been triggered in background. Please retry in a few moments.",
|
||||||
retryAfter = 30, // seconds
|
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 });
|
return BadRequest(new { error = validationError });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frameIndex < 0 || frameIndex > 6)
|
RadarFrame? frame;
|
||||||
{
|
|
||||||
return BadRequest(new { error = "Frame index must be between 0 and 6" });
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
if (frame == null || !System.IO.File.Exists(frame.ImagePath))
|
||||||
{
|
{
|
||||||
@@ -129,7 +161,7 @@ public class RadarController : ControllerBase
|
|||||||
|
|
||||||
if (result == null)
|
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);
|
return Ok(result);
|
||||||
@@ -142,10 +174,16 @@ public class RadarController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
[HttpPost("{suburb}/{state}/refresh")]
|
[HttpGet("{suburb}/{state}/timeseries")]
|
||||||
public async Task<ActionResult<CacheUpdateStatus>> RefreshCache(string suburb, string state, CancellationToken cancellationToken = default)
|
public async Task<ActionResult<RadarTimeSeriesResponse>> GetRadarTimeSeries(
|
||||||
|
string suburb,
|
||||||
|
string state,
|
||||||
|
[FromQuery] string? startTime = null,
|
||||||
|
[FromQuery] string? endTime = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -155,45 +193,45 @@ public class RadarController : ControllerBase
|
|||||||
return BadRequest(new { error = validationError });
|
return BadRequest(new { error = validationError });
|
||||||
}
|
}
|
||||||
|
|
||||||
var status = await _bomRadarService.TriggerCacheUpdateAsync(suburb, state, cancellationToken);
|
DateTime? start = null;
|
||||||
return Ok(status);
|
DateTime? end = null;
|
||||||
}
|
|
||||||
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)
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error deleting cached location for suburb: {Suburb}, state: {State}", suburb, state);
|
_logger.LogError(ex, "Error getting radar time series for suburb: {Suburb}, state: {State}", suburb, state);
|
||||||
return StatusCode(500, new { error = "An error occurred while deleting cached location", message = ex.Message });
|
return StatusCode(500, new { error = "An error occurred while getting radar time series", message = ex.Message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ public class CacheUpdateStatus
|
|||||||
/// - "Cache is valid, no update needed" - Cache exists and is still fresh
|
/// - "Cache is valid, no update needed" - Cache exists and is still fresh
|
||||||
/// - "Cache is stale, update triggered" - Cache exists but expired, update initiated
|
/// - "Cache is stale, update triggered" - Cache exists but expired, update initiated
|
||||||
/// - "No cache exists, update triggered" - No cache found, update initiated
|
/// - "No cache exists, update triggered" - No cache found, update initiated
|
||||||
|
/// - "Cache update already in progress" - An update is currently running
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Message { get; set; }
|
public string? Message { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -1,33 +1,29 @@
|
|||||||
namespace BomLocalService.Models;
|
namespace BomLocalService.Models;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </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>
|
/// <summary>
|
||||||
/// Number of minutes ago this frame represents (40, 35, 30, 25, 20, 15, 10).
|
/// Number of minutes ago this frame represents (40, 35, 30, 25, 20, 15, 10).
|
||||||
/// Frame 0 = 40 minutes ago, Frame 6 = 10 minutes ago.
|
/// Frame 0 = 40 minutes ago, Frame 6 = 10 minutes ago.
|
||||||
|
/// This is relative to the cache folder's observation time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int MinutesAgo { get; set; }
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -2,7 +2,6 @@ using BomLocalService.Models;
|
|||||||
using BomLocalService.Services.Interfaces;
|
using BomLocalService.Services.Interfaces;
|
||||||
using BomLocalService.Utilities;
|
using BomLocalService.Utilities;
|
||||||
using Microsoft.Playwright;
|
using Microsoft.Playwright;
|
||||||
using System.Collections.Concurrent;
|
|
||||||
|
|
||||||
namespace BomLocalService.Services;
|
namespace BomLocalService.Services;
|
||||||
|
|
||||||
@@ -13,9 +12,9 @@ public class BomRadarService : IBomRadarService, IDisposable
|
|||||||
private readonly IBrowserService _browserService;
|
private readonly IBrowserService _browserService;
|
||||||
private readonly IScrapingService _scrapingService;
|
private readonly IScrapingService _scrapingService;
|
||||||
private readonly IDebugService _debugService;
|
private readonly IDebugService _debugService;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
private readonly double _cacheExpirationMinutes;
|
private readonly double _cacheExpirationMinutes;
|
||||||
private readonly int _cacheManagementCheckIntervalMinutes;
|
private readonly int _cacheManagementCheckIntervalMinutes;
|
||||||
private readonly ConcurrentDictionary<string, string> _activeCacheFolders = new(); // locationKey -> cacheFolderPath
|
|
||||||
|
|
||||||
public BomRadarService(
|
public BomRadarService(
|
||||||
ILogger<BomRadarService> logger,
|
ILogger<BomRadarService> logger,
|
||||||
@@ -30,6 +29,7 @@ public class BomRadarService : IBomRadarService, IDisposable
|
|||||||
_browserService = browserService;
|
_browserService = browserService;
|
||||||
_scrapingService = scrapingService;
|
_scrapingService = scrapingService;
|
||||||
_debugService = debugService;
|
_debugService = debugService;
|
||||||
|
_configuration = configuration;
|
||||||
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 15.5);
|
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 15.5);
|
||||||
_cacheManagementCheckIntervalMinutes = configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 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)
|
public async Task<RadarResponse?> GetCachedRadarAsync(string suburb, string state, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var locationKey = LocationHelper.GetLocationKey(suburb, state);
|
var locationKey = LocationHelper.GetLocationKey(suburb, state);
|
||||||
var excludeFolder = _activeCacheFolders.TryGetValue(locationKey, out var activeFolder) ? activeFolder : null;
|
var excludeFolder = _cacheService.GetActiveCacheFolder(locationKey);
|
||||||
var (cacheFolderPath, cachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, excludeFolder, cancellationToken);
|
var (cacheFolderPath, cachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, excludeFolder, cancellationToken);
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath))
|
if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath))
|
||||||
{
|
{
|
||||||
@@ -54,7 +54,7 @@ public class BomRadarService : IBomRadarService, IDisposable
|
|||||||
// Determine cache state
|
// Determine cache state
|
||||||
var isValid = cachedMetadata != null && _cacheService.IsCacheValid(cachedMetadata);
|
var isValid = cachedMetadata != null && _cacheService.IsCacheValid(cachedMetadata);
|
||||||
var cacheExpiresAt = cachedMetadata != null ? cachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes) : (DateTime?)null;
|
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);
|
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);
|
var locationKey = LocationHelper.GetLocationKey(suburb, state);
|
||||||
|
|
||||||
// Check if an update is already in progress
|
// Check if an update is already in progress
|
||||||
var isAlreadyUpdating = _activeCacheFolders.ContainsKey(locationKey);
|
var isAlreadyUpdating = _cacheService.IsLocationUpdating(locationKey);
|
||||||
var excludeFolder = _activeCacheFolders.TryGetValue(locationKey, out var activeFolder) ? activeFolder : null;
|
var excludeFolder = _cacheService.GetActiveCacheFolder(locationKey);
|
||||||
var (cacheFolderPath, cachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, excludeFolder, cancellationToken);
|
var (cacheFolderPath, cachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, excludeFolder, cancellationToken);
|
||||||
|
|
||||||
if (isAlreadyUpdating)
|
if (isAlreadyUpdating)
|
||||||
{
|
{
|
||||||
@@ -170,14 +170,14 @@ public class BomRadarService : IBomRadarService, IDisposable
|
|||||||
|
|
||||||
// Check cache FIRST, before acquiring semaphore (cached requests shouldn't block)
|
// Check cache FIRST, before acquiring semaphore (cached requests shouldn't block)
|
||||||
var locationKey = LocationHelper.GetLocationKey(suburb, state);
|
var locationKey = LocationHelper.GetLocationKey(suburb, state);
|
||||||
var excludeFolder = _activeCacheFolders.TryGetValue(locationKey, out var activeFolder) ? activeFolder : null;
|
var excludeFolder = _cacheService.GetActiveCacheFolder(locationKey);
|
||||||
var isUpdating = !string.IsNullOrEmpty(activeFolder);
|
var isUpdating = _cacheService.IsLocationUpdating(locationKey);
|
||||||
var (cacheFolderPath, cachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, excludeFolder, cancellationToken);
|
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 there's an active update in progress, return existing cache (if available) with IsUpdating=true
|
||||||
if (isUpdating)
|
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);
|
_logger.LogInformation("Cache update in progress for {Suburb}, {State}, returning existing cached data", suburb, state);
|
||||||
var frames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken);
|
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);
|
var isValid = _cacheService.IsCacheValid(cachedMetadata);
|
||||||
if (isValid)
|
if (isValid)
|
||||||
@@ -231,39 +231,21 @@ public class BomRadarService : IBomRadarService, IDisposable
|
|||||||
{
|
{
|
||||||
// Create cache folder and track it before double-checking
|
// Create cache folder and track it before double-checking
|
||||||
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
|
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
|
||||||
newCacheFolderPath = FilePathHelper.GetCacheFolderPath(_cacheService.GetCacheDirectory(), suburb, state, timestamp);
|
newCacheFolderPath = _cacheService.CreateCacheFolder(suburb, state, timestamp);
|
||||||
Directory.CreateDirectory(newCacheFolderPath);
|
|
||||||
|
|
||||||
// Track this folder as being written to
|
// Track this folder as being written to
|
||||||
_activeCacheFolders[locationKey] = newCacheFolderPath;
|
_cacheService.SetActiveCacheFolder(locationKey, newCacheFolderPath);
|
||||||
_logger.LogDebug("Tracking cache folder being written to: {Folder} for {Location}", newCacheFolderPath, locationKey);
|
|
||||||
|
|
||||||
// Double-check cache after acquiring semaphore (another request might have just created it)
|
// Double-check cache after acquiring semaphore (another request might have just created it)
|
||||||
// Exclude the folder we're about to write to
|
// 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))
|
if (!string.IsNullOrEmpty(recheckCacheFolderPath) && Directory.Exists(recheckCacheFolderPath) && recheckCachedMetadata != null && _cacheService.IsCacheValid(recheckCachedMetadata))
|
||||||
{
|
{
|
||||||
// Remove from tracking since we're not using this folder
|
// 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
|
// Clean up the empty folder we created since we're not using it
|
||||||
if (!string.IsNullOrEmpty(newCacheFolderPath) && Directory.Exists(newCacheFolderPath))
|
_cacheService.TryDeleteEmptyCacheFolder(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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Cache became valid while waiting for semaphore, returning cached screenshots");
|
_logger.LogInformation("Cache became valid while waiting for semaphore, returning cached screenshots");
|
||||||
var recheckFrames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken);
|
var recheckFrames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken);
|
||||||
@@ -291,8 +273,7 @@ public class BomRadarService : IBomRadarService, IDisposable
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
// Remove from active tracking once complete
|
// Remove from active tracking once complete
|
||||||
_activeCacheFolders.TryRemove(locationKey, out _);
|
_cacheService.ClearActiveCacheFolder(locationKey);
|
||||||
_logger.LogDebug("Cache folder complete, removed from active tracking: {Folder}", newCacheFolderPath);
|
|
||||||
|
|
||||||
// Update result with cache state
|
// Update result with cache state
|
||||||
result.IsUpdating = false;
|
result.IsUpdating = false;
|
||||||
@@ -320,23 +301,12 @@ public class BomRadarService : IBomRadarService, IDisposable
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
// Always remove from active tracking, even on error
|
// Always remove from active tracking, even on error
|
||||||
_activeCacheFolders.TryRemove(locationKey, out _);
|
_cacheService.ClearActiveCacheFolder(locationKey);
|
||||||
|
|
||||||
// Clean up incomplete cache folder if error occurred
|
// Clean up incomplete cache folder if error occurred
|
||||||
if (!string.IsNullOrEmpty(newCacheFolderPath) && Directory.Exists(newCacheFolderPath))
|
if (!string.IsNullOrEmpty(newCacheFolderPath) && _cacheService.TryDeleteIncompleteCacheFolder(newCacheFolderPath))
|
||||||
{
|
{
|
||||||
try
|
_logger.LogWarning("Cleaned up incomplete cache folder due to error: {Folder}", newCacheFolderPath);
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up empty debug folder if we returned early without scraping
|
// 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
|
// Return cached metadata if available, otherwise return null
|
||||||
var locationKey = LocationHelper.GetLocationKey(suburb, state);
|
var locationKey = LocationHelper.GetLocationKey(suburb, state);
|
||||||
var excludeFolder = _activeCacheFolders.TryGetValue(locationKey, out var activeFolder) ? activeFolder : null;
|
var excludeFolder = _cacheService.GetActiveCacheFolder(locationKey);
|
||||||
var (_, metadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, excludeFolder, cancellationToken);
|
var (_, metadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, excludeFolder, cancellationToken);
|
||||||
|
|
||||||
if (metadata != null)
|
if (metadata != null)
|
||||||
{
|
{
|
||||||
@@ -388,6 +358,253 @@ public class BomRadarService : IBomRadarService, IDisposable
|
|||||||
return _cacheService.DeleteCachedLocationAsync(suburb, state, cancellationToken);
|
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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_browserService?.Dispose();
|
_browserService?.Dispose();
|
||||||
|
|||||||
+288
-45
@@ -1,6 +1,7 @@
|
|||||||
using BomLocalService.Models;
|
using BomLocalService.Models;
|
||||||
using BomLocalService.Services.Interfaces;
|
using BomLocalService.Services.Interfaces;
|
||||||
using BomLocalService.Utilities;
|
using BomLocalService.Utilities;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace BomLocalService.Services;
|
namespace BomLocalService.Services;
|
||||||
@@ -10,10 +11,13 @@ public class CacheService : ICacheService
|
|||||||
private readonly ILogger<CacheService> _logger;
|
private readonly ILogger<CacheService> _logger;
|
||||||
private readonly string _cacheDirectory;
|
private readonly string _cacheDirectory;
|
||||||
private readonly double _cacheExpirationMinutes;
|
private readonly double _cacheExpirationMinutes;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly ConcurrentDictionary<string, string> _activeCacheFolders = new(); // locationKey -> cacheFolderPath
|
||||||
|
|
||||||
public CacheService(ILogger<CacheService> logger, IConfiguration configuration)
|
public CacheService(ILogger<CacheService> logger, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_configuration = configuration;
|
||||||
_cacheDirectory = FilePathHelper.GetCacheDirectory(configuration);
|
_cacheDirectory = FilePathHelper.GetCacheDirectory(configuration);
|
||||||
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 15.5);
|
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 15.5);
|
||||||
|
|
||||||
@@ -22,11 +26,12 @@ public class CacheService : ICacheService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public async Task<(string? cacheFolderPath, LastUpdatedInfo? metadata)> GetCachedScreenshotWithMetadataAsync(
|
public async Task<(string? cacheFolderPath, LastUpdatedInfo? metadata)> GetCachedScreenshotWithMetadataAsync(
|
||||||
string suburb,
|
string suburb,
|
||||||
string state,
|
string state,
|
||||||
|
CachedDataType dataType,
|
||||||
string? excludeFolder = null,
|
string? excludeFolder = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -48,7 +53,7 @@ public class CacheService : ICacheService
|
|||||||
})
|
})
|
||||||
.ToList();
|
.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)
|
foreach (var folder in folders)
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(folder))
|
if (!Directory.Exists(folder))
|
||||||
@@ -61,10 +66,10 @@ public class CacheService : ICacheService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if folder is complete: must have all 7 frames, metadata.json, and frames.json
|
// Check if folder is complete for the specified data type
|
||||||
if (!CacheHelper.IsCacheFolderComplete(folder))
|
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)
|
continue; // Skip incomplete folders (currently being written to)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,40 +90,15 @@ public class CacheService : ICacheService
|
|||||||
string state,
|
string state,
|
||||||
CancellationToken cancellationToken = default)
|
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))
|
if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath))
|
||||||
{
|
{
|
||||||
return new List<RadarFrame>();
|
return new List<RadarFrame>();
|
||||||
}
|
}
|
||||||
|
|
||||||
var frames = new List<RadarFrame>();
|
var cachedFrames = await GetFramesFromCacheFolderAsync(suburb, state, Path.GetFileName(cacheFolderPath), CachedDataType.Radar, cancellationToken);
|
||||||
|
return cachedFrames.Cast<RadarFrame>().ToList();
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -130,26 +110,27 @@ public class CacheService : ICacheService
|
|||||||
int frameIndex,
|
int frameIndex,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (frameIndex < 0 || frameIndex > 6)
|
var frameCount = CacheHelper.GetFrameCountForDataType(_configuration, CachedDataType.Radar);
|
||||||
|
if (frameIndex < 0 || frameIndex >= frameCount)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var (cacheFolderPath, _) = await GetCachedScreenshotWithMetadataAsync(suburb, state, null, cancellationToken);
|
var (cacheFolderPath, _) = await GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, null, cancellationToken);
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(cacheFolderPath))
|
if (string.IsNullOrEmpty(cacheFolderPath))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var framePath = FilePathHelper.GetFrameFilePath(cacheFolderPath, frameIndex);
|
var framePath = FilePathHelper.GetFrameFilePath(cacheFolderPath, CachedDataType.Radar, frameIndex);
|
||||||
if (!File.Exists(framePath))
|
if (!File.Exists(framePath))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load frame metadata to get accurate minutesAgo
|
// 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 frameMetadata = framesMetadata.FirstOrDefault(f => f.FrameIndex == frameIndex);
|
||||||
var minutesAgo = frameMetadata != null
|
var minutesAgo = frameMetadata != null
|
||||||
? frameMetadata.MinutesAgo
|
? frameMetadata.MinutesAgo
|
||||||
@@ -185,9 +166,9 @@ public class CacheService : ICacheService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Saves frame metadata to frames.json
|
/// Saves frame metadata to frames.json in the data type subfolder
|
||||||
/// </summary>
|
/// </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
|
try
|
||||||
{
|
{
|
||||||
@@ -197,7 +178,13 @@ public class CacheService : ICacheService
|
|||||||
MinutesAgo = f.MinutesAgo
|
MinutesAgo = f.MinutesAgo
|
||||||
}).ToList();
|
}).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 });
|
var json = JsonSerializer.Serialize(framesMetadata, new JsonSerializerOptions { WriteIndented = true });
|
||||||
await File.WriteAllTextAsync(framesPath, json, CancellationToken.None);
|
await File.WriteAllTextAsync(framesPath, json, CancellationToken.None);
|
||||||
_logger.LogDebug("Saved frames metadata to: {Path}", framesPath);
|
_logger.LogDebug("Saved frames metadata to: {Path}", framesPath);
|
||||||
@@ -209,11 +196,11 @@ public class CacheService : ICacheService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads frame metadata from frames.json
|
/// Loads frame metadata from frames.json in the data type subfolder
|
||||||
/// </summary>
|
/// </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))
|
if (!File.Exists(framesPath))
|
||||||
{
|
{
|
||||||
return new List<FrameMetadata>();
|
return new List<FrameMetadata>();
|
||||||
@@ -324,6 +311,120 @@ public class CacheService : ICacheService
|
|||||||
return Task.FromResult(deleted);
|
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>
|
/// <summary>
|
||||||
/// Gets the cache directory path
|
/// Gets the cache directory path
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -352,8 +453,8 @@ public class CacheService : ICacheService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Check if folder is incomplete
|
// Check if folder is incomplete (check for radar data type)
|
||||||
if (!CacheHelper.IsCacheFolderComplete(folder))
|
if (!CacheHelper.IsCacheFolderCompleteForDataType(folder, CachedDataType.Radar, _configuration))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Found incomplete cache folder from previous session, deleting: {Folder}", Path.GetFileName(folder));
|
_logger.LogInformation("Found incomplete cache folder from previous session, deleting: {Folder}", Path.GetFileName(folder));
|
||||||
Directory.Delete(folder, recursive: true);
|
Directory.Delete(folder, recursive: true);
|
||||||
@@ -382,5 +483,147 @@ public class CacheService : ICacheService
|
|||||||
|
|
||||||
return deletedCount;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,5 +75,31 @@ public interface IBomRadarService
|
|||||||
/// <param name="cancellationToken">Cancellation token to cancel the operation</param>
|
/// <param name="cancellationToken">Cancellation token to cancel the operation</param>
|
||||||
/// <returns>The cached frame, or null if not found</returns>
|
/// <returns>The cached frame, or null if not found</returns>
|
||||||
Task<RadarFrame?> GetCachedFrameAsync(string suburb, string state, int frameIndex, CancellationToken cancellationToken = default);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,20 +9,51 @@ namespace BomLocalService.Services.Interfaces;
|
|||||||
public interface ICacheService
|
public interface ICacheService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <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.
|
/// Searches for cache folders matching the location pattern and loads the corresponding metadata JSON file.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="suburb">The suburb name (e.g., "Pomona", "Brisbane")</param>
|
/// <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="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="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>
|
/// <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>
|
/// <returns>Tuple containing the cache folder path and metadata, or (null, null) if not found</returns>
|
||||||
Task<(string? cacheFolderPath, LastUpdatedInfo? metadata)> GetCachedScreenshotWithMetadataAsync(
|
Task<(string? cacheFolderPath, LastUpdatedInfo? metadata)> GetCachedScreenshotWithMetadataAsync(
|
||||||
string suburb,
|
string suburb,
|
||||||
string state,
|
string state,
|
||||||
|
CachedDataType dataType,
|
||||||
string? excludeFolder = null,
|
string? excludeFolder = null,
|
||||||
CancellationToken cancellationToken = default);
|
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>
|
/// <summary>
|
||||||
/// Gets all cached frames for a location.
|
/// Gets all cached frames for a location.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -59,12 +90,13 @@ public interface ICacheService
|
|||||||
Task SaveMetadataAsync(string cacheFolderPath, LastUpdatedInfo metadata, CancellationToken cancellationToken = default);
|
Task SaveMetadataAsync(string cacheFolderPath, LastUpdatedInfo metadata, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="cacheFolderPath">Full path to the cache folder</param>
|
/// <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="frames">List of frames with their metadata</param>
|
||||||
/// <param name="cancellationToken">Cancellation token to cancel the operation</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>
|
/// <summary>
|
||||||
/// Checks if cached metadata is still valid (not expired).
|
/// Checks if cached metadata is still valid (not expired).
|
||||||
@@ -107,5 +139,61 @@ public interface ICacheService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>The number of incomplete folders that were deleted</returns>
|
/// <returns>The number of incomplete folders that were deleted</returns>
|
||||||
int CleanupIncompleteCacheFolders();
|
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
@@ -450,6 +450,7 @@ public class ScrapingService : IScrapingService
|
|||||||
// Step 15-21: Capture all 7 frames
|
// Step 15-21: Capture all 7 frames
|
||||||
var frames = new List<RadarFrame>();
|
var frames = new List<RadarFrame>();
|
||||||
var stepForwardButton = page.Locator("button[data-testid='bom-scrub-utils__right__step-forward']").First;
|
var stepForwardButton = page.Locator("button[data-testid='bom-scrub-utils__right__step-forward']").First;
|
||||||
|
int? previousMinutesAgo = null;
|
||||||
|
|
||||||
for (int frameIndex = 0; frameIndex < 7; frameIndex++)
|
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);
|
_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
|
// 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);
|
await CaptureMapScreenshotAsync(page, mapContainer, framePath, containerClip);
|
||||||
|
|
||||||
frames.Add(new RadarFrame
|
frames.Add(new RadarFrame
|
||||||
@@ -479,6 +504,8 @@ public class ScrapingService : IScrapingService
|
|||||||
MinutesAgo = minutesAgo.Value
|
MinutesAgo = minutesAgo.Value
|
||||||
});
|
});
|
||||||
|
|
||||||
|
previousMinutesAgo = minutesAgo;
|
||||||
|
|
||||||
_logger.LogInformation("Frame {FrameIndex} saved: {Path} ({MinutesAgo} minutes ago)",
|
_logger.LogInformation("Frame {FrameIndex} saved: {Path} ({MinutesAgo} minutes ago)",
|
||||||
frameIndex, framePath, minutesAgo.Value);
|
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 not the last frame, click step forward to prepare for next frame
|
||||||
if (frameIndex < 6)
|
if (frameIndex < 6)
|
||||||
{
|
{
|
||||||
// Wait for any modal overlays to disappear before clicking
|
// Dismiss any modal overlays (BOM, reCAPTCHA, feedback forms) before clicking
|
||||||
try
|
await DismissModalOverlaysAsync(page);
|
||||||
{
|
|
||||||
await page.WaitForFunctionAsync(@"() => {
|
// Get current minutesAgo before clicking (to detect change)
|
||||||
const overlay = document.querySelector('.bom-modal-overlay--after-open');
|
var currentMinutesAgo = await ExtractMinutesAgoFromDisplayAsync(page);
|
||||||
return !overlay || overlay.style.display === 'none';
|
|
||||||
}", new PageWaitForFunctionOptions { Timeout = 5000 });
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// If overlay doesn't disappear, try to dismiss it by clicking outside or pressing Escape
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await page.Keyboard.PressAsync("Escape");
|
|
||||||
await page.WaitForTimeoutAsync(500);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Ignore if Escape doesn't work
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use force click to bypass any remaining overlays
|
// Use force click to bypass any remaining overlays
|
||||||
await stepForwardButton.ClickAsync(new LocatorClickOptions { Force = true });
|
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
|
// Step 22: Save metadata and frame information
|
||||||
await _cacheService.SaveMetadataAsync(cacheFolderPath, lastUpdatedInfo, cancellationToken);
|
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
|
// Step 23: Return response with all frames
|
||||||
// Note: ScrapingService doesn't have access to cache management check interval
|
// 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>
|
/// <summary>
|
||||||
/// Captures map screenshot with crop configuration
|
/// Captures map screenshot with crop configuration
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -721,6 +874,9 @@ public class ScrapingService : IScrapingService
|
|||||||
// Continue if network idle timeout - fonts may already be loaded
|
// 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
|
// Take high-quality screenshot with explicit PNG format and disabled animations
|
||||||
await page.ScreenshotAsync(new PageScreenshotOptions
|
await page.ScreenshotAsync(new PageScreenshotOptions
|
||||||
{
|
{
|
||||||
|
|||||||
+34
-20
@@ -1,3 +1,6 @@
|
|||||||
|
using BomLocalService.Models;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace BomLocalService.Utilities;
|
namespace BomLocalService.Utilities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -6,40 +9,43 @@ namespace BomLocalService.Utilities;
|
|||||||
public static class CacheHelper
|
public static class CacheHelper
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks if a cache folder is complete (has all required files).
|
/// Gets the configured frame count for a data type from configuration.
|
||||||
/// A complete cache folder must have:
|
|
||||||
/// - All 7 frame images (frame_0.png through frame_6.png)
|
|
||||||
/// - metadata.json file
|
|
||||||
/// - frames.json file
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cacheFolderPath">The path to the cache folder to check</param>
|
public static int GetFrameCountForDataType(IConfiguration configuration, CachedDataType dataType)
|
||||||
/// <returns>True if the folder is complete, false otherwise</returns>
|
{
|
||||||
public static bool IsCacheFolderComplete(string cacheFolderPath)
|
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))
|
if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for all 7 frames
|
var dataTypeFolder = FilePathHelper.GetDataTypeFolderPath(cacheFolderPath, dataType);
|
||||||
for (int i = 0; i < 7; i++)
|
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))
|
if (!File.Exists(framePath))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for metadata.json
|
// Check for frames.json in data type folder
|
||||||
var metadataPath = FilePathHelper.GetMetadataFilePath(cacheFolderPath);
|
var framesMetadataPath = FilePathHelper.GetFramesMetadataFilePath(cacheFolderPath, dataType);
|
||||||
if (!File.Exists(metadataPath))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for frames.json
|
|
||||||
var framesMetadataPath = FilePathHelper.GetFramesMetadataFilePath(cacheFolderPath);
|
|
||||||
if (!File.Exists(framesMetadataPath))
|
if (!File.Exists(framesMetadataPath))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -47,5 +53,13 @@ public static class CacheHelper
|
|||||||
|
|
||||||
return true;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using BomLocalService.Models;
|
||||||
|
|
||||||
namespace BomLocalService.Utilities;
|
namespace BomLocalService.Utilities;
|
||||||
|
|
||||||
public static class FilePathHelper
|
public static class FilePathHelper
|
||||||
@@ -51,12 +53,34 @@ public static class FilePathHelper
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the frame file path within a cache folder.
|
/// Gets the data type subfolder path within a cache folder.
|
||||||
/// Format: "{CacheFolderPath}/frame_{FrameIndex}.png"
|
/// Format: "{CacheFolderPath}/{DataType}"
|
||||||
|
/// Example: "/app/cache/Pomona_QLD_20251207_000906/radar"
|
||||||
/// </summary>
|
/// </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)
|
public static string GetFrameFilePath(string cacheFolderPath, int frameIndex)
|
||||||
{
|
{
|
||||||
return Path.Combine(cacheFolderPath, $"frame_{frameIndex}.png");
|
return GetFrameFilePath(cacheFolderPath, CachedDataType.Radar, frameIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -90,12 +114,23 @@ public static class FilePathHelper
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the frames metadata file path within a cache folder.
|
/// Gets the frames metadata file path within a data type subfolder.
|
||||||
/// Format: "{CacheFolderPath}/frames.json"
|
/// Format: "{CacheFolderPath}/{DataType}/frames.json"
|
||||||
/// </summary>
|
/// </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)
|
public static string GetFramesMetadataFilePath(string cacheFolderPath)
|
||||||
{
|
{
|
||||||
return Path.Combine(cacheFolderPath, "frames.json");
|
return GetFramesMetadataFilePath(cacheFolderPath, CachedDataType.Radar);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1422
-149
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -32,5 +32,10 @@
|
|||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
"WaitMs": 2000
|
"WaitMs": 2000
|
||||||
},
|
},
|
||||||
"Timezone": "Australia/Brisbane"
|
"Timezone": "Australia/Brisbane",
|
||||||
|
"CachedDataTypes": {
|
||||||
|
"Radar": {
|
||||||
|
"FrameCount": 7
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user