Skip to content

Commit

Permalink
✨ Add initial version of the image service backend.
Browse files Browse the repository at this point in the history
  • Loading branch information
hexawyz committed Jan 18, 2025
1 parent 1724cb9 commit 034266b
Show file tree
Hide file tree
Showing 17 changed files with 400 additions and 5 deletions.
2 changes: 1 addition & 1 deletion src/Exo/Core/Exo.Core/Configuration/GuidNameSerializer.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;

namespace Exo.Configuration;

Expand Down
36 changes: 36 additions & 0 deletions src/Exo/Core/Exo.Core/Configuration/ImageNameSerializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System.Buffers;
using System.Diagnostics.CodeAnalysis;

namespace Exo.Configuration;

public sealed class ImageNameSerializer : INameSerializer<string>
{
private static readonly SearchValues<char> AllowedCharacters = SearchValues.Create("+-0123456789=ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz");

public static bool IsNameValid(ReadOnlySpan<char> name) => !name.ContainsAnyExcept(AllowedCharacters);

public static ImageNameSerializer Instance = new();

private ImageNameSerializer() { }

public string FileNamePattern => "*";

public string Parse(ReadOnlySpan<char> text)
{
if (!IsNameValid(text)) throw new ArgumentException("Invalid character.");
return text.ToString();
}

public bool TryParse(ReadOnlySpan<char> text, [NotNullWhen(true)] out string? result)
{
if (!IsNameValid(text))
{
result = null;
return false;
}
result = text.ToString();
return true;
}

public string ToString(string value) => value;
}
17 changes: 17 additions & 0 deletions src/Exo/Core/Exo.Core/Configuration/UInt128NameSerializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;

namespace Exo.Configuration;

public sealed class UInt128NameSerializer : INameSerializer<UInt128>
{
public static UInt128NameSerializer Instance = new();

private UInt128NameSerializer() { }

public string FileNamePattern => "????????????????????????????????";

public UInt128 Parse(ReadOnlySpan<char> text) => UInt128.Parse(text, NumberStyles.HexNumber, CultureInfo.InvariantCulture);
public bool TryParse(ReadOnlySpan<char> text, [NotNullWhen(true)] out UInt128 result) => UInt128.TryParse(text, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out result);
public string ToString(UInt128 value) => value.ToString("X32", CultureInfo.InvariantCulture);
}
1 change: 1 addition & 0 deletions src/Exo/Core/Exo.Core/Images/ImageFormat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ public enum ImageFormat
Gif = 2,
Jpeg = 3,
Png = 4,
WebP = 5,
}
1 change: 1 addition & 0 deletions src/Exo/Core/Exo.Core/Images/ImageFormats.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ public enum ImageFormats
Gif = 0x00000100,
Jpeg = 0x00001000,
Png = 0x00010000,
WebP = 0x00100000,
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ namespace Exo.Service;

