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 } },