Files
satellite-provider/SatelliteProvider.Services/RouteProcessingService.cs
T
Anton Martynenko bf2030e3c6 improved zip file
2025-11-20 12:32:56 +01:00

758 lines
29 KiB
C#

using System.IO.Compression;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SatelliteProvider.Common.Configs;
using SatelliteProvider.Common.Utils;
using SatelliteProvider.DataAccess.Repositories;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace SatelliteProvider.Services;
public class RouteProcessingService : BackgroundService
{
private readonly IRouteRepository _routeRepository;
private readonly IRegionRepository _regionRepository;
private readonly IServiceProvider _serviceProvider;
private readonly StorageConfig _storageConfig;
private readonly ILogger<RouteProcessingService> _logger;
private readonly TimeSpan _checkInterval = TimeSpan.FromSeconds(5);
public RouteProcessingService(
IRouteRepository routeRepository,
IRegionRepository regionRepository,
IServiceProvider serviceProvider,
IOptions<StorageConfig> storageConfig,
ILogger<RouteProcessingService> logger)
{
_routeRepository = routeRepository;
_regionRepository = regionRepository;
_serviceProvider = serviceProvider;
_storageConfig = storageConfig.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Route Processing Service started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ProcessPendingRoutesAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in route processing service");
}
await Task.Delay(_checkInterval, stoppingToken);
}
_logger.LogInformation("Route Processing Service stopped");
}
private async Task ProcessPendingRoutesAsync(CancellationToken cancellationToken)
{
var pendingRoutes = await GetRoutesWithPendingMapsAsync();
foreach (var route in pendingRoutes)
{
if (cancellationToken.IsCancellationRequested)
break;
try
{
await ProcessRouteSequentiallyAsync(route.Id, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing route {RouteId}", route.Id);
}
}
}
private async Task<List<(Guid Id, bool RequestMaps)>> GetRoutesWithPendingMapsAsync()
{
var routes = await _routeRepository.GetRoutesWithPendingMapsAsync();
return routes.Select(r => (r.Id, r.RequestMaps)).ToList();
}
private async Task ProcessRouteSequentiallyAsync(Guid routeId, CancellationToken cancellationToken)
{
var route = await _routeRepository.GetByIdAsync(routeId);
if (route == null)
{
_logger.LogWarning("Route {RouteId}: Route not found, skipping processing", routeId);
return;
}
if (!route.RequestMaps || route.MapsReady)
{
return;
}
var routePointsList = (await _routeRepository.GetRoutePointsAsync(routeId)).ToList();
var regionIdsList = (await _routeRepository.GetRegionIdsByRouteAsync(routeId)).ToList();
var geofenceRegionIdsList = (await _routeRepository.GetGeofenceRegionIdsByRouteAsync(routeId)).ToList();
var allRegionIds = regionIdsList.Union(geofenceRegionIdsList).ToList();
if (regionIdsList.Count == 0 && routePointsList.Count > 0)
{
using var scope = _serviceProvider.CreateScope();
var regionService = scope.ServiceProvider.GetRequiredService<Common.Interfaces.IRegionService>();
foreach (var point in routePointsList)
{
var regionId = Guid.NewGuid();
await regionService.RequestRegionAsync(
regionId,
point.Latitude,
point.Longitude,
route.RegionSizeMeters,
route.ZoomLevel,
stitchTiles: false);
await _routeRepository.LinkRouteToRegionAsync(routeId, regionId);
}
return;
}
var regions = new List<DataAccess.Models.RegionEntity>();
foreach (var regionId in allRegionIds)
{
var region = await _regionRepository.GetByIdAsync(regionId);
if (region != null)
{
regions.Add(region);
}
}
var completedRegions = regions.Where(r => r.Status == "completed").ToList();
var failedRegions = regions.Where(r => r.Status == "failed").ToList();
var processingRegions = regions.Where(r => r.Status == "queued" || r.Status == "processing").ToList();
var completedRoutePointRegions = completedRegions.Where(r => !geofenceRegionIdsList.Contains(r.Id)).ToList();
var completedGeofenceRegions = completedRegions.Where(r => geofenceRegionIdsList.Contains(r.Id)).ToList();
var hasRoutePointRegions = regionIdsList.Count > 0;
var hasEnoughRoutePointRegions = !hasRoutePointRegions || completedRoutePointRegions.Count >= routePointsList.Count;
var hasAllGeofenceRegions = geofenceRegionIdsList.Count == 0 || completedGeofenceRegions.Count >= geofenceRegionIdsList.Count;
var hasEnoughCompleted = hasEnoughRoutePointRegions && hasAllGeofenceRegions;
var activeRegions = completedRegions.Count + processingRegions.Count;
var shouldRetryFailed = failedRegions.Count > 0 && !hasEnoughCompleted && activeRegions < allRegionIds.Count;
if (hasEnoughCompleted)
{
var orderedRouteRegions = MatchRegionsToRoutePoints(routePointsList, completedRoutePointRegions, routeId);
var routeRegionIds = orderedRouteRegions.Select(r => r.Id).ToList();
var allRegionIdsForStitching = routeRegionIds.Concat(completedGeofenceRegions.Select(r => r.Id)).Distinct();
var geofenceRegionIdsForBorders = completedGeofenceRegions.Select(r => r.Id).ToList();
await GenerateRouteMapsAsync(routeId, route, allRegionIdsForStitching, geofenceRegionIdsForBorders, routePointsList, cancellationToken);
return;
}
if (shouldRetryFailed)
{
using var scope = _serviceProvider.CreateScope();
var regionService = scope.ServiceProvider.GetRequiredService<Common.Interfaces.IRegionService>();
foreach (var failedRegion in failedRegions)
{
var newRegionId = Guid.NewGuid();
await regionService.RequestRegionAsync(
newRegionId,
failedRegion.Latitude,
failedRegion.Longitude,
failedRegion.SizeMeters,
failedRegion.ZoomLevel,
stitchTiles: false);
await _routeRepository.LinkRouteToRegionAsync(routeId, newRegionId);
}
return;
}
var anyProcessing = processingRegions.Count > 0;
if (anyProcessing)
{
return;
}
_logger.LogWarning("Route {RouteId}: No processing regions, but not enough completed. This is an unexpected state - hasEnoughCompleted={HasEnough}, shouldRetryFailed={ShouldRetry}, anyProcessing={AnyProcessing}",
routeId, hasEnoughCompleted, shouldRetryFailed, anyProcessing);
}
private async Task GenerateRouteMapsAsync(
Guid routeId,
DataAccess.Models.RouteEntity route,
IEnumerable<Guid> regionIds,
List<Guid> geofenceRegionIds,
List<DataAccess.Models.RoutePointEntity> routePoints,
CancellationToken cancellationToken)
{
try
{
var readyDir = _storageConfig.ReadyDirectory;
Directory.CreateDirectory(readyDir);
var allTiles = new Dictionary<string, TileInfo>();
int totalTilesFromRegions = 0;
int duplicateTiles = 0;
foreach (var regionId in regionIds)
{
var region = await _regionRepository.GetByIdAsync(regionId);
if (region == null || string.IsNullOrEmpty(region.CsvFilePath) || !File.Exists(region.CsvFilePath))
{
_logger.LogWarning("Route {RouteId}: Region {RegionId} CSV not found", routeId, regionId);
continue;
}
var csvLines = await File.ReadAllLinesAsync(region.CsvFilePath, cancellationToken);
var lineNumber = 0;
foreach (var line in csvLines.Skip(1))
{
lineNumber++;
var parts = line.Split(',');
if (parts.Length < 3) continue;
if (!double.TryParse(parts[0], out var lat))
{
continue;
}
if (!double.TryParse(parts[1], out var lon))
{
continue;
}
var filePath = parts[2];
totalTilesFromRegions++;
var key = $"{lat:F6}_{lon:F6}";
if (!allTiles.ContainsKey(key))
{
allTiles[key] = new TileInfo
{
Latitude = lat,
Longitude = lon,
FilePath = filePath
};
}
else
{
duplicateTiles++;
}
}
}
var csvPath = Path.Combine(readyDir, $"route_{routeId}_ready.csv");
await GenerateRouteCsvAsync(csvPath, allTiles.Values, cancellationToken);
string? stitchedImagePath = null;
if (route.RequestMaps)
{
var geofencePolygonBounds = new List<(int MinX, int MinY, int MaxX, int MaxY)>();
var geofencesByPolygon = await _routeRepository.GetGeofenceRegionsByPolygonAsync(routeId);
foreach (var (polygonIndex, polygonRegionIds) in geofencesByPolygon.OrderBy(kvp => kvp.Key))
{
int? minX = null, minY = null, maxX = null, maxY = null;
foreach (var geofenceId in polygonRegionIds)
{
var region = await _regionRepository.GetByIdAsync(geofenceId);
if (region != null && !string.IsNullOrEmpty(region.CsvFilePath) && File.Exists(region.CsvFilePath))
{
var csvLines = await File.ReadAllLinesAsync(region.CsvFilePath, cancellationToken);
foreach (var line in csvLines.Skip(1))
{
var parts = line.Split(',');
if (parts.Length >= 3)
{
if (double.TryParse(parts[0], out var lat) && double.TryParse(parts[1], out var lon))
{
var tile = GeoUtils.WorldToTilePos(new Common.DTO.GeoPoint { Lat = lat, Lon = lon }, route.ZoomLevel);
minX = minX == null ? tile.x : Math.Min(minX.Value, tile.x);
minY = minY == null ? tile.y : Math.Min(minY.Value, tile.y);
maxX = maxX == null ? tile.x : Math.Max(maxX.Value, tile.x);
maxY = maxY == null ? tile.y : Math.Max(maxY.Value, tile.y);
}
}
}
}
}
if (minX.HasValue && minY.HasValue && maxX.HasValue && maxY.HasValue)
{
geofencePolygonBounds.Add((minX.Value, minY.Value, maxX.Value, maxY.Value));
}
}
stitchedImagePath = Path.Combine(readyDir, $"route_{routeId}_stitched.jpg");
await StitchRouteTilesAsync(allTiles.Values.ToList(), stitchedImagePath, route.ZoomLevel, geofencePolygonBounds, routePoints, cancellationToken);
}
string? tilesZipPath = null;
if (route.CreateTilesZip)
{
tilesZipPath = Path.Combine(readyDir, $"route_{routeId}_tiles.zip");
await CreateTilesZipAsync(tilesZipPath, allTiles.Values, cancellationToken);
}
var summaryPath = Path.Combine(readyDir, $"route_{routeId}_summary.txt");
await GenerateRouteSummaryAsync(summaryPath, route, allTiles.Count, totalTilesFromRegions, duplicateTiles, tilesZipPath, cancellationToken);
route.MapsReady = true;
route.CsvFilePath = csvPath;
route.SummaryFilePath = summaryPath;
route.StitchedImagePath = stitchedImagePath;
route.TilesZipPath = tilesZipPath;
route.UpdatedAt = DateTime.UtcNow;
await _routeRepository.UpdateRouteAsync(route);
await CleanupRegionFilesAsync(regionIds, cancellationToken);
_logger.LogInformation("Route {RouteId} maps processing completed successfully", routeId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating maps for route {RouteId}", routeId);
throw;
}
}
private async Task CleanupRegionFilesAsync(IEnumerable<Guid> regionIds, CancellationToken cancellationToken)
{
foreach (var regionId in regionIds)
{
var region = await _regionRepository.GetByIdAsync(regionId);
if (region == null) continue;
if (!string.IsNullOrEmpty(region.CsvFilePath) && File.Exists(region.CsvFilePath))
{
try
{
File.Delete(region.CsvFilePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete region CSV file: {FilePath}", region.CsvFilePath);
}
}
if (!string.IsNullOrEmpty(region.SummaryFilePath) && File.Exists(region.SummaryFilePath))
{
try
{
File.Delete(region.SummaryFilePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete region summary file: {FilePath}", region.SummaryFilePath);
}
}
var readyDir = _storageConfig.ReadyDirectory;
var stitchedImagePath = Path.Combine(readyDir, $"region_{regionId}_stitched.jpg");
if (File.Exists(stitchedImagePath))
{
try
{
File.Delete(stitchedImagePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete region stitched image: {FilePath}", stitchedImagePath);
}
}
}
}
private async Task GenerateRouteCsvAsync(
string filePath,
IEnumerable<TileInfo> tiles,
CancellationToken cancellationToken)
{
var orderedTiles = tiles.OrderByDescending(t => t.Latitude).ThenBy(t => t.Longitude).ToList();
using var writer = new StreamWriter(filePath);
await writer.WriteLineAsync("latitude,longitude,file_path");
foreach (var tile in orderedTiles)
{
await writer.WriteLineAsync($"{tile.Latitude:F6},{tile.Longitude:F6},{tile.FilePath}");
}
_logger.LogInformation("Route CSV generated: {FilePath} with {Count} tiles", filePath, orderedTiles.Count);
}
private async Task GenerateRouteSummaryAsync(
string filePath,
DataAccess.Models.RouteEntity route,
int uniqueTiles,
int totalTilesFromRegions,
int duplicateTiles,
string? tilesZipPath,
CancellationToken cancellationToken)
{
var summary = new System.Text.StringBuilder();
summary.AppendLine("Route Maps Summary");
summary.AppendLine("==================");
summary.AppendLine($"Route ID: {route.Id}");
summary.AppendLine($"Route Name: {route.Name}");
if (!string.IsNullOrEmpty(route.Description))
{
summary.AppendLine($"Description: {route.Description}");
}
summary.AppendLine($"Total Points: {route.TotalPoints}");
summary.AppendLine($"Total Distance: {route.TotalDistanceMeters:F2} meters");
summary.AppendLine($"Region Size: {route.RegionSizeMeters:F0} meters");
summary.AppendLine($"Zoom Level: {route.ZoomLevel}");
summary.AppendLine();
summary.AppendLine("Tile Statistics:");
summary.AppendLine($"- Unique Tiles: {uniqueTiles}");
summary.AppendLine($"- Total Tiles from Regions: {totalTilesFromRegions}");
summary.AppendLine($"- Duplicate Tiles (overlap): {duplicateTiles}");
summary.AppendLine();
summary.AppendLine("Files Created:");
summary.AppendLine($"- CSV: route_{route.Id}_ready.csv");
summary.AppendLine($"- Summary: route_{route.Id}_summary.txt");
if (route.RequestMaps)
{
summary.AppendLine($"- Stitched Map: route_{route.Id}_stitched.jpg");
}
if (tilesZipPath != null)
{
summary.AppendLine($"- Tiles ZIP: route_{route.Id}_tiles.zip");
}
summary.AppendLine();
summary.AppendLine($"Completed: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
await File.WriteAllTextAsync(filePath, summary.ToString(), cancellationToken);
_logger.LogInformation("Route summary generated: {FilePath}", filePath);
}
private async Task StitchRouteTilesAsync(
List<TileInfo> tiles,
string outputPath,
int zoomLevel,
List<(int MinX, int MinY, int MaxX, int MaxY)> geofencePolygonBounds,
List<DataAccess.Models.RoutePointEntity> routePoints,
CancellationToken cancellationToken)
{
if (tiles.Count == 0)
{
_logger.LogWarning("No tiles to stitch for route map");
return;
}
const int tileSizePixels = 256;
var tileCoords = tiles.Select(t =>
{
var (tileX, tileY) = ExtractTileCoordinatesFromFilename(t.FilePath);
return new
{
t.Latitude,
t.Longitude,
t.FilePath,
TileX = tileX,
TileY = tileY
};
}).Where(t => t.TileX >= 0 && t.TileY >= 0).ToList();
var minX = tileCoords.Min(t => t.TileX);
var maxX = tileCoords.Max(t => t.TileX);
var minY = tileCoords.Min(t => t.TileY);
var maxY = tileCoords.Max(t => t.TileY);
var gridWidth = maxX - minX + 1;
var gridHeight = maxY - minY + 1;
var imageWidth = gridWidth * tileSizePixels;
var imageHeight = gridHeight * tileSizePixels;
using var stitchedImage = new Image<Rgb24>(imageWidth, imageHeight);
stitchedImage.Mutate(ctx => ctx.BackgroundColor(Color.Black));
var uniqueTileCoords = tileCoords
.GroupBy(t => $"{t.TileX}_{t.TileY}")
.Select(g => g.First())
.OrderBy(t => t.TileY)
.ThenBy(t => t.TileX)
.ToList();
int placedTiles = 0;
int missingTiles = 0;
foreach (var tile in uniqueTileCoords)
{
var destX = (tile.TileX - minX) * tileSizePixels;
var destY = (tile.TileY - minY) * tileSizePixels;
if (File.Exists(tile.FilePath))
{
try
{
using var tileImage = await Image.LoadAsync<Rgb24>(tile.FilePath, cancellationToken);
stitchedImage.Mutate(ctx => ctx.DrawImage(tileImage, new Point(destX, destY), 1f));
placedTiles++;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load tile at {FilePath}, leaving black", tile.FilePath);
missingTiles++;
}
}
else
{
_logger.LogWarning("Tile file not found: {FilePath}, leaving black", tile.FilePath);
missingTiles++;
}
}
if (geofencePolygonBounds.Count > 0)
{
for (int i = 0; i < geofencePolygonBounds.Count; i++)
{
var (geoMinX, geoMinY, geoMaxX, geoMaxY) = geofencePolygonBounds[i];
var x1 = (geoMinX - minX) * tileSizePixels;
var y1 = (geoMinY - minY + 1) * tileSizePixels;
var x2 = (geoMaxX - minX + 2) * tileSizePixels - 1;
var y2 = (geoMaxY - minY + 1) * tileSizePixels - 1;
x1 = Math.Max(0, Math.Min(x1, imageWidth - 1));
y1 = Math.Max(0, Math.Min(y1, imageHeight - 1));
x2 = Math.Max(0, Math.Min(x2, imageWidth - 1));
y2 = Math.Max(0, Math.Min(y2, imageHeight - 1));
if (x1 >= 0 && y1 >= 0 && x2 < imageWidth && y2 < imageHeight && x2 > x1 && y2 > y1)
{
DrawRectangleBorder(stitchedImage, x1, y1, x2, y2, new Rgb24(255, 255, 0));
}
}
}
foreach (var point in routePoints)
{
var geoPoint = new Common.DTO.GeoPoint { Lat = point.Latitude, Lon = point.Longitude };
var (tileX, tileY) = GeoUtils.WorldToTilePos(geoPoint, zoomLevel);
var pixelX = (tileX - minX) * tileSizePixels + tileSizePixels / 2;
var pixelY = (tileY - minY) * tileSizePixels + tileSizePixels / 2;
if (pixelX >= 0 && pixelX < imageWidth && pixelY >= 0 && pixelY < imageHeight)
{
DrawCross(stitchedImage, pixelX, pixelY, new Rgb24(255, 0, 0), 50);
}
}
await stitchedImage.SaveAsJpegAsync(outputPath, cancellationToken);
}
private List<DataAccess.Models.RegionEntity> MatchRegionsToRoutePoints(
List<DataAccess.Models.RoutePointEntity> routePoints,
List<DataAccess.Models.RegionEntity> regions,
Guid routeId)
{
var orderedRegions = new List<DataAccess.Models.RegionEntity>();
var availableRegions = new List<DataAccess.Models.RegionEntity>(regions);
foreach (var point in routePoints)
{
var matchedRegion = availableRegions
.OrderBy(r => CalculateDistance(point.Latitude, point.Longitude, r.Latitude, r.Longitude))
.FirstOrDefault();
if (matchedRegion != null)
{
orderedRegions.Add(matchedRegion);
availableRegions.Remove(matchedRegion);
}
}
return orderedRegions;
}
private static double CalculateDistance(double lat1, double lon1, double lat2, double lon2)
{
const double earthRadiusMeters = 6371000;
var dLat = (lat2 - lat1) * Math.PI / 180.0;
var dLon = (lon2 - lon1) * Math.PI / 180.0;
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
Math.Cos(lat1 * Math.PI / 180.0) * Math.Cos(lat2 * Math.PI / 180.0) *
Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
return earthRadiusMeters * c;
}
private static (int TileX, int TileY) ExtractTileCoordinatesFromFilename(string filePath)
{
try
{
var filename = Path.GetFileNameWithoutExtension(filePath);
var parts = filename.Split('_');
if (parts.Length >= 4 && parts[0] == "tile")
{
if (int.TryParse(parts[2], out var tileX) && int.TryParse(parts[3], out var tileY))
{
return (tileX, tileY);
}
}
}
catch
{
}
return (-1, -1);
}
private static void DrawRectangleBorder(Image<Rgb24> image, int x1, int y1, int x2, int y2, Rgb24 color)
{
const int thickness = 5;
for (int t = 0; t < thickness; t++)
{
for (int x = x1; x <= x2; x++)
{
int topY = y1 + t;
int bottomY = y2 - t;
if (x >= 0 && x < image.Width && topY >= 0 && topY < image.Height)
image[x, topY] = color;
if (x >= 0 && x < image.Width && bottomY >= 0 && bottomY < image.Height)
image[x, bottomY] = color;
}
for (int y = y1; y <= y2; y++)
{
int leftX = x1 + t;
int rightX = x2 - t;
if (leftX >= 0 && leftX < image.Width && y >= 0 && y < image.Height)
image[leftX, y] = color;
if (rightX >= 0 && rightX < image.Width && y >= 0 && y < image.Height)
image[rightX, y] = color;
}
}
}
private static void DrawCross(Image<Rgb24> image, int centerX, int centerY, Rgb24 color, int armLength)
{
const int thickness = 10;
int halfThickness = thickness / 2;
for (int dx = -armLength; dx <= armLength; dx++)
{
for (int t = -halfThickness; t <= halfThickness; t++)
{
int x = centerX + dx;
int y = centerY + t;
if (x >= 0 && x < image.Width && y >= 0 && y < image.Height)
image[x, y] = color;
}
}
for (int dy = -armLength; dy <= armLength; dy++)
{
for (int t = -halfThickness; t <= halfThickness; t++)
{
int x = centerX + t;
int y = centerY + dy;
if (x >= 0 && x < image.Width && y >= 0 && y < image.Height)
image[x, y] = color;
}
}
}
private Task CreateTilesZipAsync(
string zipFilePath,
IEnumerable<TileInfo> tiles,
CancellationToken cancellationToken)
{
return Task.Run(() =>
{
if (File.Exists(zipFilePath))
{
File.Delete(zipFilePath);
}
using var zipArchive = ZipFile.Open(zipFilePath, ZipArchiveMode.Create);
int addedFiles = 0;
int missingFiles = 0;
var tilesBasePath = _storageConfig.TilesDirectory;
var normalizedBasePath = Path.GetFullPath(tilesBasePath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
foreach (var tile in tiles)
{
if (cancellationToken.IsCancellationRequested)
break;
if (File.Exists(tile.FilePath))
{
try
{
var fullPath = Path.GetFullPath(tile.FilePath);
string entryName;
if (fullPath.StartsWith(normalizedBasePath, StringComparison.OrdinalIgnoreCase))
{
var relativePath = fullPath.Substring(normalizedBasePath.Length + 1);
relativePath = relativePath.Replace(Path.DirectorySeparatorChar, '/');
entryName = "tiles/" + relativePath;
}
else
{
entryName = "tiles/" + Path.GetFileName(tile.FilePath);
}
zipArchive.CreateEntryFromFile(tile.FilePath, entryName, CompressionLevel.Optimal);
addedFiles++;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to add tile to zip: {FilePath}", tile.FilePath);
}
}
else
{
_logger.LogWarning("Tile file not found for zip: {FilePath}", tile.FilePath);
missingFiles++;
}
}
_logger.LogInformation("Tiles zip created: {ZipPath} with {AddedFiles} tiles ({MissingFiles} missing)",
zipFilePath, addedFiles, missingFiles);
}, cancellationToken);
}
}
public class TileInfo
{
public double Latitude { get; set; }
public double Longitude { get; set; }
public string FilePath { get; set; } = string.Empty;
}