mirror of
https://github.com/alexhopeoconnor/bom-local-service.git
synced 2026-10-03 21:21:59 +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
|
||||
{
|
||||
private readonly IBomRadarService _bomRadarService;
|
||||
private readonly ICacheService _cacheService;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<RadarController> _logger;
|
||||
|
||||
public RadarController(IBomRadarService bomRadarService, ILogger<RadarController> logger)
|
||||
public RadarController(
|
||||
IBomRadarService bomRadarService,
|
||||
ICacheService cacheService,
|
||||
IConfiguration configuration,
|
||||
ILogger<RadarController> logger)
|
||||
{
|
||||
_bomRadarService = bomRadarService;
|
||||
_cacheService = cacheService;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -50,10 +58,28 @@ public class RadarController : ControllerBase
|
||||
|
||||
if (result == null || result.Frames.Count == 0)
|
||||
{
|
||||
// Get cache status to include in 404 response
|
||||
// Use cache service directly since this is cache management, not radar-specific
|
||||
var cacheExpirationMinutes = (int)_configuration.GetValue<double>("CacheExpirationMinutes", 15.5);
|
||||
var cacheManagementCheckIntervalMinutes = _configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
|
||||
var cacheStatus = await _cacheService.GetCacheStatusAsync(
|
||||
suburb,
|
||||
state,
|
||||
CachedDataType.Radar,
|
||||
cacheExpirationMinutes,
|
||||
cacheManagementCheckIntervalMinutes,
|
||||
cancellationToken);
|
||||
|
||||
return NotFound(new {
|
||||
error = "Screenshots not found in cache. Cache update has been triggered in background. Please retry in a few moments.",
|
||||
retryAfter = 30, // seconds
|
||||
refreshEndpoint = $"/api/radar/{suburb}/{state}/refresh"
|
||||
refreshEndpoint = $"/api/cache/{suburb}/{state}/refresh",
|
||||
updateTriggered = cacheStatus.UpdateTriggered,
|
||||
cacheExists = cacheStatus.CacheExists,
|
||||
cacheIsValid = cacheStatus.CacheIsValid,
|
||||
cacheExpiresAt = cacheStatus.CacheExpiresAt,
|
||||
nextUpdateTime = cacheStatus.NextUpdateTime,
|
||||
message = cacheStatus.Message
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,12 +115,18 @@ public class RadarController : ControllerBase
|
||||
return BadRequest(new { error = validationError });
|
||||
}
|
||||
|
||||
if (frameIndex < 0 || frameIndex > 6)
|
||||
{
|
||||
return BadRequest(new { error = "Frame index must be between 0 and 6" });
|
||||
}
|
||||
RadarFrame? frame;
|
||||
|
||||
var frame = await _bomRadarService.GetCachedFrameAsync(suburb, state, frameIndex, cancellationToken);
|
||||
// Support cacheFolder query parameter for historical data
|
||||
var cacheFolder = Request.Query["cacheFolder"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(cacheFolder))
|
||||
{
|
||||
frame = await _bomRadarService.GetFrameFromCacheFolderAsync(suburb, state, cacheFolder, frameIndex, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
frame = await _bomRadarService.GetCachedFrameAsync(suburb, state, frameIndex, cancellationToken);
|
||||
}
|
||||
|
||||
if (frame == null || !System.IO.File.Exists(frame.ImagePath))
|
||||
{
|
||||
@@ -129,7 +161,7 @@ public class RadarController : ControllerBase
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
return NotFound(new { error = "No cached data found. Use POST /api/radar/{suburb}/{state}/refresh to trigger cache update." });
|
||||
return NotFound(new { error = "No cached data found. Use POST /api/cache/{suburb}/{state}/refresh to trigger cache update." });
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
@@ -142,10 +174,16 @@ public class RadarController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually trigger a cache refresh for a location. Returns status of the update operation.
|
||||
/// Get historical radar data (frames from multiple cache folders) between specified timestamps.
|
||||
/// Radar frames can be joined across cache folders because they represent a historical time series.
|
||||
/// </summary>
|
||||
[HttpPost("{suburb}/{state}/refresh")]
|
||||
public async Task<ActionResult<CacheUpdateStatus>> RefreshCache(string suburb, string state, CancellationToken cancellationToken = default)
|
||||
[HttpGet("{suburb}/{state}/timeseries")]
|
||||
public async Task<ActionResult<RadarTimeSeriesResponse>> GetRadarTimeSeries(
|
||||
string suburb,
|
||||
string state,
|
||||
[FromQuery] string? startTime = null,
|
||||
[FromQuery] string? endTime = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -155,45 +193,45 @@ public class RadarController : ControllerBase
|
||||
return BadRequest(new { error = validationError });
|
||||
}
|
||||
|
||||
var status = await _bomRadarService.TriggerCacheUpdateAsync(suburb, state, cancellationToken);
|
||||
return Ok(status);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error triggering cache update for suburb: {Suburb}, state: {State}", suburb, state);
|
||||
return StatusCode(500, new { error = "An error occurred while triggering cache update", message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete cached data for a location.
|
||||
/// </summary>
|
||||
[HttpDelete("{suburb}/{state}")]
|
||||
public async Task<ActionResult> DeleteCache(string suburb, string state, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var validationError = ValidationHelper.ValidateLocation(suburb, state);
|
||||
if (validationError != null)
|
||||
{
|
||||
return BadRequest(new { error = validationError });
|
||||
}
|
||||
|
||||
var deleted = await _bomRadarService.DeleteCachedLocationAsync(suburb, state, cancellationToken);
|
||||
DateTime? start = null;
|
||||
DateTime? end = null;
|
||||
|
||||
if (deleted)
|
||||
if (!string.IsNullOrEmpty(startTime))
|
||||
{
|
||||
return Ok(new { message = $"Cache deleted for {suburb}, {state}" });
|
||||
if (!DateTime.TryParse(startTime, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsedStart))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid startTime format. Use ISO 8601 format (e.g., 2025-12-07T00:00:00Z)" });
|
||||
}
|
||||
start = parsedStart.ToUniversalTime();
|
||||
}
|
||||
else
|
||||
|
||||
if (!string.IsNullOrEmpty(endTime))
|
||||
{
|
||||
return NotFound(new { error = $"No cached data found for {suburb}, {state}" });
|
||||
if (!DateTime.TryParse(endTime, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsedEnd))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid endTime format. Use ISO 8601 format (e.g., 2025-12-07T12:00:00Z)" });
|
||||
}
|
||||
end = parsedEnd.ToUniversalTime();
|
||||
}
|
||||
|
||||
if (start.HasValue && end.HasValue && start.Value > end.Value)
|
||||
{
|
||||
return BadRequest(new { error = "startTime must be before or equal to endTime" });
|
||||
}
|
||||
|
||||
var result = await _bomRadarService.GetRadarTimeSeriesAsync(suburb, state, start, end, cancellationToken);
|
||||
|
||||
if (result.CacheFolders.Count == 0)
|
||||
{
|
||||
return NotFound(new { error = "No historical data found for the specified time range." });
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting cached location for suburb: {Suburb}, state: {State}", suburb, state);
|
||||
return StatusCode(500, new { error = "An error occurred while deleting cached location", message = ex.Message });
|
||||
_logger.LogError(ex, "Error getting radar time series for suburb: {Suburb}, state: {State}", suburb, state);
|
||||
return StatusCode(500, new { error = "An error occurred while getting radar time series", message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 stale, update triggered" - Cache exists but expired, update initiated
|
||||
/// - "No cache exists, update triggered" - No cache found, update initiated
|
||||
/// - "Cache update already in progress" - An update is currently running
|
||||
/// </summary>
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single frame from the radar slideshow (0-6).
|
||||
/// Represents a single radar frame (historical precipitation data).
|
||||
/// Frame 0 is oldest (40 minutes ago), Frame 6 is newest (10 minutes ago).
|
||||
/// Radar frames can be joined across cache folders to create extended historical slideshows.
|
||||
/// </summary>
|
||||
public class RadarFrame
|
||||
public class RadarFrame : CachedFrame
|
||||
{
|
||||
/// <summary>
|
||||
/// Frame index (0-6), where 0 is the oldest frame (40 minutes ago) and 6 is the newest (10 minutes ago).
|
||||
/// </summary>
|
||||
public int FrameIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Full file system path to the frame image file (server-side only).
|
||||
/// Format: "{CacheDirectory}/{LocationKey}_{Timestamp}/frame_{FrameIndex}.png"
|
||||
/// Example: "/app/cache/Pomona_QLD_20251207_000906/frame_0.png"
|
||||
/// </summary>
|
||||
public string ImagePath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// URL endpoint to retrieve this frame image.
|
||||
/// Format: "/api/radar/{Suburb}/{State}/frame/{FrameIndex}"
|
||||
/// Example: "/api/radar/Pomona/QLD/frame/0"
|
||||
/// </summary>
|
||||
public string ImageUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Number of minutes ago this frame represents (40, 35, 30, 25, 20, 15, 10).
|
||||
/// Frame 0 = 40 minutes ago, Frame 6 = 10 minutes ago.
|
||||
/// This is relative to the cache folder's observation time.
|
||||
/// </summary>
|
||||
public int MinutesAgo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The absolute UTC observation time for this frame.
|
||||
/// Calculated as: ObservationTime - MinutesAgo.
|
||||
/// This is set when frames are joined across cache folders in timeseries responses.
|
||||
/// </summary>
|
||||
public DateTime? AbsoluteObservationTime { get; set; }
|
||||
|
||||
public RadarFrame()
|
||||
{
|
||||
DataType = CachedDataType.Radar;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.Utilities;
|
||||
using Microsoft.Playwright;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace BomLocalService.Services;
|
||||
|
||||
@@ -13,9 +12,9 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
private readonly IBrowserService _browserService;
|
||||
private readonly IScrapingService _scrapingService;
|
||||
private readonly IDebugService _debugService;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly double _cacheExpirationMinutes;
|
||||
private readonly int _cacheManagementCheckIntervalMinutes;
|
||||
private readonly ConcurrentDictionary<string, string> _activeCacheFolders = new(); // locationKey -> cacheFolderPath
|
||||
|
||||
public BomRadarService(
|
||||
ILogger<BomRadarService> logger,
|
||||
@@ -30,6 +29,7 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
_browserService = browserService;
|
||||
_scrapingService = scrapingService;
|
||||
_debugService = debugService;
|
||||
_configuration = configuration;
|
||||
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 15.5);
|
||||
_cacheManagementCheckIntervalMinutes = configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
|
||||
}
|
||||
@@ -37,8 +37,8 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
public async Task<RadarResponse?> GetCachedRadarAsync(string suburb, string state, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var locationKey = LocationHelper.GetLocationKey(suburb, state);
|
||||
var excludeFolder = _activeCacheFolders.TryGetValue(locationKey, out var activeFolder) ? activeFolder : null;
|
||||
var (cacheFolderPath, cachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, excludeFolder, cancellationToken);
|
||||
var excludeFolder = _cacheService.GetActiveCacheFolder(locationKey);
|
||||
var (cacheFolderPath, cachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, excludeFolder, cancellationToken);
|
||||
|
||||
if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath))
|
||||
{
|
||||
@@ -54,7 +54,7 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
// Determine cache state
|
||||
var isValid = cachedMetadata != null && _cacheService.IsCacheValid(cachedMetadata);
|
||||
var cacheExpiresAt = cachedMetadata != null ? cachedMetadata.ObservationTime.AddMinutes(_cacheExpirationMinutes) : (DateTime?)null;
|
||||
var isUpdating = _activeCacheFolders.ContainsKey(locationKey);
|
||||
var isUpdating = _cacheService.IsLocationUpdating(locationKey);
|
||||
|
||||
return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, cachedMetadata, suburb, state, isValid, cacheExpiresAt, isUpdating, _cacheManagementCheckIntervalMinutes);
|
||||
}
|
||||
@@ -83,9 +83,9 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
var locationKey = LocationHelper.GetLocationKey(suburb, state);
|
||||
|
||||
// Check if an update is already in progress
|
||||
var isAlreadyUpdating = _activeCacheFolders.ContainsKey(locationKey);
|
||||
var excludeFolder = _activeCacheFolders.TryGetValue(locationKey, out var activeFolder) ? activeFolder : null;
|
||||
var (cacheFolderPath, cachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, excludeFolder, cancellationToken);
|
||||
var isAlreadyUpdating = _cacheService.IsLocationUpdating(locationKey);
|
||||
var excludeFolder = _cacheService.GetActiveCacheFolder(locationKey);
|
||||
var (cacheFolderPath, cachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, excludeFolder, cancellationToken);
|
||||
|
||||
if (isAlreadyUpdating)
|
||||
{
|
||||
@@ -170,14 +170,14 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
|
||||
// Check cache FIRST, before acquiring semaphore (cached requests shouldn't block)
|
||||
var locationKey = LocationHelper.GetLocationKey(suburb, state);
|
||||
var excludeFolder = _activeCacheFolders.TryGetValue(locationKey, out var activeFolder) ? activeFolder : null;
|
||||
var isUpdating = !string.IsNullOrEmpty(activeFolder);
|
||||
var (cacheFolderPath, cachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, excludeFolder, cancellationToken);
|
||||
var excludeFolder = _cacheService.GetActiveCacheFolder(locationKey);
|
||||
var isUpdating = _cacheService.IsLocationUpdating(locationKey);
|
||||
var (cacheFolderPath, cachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, excludeFolder, cancellationToken);
|
||||
|
||||
// If there's an active update in progress, return existing cache (if available) with IsUpdating=true
|
||||
if (isUpdating)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(cacheFolderPath) && Directory.Exists(cacheFolderPath) && CacheHelper.IsCacheFolderComplete(cacheFolderPath))
|
||||
if (!string.IsNullOrEmpty(cacheFolderPath) && Directory.Exists(cacheFolderPath) && CacheHelper.IsCacheFolderComplete(cacheFolderPath, _configuration))
|
||||
{
|
||||
_logger.LogInformation("Cache update in progress for {Suburb}, {State}, returning existing cached data", suburb, state);
|
||||
var frames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken);
|
||||
@@ -192,7 +192,7 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(cacheFolderPath) && Directory.Exists(cacheFolderPath) && cachedMetadata != null && CacheHelper.IsCacheFolderComplete(cacheFolderPath))
|
||||
if (!string.IsNullOrEmpty(cacheFolderPath) && Directory.Exists(cacheFolderPath) && cachedMetadata != null && CacheHelper.IsCacheFolderComplete(cacheFolderPath, _configuration))
|
||||
{
|
||||
var isValid = _cacheService.IsCacheValid(cachedMetadata);
|
||||
if (isValid)
|
||||
@@ -231,39 +231,21 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
{
|
||||
// Create cache folder and track it before double-checking
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
|
||||
newCacheFolderPath = FilePathHelper.GetCacheFolderPath(_cacheService.GetCacheDirectory(), suburb, state, timestamp);
|
||||
Directory.CreateDirectory(newCacheFolderPath);
|
||||
newCacheFolderPath = _cacheService.CreateCacheFolder(suburb, state, timestamp);
|
||||
|
||||
// Track this folder as being written to
|
||||
_activeCacheFolders[locationKey] = newCacheFolderPath;
|
||||
_logger.LogDebug("Tracking cache folder being written to: {Folder} for {Location}", newCacheFolderPath, locationKey);
|
||||
_cacheService.SetActiveCacheFolder(locationKey, newCacheFolderPath);
|
||||
|
||||
// Double-check cache after acquiring semaphore (another request might have just created it)
|
||||
// Exclude the folder we're about to write to
|
||||
var (recheckCacheFolderPath, recheckCachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, newCacheFolderPath, cancellationToken);
|
||||
var (recheckCacheFolderPath, recheckCachedMetadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, newCacheFolderPath, cancellationToken);
|
||||
if (!string.IsNullOrEmpty(recheckCacheFolderPath) && Directory.Exists(recheckCacheFolderPath) && recheckCachedMetadata != null && _cacheService.IsCacheValid(recheckCachedMetadata))
|
||||
{
|
||||
// Remove from tracking since we're not using this folder
|
||||
_activeCacheFolders.TryRemove(locationKey, out _);
|
||||
_cacheService.ClearActiveCacheFolder(locationKey);
|
||||
|
||||
// Clean up the empty folder we created since we're not using it
|
||||
if (!string.IsNullOrEmpty(newCacheFolderPath) && Directory.Exists(newCacheFolderPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
// Check if folder is empty (only . and ..)
|
||||
var files = Directory.GetFiles(newCacheFolderPath);
|
||||
if (files.Length == 0)
|
||||
{
|
||||
Directory.Delete(newCacheFolderPath, recursive: true);
|
||||
_logger.LogDebug("Cleaned up empty cache folder that was created but not used: {Folder}", newCacheFolderPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to clean up empty cache folder: {Folder}", newCacheFolderPath);
|
||||
}
|
||||
}
|
||||
_cacheService.TryDeleteEmptyCacheFolder(newCacheFolderPath);
|
||||
|
||||
_logger.LogInformation("Cache became valid while waiting for semaphore, returning cached screenshots");
|
||||
var recheckFrames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken);
|
||||
@@ -291,8 +273,7 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
cancellationToken);
|
||||
|
||||
// Remove from active tracking once complete
|
||||
_activeCacheFolders.TryRemove(locationKey, out _);
|
||||
_logger.LogDebug("Cache folder complete, removed from active tracking: {Folder}", newCacheFolderPath);
|
||||
_cacheService.ClearActiveCacheFolder(locationKey);
|
||||
|
||||
// Update result with cache state
|
||||
result.IsUpdating = false;
|
||||
@@ -320,23 +301,12 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
finally
|
||||
{
|
||||
// Always remove from active tracking, even on error
|
||||
_activeCacheFolders.TryRemove(locationKey, out _);
|
||||
_cacheService.ClearActiveCacheFolder(locationKey);
|
||||
|
||||
// Clean up incomplete cache folder if error occurred
|
||||
if (!string.IsNullOrEmpty(newCacheFolderPath) && Directory.Exists(newCacheFolderPath))
|
||||
if (!string.IsNullOrEmpty(newCacheFolderPath) && _cacheService.TryDeleteIncompleteCacheFolder(newCacheFolderPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!CacheHelper.IsCacheFolderComplete(newCacheFolderPath))
|
||||
{
|
||||
Directory.Delete(newCacheFolderPath, recursive: true);
|
||||
_logger.LogWarning("Cleaned up incomplete cache folder due to error: {Folder}", newCacheFolderPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to clean up incomplete cache folder: {Folder}", newCacheFolderPath);
|
||||
}
|
||||
_logger.LogWarning("Cleaned up incomplete cache folder due to error: {Folder}", newCacheFolderPath);
|
||||
}
|
||||
|
||||
// Clean up empty debug folder if we returned early without scraping
|
||||
@@ -365,8 +335,8 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
{
|
||||
// Return cached metadata if available, otherwise return null
|
||||
var locationKey = LocationHelper.GetLocationKey(suburb, state);
|
||||
var excludeFolder = _activeCacheFolders.TryGetValue(locationKey, out var activeFolder) ? activeFolder : null;
|
||||
var (_, metadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, excludeFolder, cancellationToken);
|
||||
var excludeFolder = _cacheService.GetActiveCacheFolder(locationKey);
|
||||
var (_, metadata) = await _cacheService.GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, excludeFolder, cancellationToken);
|
||||
|
||||
if (metadata != null)
|
||||
{
|
||||
@@ -388,6 +358,253 @@ public class BomRadarService : IBomRadarService, IDisposable
|
||||
return _cacheService.DeleteCachedLocationAsync(suburb, state, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<CacheRange> GetCacheRangeAsync(string suburb, string state, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var allFolders = await _cacheService.GetAllCacheFoldersAsync(suburb, state, cancellationToken);
|
||||
|
||||
if (allFolders.Count == 0)
|
||||
{
|
||||
return new CacheRange
|
||||
{
|
||||
TotalCacheFolders = 0
|
||||
};
|
||||
}
|
||||
|
||||
var oldest = allFolders.First();
|
||||
var newest = allFolders.Last();
|
||||
|
||||
var timeSpanMinutes = allFolders.Count >= 2
|
||||
? (int?)(newest.CacheTimestamp - oldest.CacheTimestamp).TotalMinutes
|
||||
: null;
|
||||
|
||||
return new CacheRange
|
||||
{
|
||||
OldestCache = oldest,
|
||||
NewestCache = newest,
|
||||
TotalCacheFolders = allFolders.Count,
|
||||
TimeSpanMinutes = timeSpanMinutes
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<RadarTimeSeriesResponse> GetRadarTimeSeriesAsync(
|
||||
string suburb,
|
||||
string state,
|
||||
DateTime? startTime,
|
||||
DateTime? endTime,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var allFolders = await _cacheService.GetAllCacheFoldersAsync(suburb, state, cancellationToken);
|
||||
|
||||
// Filter by time range if provided
|
||||
var filteredFolders = allFolders.AsEnumerable();
|
||||
if (startTime.HasValue)
|
||||
{
|
||||
filteredFolders = filteredFolders.Where(f => f.CacheTimestamp >= startTime.Value);
|
||||
}
|
||||
if (endTime.HasValue)
|
||||
{
|
||||
filteredFolders = filteredFolders.Where(f => f.CacheTimestamp <= endTime.Value);
|
||||
}
|
||||
|
||||
var result = new List<RadarCacheFolderFrames>();
|
||||
var encodedSuburb = Uri.EscapeDataString(suburb);
|
||||
var encodedState = Uri.EscapeDataString(state);
|
||||
|
||||
// Process folders in reverse chronological order (newest first) so we keep frames from most recent cache folders
|
||||
// Use a HashSet to track unique absolute observation times we've already seen
|
||||
var seenAbsoluteTimes = new HashSet<DateTime>();
|
||||
|
||||
foreach (var folderInfo in filteredFolders.OrderByDescending(f => f.CacheTimestamp))
|
||||
{
|
||||
// Load frames from this folder's radar subfolder
|
||||
var cachedFrames = await _cacheService.GetFramesFromCacheFolderAsync(
|
||||
suburb,
|
||||
state,
|
||||
folderInfo.FolderName,
|
||||
CachedDataType.Radar,
|
||||
cancellationToken);
|
||||
|
||||
var radarFrames = cachedFrames.Cast<RadarFrame>()
|
||||
.OrderBy(f => f.FrameIndex) // Ensure frames are sorted by index
|
||||
.ToList();
|
||||
|
||||
if (radarFrames.Count > 0)
|
||||
{
|
||||
// Generate URLs and calculate absolute observation times for each frame
|
||||
var uniqueFrames = new List<RadarFrame>();
|
||||
|
||||
foreach (var frame in radarFrames)
|
||||
{
|
||||
frame.ImageUrl = $"/api/radar/{encodedSuburb}/{encodedState}/frame/{frame.FrameIndex}?cacheFolder={Uri.EscapeDataString(folderInfo.FolderName)}";
|
||||
|
||||
// Calculate absolute observation time for this frame
|
||||
// minutesAgo is relative to the cache folder's observation time
|
||||
// Validate that ObservationTime is reasonable (not default/min value)
|
||||
if (folderInfo.ObservationTime > DateTime.MinValue.AddYears(1) && frame.MinutesAgo >= 0)
|
||||
{
|
||||
frame.AbsoluteObservationTime = folderInfo.ObservationTime.AddMinutes(-frame.MinutesAgo);
|
||||
|
||||
// Only include frames with unique absolute observation times
|
||||
// Since we process folders newest-first, this ensures we keep frames from the most recent cache folder
|
||||
if (frame.AbsoluteObservationTime.HasValue && !seenAbsoluteTimes.Contains(frame.AbsoluteObservationTime.Value))
|
||||
{
|
||||
seenAbsoluteTimes.Add(frame.AbsoluteObservationTime.Value);
|
||||
uniqueFrames.Add(frame);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Skipping frame {FrameIndex} from folder {FolderName}: Invalid ObservationTime ({ObservationTime}) or MinutesAgo ({MinutesAgo})",
|
||||
frame.FrameIndex, folderInfo.FolderName, folderInfo.ObservationTime, frame.MinutesAgo);
|
||||
}
|
||||
}
|
||||
|
||||
// Only add folder to result if it has unique frames
|
||||
if (uniqueFrames.Count > 0)
|
||||
{
|
||||
// Sort frames within folder by absolute observation time (oldest first) for slideshow
|
||||
uniqueFrames = uniqueFrames
|
||||
.OrderBy(f => f.AbsoluteObservationTime ?? DateTime.MaxValue)
|
||||
.ToList();
|
||||
|
||||
result.Add(new RadarCacheFolderFrames
|
||||
{
|
||||
CacheFolderName = folderInfo.FolderName,
|
||||
CacheTimestamp = folderInfo.CacheTimestamp,
|
||||
ObservationTime = folderInfo.ObservationTime,
|
||||
Frames = uniqueFrames
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten all frames, sort by absolute observation time, and filter out frames that are too close together
|
||||
// Frames should be at least 4 minutes apart, and must be in strict chronological order
|
||||
var allFrames = result.SelectMany(r => r.Frames.Select(f => new { Folder = r, Frame = f }))
|
||||
.Where(x => x.Frame.AbsoluteObservationTime.HasValue)
|
||||
.OrderBy(x => x.Frame.AbsoluteObservationTime!.Value)
|
||||
.ToList();
|
||||
|
||||
// Filter out frames that are too close together (< 4 minutes) or out of order
|
||||
// Track which absolute times passed the filter
|
||||
var acceptedAbsoluteTimes = new HashSet<DateTime>();
|
||||
DateTime? lastAbsoluteTime = null;
|
||||
const int minMinutesBetweenFrames = 4;
|
||||
|
||||
foreach (var item in allFrames)
|
||||
{
|
||||
var absoluteTime = item.Frame.AbsoluteObservationTime!.Value;
|
||||
|
||||
// Skip if this frame is older than the previous one (shouldn't happen after sorting, but safety check)
|
||||
if (lastAbsoluteTime.HasValue && absoluteTime < lastAbsoluteTime.Value)
|
||||
{
|
||||
_logger.LogWarning("Skipping frame {FrameIndex} from folder {FolderName}: absolute time {AbsoluteTime} is older than previous {PreviousTime}",
|
||||
item.Frame.FrameIndex, item.Folder.CacheFolderName, absoluteTime, lastAbsoluteTime.Value);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if this frame is too close to the previous one (< 4 minutes)
|
||||
if (lastAbsoluteTime.HasValue)
|
||||
{
|
||||
var timeDiff = absoluteTime - lastAbsoluteTime.Value;
|
||||
if (timeDiff.TotalMinutes < minMinutesBetweenFrames)
|
||||
{
|
||||
_logger.LogDebug("Skipping frame {FrameIndex} from folder {FolderName}: only {Minutes} minutes after previous frame (minimum {MinMinutes})",
|
||||
item.Frame.FrameIndex, item.Folder.CacheFolderName, timeDiff.TotalMinutes, minMinutesBetweenFrames);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
acceptedAbsoluteTimes.Add(absoluteTime);
|
||||
lastAbsoluteTime = absoluteTime;
|
||||
}
|
||||
|
||||
// Prune frames from original folder structure - only keep frames that passed the filter
|
||||
// This preserves the original folder order and structure
|
||||
foreach (var folder in result)
|
||||
{
|
||||
folder.Frames = folder.Frames
|
||||
.Where(f => f.AbsoluteObservationTime.HasValue && acceptedAbsoluteTimes.Contains(f.AbsoluteObservationTime.Value))
|
||||
.OrderBy(f => f.AbsoluteObservationTime!.Value) // Sort within folder by absolute time
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Remove empty folders and sort folders to ensure chronological order when client flattens
|
||||
// To guarantee chronological order when iterating folder-by-folder, we need to ensure
|
||||
// that the LAST frame of folder N is before the FIRST frame of folder N+1
|
||||
// Since we've already deduplicated and filtered, folders should not have overlapping frames
|
||||
// Sort by earliest frame - if folders overlap, the deduplication should have prevented it
|
||||
result = result
|
||||
.Where(r => r.Frames.Count > 0) // Only include folders that still have frames after filtering
|
||||
.OrderBy(r => r.Frames
|
||||
.Where(f => f.AbsoluteObservationTime.HasValue)
|
||||
.Select(f => f.AbsoluteObservationTime!.Value)
|
||||
.DefaultIfEmpty(DateTime.MaxValue)
|
||||
.Min()) // Sort by earliest frame
|
||||
.ToList();
|
||||
|
||||
// Validate that folders don't overlap (last frame of N < first frame of N+1)
|
||||
for (int i = 0; i < result.Count - 1; i++)
|
||||
{
|
||||
var currentFolder = result[i];
|
||||
var nextFolder = result[i + 1];
|
||||
|
||||
var currentLast = currentFolder.Frames
|
||||
.Where(f => f.AbsoluteObservationTime.HasValue)
|
||||
.Select(f => f.AbsoluteObservationTime!.Value)
|
||||
.DefaultIfEmpty(DateTime.MinValue)
|
||||
.Max();
|
||||
|
||||
var nextFirst = nextFolder.Frames
|
||||
.Where(f => f.AbsoluteObservationTime.HasValue)
|
||||
.Select(f => f.AbsoluteObservationTime!.Value)
|
||||
.DefaultIfEmpty(DateTime.MaxValue)
|
||||
.Min();
|
||||
|
||||
if (currentLast >= nextFirst)
|
||||
{
|
||||
_logger.LogWarning("Folder overlap detected: {CurrentFolder} ends at {CurrentLast}, {NextFolder} starts at {NextFirst}. This may cause out-of-order frames when client flattens.",
|
||||
currentFolder.CacheFolderName, currentLast, nextFolder.CacheFolderName, nextFirst);
|
||||
}
|
||||
}
|
||||
|
||||
var totalFrames = result.Sum(cf => cf.Frames.Count);
|
||||
|
||||
return new RadarTimeSeriesResponse
|
||||
{
|
||||
CacheFolders = result,
|
||||
StartTime = startTime,
|
||||
EndTime = endTime,
|
||||
TotalFrames = totalFrames
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<RadarFrame?> GetFrameFromCacheFolderAsync(
|
||||
string suburb,
|
||||
string state,
|
||||
string cacheFolderName,
|
||||
int frameIndex,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cachedFrames = await _cacheService.GetFramesFromCacheFolderAsync(
|
||||
suburb,
|
||||
state,
|
||||
cacheFolderName,
|
||||
CachedDataType.Radar,
|
||||
cancellationToken);
|
||||
|
||||
var radarFrame = cachedFrames.Cast<RadarFrame>().FirstOrDefault(f => f.FrameIndex == frameIndex);
|
||||
|
||||
if (radarFrame != null)
|
||||
{
|
||||
// Generate URL
|
||||
radarFrame.ImageUrl = $"/api/radar/{Uri.EscapeDataString(suburb)}/{Uri.EscapeDataString(state)}/frame/{frameIndex}?cacheFolder={Uri.EscapeDataString(cacheFolderName)}";
|
||||
}
|
||||
|
||||
return radarFrame;
|
||||
}
|
||||
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_browserService?.Dispose();
|
||||
|
||||
+288
-45
@@ -1,6 +1,7 @@
|
||||
using BomLocalService.Models;
|
||||
using BomLocalService.Services.Interfaces;
|
||||
using BomLocalService.Utilities;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace BomLocalService.Services;
|
||||
@@ -10,10 +11,13 @@ public class CacheService : ICacheService
|
||||
private readonly ILogger<CacheService> _logger;
|
||||
private readonly string _cacheDirectory;
|
||||
private readonly double _cacheExpirationMinutes;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ConcurrentDictionary<string, string> _activeCacheFolders = new(); // locationKey -> cacheFolderPath
|
||||
|
||||
public CacheService(ILogger<CacheService> logger, IConfiguration configuration)
|
||||
{
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
_cacheDirectory = FilePathHelper.GetCacheDirectory(configuration);
|
||||
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 15.5);
|
||||
|
||||
@@ -22,11 +26,12 @@ public class CacheService : ICacheService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cached screenshot folder path and metadata for a location
|
||||
/// Gets the cached screenshot folder path and metadata for a location and data type
|
||||
/// </summary>
|
||||
public async Task<(string? cacheFolderPath, LastUpdatedInfo? metadata)> GetCachedScreenshotWithMetadataAsync(
|
||||
string suburb,
|
||||
string state,
|
||||
CachedDataType dataType,
|
||||
string? excludeFolder = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -48,7 +53,7 @@ public class CacheService : ICacheService
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Find the first complete cache folder (has all 7 frames + metadata)
|
||||
// Find the first complete cache folder for the specified data type
|
||||
foreach (var folder in folders)
|
||||
{
|
||||
if (!Directory.Exists(folder))
|
||||
@@ -61,10 +66,10 @@ public class CacheService : ICacheService
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if folder is complete: must have all 7 frames, metadata.json, and frames.json
|
||||
if (!CacheHelper.IsCacheFolderComplete(folder))
|
||||
// Check if folder is complete for the specified data type
|
||||
if (!CacheHelper.IsCacheFolderCompleteForDataType(folder, dataType, _configuration))
|
||||
{
|
||||
_logger.LogDebug("Skipping incomplete cache folder: {Folder}", folder);
|
||||
_logger.LogDebug("Skipping incomplete cache folder for {DataType}: {Folder}", dataType, folder);
|
||||
continue; // Skip incomplete folders (currently being written to)
|
||||
}
|
||||
|
||||
@@ -85,40 +90,15 @@ public class CacheService : ICacheService
|
||||
string state,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (cacheFolderPath, _) = await GetCachedScreenshotWithMetadataAsync(suburb, state, null, cancellationToken);
|
||||
var (cacheFolderPath, _) = await GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, null, cancellationToken);
|
||||
|
||||
if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath))
|
||||
{
|
||||
return new List<RadarFrame>();
|
||||
}
|
||||
|
||||
var frames = new List<RadarFrame>();
|
||||
|
||||
// Load frame metadata from frames.json if available
|
||||
var framesMetadata = await LoadFramesMetadataAsync(cacheFolderPath, cancellationToken);
|
||||
var metadataDict = framesMetadata.ToDictionary(f => f.FrameIndex, f => f.MinutesAgo);
|
||||
|
||||
// Load frames from folder (frame_0.png through frame_6.png)
|
||||
for (int i = 0; i < 7; i++)
|
||||
{
|
||||
var framePath = FilePathHelper.GetFrameFilePath(cacheFolderPath, i);
|
||||
if (File.Exists(framePath))
|
||||
{
|
||||
// Use stored minutesAgo if available, otherwise default
|
||||
var minutesAgo = metadataDict.ContainsKey(i)
|
||||
? metadataDict[i]
|
||||
: 40 - (i * 5);
|
||||
|
||||
frames.Add(new RadarFrame
|
||||
{
|
||||
FrameIndex = i,
|
||||
ImagePath = framePath,
|
||||
MinutesAgo = minutesAgo
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return frames;
|
||||
var cachedFrames = await GetFramesFromCacheFolderAsync(suburb, state, Path.GetFileName(cacheFolderPath), CachedDataType.Radar, cancellationToken);
|
||||
return cachedFrames.Cast<RadarFrame>().ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -130,26 +110,27 @@ public class CacheService : ICacheService
|
||||
int frameIndex,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (frameIndex < 0 || frameIndex > 6)
|
||||
var frameCount = CacheHelper.GetFrameCountForDataType(_configuration, CachedDataType.Radar);
|
||||
if (frameIndex < 0 || frameIndex >= frameCount)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var (cacheFolderPath, _) = await GetCachedScreenshotWithMetadataAsync(suburb, state, null, cancellationToken);
|
||||
var (cacheFolderPath, _) = await GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, null, cancellationToken);
|
||||
|
||||
if (string.IsNullOrEmpty(cacheFolderPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var framePath = FilePathHelper.GetFrameFilePath(cacheFolderPath, frameIndex);
|
||||
var framePath = FilePathHelper.GetFrameFilePath(cacheFolderPath, CachedDataType.Radar, frameIndex);
|
||||
if (!File.Exists(framePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load frame metadata to get accurate minutesAgo
|
||||
var framesMetadata = await LoadFramesMetadataAsync(cacheFolderPath, cancellationToken);
|
||||
var framesMetadata = await LoadFramesMetadataAsync(cacheFolderPath, CachedDataType.Radar, cancellationToken);
|
||||
var frameMetadata = framesMetadata.FirstOrDefault(f => f.FrameIndex == frameIndex);
|
||||
var minutesAgo = frameMetadata != null
|
||||
? frameMetadata.MinutesAgo
|
||||
@@ -185,9 +166,9 @@ public class CacheService : ICacheService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves frame metadata to frames.json
|
||||
/// Saves frame metadata to frames.json in the data type subfolder
|
||||
/// </summary>
|
||||
public async Task SaveFramesMetadataAsync(string cacheFolderPath, List<RadarFrame> frames, CancellationToken cancellationToken = default)
|
||||
public async Task SaveFramesMetadataAsync(string cacheFolderPath, CachedDataType dataType, List<RadarFrame> frames, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -197,7 +178,13 @@ public class CacheService : ICacheService
|
||||
MinutesAgo = f.MinutesAgo
|
||||
}).ToList();
|
||||
|
||||
var framesPath = Path.Combine(cacheFolderPath, "frames.json");
|
||||
var framesPath = FilePathHelper.GetFramesMetadataFilePath(cacheFolderPath, dataType);
|
||||
var framesDir = Path.GetDirectoryName(framesPath);
|
||||
if (!string.IsNullOrEmpty(framesDir) && !Directory.Exists(framesDir))
|
||||
{
|
||||
Directory.CreateDirectory(framesDir);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(framesMetadata, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(framesPath, json, CancellationToken.None);
|
||||
_logger.LogDebug("Saved frames metadata to: {Path}", framesPath);
|
||||
@@ -209,11 +196,11 @@ public class CacheService : ICacheService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads frame metadata from frames.json
|
||||
/// Loads frame metadata from frames.json in the data type subfolder
|
||||
/// </summary>
|
||||
private async Task<List<FrameMetadata>> LoadFramesMetadataAsync(string cacheFolderPath, CancellationToken cancellationToken = default)
|
||||
private async Task<List<FrameMetadata>> LoadFramesMetadataAsync(string cacheFolderPath, CachedDataType dataType, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var framesPath = Path.Combine(cacheFolderPath, "frames.json");
|
||||
var framesPath = FilePathHelper.GetFramesMetadataFilePath(cacheFolderPath, dataType);
|
||||
if (!File.Exists(framesPath))
|
||||
{
|
||||
return new List<FrameMetadata>();
|
||||
@@ -324,6 +311,120 @@ public class CacheService : ICacheService
|
||||
return Task.FromResult(deleted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all complete cache folders for a location, ordered by timestamp (oldest first)
|
||||
/// </summary>
|
||||
public async Task<List<CacheFolder>> GetAllCacheFoldersAsync(
|
||||
string suburb,
|
||||
string state,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pattern = FilePathHelper.GetCacheFolderPattern(suburb, state);
|
||||
var folders = Directory.GetDirectories(_cacheDirectory, pattern)
|
||||
.Select(f =>
|
||||
{
|
||||
var folderName = Path.GetFileName(f);
|
||||
var timestamp = LocationHelper.ParseTimestampFromFilename(folderName);
|
||||
return new { Folder = f, FolderName = folderName, Timestamp = timestamp };
|
||||
})
|
||||
.Where(x => x.Timestamp.HasValue)
|
||||
.Select(x => new { x.Folder, x.FolderName, Timestamp = x.Timestamp!.Value })
|
||||
.OrderBy(x => x.Timestamp) // Oldest first
|
||||
.ToList();
|
||||
|
||||
var result = new List<CacheFolder>();
|
||||
|
||||
foreach (var folder in folders)
|
||||
{
|
||||
if (!Directory.Exists(folder.Folder))
|
||||
continue;
|
||||
|
||||
var metadata = await LoadMetadataAsync(folder.Folder, cancellationToken);
|
||||
if (metadata == null)
|
||||
continue;
|
||||
|
||||
// Check which data types are available in this folder
|
||||
var availableDataTypes = new List<CachedDataType>();
|
||||
if (CacheHelper.IsCacheFolderCompleteForDataType(folder.Folder, CachedDataType.Radar, _configuration))
|
||||
{
|
||||
availableDataTypes.Add(CachedDataType.Radar);
|
||||
}
|
||||
|
||||
if (availableDataTypes.Count > 0)
|
||||
{
|
||||
result.Add(new CacheFolder
|
||||
{
|
||||
FolderName = folder.FolderName,
|
||||
CacheTimestamp = folder.Timestamp,
|
||||
ObservationTime = metadata.ObservationTime,
|
||||
AvailableDataTypes = availableDataTypes,
|
||||
IsComplete = true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets frames from a specific cache folder and data type
|
||||
/// </summary>
|
||||
public async Task<List<CachedFrame>> GetFramesFromCacheFolderAsync(
|
||||
string suburb,
|
||||
string state,
|
||||
string cacheFolderName,
|
||||
CachedDataType dataType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var folderPath = Path.Combine(_cacheDirectory, cacheFolderName);
|
||||
|
||||
if (!Directory.Exists(folderPath))
|
||||
{
|
||||
return new List<CachedFrame>();
|
||||
}
|
||||
|
||||
var frameCount = CacheHelper.GetFrameCountForDataType(_configuration, dataType);
|
||||
var frames = new List<CachedFrame>();
|
||||
|
||||
// Load frame metadata from frames.json if available
|
||||
var framesMetadata = await LoadFramesMetadataAsync(folderPath, dataType, cancellationToken);
|
||||
var metadataDict = framesMetadata.ToDictionary(f => f.FrameIndex, f => f.MinutesAgo);
|
||||
|
||||
// Load frames from data type subfolder
|
||||
for (int i = 0; i < frameCount; i++)
|
||||
{
|
||||
var framePath = FilePathHelper.GetFrameFilePath(folderPath, dataType, i);
|
||||
if (File.Exists(framePath))
|
||||
{
|
||||
if (dataType == CachedDataType.Radar)
|
||||
{
|
||||
var minutesAgo = metadataDict.ContainsKey(i)
|
||||
? metadataDict[i]
|
||||
: 40 - (i * 5);
|
||||
|
||||
frames.Add(new RadarFrame
|
||||
{
|
||||
FrameIndex = i,
|
||||
ImagePath = framePath,
|
||||
MinutesAgo = minutesAgo
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// For future data types, create base CachedFrame
|
||||
frames.Add(new CachedFrame
|
||||
{
|
||||
FrameIndex = i,
|
||||
ImagePath = framePath,
|
||||
DataType = dataType
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cache directory path
|
||||
/// </summary>
|
||||
@@ -352,8 +453,8 @@ public class CacheService : ICacheService
|
||||
{
|
||||
try
|
||||
{
|
||||
// Check if folder is incomplete
|
||||
if (!CacheHelper.IsCacheFolderComplete(folder))
|
||||
// Check if folder is incomplete (check for radar data type)
|
||||
if (!CacheHelper.IsCacheFolderCompleteForDataType(folder, CachedDataType.Radar, _configuration))
|
||||
{
|
||||
_logger.LogInformation("Found incomplete cache folder from previous session, deleting: {Folder}", Path.GetFileName(folder));
|
||||
Directory.Delete(folder, recursive: true);
|
||||
@@ -382,5 +483,147 @@ public class CacheService : ICacheService
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
public bool IsLocationUpdating(string locationKey)
|
||||
{
|
||||
return _activeCacheFolders.ContainsKey(locationKey);
|
||||
}
|
||||
|
||||
public string? GetActiveCacheFolder(string locationKey)
|
||||
{
|
||||
return _activeCacheFolders.TryGetValue(locationKey, out var folder) ? folder : null;
|
||||
}
|
||||
|
||||
public void SetActiveCacheFolder(string locationKey, string cacheFolderPath)
|
||||
{
|
||||
_activeCacheFolders[locationKey] = cacheFolderPath;
|
||||
_logger.LogDebug("Tracking active cache folder: {Folder} for location: {Location}", cacheFolderPath, locationKey);
|
||||
}
|
||||
|
||||
public void ClearActiveCacheFolder(string locationKey)
|
||||
{
|
||||
if (_activeCacheFolders.TryRemove(locationKey, out var folder))
|
||||
{
|
||||
_logger.LogDebug("Cleared active cache folder tracking: {Folder} for location: {Location}", folder, locationKey);
|
||||
}
|
||||
}
|
||||
|
||||
public string CreateCacheFolder(string suburb, string state, string timestamp)
|
||||
{
|
||||
var cacheFolderPath = FilePathHelper.GetCacheFolderPath(_cacheDirectory, suburb, state, timestamp);
|
||||
Directory.CreateDirectory(cacheFolderPath);
|
||||
|
||||
// Create radar subfolder structure (for future: create other data type folders as needed)
|
||||
var radarFolder = FilePathHelper.GetDataTypeFolderPath(cacheFolderPath, CachedDataType.Radar);
|
||||
Directory.CreateDirectory(radarFolder);
|
||||
|
||||
_logger.LogDebug("Created cache folder: {Folder}", cacheFolderPath);
|
||||
return cacheFolderPath;
|
||||
}
|
||||
|
||||
public bool TryDeleteIncompleteCacheFolder(string cacheFolderPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Check if folder is incomplete for radar data type
|
||||
if (!CacheHelper.IsCacheFolderCompleteForDataType(cacheFolderPath, CachedDataType.Radar, _configuration))
|
||||
{
|
||||
Directory.Delete(cacheFolderPath, recursive: true);
|
||||
_logger.LogDebug("Deleted incomplete cache folder: {Folder}", cacheFolderPath);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete incomplete cache folder: {Folder}", cacheFolderPath);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryDeleteEmptyCacheFolder(string cacheFolderPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var files = Directory.GetFiles(cacheFolderPath, "*", SearchOption.AllDirectories);
|
||||
if (files.Length == 0)
|
||||
{
|
||||
Directory.Delete(cacheFolderPath, recursive: true);
|
||||
_logger.LogDebug("Deleted empty cache folder: {Folder}", cacheFolderPath);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete empty cache folder: {Folder}", cacheFolderPath);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<CacheUpdateStatus> GetCacheStatusAsync(
|
||||
string suburb,
|
||||
string state,
|
||||
CachedDataType dataType,
|
||||
int cacheExpirationMinutes,
|
||||
int cacheManagementCheckIntervalMinutes,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var status = new CacheUpdateStatus();
|
||||
var locationKey = LocationHelper.GetLocationKey(suburb, state);
|
||||
|
||||
// Check if an update is already in progress
|
||||
var isUpdating = IsLocationUpdating(locationKey);
|
||||
var excludeFolder = GetActiveCacheFolder(locationKey);
|
||||
var (cacheFolderPath, cachedMetadata) = await GetCachedScreenshotWithMetadataAsync(suburb, state, dataType, excludeFolder, cancellationToken);
|
||||
|
||||
status.CacheExists = !string.IsNullOrEmpty(cacheFolderPath) && Directory.Exists(cacheFolderPath);
|
||||
|
||||
if (cachedMetadata != null)
|
||||
{
|
||||
status.CacheIsValid = IsCacheValid(cachedMetadata);
|
||||
status.CacheExpiresAt = cachedMetadata.ObservationTime.AddMinutes(cacheExpirationMinutes);
|
||||
}
|
||||
else
|
||||
{
|
||||
status.CacheIsValid = false;
|
||||
}
|
||||
|
||||
// Set update status - if an update is in progress, UpdateTriggered would be false but Message indicates it
|
||||
// For GetCacheStatusAsync, we don't trigger updates, but we can indicate if one is in progress
|
||||
status.UpdateTriggered = false; // This method doesn't trigger updates
|
||||
if (isUpdating)
|
||||
{
|
||||
status.Message = "Cache update already in progress";
|
||||
// Update in progress - estimate completion in ~2 minutes
|
||||
status.NextUpdateTime = DateTime.UtcNow.AddMinutes(2);
|
||||
}
|
||||
else if (status.CacheIsValid && status.CacheExpiresAt.HasValue)
|
||||
{
|
||||
// Cache is valid - next update will be when cache expires
|
||||
status.Message = "Cache is valid, no update needed";
|
||||
status.NextUpdateTime = status.CacheExpiresAt.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Cache is invalid/missing - next update would be after background service check
|
||||
status.Message = status.CacheExists ? "Cache is stale" : "No cache exists";
|
||||
var now = DateTime.UtcNow;
|
||||
var minutesUntilNextCheck = cacheManagementCheckIntervalMinutes - (now.Minute % cacheManagementCheckIntervalMinutes);
|
||||
status.NextUpdateTime = now.AddMinutes(minutesUntilNextCheck);
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,5 +75,31 @@ public interface IBomRadarService
|
||||
/// <param name="cancellationToken">Cancellation token to cancel the operation</param>
|
||||
/// <returns>The cached frame, or null if not found</returns>
|
||||
Task<RadarFrame?> GetCachedFrameAsync(string suburb, string state, int frameIndex, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets information about the available cache range for a location (oldest and newest cache folders).
|
||||
/// </summary>
|
||||
Task<CacheRange> GetCacheRangeAsync(string suburb, string state, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets historical radar data (frames from multiple cache folders) between specified timestamps.
|
||||
/// Radar frames can be joined across cache folders because they represent a historical time series.
|
||||
/// </summary>
|
||||
Task<RadarTimeSeriesResponse> GetRadarTimeSeriesAsync(
|
||||
string suburb,
|
||||
string state,
|
||||
DateTime? startTime,
|
||||
DateTime? endTime,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific radar frame from a specific cache folder.
|
||||
/// </summary>
|
||||
Task<RadarFrame?> GetFrameFromCacheFolderAsync(
|
||||
string suburb,
|
||||
string state,
|
||||
string cacheFolderName,
|
||||
int frameIndex,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,20 +9,51 @@ namespace BomLocalService.Services.Interfaces;
|
||||
public interface ICacheService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the cached screenshot folder path and associated metadata for a location.
|
||||
/// Gets the cached screenshot folder path and associated metadata for a location and data type.
|
||||
/// Searches for cache folders matching the location pattern and loads the corresponding metadata JSON file.
|
||||
/// </summary>
|
||||
/// <param name="suburb">The suburb name (e.g., "Pomona", "Brisbane")</param>
|
||||
/// <param name="state">The Australian state abbreviation (e.g., "QLD", "NSW", "VIC")</param>
|
||||
/// <param name="dataType">The type of cached data to retrieve</param>
|
||||
/// <param name="excludeFolder">Optional folder path to exclude from search (e.g., folder currently being written to)</param>
|
||||
/// <param name="cancellationToken">Cancellation token to cancel the operation</param>
|
||||
/// <returns>Tuple containing the cache folder path and metadata, or (null, null) if not found</returns>
|
||||
Task<(string? cacheFolderPath, LastUpdatedInfo? metadata)> GetCachedScreenshotWithMetadataAsync(
|
||||
string suburb,
|
||||
string state,
|
||||
CachedDataType dataType,
|
||||
string? excludeFolder = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all complete cache folders for a location, ordered by timestamp (oldest first).
|
||||
/// Returns basic folder information without loading all frame data.
|
||||
/// </summary>
|
||||
/// <param name="suburb">The suburb name (e.g., "Pomona", "Brisbane")</param>
|
||||
/// <param name="state">The Australian state abbreviation (e.g., "QLD", "NSW", "VIC")</param>
|
||||
/// <param name="cancellationToken">Cancellation token to cancel the operation</param>
|
||||
/// <returns>List of cache folders with available data types</returns>
|
||||
Task<List<CacheFolder>> GetAllCacheFoldersAsync(
|
||||
string suburb,
|
||||
string state,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets frames from a specific cache folder and data type.
|
||||
/// </summary>
|
||||
/// <param name="suburb">The suburb name (e.g., "Pomona", "Brisbane")</param>
|
||||
/// <param name="state">The Australian state abbreviation (e.g., "QLD", "NSW", "VIC")</param>
|
||||
/// <param name="cacheFolderName">The cache folder name</param>
|
||||
/// <param name="dataType">The type of cached data to retrieve</param>
|
||||
/// <param name="cancellationToken">Cancellation token to cancel the operation</param>
|
||||
/// <returns>List of cached frames, or empty list if not found</returns>
|
||||
Task<List<CachedFrame>> GetFramesFromCacheFolderAsync(
|
||||
string suburb,
|
||||
string state,
|
||||
string cacheFolderName,
|
||||
CachedDataType dataType,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all cached frames for a location.
|
||||
/// </summary>
|
||||
@@ -59,12 +90,13 @@ public interface ICacheService
|
||||
Task SaveMetadataAsync(string cacheFolderPath, LastUpdatedInfo metadata, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Saves frame metadata (frame index and minutes ago) to a frames.json file in the cache folder.
|
||||
/// Saves frame metadata (frame index and minutes ago) to a frames.json file in the data type subfolder.
|
||||
/// </summary>
|
||||
/// <param name="cacheFolderPath">Full path to the cache folder</param>
|
||||
/// <param name="dataType">The type of cached data</param>
|
||||
/// <param name="frames">List of frames with their metadata</param>
|
||||
/// <param name="cancellationToken">Cancellation token to cancel the operation</param>
|
||||
Task SaveFramesMetadataAsync(string cacheFolderPath, List<RadarFrame> frames, CancellationToken cancellationToken = default);
|
||||
Task SaveFramesMetadataAsync(string cacheFolderPath, CachedDataType dataType, List<RadarFrame> frames, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if cached metadata is still valid (not expired).
|
||||
@@ -107,5 +139,61 @@ public interface ICacheService
|
||||
/// </summary>
|
||||
/// <returns>The number of incomplete folders that were deleted</returns>
|
||||
int CleanupIncompleteCacheFolders();
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a location is currently being updated (has an active cache folder being written to).
|
||||
/// </summary>
|
||||
bool IsLocationUpdating(string locationKey);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the active cache folder path for a location (if one is being written to).
|
||||
/// </summary>
|
||||
string? GetActiveCacheFolder(string locationKey);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the active cache folder for a location (indicates a cache update is in progress).
|
||||
/// </summary>
|
||||
void SetActiveCacheFolder(string locationKey, string cacheFolderPath);
|
||||
|
||||
/// <summary>
|
||||
/// Clears the active cache folder for a location (indicates cache update is complete).
|
||||
/// </summary>
|
||||
void ClearActiveCacheFolder(string locationKey);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new cache folder for a location and timestamp.
|
||||
/// Creates the folder structure and ensures data type subfolders are ready.
|
||||
/// </summary>
|
||||
string CreateCacheFolder(string suburb, string state, string timestamp);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an incomplete cache folder (cleanup on error).
|
||||
/// Only deletes if the folder is incomplete (missing required files).
|
||||
/// </summary>
|
||||
bool TryDeleteIncompleteCacheFolder(string cacheFolderPath);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an empty cache folder.
|
||||
/// </summary>
|
||||
bool TryDeleteEmptyCacheFolder(string cacheFolderPath);
|
||||
|
||||
/// <summary>
|
||||
/// Gets cache status information for a location and data type without triggering an update.
|
||||
/// Returns information about whether cache exists, is valid, is updating, etc.
|
||||
/// </summary>
|
||||
/// <param name="suburb">The suburb name (e.g., "Pomona", "Brisbane")</param>
|
||||
/// <param name="state">The Australian state abbreviation (e.g., "QLD", "NSW", "VIC")</param>
|
||||
/// <param name="dataType">The type of cached data to check</param>
|
||||
/// <param name="cacheExpirationMinutes">The number of minutes after observation time that cache expires (will be cast to int)</param>
|
||||
/// <param name="cacheManagementCheckIntervalMinutes">The interval in minutes that the background cache management service checks for updates</param>
|
||||
/// <param name="cancellationToken">Cancellation token to cancel the operation</param>
|
||||
/// <returns>Cache status information</returns>
|
||||
Task<CacheUpdateStatus> GetCacheStatusAsync(
|
||||
string suburb,
|
||||
string state,
|
||||
CachedDataType dataType,
|
||||
int cacheExpirationMinutes,
|
||||
int cacheManagementCheckIntervalMinutes,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
|
||||
+181
-25
@@ -450,6 +450,7 @@ public class ScrapingService : IScrapingService
|
||||
// Step 15-21: Capture all 7 frames
|
||||
var frames = new List<RadarFrame>();
|
||||
var stepForwardButton = page.Locator("button[data-testid='bom-scrub-utils__right__step-forward']").First;
|
||||
int? previousMinutesAgo = null;
|
||||
|
||||
for (int frameIndex = 0; frameIndex < 7; frameIndex++)
|
||||
{
|
||||
@@ -468,8 +469,32 @@ public class ScrapingService : IScrapingService
|
||||
_logger.LogWarning("Failed to extract minutes from display label for frame {FrameIndex}, using default: {MinutesAgo}", frameIndex, minutesAgo);
|
||||
}
|
||||
|
||||
// Validate that minutesAgo is different from previous frame (unless it's frame 0)
|
||||
if (frameIndex > 0 && previousMinutesAgo.HasValue && minutesAgo == previousMinutesAgo.Value)
|
||||
{
|
||||
_logger.LogWarning("Frame {FrameIndex} has same minutesAgo ({MinutesAgo}) as previous frame. Waiting for display to update...", frameIndex, minutesAgo);
|
||||
// Wait for display label to change
|
||||
await WaitForDisplayLabelToChangeAsync(page, previousMinutesAgo.Value);
|
||||
// Re-extract after waiting
|
||||
minutesAgo = await ExtractMinutesAgoFromDisplayAsync(page);
|
||||
if (minutesAgo == null || minutesAgo == previousMinutesAgo.Value)
|
||||
{
|
||||
// Still same or failed - use calculated fallback
|
||||
var (_, defaultMinutesAgo) = frameInfo[frameIndex];
|
||||
minutesAgo = defaultMinutesAgo;
|
||||
_logger.LogWarning("Display label did not update for frame {FrameIndex}, using calculated default: {MinutesAgo}", frameIndex, minutesAgo);
|
||||
}
|
||||
}
|
||||
|
||||
// Take screenshot with crop configuration
|
||||
var framePath = FilePathHelper.GetFrameFilePath(cacheFolderPath, frameIndex);
|
||||
// Ensure radar subfolder exists
|
||||
var radarFolder = FilePathHelper.GetDataTypeFolderPath(cacheFolderPath, CachedDataType.Radar);
|
||||
if (!Directory.Exists(radarFolder))
|
||||
{
|
||||
Directory.CreateDirectory(radarFolder);
|
||||
}
|
||||
|
||||
var framePath = FilePathHelper.GetFrameFilePath(cacheFolderPath, CachedDataType.Radar, frameIndex);
|
||||
await CaptureMapScreenshotAsync(page, mapContainer, framePath, containerClip);
|
||||
|
||||
frames.Add(new RadarFrame
|
||||
@@ -479,6 +504,8 @@ public class ScrapingService : IScrapingService
|
||||
MinutesAgo = minutesAgo.Value
|
||||
});
|
||||
|
||||
previousMinutesAgo = minutesAgo;
|
||||
|
||||
_logger.LogInformation("Frame {FrameIndex} saved: {Path} ({MinutesAgo} minutes ago)",
|
||||
frameIndex, framePath, minutesAgo.Value);
|
||||
|
||||
@@ -488,32 +515,25 @@ public class ScrapingService : IScrapingService
|
||||
// If not the last frame, click step forward to prepare for next frame
|
||||
if (frameIndex < 6)
|
||||
{
|
||||
// Wait for any modal overlays to disappear before clicking
|
||||
try
|
||||
{
|
||||
await page.WaitForFunctionAsync(@"() => {
|
||||
const overlay = document.querySelector('.bom-modal-overlay--after-open');
|
||||
return !overlay || overlay.style.display === 'none';
|
||||
}", new PageWaitForFunctionOptions { Timeout = 5000 });
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If overlay doesn't disappear, try to dismiss it by clicking outside or pressing Escape
|
||||
try
|
||||
{
|
||||
await page.Keyboard.PressAsync("Escape");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore if Escape doesn't work
|
||||
}
|
||||
}
|
||||
// Dismiss any modal overlays (BOM, reCAPTCHA, feedback forms) before clicking
|
||||
await DismissModalOverlaysAsync(page);
|
||||
|
||||
// Get current minutesAgo before clicking (to detect change)
|
||||
var currentMinutesAgo = await ExtractMinutesAgoFromDisplayAsync(page);
|
||||
|
||||
// Use force click to bypass any remaining overlays
|
||||
await stepForwardButton.ClickAsync(new LocatorClickOptions { Force = true });
|
||||
// Wait for map to update to next frame
|
||||
await page.WaitForTimeoutAsync(1000); // Wait for frame transition
|
||||
|
||||
// Wait for display label to actually change (not just a fixed timeout)
|
||||
if (currentMinutesAgo.HasValue)
|
||||
{
|
||||
await WaitForDisplayLabelToChangeAsync(page, currentMinutesAgo.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback to fixed wait if we can't detect change
|
||||
await page.WaitForTimeoutAsync(1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,7 +541,7 @@ public class ScrapingService : IScrapingService
|
||||
|
||||
// Step 22: Save metadata and frame information
|
||||
await _cacheService.SaveMetadataAsync(cacheFolderPath, lastUpdatedInfo, cancellationToken);
|
||||
await _cacheService.SaveFramesMetadataAsync(cacheFolderPath, frames, cancellationToken);
|
||||
await _cacheService.SaveFramesMetadataAsync(cacheFolderPath, CachedDataType.Radar, frames, cancellationToken);
|
||||
|
||||
// Step 23: Return response with all frames
|
||||
// Note: ScrapingService doesn't have access to cache management check interval
|
||||
@@ -675,6 +695,139 @@ public class ScrapingService : IScrapingService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the display label to change from the current minutesAgo value
|
||||
/// </summary>
|
||||
private async Task WaitForDisplayLabelToChangeAsync(IPage page, int currentMinutesAgo, int maxWaitMs = 5000)
|
||||
{
|
||||
try
|
||||
{
|
||||
var startTime = DateTime.UtcNow;
|
||||
while ((DateTime.UtcNow - startTime).TotalMilliseconds < maxWaitMs)
|
||||
{
|
||||
var newMinutesAgo = await ExtractMinutesAgoFromDisplayAsync(page);
|
||||
if (newMinutesAgo.HasValue && newMinutesAgo.Value != currentMinutesAgo)
|
||||
{
|
||||
// Display has changed
|
||||
return;
|
||||
}
|
||||
await page.WaitForTimeoutAsync(200); // Check every 200ms
|
||||
}
|
||||
_logger.LogDebug("Display label did not change from {CurrentMinutesAgo} within {MaxWaitMs}ms", currentMinutesAgo, maxWaitMs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error waiting for display label to change");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dismisses any modal overlays (BOM modals, reCAPTCHA, feedback forms) that might block the radar
|
||||
/// Uses a single efficient check and minimal delays
|
||||
/// </summary>
|
||||
private async Task DismissModalOverlaysAsync(IPage page)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Single check for all modal types (BOM, reCAPTCHA, feedback forms)
|
||||
var hasModal = await page.EvaluateAsync<bool>(@"() => {
|
||||
// Check BOM modal overlay
|
||||
const bomOverlay = document.querySelector('.bom-modal-overlay--after-open');
|
||||
if (bomOverlay && bomOverlay.style.display !== 'none') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check reCAPTCHA modals (large visible elements)
|
||||
const recaptchaSelectors = [
|
||||
'.g-recaptcha',
|
||||
'#g-recaptcha',
|
||||
'.rc-anchor-container'
|
||||
];
|
||||
for (const selector of recaptchaSelectors) {
|
||||
const el = document.querySelector(selector);
|
||||
if (el) {
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (style.display !== 'none' && style.visibility !== 'hidden' &&
|
||||
rect.width > 200 && rect.height > 200) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check feedback forms with reCAPTCHA
|
||||
const allForms = document.querySelectorAll('form');
|
||||
for (const form of allForms) {
|
||||
const action = form.getAttribute('action') || '';
|
||||
const id = form.getAttribute('id') || '';
|
||||
if (action.includes('feedback') || id.includes('feedback')) {
|
||||
const style = window.getComputedStyle(form);
|
||||
const rect = form.getBoundingClientRect();
|
||||
if (style.display !== 'none' && rect.width > 200 && rect.height > 200) {
|
||||
const text = form.textContent || '';
|
||||
if (text.includes('reCAPTCHA') || text.includes('recaptcha') || text.includes('Tell us why')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}");
|
||||
|
||||
if (!hasModal)
|
||||
{
|
||||
return; // No modal, exit quickly
|
||||
}
|
||||
|
||||
_logger.LogDebug("Modal overlay detected, dismissing");
|
||||
|
||||
// Single Escape key press (works for most modals)
|
||||
await page.Keyboard.PressAsync("Escape");
|
||||
await page.WaitForTimeoutAsync(300); // Minimal delay
|
||||
|
||||
// If still visible, try clicking outside (on map)
|
||||
var stillVisible = await page.EvaluateAsync<bool>(@"() => {
|
||||
const bomOverlay = document.querySelector('.bom-modal-overlay--after-open');
|
||||
if (bomOverlay && bomOverlay.style.display !== 'none') return true;
|
||||
|
||||
// Check feedback forms
|
||||
const allForms = document.querySelectorAll('form');
|
||||
for (const form of allForms) {
|
||||
const action = form.getAttribute('action') || '';
|
||||
const id = form.getAttribute('id') || '';
|
||||
if (action.includes('feedback') || id.includes('feedback')) {
|
||||
const style = window.getComputedStyle(form);
|
||||
const rect = form.getBoundingClientRect();
|
||||
if (style.display !== 'none' && rect.width > 200 && rect.height > 200) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}");
|
||||
|
||||
if (stillVisible)
|
||||
{
|
||||
// Click on map to dismiss (only if Escape didn't work)
|
||||
try
|
||||
{
|
||||
var mapContainer = page.Locator(".esri-view-surface").First;
|
||||
await mapContainer.ClickAsync(new LocatorClickOptions { Force = true });
|
||||
await page.WaitForTimeoutAsync(200); // Minimal delay
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore if click fails
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error dismissing modal overlay, continuing");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures map screenshot with crop configuration
|
||||
/// </summary>
|
||||
@@ -721,6 +874,9 @@ public class ScrapingService : IScrapingService
|
||||
// Continue if network idle timeout - fonts may already be loaded
|
||||
}
|
||||
|
||||
// Dismiss any modal overlays (BOM, reCAPTCHA, feedback forms) before taking screenshot
|
||||
await DismissModalOverlaysAsync(page);
|
||||
|
||||
// Take high-quality screenshot with explicit PNG format and disabled animations
|
||||
await page.ScreenshotAsync(new PageScreenshotOptions
|
||||
{
|
||||
|
||||
+34
-20
@@ -1,3 +1,6 @@
|
||||
using BomLocalService.Models;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace BomLocalService.Utilities;
|
||||
|
||||
/// <summary>
|
||||
@@ -6,40 +9,43 @@ namespace BomLocalService.Utilities;
|
||||
public static class CacheHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if a cache folder is complete (has all required files).
|
||||
/// A complete cache folder must have:
|
||||
/// - All 7 frame images (frame_0.png through frame_6.png)
|
||||
/// - metadata.json file
|
||||
/// - frames.json file
|
||||
/// Gets the configured frame count for a data type from configuration.
|
||||
/// </summary>
|
||||
/// <param name="cacheFolderPath">The path to the cache folder to check</param>
|
||||
/// <returns>True if the folder is complete, false otherwise</returns>
|
||||
public static bool IsCacheFolderComplete(string cacheFolderPath)
|
||||
public static int GetFrameCountForDataType(IConfiguration configuration, CachedDataType dataType)
|
||||
{
|
||||
var dataTypeName = dataType.ToString();
|
||||
var frameCount = configuration.GetValue<int>($"CachedDataTypes:{dataTypeName}:FrameCount", 7);
|
||||
return frameCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a cache folder has complete data for a specific data type.
|
||||
/// </summary>
|
||||
public static bool IsCacheFolderCompleteForDataType(string cacheFolderPath, CachedDataType dataType, IConfiguration configuration)
|
||||
{
|
||||
if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for all 7 frames
|
||||
for (int i = 0; i < 7; i++)
|
||||
var dataTypeFolder = FilePathHelper.GetDataTypeFolderPath(cacheFolderPath, dataType);
|
||||
if (!Directory.Exists(dataTypeFolder))
|
||||
{
|
||||
var framePath = FilePathHelper.GetFrameFilePath(cacheFolderPath, i);
|
||||
return false;
|
||||
}
|
||||
|
||||
var expectedFrameCount = GetFrameCountForDataType(configuration, dataType);
|
||||
for (int i = 0; i < expectedFrameCount; i++)
|
||||
{
|
||||
var framePath = FilePathHelper.GetFrameFilePath(cacheFolderPath, dataType, i);
|
||||
if (!File.Exists(framePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for metadata.json
|
||||
var metadataPath = FilePathHelper.GetMetadataFilePath(cacheFolderPath);
|
||||
if (!File.Exists(metadataPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for frames.json
|
||||
var framesMetadataPath = FilePathHelper.GetFramesMetadataFilePath(cacheFolderPath);
|
||||
// Check for frames.json in data type folder
|
||||
var framesMetadataPath = FilePathHelper.GetFramesMetadataFilePath(cacheFolderPath, dataType);
|
||||
if (!File.Exists(framesMetadataPath))
|
||||
{
|
||||
return false;
|
||||
@@ -47,5 +53,13 @@ public static class CacheHelper
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a cache folder is complete (has radar data - for backward compatibility).
|
||||
/// </summary>
|
||||
public static bool IsCacheFolderComplete(string cacheFolderPath, IConfiguration configuration)
|
||||
{
|
||||
return IsCacheFolderCompleteForDataType(cacheFolderPath, CachedDataType.Radar, configuration);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using BomLocalService.Models;
|
||||
|
||||
namespace BomLocalService.Utilities;
|
||||
|
||||
public static class FilePathHelper
|
||||
@@ -51,12 +53,34 @@ public static class FilePathHelper
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the frame file path within a cache folder.
|
||||
/// Format: "{CacheFolderPath}/frame_{FrameIndex}.png"
|
||||
/// Gets the data type subfolder path within a cache folder.
|
||||
/// Format: "{CacheFolderPath}/{DataType}"
|
||||
/// Example: "/app/cache/Pomona_QLD_20251207_000906/radar"
|
||||
/// </summary>
|
||||
public static string GetDataTypeFolderPath(string cacheFolderPath, CachedDataType dataType)
|
||||
{
|
||||
var dataTypeFolder = dataType.ToString().ToLowerInvariant();
|
||||
return Path.Combine(cacheFolderPath, dataTypeFolder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the frame file path within a data type subfolder.
|
||||
/// Format: "{CacheFolderPath}/{DataType}/frame_{FrameIndex}.png"
|
||||
/// </summary>
|
||||
public static string GetFrameFilePath(string cacheFolderPath, CachedDataType dataType, int frameIndex)
|
||||
{
|
||||
var dataTypeFolder = GetDataTypeFolderPath(cacheFolderPath, dataType);
|
||||
return Path.Combine(dataTypeFolder, $"frame_{frameIndex}.png");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the frame file path within a cache folder (legacy - defaults to radar).
|
||||
/// Format: "{CacheFolderPath}/radar/frame_{FrameIndex}.png"
|
||||
/// </summary>
|
||||
[Obsolete("Use GetFrameFilePath with dataType parameter")]
|
||||
public static string GetFrameFilePath(string cacheFolderPath, int frameIndex)
|
||||
{
|
||||
return Path.Combine(cacheFolderPath, $"frame_{frameIndex}.png");
|
||||
return GetFrameFilePath(cacheFolderPath, CachedDataType.Radar, frameIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -90,12 +114,23 @@ public static class FilePathHelper
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the frames metadata file path within a cache folder.
|
||||
/// Format: "{CacheFolderPath}/frames.json"
|
||||
/// Gets the frames metadata file path within a data type subfolder.
|
||||
/// Format: "{CacheFolderPath}/{DataType}/frames.json"
|
||||
/// </summary>
|
||||
public static string GetFramesMetadataFilePath(string cacheFolderPath, CachedDataType dataType)
|
||||
{
|
||||
var dataTypeFolder = GetDataTypeFolderPath(cacheFolderPath, dataType);
|
||||
return Path.Combine(dataTypeFolder, "frames.json");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the frames metadata file path within a cache folder (legacy - defaults to radar).
|
||||
/// Format: "{CacheFolderPath}/radar/frames.json"
|
||||
/// </summary>
|
||||
[Obsolete("Use GetFramesMetadataFilePath with dataType parameter")]
|
||||
public static string GetFramesMetadataFilePath(string cacheFolderPath)
|
||||
{
|
||||
return Path.Combine(cacheFolderPath, "frames.json");
|
||||
return GetFramesMetadataFilePath(cacheFolderPath, CachedDataType.Radar);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1422
-149
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -32,5 +32,10 @@
|
||||
"Enabled": false,
|
||||
"WaitMs": 2000
|
||||
},
|
||||
"Timezone": "Australia/Brisbane"
|
||||
"Timezone": "Australia/Brisbane",
|
||||
"CachedDataTypes": {
|
||||
"Radar": {
|
||||
"FrameCount": 7
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user