-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ Add initial version of the image service backend.
- Loading branch information
Showing
17 changed files
with
400 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
|
||
|
36 changes: 36 additions & 0 deletions
36
src/Exo/Core/Exo.Core/Configuration/ImageNameSerializer.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
17
src/Exo/Core/Exo.Core/Configuration/UInt128NameSerializer.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,4 +9,5 @@ public enum ImageFormat | |
Gif = 2, | ||
Jpeg = 3, | ||
Png = 4, | ||
WebP = 5, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -10,4 +10,5 @@ public enum ImageFormats | |
Gif = 0x00000100, | ||
Jpeg = 0x00001000, | ||
Png = 0x00010000, | ||
WebP = 0x00100000, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
Oops, something went wrong.