internal static class ConfigurationContainerNames
{
public const string Images = "img";
public const string Devices = "dev";
public const string Discovery = "dcv";
public const string DiscoveryFactory = "fac";
Expand Down
10 changes: 6 additions & 4 deletions src/Exo/Service/Exo.Service.Core/Exo.Service.Core.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
Expand All @@ -20,9 +20,11 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="System.Reflection.MetadataLoadContext" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="SixLabors.ImageSharp" />
<PackageReference Include="System.IO.Hashing" />
<PackageReference Include="System.Reflection.MetadataLoadContext" />
</ItemGroup>

<ItemGroup>
Expand Down
7 changes: 7 additions & 0 deletions src/Exo/Service/Exo.Service.Core/ImageChangeNotification.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Exo.Service;

internal readonly struct ImageChangeNotification(WatchNotificationKind kind, ImageInformation imageInformation)
{
public WatchNotificationKind Kind { get; } = kind;
public ImageInformation ImageInformation { get; } = imageInformation;
}
14 changes: 14 additions & 0 deletions src/Exo/Service/Exo.Service.Core/ImageInformation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Exo.Images;

namespace Exo.Service;

internal readonly struct ImageInformation(UInt128 imageId, string imageName, string fileName, ushort width, ushort height, ImageFormat format, bool isAnimated)
{
public UInt128 ImageId { get; } = imageId;
public string ImageName { get; } = imageName;
public string FileName { get; } = fileName;
public ushort Width { get; } = width;
public ushort Height { get; } = height;
public ImageFormat Format { get; } = format;
public bool IsAnimated { get; } = isAnimated;
}
176 changes: 176 additions & 0 deletions src/Exo/Service/Exo.Service.Core/ImagesService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
using System.Globalization;
using System.IO.Hashing;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Exo.Configuration;
using Exo.Images;

namespace Exo.Service;

internal sealed class ImagesService
{
[TypeId(0x1D185C1A, 0x4903, 0x4D4A, 0x91, 0x20, 0x69, 0x4A, 0xE5, 0x2C, 0x07, 0x7A)]
private readonly struct ImageMetadata(UInt128 id, ushort width, ushort height, ImageFormat format, bool isAnimated)
{
public UInt128 Id { get; } = id;
public ushort Width { get; } = width;
public ushort Height { get; } = height;
public ImageFormat Format { get; } = format;
public bool IsAnimated { get; } = isAnimated;
}

public async Task<ImagesService> CreateAsync(IConfigurationContainer<string> imagesConfigurationContainer, string imageCacheDirectory, CancellationToken cancellationToken)
{
if (!Path.IsPathRooted(imageCacheDirectory)) throw new ArgumentException("Images directory path must be rooted.");

imageCacheDirectory = Path.GetFullPath(imageCacheDirectory);
Directory.CreateDirectory(imageCacheDirectory);

var imageNames = await imagesConfigurationContainer.GetKeysAsync(cancellationToken).ConfigureAwait(false);

var imageCollection = new Dictionary<string, ImageMetadata>(StringComparer.OrdinalIgnoreCase);

foreach (var imageName in imageNames)
{
var result = await imagesConfigurationContainer.ReadValueAsync<ImageMetadata>(imageName, cancellationToken).ConfigureAwait(false);
if (result.Found)
{
if (!File.Exists(Path.Combine(imageCacheDirectory, imageName + ".dat")))
{
// TODO: Log warning about missing image being removed from the collection.
await imagesConfigurationContainer.DeleteValueAsync<ImageMetadata>(imageName).ConfigureAwait(false);
}
imageCollection.Add(imageName, result.Value);
}
}

return new(imagesConfigurationContainer, imageCacheDirectory, imageCollection);
}

private readonly Dictionary<string, ImageMetadata> _imageCollection;
private readonly IConfigurationContainer<string> _imagesConfigurationContainer;
private readonly string _imageCacheDirectory;
private ChannelWriter<ImageChangeNotification>[]? _changeListeners;
private readonly AsyncLock _lock;

private ImagesService(IConfigurationContainer<string> imagesConfigurationContainer, string imageCacheDirectory, Dictionary<string, ImageMetadata> imageCollection)
{
_imagesConfigurationContainer = imagesConfigurationContainer;
_imageCacheDirectory = imageCacheDirectory;
_imageCollection = imageCollection;
_lock = new();
}

private string GetFileName(UInt128 imageId) => Path.Combine(_imageCacheDirectory, imageId.ToString("X32", CultureInfo.InvariantCulture));

public async IAsyncEnumerable<ImageChangeNotification> WatchChangesAsync([EnumeratorCancellation] CancellationToken cancellationToken)
{
var channel = Watcher.CreateSingleWriterChannel<ImageChangeNotification>();

ImageInformation[]? images;
using (await _lock.WaitAsync(cancellationToken).ConfigureAwait(false))
{
images = new ImageInformation[_imageCollection.Count];
int i = 0;
foreach (var (name, metadata) in _imageCollection)
{
images[i++] = new(metadata.Id, name, GetFileName(metadata.Id), metadata.Width, metadata.Height, metadata.Format, metadata.IsAnimated);
}
ArrayExtensions.InterlockedAdd(ref _changeListeners, channel);
}

try
{
foreach (var image in images)
{
yield return new(WatchNotificationKind.Enumeration, image);
}
images = null;

await foreach (var notification in channel.Reader.ReadAllAsync(cancellationToken))
{
yield return notification;
}
}
finally
{
ArrayExtensions.InterlockedRemove(ref _changeListeners, channel);
}
}

public async ValueTask AddImageAsync(string imageName, byte[] data, CancellationToken cancellationToken)
{
if (!ImageNameSerializer.IsNameValid(imageName)) throw new ArgumentException("Invalid name.");
using (await _lock.WaitAsync(cancellationToken).ConfigureAwait(false))
{
if (_imageCollection.ContainsKey(imageName)) throw new ArgumentException("Name already in use.");

var info = SixLabors.ImageSharp.Image.Identify(data);

ImageFormat imageFormat;
bool isAnimated = false;

if (info.Metadata.DecodedImageFormat == SixLabors.ImageSharp.Formats.Bmp.BmpFormat.Instance)
{
imageFormat = ImageFormat.Bitmap;
}
else if (info.Metadata.DecodedImageFormat == SixLabors.ImageSharp.Formats.Gif.GifFormat.Instance)
{
imageFormat = ImageFormat.Gif;
isAnimated = info.FrameMetadataCollection.Count > 1;
}
else if (info.Metadata.DecodedImageFormat == SixLabors.ImageSharp.Formats.Png.PngFormat.Instance)
{
imageFormat = ImageFormat.Png;
}
else if (info.Metadata.DecodedImageFormat == SixLabors.ImageSharp.Formats.Jpeg.JpegFormat.Instance)
{
imageFormat = ImageFormat.Jpeg;
}
else if (info.Metadata.DecodedImageFormat == SixLabors.ImageSharp.Formats.Webp.WebpFormat.Instance)
{
imageFormat = ImageFormat.WebP;
isAnimated = info.FrameMetadataCollection.Count > 1;
}
else
{
throw new InvalidDataException("Invalid image format.");
}

var metadata = new ImageMetadata
(
XxHash128.HashToUInt128(data, unchecked((long)0x90AB71E534FD62C8U)),
checked((ushort)info.Width),
checked((ushort)info.Height),
imageFormat,
isAnimated
);

string fileName = GetFileName(metadata.Id);
if (File.Exists(fileName)) throw new InvalidOperationException("An image with the same data already exists.");

await _imagesConfigurationContainer.WriteValueAsync(imageName, metadata, cancellationToken).ConfigureAwait(false);

await File.WriteAllBytesAsync(fileName, data, cancellationToken).ConfigureAwait(false);

if (Volatile.Read(ref _changeListeners) is { } changeListeners)
{
changeListeners.TryWrite(new(WatchNotificationKind.Addition, new(metadata.Id, imageName, fileName, metadata.Width, metadata.Height, metadata.Format, metadata.IsAnimated)));
}
}
}

public async ValueTask RemoveImageAsync(string imageName, CancellationToken cancellationToken)
{
if (!ImageNameSerializer.IsNameValid(imageName)) throw new ArgumentException("Invalid name.");
using (await _lock.WaitAsync(cancellationToken).ConfigureAwait(false))
{
if (_imageCollection.TryGetValue(imageName, out var metadata))
{
string fileName = GetFileName(metadata.Id);
File.Delete(fileName);
_imageCollection.Remove(imageName);
}
}
}
}
26 changes: 26 additions & 0 deletions src/Exo/Service/Exo.Service.Grpc/GrpcConvert.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,13 @@
using GrpcSensorInformation = Exo.Contracts.Ui.Settings.SensorInformation;
using GrpcVendorIdSource = Exo.Contracts.Ui.Settings.VendorIdSource;
using GrpcWatchNotificationKind = Exo.Contracts.Ui.WatchNotificationKind;
using GrpcImageInformation = Exo.Contracts.Ui.Settings.ImageInformation;
using GrpcImageFormat = Exo.Contracts.Ui.Settings.ImageFormat;
using VendorIdSource = DeviceTools.VendorIdSource;
using Exo.Contracts.Ui.Settings.Cooling;
using Exo.Cooling.Configuration;
using System.Numerics;
using Exo.Images;

namespace Exo.Service.Grpc;

Expand Down Expand Up @@ -124,6 +127,17 @@ public static GrpcSensorDeviceInformation ToGrpc(this SensorDeviceInformation se
Sensors = ImmutableArray.CreateRange(sensorDeviceInformation.Sensors, ToGrpc),
};

public static GrpcImageInformation ToGrpc(this ImageInformation imageInformation)
=> new()
{
ImageName = imageInformation.ImageName,
FileName = imageInformation.FileName,
Width = imageInformation.Width,
Height = imageInformation.Height,
Format = imageInformation.Format.ToGrpc(),
IsAnimated = imageInformation.IsAnimated,
};

public static GrpcSensorInformation ToGrpc(this SensorInformation sensorInformation)
=> new()
{
Expand Down Expand Up @@ -429,4 +443,16 @@ public static GrpcMetadataArchiveCategory ToGrpc(this MetadataArchiveCategory ca
MetadataArchiveCategory.Coolers => GrpcMetadataArchiveCategory.Coolers,
_ => throw new NotImplementedException()
};

public static GrpcImageFormat ToGrpc(this ImageFormat imageFormat)
=> imageFormat switch
{
ImageFormat.Raw => GrpcImageFormat.Raw,
ImageFormat.Bitmap => GrpcImageFormat.Bitmap,
ImageFormat.Gif => GrpcImageFormat.Gif,
ImageFormat.Jpeg => GrpcImageFormat.Jpeg,
ImageFormat.Png => GrpcImageFormat.Png,
ImageFormat.WebP => GrpcImageFormat.WebP,
_ => throw new NotImplementedException(),
};
}
33 changes: 33 additions & 0 deletions src/Exo/Service/Exo.Service.Grpc/GrpcImagesService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Runtime.CompilerServices;
using Exo.Contracts.Ui.Settings;

namespace Exo.Service.Grpc;

internal sealed class GrpcImagesService : IImagesService
{
private readonly ImagesService _imagesService;

public GrpcImagesService(ImagesService imagesService) => _imagesService = imagesService;

public async IAsyncEnumerable<WatchNotification<Contracts.Ui.Settings.ImageInformation>> WatchImagesAsync([EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var notification in _imagesService.WatchChangesAsync(cancellationToken).ConfigureAwait(false))
{
yield return new()
{
NotificationKind = notification.Kind.ToGrpc(),
Details = notification.ImageInformation.ToGrpc(),
};
}
}

public async ValueTask AddImageAsync(ImageRegistrationRequest request, CancellationToken cancellationToken)
{
await _imagesService.AddImageAsync(request.ImageName, request.Data, cancellationToken).ConfigureAwait(false);
}

public async ValueTask RemoveImageAsync(ImageReference request, CancellationToken cancellationToken)
{
await _imagesService.RemoveImageAsync(request.ImageName, cancellationToken).ConfigureAwait(false);
}
}
Loading

0 comments on commit 034266b

Please sign in to comment.