diff --git a/Models/ScreenshotCropConfig.cs b/Models/ScreenshotCropConfig.cs
index 20a6f02..d2dee12 100644
--- a/Models/ScreenshotCropConfig.cs
+++ b/Models/ScreenshotCropConfig.cs
@@ -19,10 +19,11 @@ public class ScreenshotCropConfig
public int Y { get; set; } = 0;
///
- /// Width of the crop area in pixels.
- /// If null, uses the remaining container width from X offset to the right edge.
+ /// Right offset in pixels from the right edge of the map container.
+ /// How much to cut off from the right side.
+ /// Default: 0 (no cut from right edge).
///
- public int? Width { get; set; }
+ public int RightOffset { get; set; } = 0;
///
/// Height of the crop area in pixels.
diff --git a/Program.cs b/Program.cs
index 35449c1..719f5f6 100644
--- a/Program.cs
+++ b/Program.cs
@@ -47,6 +47,15 @@ if (enableHttpsRedirection)
// No authorization required - service is designed to run behind a reverse proxy if auth is needed
app.MapControllers();
+// Cleanup incomplete cache folders from previous crashes/restarts before starting services
+var cacheService = app.Services.GetRequiredService();
+var deletedCount = cacheService.CleanupIncompleteCacheFolders();
+if (deletedCount > 0)
+{
+ var logger = app.Services.GetRequiredService>();
+ logger.LogInformation("Startup recovery: cleaned up {Count} incomplete cache folder(s) from previous session", deletedCount);
+}
+
// Cleanup on shutdown
app.Lifetime.ApplicationStopped.Register(() =>
{
diff --git a/Services/BomRadarService.cs b/Services/BomRadarService.cs
index 72cce41..023a4f6 100644
--- a/Services/BomRadarService.cs
+++ b/Services/BomRadarService.cs
@@ -166,7 +166,7 @@ public class BomRadarService : IBomRadarService, IDisposable
// 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))
+ if (!string.IsNullOrEmpty(cacheFolderPath) && Directory.Exists(cacheFolderPath) && CacheHelper.IsCacheFolderComplete(cacheFolderPath))
{
_logger.LogInformation("Cache update in progress for {Suburb}, {State}, returning existing cached data", suburb, state);
var frames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken);
@@ -181,7 +181,7 @@ public class BomRadarService : IBomRadarService, IDisposable
}
}
- if (!string.IsNullOrEmpty(cacheFolderPath) && Directory.Exists(cacheFolderPath) && cachedMetadata != null)
+ if (!string.IsNullOrEmpty(cacheFolderPath) && Directory.Exists(cacheFolderPath) && cachedMetadata != null && CacheHelper.IsCacheFolderComplete(cacheFolderPath))
{
var isValid = _cacheService.IsCacheValid(cachedMetadata);
if (isValid)
@@ -308,6 +308,26 @@ public class BomRadarService : IBomRadarService, IDisposable
}
finally
{
+ // Always remove from active tracking, even on error
+ _activeCacheFolders.TryRemove(locationKey, out _);
+
+ // Clean up incomplete cache folder if error occurred
+ if (!string.IsNullOrEmpty(newCacheFolderPath) && Directory.Exists(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);
+ }
+ }
+
// Clean up empty debug folder if we returned early without scraping
if (!string.IsNullOrEmpty(debugFolder) && Directory.Exists(debugFolder))
{
diff --git a/Services/CacheService.cs b/Services/CacheService.cs
index eb8f2a2..d6afc4b 100644
--- a/Services/CacheService.cs
+++ b/Services/CacheService.cs
@@ -61,32 +61,13 @@ public class CacheService : ICacheService
continue;
}
- // Check if folder is complete: must have all 7 frames and metadata.json
- var hasAllFrames = true;
- for (int i = 0; i < 7; i++)
- {
- var framePath = FilePathHelper.GetFrameFilePath(folder, i);
- if (!File.Exists(framePath))
- {
- hasAllFrames = false;
- break;
- }
- }
-
- if (!hasAllFrames)
+ // Check if folder is complete: must have all 7 frames, metadata.json, and frames.json
+ if (!CacheHelper.IsCacheFolderComplete(folder))
{
_logger.LogDebug("Skipping incomplete cache folder: {Folder}", folder);
continue; // Skip incomplete folders (currently being written to)
}
- // Check if metadata exists (indicates folder is complete)
- var metadataPath = FilePathHelper.GetMetadataFilePath(folder);
- if (!File.Exists(metadataPath))
- {
- _logger.LogDebug("Skipping cache folder without metadata: {Folder}", folder);
- continue; // Skip folders without metadata
- }
-
// This folder is complete
var metadata = await LoadMetadataAsync(folder, cancellationToken);
return (folder, metadata);
@@ -347,5 +328,59 @@ public class CacheService : ICacheService
/// Gets the cache directory path
///
public string GetCacheDirectory() => _cacheDirectory;
+
+ ///
+ /// Cleans up incomplete cache folders from previous crashes or restarts
+ ///
+ public int CleanupIncompleteCacheFolders()
+ {
+ if (!Directory.Exists(_cacheDirectory))
+ {
+ return 0;
+ }
+
+ var deletedCount = 0;
+
+ try
+ {
+ // Get all cache folders (they match the pattern LocationKey_Timestamp)
+ var folders = Directory.GetDirectories(_cacheDirectory)
+ .Where(f => !Path.GetFileName(f).StartsWith("debug", StringComparison.OrdinalIgnoreCase)) // Exclude debug folder
+ .ToList();
+
+ foreach (var folder in folders)
+ {
+ try
+ {
+ // Check if folder is incomplete
+ if (!CacheHelper.IsCacheFolderComplete(folder))
+ {
+ _logger.LogInformation("Found incomplete cache folder from previous session, deleting: {Folder}", Path.GetFileName(folder));
+ Directory.Delete(folder, recursive: true);
+ deletedCount++;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to check or delete incomplete cache folder: {Folder}", folder);
+ }
+ }
+
+ if (deletedCount > 0)
+ {
+ _logger.LogInformation("Startup cleanup completed: deleted {Count} incomplete cache folder(s)", deletedCount);
+ }
+ else
+ {
+ _logger.LogDebug("Startup cleanup: no incomplete cache folders found");
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error during startup cleanup of incomplete cache folders");
+ }
+
+ return deletedCount;
+ }
}
diff --git a/Services/Interfaces/ICacheService.cs b/Services/Interfaces/ICacheService.cs
index 9737d67..2d8f720 100644
--- a/Services/Interfaces/ICacheService.cs
+++ b/Services/Interfaces/ICacheService.cs
@@ -99,5 +99,13 @@ public interface ICacheService
///
/// The full path to the cache directory
string GetCacheDirectory();
+
+ ///
+ /// Cleans up incomplete cache folders (e.g., from a previous crash or restart).
+ /// Scans all cache folders and deletes any that are incomplete (missing frames, metadata, etc.).
+ /// This should be called on application startup to recover from interrupted cache updates.
+ ///
+ /// The number of incomplete folders that were deleted
+ int CleanupIncompleteCacheFolders();
}
diff --git a/Services/ScrapingService.cs b/Services/ScrapingService.cs
index 934d15c..4a65f61 100644
--- a/Services/ScrapingService.cs
+++ b/Services/ScrapingService.cs
@@ -61,12 +61,12 @@ public class ScrapingService : IScrapingService
{
X = cropSection.GetValue("X", 0),
Y = cropSection.GetValue("Y", 0),
- Width = cropSection.GetValue("Width"),
+ RightOffset = cropSection.GetValue("RightOffset", 0),
Height = cropSection.GetValue("Height")
};
- _logger.LogInformation("Screenshot crop config: X={X}, Y={Y}, Width={Width}, Height={Height}",
- _cropConfig.X, _cropConfig.Y, _cropConfig.Width, _cropConfig.Height);
+ _logger.LogInformation("Screenshot crop config: X={X}, Y={Y}, RightOffset={RightOffset}, Height={Height}",
+ _cropConfig.X, _cropConfig.Y, _cropConfig.RightOffset, _cropConfig.Height);
}
///
@@ -561,11 +561,11 @@ public class ScrapingService : IScrapingService
var x = containerClip.X + _cropConfig.X;
var y = containerClip.Y + _cropConfig.Y;
- // Calculate width (use configured or remaining width from X to right edge)
- var width = _cropConfig.Width ?? (containerClip.Width - _cropConfig.X);
+ // Calculate width: container width minus left offset (X) minus right offset
+ var width = Math.Max(0, containerClip.Width - _cropConfig.X - _cropConfig.RightOffset);
// Calculate height (use configured or remaining height)
- var height = _cropConfig.Height ?? (containerClip.Height - _cropConfig.Y);
+ var height = _cropConfig.Height ?? Math.Max(0, containerClip.Height - _cropConfig.Y);
// Validate bounds
if (x < containerClip.X || y < containerClip.Y)
@@ -695,7 +695,36 @@ public class ScrapingService : IScrapingService
///
private async Task CaptureMapScreenshotAsync(IPage page, ILocator mapContainer, string outputPath, Clip containerClip)
{
- var cropArea = CalculateCropArea(containerClip);
+ Clip cropArea;
+ try
+ {
+ cropArea = CalculateCropArea(containerClip);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to calculate crop area, using full container bounds. Container: X={X}, Y={Y}, Width={Width}, Height={Height}",
+ containerClip.X, containerClip.Y, containerClip.Width, containerClip.Height);
+ // Fallback to full container if crop calculation fails
+ cropArea = containerClip;
+ }
+
+ // Validate crop area is within page bounds before attempting screenshot
+ var viewportSize = page.ViewportSize;
+ if (viewportSize == null || cropArea.X < 0 || cropArea.Y < 0 ||
+ cropArea.X + cropArea.Width > viewportSize.Width ||
+ cropArea.Y + cropArea.Height > viewportSize.Height)
+ {
+ _logger.LogWarning("Crop area is outside viewport bounds, using full container. Crop: X={X}, Y={Y}, Width={Width}, Height={Height}, Viewport: {ViewportWidth}x{ViewportHeight}",
+ cropArea.X, cropArea.Y, cropArea.Width, cropArea.Height, viewportSize?.Width ?? 0, viewportSize?.Height ?? 0);
+ cropArea = containerClip;
+ }
+
+ // Final validation - ensure dimensions are positive
+ if (cropArea.Width <= 0 || cropArea.Height <= 0)
+ {
+ _logger.LogError("Invalid crop dimensions: {Width}x{Height}, using full container", cropArea.Width, cropArea.Height);
+ cropArea = containerClip;
+ }
await page.ScreenshotAsync(new PageScreenshotOptions
{
diff --git a/Utilities/CacheHelper.cs b/Utilities/CacheHelper.cs
new file mode 100644
index 0000000..4775bbf
--- /dev/null
+++ b/Utilities/CacheHelper.cs
@@ -0,0 +1,51 @@
+namespace BomLocalService.Utilities;
+
+///
+/// Utility methods for cache folder validation and operations.
+///
+public static class CacheHelper
+{
+ ///
+ /// Checks if a cache folder is complete (has all required files).
+ /// A complete cache folder must have:
+ /// - All 7 frame images (frame_0.png through frame_6.png)
+ /// - metadata.json file
+ /// - frames.json file
+ ///
+ /// The path to the cache folder to check
+ /// True if the folder is complete, false otherwise
+ public static bool IsCacheFolderComplete(string cacheFolderPath)
+ {
+ if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath))
+ {
+ return false;
+ }
+
+ // Check for all 7 frames
+ for (int i = 0; i < 7; i++)
+ {
+ var framePath = FilePathHelper.GetFrameFilePath(cacheFolderPath, 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);
+ if (!File.Exists(framesMetadataPath))
+ {
+ return false;
+ }
+
+ return true;
+ }
+}
+
diff --git a/Utilities/FilePathHelper.cs b/Utilities/FilePathHelper.cs
index e271416..0eded19 100644
--- a/Utilities/FilePathHelper.cs
+++ b/Utilities/FilePathHelper.cs
@@ -88,5 +88,14 @@ public static class FilePathHelper
{
return Path.Combine(cacheFolderPath, ".writing");
}
+
+ ///
+ /// Gets the frames metadata file path within a cache folder.
+ /// Format: "{CacheFolderPath}/frames.json"
+ ///
+ public static string GetFramesMetadataFilePath(string cacheFolderPath)
+ {
+ return Path.Combine(cacheFolderPath, "frames.json");
+ }
}
diff --git a/appsettings.json b/appsettings.json
index 82708fe..274ad0b 100644
--- a/appsettings.json
+++ b/appsettings.json
@@ -24,7 +24,7 @@
"Crop": {
"X": 250,
"Y": 0,
- "Width": null,
+ "RightOffset": 250,
"Height": null
}
},