Refactor annotation tool from WPF desktop app to .NET API

Replace the WPF desktop application (Azaion.Suite, Azaion.Annotator,
Azaion.Common, Azaion.Inference, Azaion.Loader, Azaion.LoaderUI,
Azaion.Dataset, Azaion.Test) with a standalone .NET Web API in src/.

Made-with: Cursor
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-03-25 04:40:03 +02:00
parent e7ea5a8ded
commit 9e7dc290db
367 changed files with 8840 additions and 16583 deletions
+16
View File
@@ -0,0 +1,16 @@
using System.Threading.Channels;
using Azaion.Annotations.DTOs;
namespace Azaion.Annotations.Services;
public class AnnotationEventService
{
private readonly Channel<AnnotationEventDto> _channel = Channel.CreateUnbounded<AnnotationEventDto>();
public ChannelReader<AnnotationEventDto> Reader => _channel.Reader;
public async ValueTask PublishAsync(AnnotationEventDto evt)
{
await _channel.Writer.WriteAsync(evt);
}
}
+289
View File
@@ -0,0 +1,289 @@
using System.IO.Hashing;
using LinqToDB;
using LinqToDB.Data;
using Azaion.Annotations.Database;
using Azaion.Annotations.Database.Entities;
using Azaion.Annotations.DTOs;
using Azaion.Annotations.Enums;
namespace Azaion.Annotations.Services;
public class AnnotationService(AppDataConnection db, PathResolver pathResolver, AnnotationEventService events)
{
public async Task<Annotation> CreateAnnotation(CreateAnnotationRequest request, Guid userId)
{
string id;
if (request.Image is { Length: > 0 })
{
id = ComputeHash(request.Image);
var imgPath = await pathResolver.GetImagePath(id);
Directory.CreateDirectory(Path.GetDirectoryName(imgPath)!);
await File.WriteAllBytesAsync(imgPath, request.Image);
var existingMedia = await db.Media.FirstOrDefaultAsync(m => m.Id == id);
if (existingMedia == null)
{
await db.InsertAsync(new Media
{
Id = id,
Name = id,
Path = imgPath,
MediaType = MediaType.Image,
MediaStatus = MediaStatus.New,
WaypointId = request.WaypointId,
UserId = userId
});
}
}
else if (!string.IsNullOrEmpty(request.MediaId))
{
var media = await db.Media.FirstOrDefaultAsync(m => m.Id == request.MediaId)
?? throw new KeyNotFoundException($"Media {request.MediaId} not found");
id = request.MediaId;
var imgPath = await pathResolver.GetImagePath(id);
if (File.Exists(media.Path) && !File.Exists(imgPath))
{
Directory.CreateDirectory(Path.GetDirectoryName(imgPath)!);
File.Copy(media.Path, imgPath);
}
}
else
{
throw new ArgumentException("Provide either Image bytes or MediaId");
}
var annotation = new Annotation
{
Id = id,
MediaId = id,
Time = request.VideoTime,
CreatedDate = DateTime.UtcNow,
UserId = userId,
Source = request.Source,
Status = AnnotationStatus.Created
};
await db.InsertAsync(annotation);
var detections = request.Detections.Select(d => new Detection
{
Id = Guid.NewGuid(),
CenterX = d.CenterX,
CenterY = d.CenterY,
Width = d.Width,
Height = d.Height,
ClassNum = d.ClassNum,
Label = d.Label,
Description = d.Description,
Confidence = d.Confidence,
Affiliation = d.Affiliation,
CombatReadiness = d.CombatReadiness,
AnnotationId = id
}).ToList();
if (detections.Count > 0)
await db.BulkCopyAsync(detections);
await WriteLabelFile(id, request.Detections);
await events.PublishAsync(new AnnotationEventDto
{
AnnotationId = id,
MediaId = id,
Status = AnnotationStatus.Created,
Source = request.Source,
Detections = request.Detections,
CreatedDate = annotation.CreatedDate
});
var settings = await db.SystemSettings.FirstOrDefaultAsync();
if (settings is not { SilentDetection: true })
await FailsafeProducer.EnqueueAsync(db, id, QueueOperation.Created);
return annotation;
}
public async Task UpdateAnnotation(string id, UpdateAnnotationRequest request)
{
var exists = await db.Annotations.AnyAsync(a => a.Id == id);
if (!exists) throw new KeyNotFoundException($"Annotation {id} not found");
await db.Detections.DeleteAsync(d => d.AnnotationId == id);
var detections = request.Detections.Select(d => new Detection
{
Id = Guid.NewGuid(),
CenterX = d.CenterX,
CenterY = d.CenterY,
Width = d.Width,
Height = d.Height,
ClassNum = d.ClassNum,
Label = d.Label,
Description = d.Description,
Confidence = d.Confidence,
Affiliation = d.Affiliation,
CombatReadiness = d.CombatReadiness,
AnnotationId = id
}).ToList();
if (detections.Count > 0)
await db.BulkCopyAsync(detections);
await db.Annotations
.Where(a => a.Id == id)
.Set(a => a.Status, AnnotationStatus.Edited)
.UpdateAsync();
await WriteLabelFile(id, request.Detections);
}
public async Task UpdateStatus(string id, AnnotationStatus status)
{
var updated = await db.Annotations
.Where(a => a.Id == id)
.Set(a => a.Status, status)
.UpdateAsync();
if (updated == 0) throw new KeyNotFoundException($"Annotation {id} not found");
}
public async Task DeleteAnnotation(string id)
{
var exists = await db.Annotations.AnyAsync(a => a.Id == id);
if (!exists) throw new KeyNotFoundException($"Annotation {id} not found");
await db.Detections.DeleteAsync(d => d.AnnotationId == id);
await db.Annotations.DeleteAsync(a => a.Id == id);
await DeleteFiles(id);
}
public async Task<PaginatedResponse<AnnotationListItem>> GetAnnotations(GetAnnotationsQuery query)
{
var q = db.Annotations.AsQueryable();
if (query.From.HasValue)
q = q.Where(a => a.CreatedDate >= query.From.Value);
if (query.To.HasValue)
q = q.Where(a => a.CreatedDate <= query.To.Value);
if (query.UserId.HasValue)
q = q.Where(a => a.UserId == query.UserId.Value);
if (!string.IsNullOrEmpty(query.Name))
q = q.Where(a => db.Media.Any(m => m.Id == a.MediaId && m.Name.ToLower().Contains(query.Name.ToLower())));
if (!string.IsNullOrEmpty(query.MediaId))
q = q.Where(a => a.MediaId == query.MediaId);
if (query.FlightId.HasValue)
q = q.Where(a => db.Media.Any(m => m.Id == a.MediaId && m.WaypointId != null));
var totalCount = await q.CountAsync();
var annotations = await q
.OrderByDescending(a => a.CreatedDate)
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize)
.ToListAsync();
var annotationIds = annotations.Select(a => a.Id).ToList();
var detections = await db.Detections
.Where(d => annotationIds.Contains(d.AnnotationId))
.ToListAsync();
var detectionsByAnnotation = detections.GroupBy(d => d.AnnotationId)
.ToDictionary(g => g.Key, g => g.ToList());
var items = annotations.Select(a =>
{
detectionsByAnnotation.TryGetValue(a.Id, out var dets);
return new AnnotationListItem
{
Id = a.Id,
MediaId = a.MediaId,
Time = TimeSpan.FromTicks(a.TimeTicks).ToString(@"hh\:mm\:ss"),
CreatedDate = a.CreatedDate,
UserId = a.UserId,
Source = a.Source,
Status = a.Status,
IsSplit = a.IsSplit,
SplitTile = a.SplitTile,
Detections = (dets ?? []).Select(d => new DetectionListDto
{
Id = d.Id,
ClassNum = d.ClassNum,
Label = d.Label,
Confidence = d.Confidence,
Affiliation = d.Affiliation,
CombatReadiness = d.CombatReadiness,
CenterX = d.CenterX,
CenterY = d.CenterY,
Width = d.Width,
Height = d.Height
}).ToList()
};
}).ToList();
return new PaginatedResponse<AnnotationListItem>
{
Items = items,
TotalCount = totalCount,
Page = query.Page,
PageSize = query.PageSize
};
}
public async Task<Annotation> GetAnnotation(string id)
{
var annotation = await db.Annotations
.LoadWith(a => a.Detections)
.Where(a => a.Id == id)
.FirstOrDefaultAsync();
return annotation ?? throw new KeyNotFoundException($"Annotation {id} not found");
}
private async Task WriteLabelFile(string annotationId, List<DetectionDto> detections)
{
var labelPath = await pathResolver.GetLabelPath(annotationId);
Directory.CreateDirectory(Path.GetDirectoryName(labelPath)!);
var lines = detections.Select(d => $"{d.ClassNum} {d.CenterX} {d.CenterY} {d.Width} {d.Height}");
await File.WriteAllLinesAsync(labelPath, lines);
}
private async Task DeleteFiles(string annotationId)
{
var paths = new[]
{
await pathResolver.GetImagePath(annotationId),
await pathResolver.GetLabelPath(annotationId),
await pathResolver.GetThumbnailPath(annotationId)
};
foreach (var path in paths)
{
if (File.Exists(path))
File.Delete(path);
}
}
private static string ComputeHash(byte[] data)
{
byte[] input;
if (data.Length > 3072)
{
var buffer = new byte[8 + 3072];
BitConverter.GetBytes((long)data.Length).CopyTo(buffer, 0);
Array.Copy(data, 0, buffer, 8, 1024);
Array.Copy(data, data.Length / 2 - 512, buffer, 8 + 1024, 1024);
Array.Copy(data, data.Length - 1024, buffer, 8 + 2048, 1024);
input = buffer;
}
else
{
var buffer = new byte[8 + data.Length];
BitConverter.GetBytes((long)data.Length).CopyTo(buffer, 0);
Array.Copy(data, 0, buffer, 8, data.Length);
input = buffer;
}
var hash = XxHash64.Hash(input);
return Convert.ToHexStringLower(hash);
}
}
+118
View File
@@ -0,0 +1,118 @@
using LinqToDB;
using Azaion.Annotations.Database;
using Azaion.Annotations.Database.Entities;
using Azaion.Annotations.DTOs;
using Azaion.Annotations.Enums;
namespace Azaion.Annotations.Services;
public class DatasetService(AppDataConnection db, PathResolver pathResolver)
{
public async Task<PaginatedResponse<DatasetItem>> GetDataset(GetDatasetQuery query)
{
var q = db.Annotations.AsQueryable();
if (query.FromDate.HasValue)
q = q.Where(a => a.CreatedDate >= query.FromDate.Value);
if (query.ToDate.HasValue)
q = q.Where(a => a.CreatedDate <= query.ToDate.Value);
if (query.FlightId.HasValue)
q = q.Where(a => db.Media.Any(m => m.Id == a.MediaId && m.WaypointId != null));
if (query.Status.HasValue)
q = q.Where(a => a.Status == query.Status.Value);
if (query.ClassNum.HasValue)
q = q.Where(a => db.Detections.Any(d => d.AnnotationId == a.Id && d.ClassNum == query.ClassNum.Value));
if (query.HasDetections == true)
q = q.Where(a => db.Detections.Any(d => d.AnnotationId == a.Id));
if (query.HasDetections == false)
q = q.Where(a => !db.Detections.Any(d => d.AnnotationId == a.Id));
if (!string.IsNullOrEmpty(query.Name))
q = q.Where(a => a.Id.ToLower().Contains(query.Name.ToLower()));
var totalCount = await q.CountAsync();
var annotations = await q
.OrderByDescending(a => a.CreatedDate)
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize)
.ToListAsync();
var items = new List<DatasetItem>();
foreach (var a in annotations)
{
items.Add(new DatasetItem
{
AnnotationId = a.Id,
ImageName = $"{a.Id}.jpg",
ThumbnailPath = await pathResolver.GetThumbnailPath(a.Id),
Status = a.Status,
CreatedDate = a.CreatedDate,
Source = a.Source,
IsSplit = a.IsSplit,
IsSeed = a.Source == AnnotationSource.AI && a.Status == AnnotationStatus.Validated
});
}
return new PaginatedResponse<DatasetItem>
{
Items = items,
TotalCount = totalCount,
Page = query.Page,
PageSize = query.PageSize
};
}
public async Task<Annotation> GetAnnotationDetail(string annotationId)
{
var annotation = await db.Annotations
.LoadWith(a => a.Detections)
.Where(a => a.Id == annotationId)
.FirstOrDefaultAsync();
return annotation ?? throw new KeyNotFoundException($"Annotation {annotationId} not found");
}
public async Task UpdateStatus(string annotationId, AnnotationStatus status)
{
var updated = await db.Annotations
.Where(a => a.Id == annotationId)
.Set(a => a.Status, status)
.UpdateAsync();
if (updated == 0) throw new KeyNotFoundException($"Annotation {annotationId} not found");
}
public async Task BulkUpdateStatus(BulkStatusRequest request)
{
if (request.AnnotationIds.Count == 0)
throw new ArgumentException("Empty annotationIds list");
await db.Annotations
.Where(a => request.AnnotationIds.Contains(a.Id))
.Set(a => a.Status, request.Status)
.UpdateAsync();
}
public async Task<List<ClassDistributionItem>> GetClassDistribution()
{
var classes = await db.DetectionClasses.ToListAsync();
var classMap = classes.ToDictionary(c => c.Id, c => c);
var groups = await db.Detections
.GroupBy(d => d.ClassNum)
.Select(g => new { ClassNum = g.Key, Count = g.Count() })
.ToListAsync();
return groups.Select(g =>
{
classMap.TryGetValue(g.ClassNum, out var cls);
return new ClassDistributionItem
{
ClassNum = g.ClassNum,
Label = cls?.Name ?? $"Class {g.ClassNum}",
Color = cls?.Color ?? "#888888",
Count = g.Count
};
}).OrderByDescending(x => x.Count).ToList();
}
}
+206
View File
@@ -0,0 +1,206 @@
using System.Net;
using System.Text.Json;
using LinqToDB;
using Azaion.Annotations.Database;
using Azaion.Annotations.Database.Entities;
using Azaion.Annotations.DTOs;
using Azaion.Annotations.Enums;
using MessagePack;
using RabbitMQ.Stream.Client;
using RabbitMQ.Stream.Client.AMQP;
using RabbitMQ.Stream.Client.Reliable;
namespace Azaion.Annotations.Services;
public class RabbitMqConfig
{
public string Host { get; set; } = "rabbitmq";
public int Port { get; set; } = 5552;
public string Username { get; set; } = "azaion_producer";
public string Password { get; set; } = "producer_pass";
public string StreamName { get; set; } = "azaion-annotations";
}
public class FailsafeProducer(
IServiceScopeFactory scopeFactory,
PathResolver pathResolver,
RabbitMqConfig config,
ILogger<FailsafeProducer> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
await Task.Delay(TimeSpan.FromSeconds(5), ct);
while (!ct.IsCancellationRequested)
{
try
{
await ProcessQueue(ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, ex.Message);
await Task.Delay(TimeSpan.FromSeconds(10), ct);
}
}
}
private async Task ProcessQueue(CancellationToken ct)
{
var streamSystem = await StreamSystem.Create(new StreamSystemConfig
{
Endpoints = [new IPEndPoint(IPAddress.Parse(config.Host), config.Port)],
UserName = config.Username,
Password = config.Password
});
var producer = await Producer.Create(new ProducerConfig(streamSystem, config.StreamName));
try
{
while (!ct.IsCancellationRequested)
{
await DrainQueue(producer, ct);
await Task.Delay(TimeSpan.FromSeconds(10), ct);
}
}
finally
{
await producer.Close();
await streamSystem.Close();
}
}
private async Task DrainQueue(Producer producer, CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDataConnection>();
var records = await db.AnnotationsQueueRecords
.OrderBy(x => x.DateTime)
.ToListAsync(token: ct);
if (records.Count == 0)
return;
var createdIds = records
.Where(x => x.Operation == QueueOperation.Created)
.SelectMany(x => ParseIds(x.AnnotationIds))
.ToList();
var annotationsDict = createdIds.Count > 0
? await db.Annotations
.LoadWith(a => a.Detections)
.Where(a => createdIds.Contains(a.Id))
.ToDictionaryAsync(a => a.Id, ct)
: new Dictionary<string, Annotation>();
var messages = new List<Message>();
foreach (var record in records)
{
var ids = ParseIds(record.AnnotationIds);
if (record.Operation is QueueOperation.Validated or QueueOperation.Deleted)
{
var msg = MessagePackSerializer.Serialize(new AnnotationBulkQueueMessage
{
AnnotationIds = ids.ToArray(),
Operation = (int)record.Operation,
CreatedDate = record.DateTime
});
messages.Add(new Message(msg)
{
ApplicationProperties = new ApplicationProperties
{
{ "Operation", record.Operation.ToString() }
}
});
}
else
{
foreach (var id in ids)
{
if (!annotationsDict.TryGetValue(id, out var annotation))
continue;
byte[]? image = null;
try
{
var imgPath = await pathResolver.GetImagePath(id);
if (File.Exists(imgPath))
image = await File.ReadAllBytesAsync(imgPath, ct);
}
catch { }
var detectionsJson = JsonSerializer.Serialize(
annotation.Detections?.Select(d => new
{
d.CenterX, d.CenterY, d.Width, d.Height,
d.ClassNum, d.Label, d.Confidence
}) ?? []);
var msg = MessagePackSerializer.Serialize(new AnnotationQueueMessage
{
Name = annotation.Id,
MediaHash = annotation.MediaId,
OriginalMediaName = annotation.MediaId,
Time = TimeSpan.FromTicks(annotation.TimeTicks),
ImageExtension = ".jpg",
Detections = detectionsJson,
Image = image,
Email = "",
Source = (int)annotation.Source,
Status = (int)annotation.Status,
CreatedDate = annotation.CreatedDate
});
messages.Add(new Message(msg)
{
ApplicationProperties = new ApplicationProperties
{
{ "Operation", record.Operation.ToString() }
}
});
}
}
}
if (messages.Count > 0)
{
await producer.Send(messages, CompressionType.Gzip);
var recordIds = records.Select(x => x.Id).ToList();
await db.AnnotationsQueueRecords
.Where(x => recordIds.Contains(x.Id))
.DeleteAsync(token: ct);
}
}
private static List<string> ParseIds(string json)
{
try
{
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
}
catch
{
return [];
}
}
public static async Task EnqueueAsync(AppDataConnection db, string annotationId, QueueOperation operation)
{
var ids = JsonSerializer.Serialize(new[] { annotationId });
await db.InsertAsync(new AnnotationsQueueRecord
{
Id = Guid.NewGuid(),
DateTime = DateTime.UtcNow,
Operation = operation,
AnnotationIds = ids
});
}
}
+233
View File
@@ -0,0 +1,233 @@
using System.Diagnostics;
using System.IO.Hashing;
using LinqToDB;
using LinqToDB.Data;
using Azaion.Annotations.Database;
using Azaion.Annotations.Database.Entities;
using Azaion.Annotations.DTOs;
using Azaion.Annotations.Enums;
namespace Azaion.Annotations.Services;
public class MediaService(AppDataConnection db, PathResolver pathResolver)
{
public async Task<Media> CreateMedia(CreateMediaRequest request, Guid userId)
{
var id = request.Data is { Length: > 0 }
? ComputeHash(request.Data)
: ComputeHash(System.Text.Encoding.UTF8.GetBytes(request.Path));
var media = new Media
{
Id = id,
Name = request.Name,
Path = request.Path,
MediaType = request.MediaType,
MediaStatus = MediaStatus.New,
WaypointId = request.WaypointId,
UserId = userId
};
if (request.Data is { Length: > 0 })
{
Directory.CreateDirectory(Path.GetDirectoryName(request.Path)!);
await File.WriteAllBytesAsync(request.Path, request.Data);
}
if (media.MediaType == MediaType.Video)
media.Duration = await ExtractDuration(media.Path);
await db.InsertAsync(media);
return media;
}
public async Task<List<Media>> CreateMediaBatch(Guid waypointId, IFormFileCollection files, Guid userId)
{
if (files.Count == 0) throw new ArgumentException("No files provided");
var mediaDir = await pathResolver.GetMediaDir();
Directory.CreateDirectory(mediaDir);
var mediaList = new List<Media>();
foreach (var file in files)
{
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var bytes = ms.ToArray();
var id = ComputeHash(bytes);
var filePath = Path.Combine(mediaDir, $"{id}{Path.GetExtension(file.FileName)}");
await File.WriteAllBytesAsync(filePath, bytes);
var mediaType = ResolveMediaType(file.ContentType, file.FileName);
var media = new Media
{
Id = id,
Name = file.FileName,
Path = filePath,
MediaType = mediaType,
MediaStatus = MediaStatus.New,
WaypointId = waypointId,
UserId = userId
};
if (mediaType == MediaType.Video)
media.Duration = await ExtractDuration(filePath);
mediaList.Add(media);
}
await db.BulkCopyAsync(mediaList);
return mediaList;
}
private static MediaType ResolveMediaType(string contentType, string fileName)
{
if (contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase))
return MediaType.Video;
if (contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
return MediaType.Image;
var ext = Path.GetExtension(fileName).ToLowerInvariant();
return ext switch
{
".mp4" or ".avi" or ".mov" or ".mkv" => MediaType.Video,
".jpg" or ".jpeg" or ".png" or ".bmp" or ".tiff" => MediaType.Image,
_ => MediaType.None
};
}
public async Task<Media?> GetMediaById(string id)
{
return await db.Media.FirstOrDefaultAsync(m => m.Id == id);
}
public async Task<PaginatedResponse<MediaListItem>> GetMedia(GetMediaQuery query)
{
var q = db.Media.AsQueryable();
if (query.FlightId.HasValue)
q = q.Where(m => m.WaypointId != null);
if (!string.IsNullOrEmpty(query.Name))
q = q.Where(m => m.Name.ToLower().Contains(query.Name.ToLower()));
if (!string.IsNullOrEmpty(query.Path))
q = q.Where(m => m.Path.ToLower().Contains(query.Path.ToLower()));
var totalCount = await q.CountAsync();
var items = await q
.OrderByDescending(m => m.Id)
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize)
.Select(m => new MediaListItem
{
Id = m.Id,
Name = m.Name,
Path = m.Path,
MediaType = m.MediaType,
MediaStatus = m.MediaStatus,
Duration = m.Duration,
AnnotationCount = db.Annotations.Count(a => a.MediaId == m.Id),
WaypointId = m.WaypointId,
UserId = m.UserId
})
.ToListAsync();
return new PaginatedResponse<MediaListItem>
{
Items = items,
TotalCount = totalCount,
Page = query.Page,
PageSize = query.PageSize
};
}
public async Task DeleteMedia(string id)
{
var media = await db.Media.FirstOrDefaultAsync(m => m.Id == id)
?? throw new KeyNotFoundException($"Media {id} not found");
var annotationIds = await db.Annotations
.Where(a => a.MediaId == id)
.Select(a => a.Id)
.ToListAsync();
if (annotationIds.Count > 0)
{
await db.Detections.DeleteAsync(d => annotationIds.Contains(d.AnnotationId));
await db.Annotations.DeleteAsync(a => a.MediaId == id);
foreach (var annId in annotationIds)
{
var paths = new[]
{
await pathResolver.GetImagePath(annId),
await pathResolver.GetLabelPath(annId),
await pathResolver.GetThumbnailPath(annId)
};
foreach (var path in paths)
{
if (File.Exists(path))
File.Delete(path);
}
}
}
await db.Media.DeleteAsync(m => m.Id == id);
if (File.Exists(media.Path))
File.Delete(media.Path);
}
private static string ComputeHash(byte[] data)
{
byte[] input;
if (data.Length > 3072)
{
var buffer = new byte[8 + 3072];
BitConverter.GetBytes((long)data.Length).CopyTo(buffer, 0);
Array.Copy(data, 0, buffer, 8, 1024);
Array.Copy(data, data.Length / 2 - 512, buffer, 8 + 1024, 1024);
Array.Copy(data, data.Length - 1024, buffer, 8 + 2048, 1024);
input = buffer;
}
else
{
var buffer = new byte[8 + data.Length];
BitConverter.GetBytes((long)data.Length).CopyTo(buffer, 0);
Array.Copy(data, 0, buffer, 8, data.Length);
input = buffer;
}
var hash = XxHash64.Hash(input);
return Convert.ToHexStringLower(hash);
}
private static async Task<string?> ExtractDuration(string filePath)
{
try
{
using var process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = "ffprobe",
Arguments = $"-v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 \"{filePath}\"",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
process.Start();
var output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
if (double.TryParse(output.Trim(), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var seconds))
{
return TimeSpan.FromSeconds(seconds).ToString(@"hh\:mm\:ss");
}
}
catch (Exception)
{
}
return null;
}
}
+61
View File
@@ -0,0 +1,61 @@
using LinqToDB;
using Azaion.Annotations.Database;
namespace Azaion.Annotations.Services;
public class PathResolver(AppDataConnection db)
{
private string _videosDir = "/data/videos";
private string _imagesDir = "/data/images";
private string _labelsDir = "/data/labels";
private string _resultsDir = "/data/results";
private string _thumbnailsDir = "/data/thumbnails";
private bool _initialized;
private async Task EnsureInitialized()
{
if (_initialized) return;
var dirs = await db.DirectorySettings.FirstOrDefaultAsync();
if (dirs != null)
{
_videosDir = dirs.VideosDir;
_imagesDir = dirs.ImagesDir;
_labelsDir = dirs.LabelsDir;
_resultsDir = dirs.ResultsDir;
_thumbnailsDir = dirs.ThumbnailsDir;
}
_initialized = true;
}
public async Task<string> GetImagePath(string annotationId)
{
await EnsureInitialized();
return Path.Combine(_imagesDir, $"{annotationId}.jpg");
}
public async Task<string> GetLabelPath(string annotationId)
{
await EnsureInitialized();
return Path.Combine(_labelsDir, $"{annotationId}.txt");
}
public async Task<string> GetThumbnailPath(string annotationId)
{
await EnsureInitialized();
return Path.Combine(_thumbnailsDir, $"{annotationId}.jpg");
}
public async Task<string> GetResultPath(string annotationId)
{
await EnsureInitialized();
return Path.Combine(_resultsDir, $"{annotationId}_result.jpg");
}
public async Task<string> GetMediaDir()
{
await EnsureInitialized();
return _videosDir;
}
public void Reset() => _initialized = false;
}
+148
View File
@@ -0,0 +1,148 @@
using LinqToDB;
using Azaion.Annotations.Database;
using Azaion.Annotations.Database.Entities;
using Azaion.Annotations.DTOs;
namespace Azaion.Annotations.Services;
public class SettingsService(AppDataConnection db, PathResolver pathResolver)
{
public async Task<SystemSettings?> GetSystemSettings()
{
return await db.SystemSettings.FirstOrDefaultAsync();
}
public async Task UpdateSystemSettings(UpdateSystemSettingsRequest request)
{
var existing = await db.SystemSettings.FirstOrDefaultAsync();
if (existing == null)
{
await db.InsertAsync(new SystemSettings
{
Id = Guid.NewGuid(),
Name = request.Name,
MilitaryUnit = request.MilitaryUnit,
DefaultCameraWidth = request.DefaultCameraWidth,
DefaultCameraFoV = request.DefaultCameraFoV,
ThumbnailWidth = request.ThumbnailWidth ?? 240,
ThumbnailHeight = request.ThumbnailHeight ?? 135,
ThumbnailBorder = request.ThumbnailBorder ?? 10,
GenerateAnnotatedImage = request.GenerateAnnotatedImage ?? false,
SilentDetection = request.SilentDetection ?? false
});
return;
}
await db.SystemSettings
.Where(x => x.Id == existing.Id)
.Set(x => x.Name, request.Name ?? existing.Name)
.Set(x => x.MilitaryUnit, request.MilitaryUnit ?? existing.MilitaryUnit)
.Set(x => x.DefaultCameraWidth, request.DefaultCameraWidth ?? existing.DefaultCameraWidth)
.Set(x => x.DefaultCameraFoV, request.DefaultCameraFoV ?? existing.DefaultCameraFoV)
.Set(x => x.ThumbnailWidth, request.ThumbnailWidth ?? existing.ThumbnailWidth)
.Set(x => x.ThumbnailHeight, request.ThumbnailHeight ?? existing.ThumbnailHeight)
.Set(x => x.ThumbnailBorder, request.ThumbnailBorder ?? existing.ThumbnailBorder)
.Set(x => x.GenerateAnnotatedImage, request.GenerateAnnotatedImage ?? existing.GenerateAnnotatedImage)
.Set(x => x.SilentDetection, request.SilentDetection ?? existing.SilentDetection)
.UpdateAsync();
}
public async Task<DirectorySettings?> GetDirectorySettings()
{
return await db.DirectorySettings.FirstOrDefaultAsync();
}
public async Task UpdateDirectorySettings(UpdateDirectoriesRequest request)
{
var existing = await db.DirectorySettings.FirstOrDefaultAsync();
if (existing == null)
{
await db.InsertAsync(new DirectorySettings
{
Id = Guid.NewGuid(),
VideosDir = request.VideosDir ?? "/data/videos",
ImagesDir = request.ImagesDir ?? "/data/images",
LabelsDir = request.LabelsDir ?? "/data/labels",
ResultsDir = request.ResultsDir ?? "/data/results",
ThumbnailsDir = request.ThumbnailsDir ?? "/data/thumbnails",
GpsSatDir = request.GpsSatDir ?? "/data/gps_sat",
GpsRouteDir = request.GpsRouteDir ?? "/data/gps_route"
});
pathResolver.Reset();
return;
}
await db.DirectorySettings
.Where(x => x.Id == existing.Id)
.Set(x => x.VideosDir, request.VideosDir ?? existing.VideosDir)
.Set(x => x.ImagesDir, request.ImagesDir ?? existing.ImagesDir)
.Set(x => x.LabelsDir, request.LabelsDir ?? existing.LabelsDir)
.Set(x => x.ResultsDir, request.ResultsDir ?? existing.ResultsDir)
.Set(x => x.ThumbnailsDir, request.ThumbnailsDir ?? existing.ThumbnailsDir)
.Set(x => x.GpsSatDir, request.GpsSatDir ?? existing.GpsSatDir)
.Set(x => x.GpsRouteDir, request.GpsRouteDir ?? existing.GpsRouteDir)
.UpdateAsync();
pathResolver.Reset();
}
public async Task<UserSettings?> GetUserSettings(Guid userId)
{
return await db.UserSettings.FirstOrDefaultAsync(x => x.UserId == userId);
}
public async Task UpdateUserSettings(Guid userId, UpdateUserSettingsRequest request)
{
var existing = await db.UserSettings.FirstOrDefaultAsync(x => x.UserId == userId);
if (existing == null)
{
await db.InsertAsync(new UserSettings
{
Id = Guid.NewGuid(),
UserId = userId,
SelectedFlightId = request.SelectedFlightId,
AnnotationsLeftPanelWidth = request.AnnotationsLeftPanelWidth,
AnnotationsRightPanelWidth = request.AnnotationsRightPanelWidth,
DatasetLeftPanelWidth = request.DatasetLeftPanelWidth,
DatasetRightPanelWidth = request.DatasetRightPanelWidth
});
return;
}
await db.UserSettings
.Where(x => x.Id == existing.Id)
.Set(x => x.SelectedFlightId, request.SelectedFlightId ?? existing.SelectedFlightId)
.Set(x => x.AnnotationsLeftPanelWidth, request.AnnotationsLeftPanelWidth ?? existing.AnnotationsLeftPanelWidth)
.Set(x => x.AnnotationsRightPanelWidth, request.AnnotationsRightPanelWidth ?? existing.AnnotationsRightPanelWidth)
.Set(x => x.DatasetLeftPanelWidth, request.DatasetLeftPanelWidth ?? existing.DatasetLeftPanelWidth)
.Set(x => x.DatasetRightPanelWidth, request.DatasetRightPanelWidth ?? existing.DatasetRightPanelWidth)
.UpdateAsync();
}
public async Task<CameraSettings?> GetCameraSettings()
{
return await db.CameraSettings.FirstOrDefaultAsync();
}
public async Task UpdateCameraSettings(UpdateCameraSettingsRequest request)
{
var existing = await db.CameraSettings.FirstOrDefaultAsync();
if (existing == null)
{
await db.InsertAsync(new CameraSettings
{
Id = Guid.NewGuid(),
Altitude = request.Altitude ?? 100,
FocalLength = request.FocalLength ?? 50,
SensorWidth = request.SensorWidth ?? 36
});
return;
}
await db.CameraSettings
.Where(x => x.Id == existing.Id)
.Set(x => x.Altitude, request.Altitude ?? existing.Altitude)
.Set(x => x.FocalLength, request.FocalLength ?? existing.FocalLength)
.Set(x => x.SensorWidth, request.SensorWidth ?? existing.SensorWidth)
.UpdateAsync();
}
}
+87
View File
@@ -0,0 +1,87 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace Azaion.Annotations.Services;
public class TokenService
{
private readonly string _jwtSecret;
private readonly double _accessTokenHours;
public TokenService(string jwtSecret, double accessTokenHours = 4)
{
_jwtSecret = jwtSecret;
_accessTokenHours = accessTokenHours;
}
public string? RefreshAccessToken(string refreshToken)
{
var principal = ValidateToken(refreshToken);
if (principal == null)
return null;
var tokenType = principal.FindFirstValue("token_type");
if (tokenType != "refresh")
return null;
var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
var email = principal.FindFirstValue(ClaimTypes.Name);
var role = principal.FindFirstValue(ClaimTypes.Role);
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(email))
return null;
return CreateAccessToken(userId, email, role);
}
private string CreateAccessToken(string userId, string email, string? role)
{
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecret));
var tokenHandler = new JwtSecurityTokenHandler();
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, userId),
new(ClaimTypes.Name, email),
new("token_type", "access")
};
if (!string.IsNullOrEmpty(role))
claims.Add(new Claim(ClaimTypes.Role, role));
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.AddHours(_accessTokenHours),
SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256Signature)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
private ClaimsPrincipal? ValidateToken(string token)
{
var tokenHandler = new JwtSecurityTokenHandler();
var validationParams = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecret)),
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
};
try
{
return tokenHandler.ValidateToken(token, validationParams, out _);
}
catch
{
return null;
}
}
}