CDN-CS stuff

This commit is contained in:
Rory&
2026-01-11 16:31:22 +01:00
parent 018beebdc2
commit 654fcc29b5
22 changed files with 1055 additions and 2 deletions

View File

@@ -12,7 +12,7 @@ if [ -n "$(find "flake.lock" -mtime +7 -print)" ]; then
nix-shell $0
exit $?
else
nix flake update --extra-experimental-features 'nix-command flakes'
nix flake update --extra-experimental-features 'nix-command flakes' -vL
git add flake.lock
fi
else
@@ -22,4 +22,4 @@ else
echo "Nix flake lock was updated less than 7 days ago. Skipping update."
fi
npx -y lint-staged
npx -y lint-staged

View File

@@ -0,0 +1,19 @@
namespace Spacebar.AdminApi.Models;
public class StickerModel {
public string Id { get; set; } = null!;
public string Name { get; set; } = null!;
public string? Description { get; set; }
public bool? Available { get; set; }
public string? Tags { get; set; }
public string? PackId { get; set; }
public string? GuildId { get; set; }
public string? UserId { get; set; }
public int Type { get; set; }
public int FormatType { get; set; }
// public virtual Guild? Guild { get; set; }
// public virtual StickerPack? Pack { get; set; }
// public virtual ICollection<StickerPack> StickerPacks { get; set; } = new List<StickerPack>();
// public virtual User? User { get; set; }
// public virtual ICollection<Message> Messages { get; set; } = new List<Message>();
}

View File

@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Spacebar.AdminApi.Extensions;
using Spacebar.AdminApi.Models;
using Spacebar.AdminApi.Services;
using Spacebar.Db.Contexts;
using Spacebar.Db.Models;
using Spacebar.RabbitMqUtilities;
namespace Spacebar.AdminApi.Controllers.Media;
[ApiController]
[Route("/media/sticker")]
public class StickerController(ILogger<StickerController> logger, SpacebarDbContext db, RabbitMQService mq, AuthenticationService auth, IServiceProvider sp) : ControllerBase {
[HttpGet("")]
public async IAsyncEnumerable<StickerModel> GetStickers() {
(await auth.GetCurrentUser(Request)).GetRights().AssertHasAllRights(SpacebarRights.Rights.OPERATOR);
// var db2 = sp.CreateScope().ServiceProvider.GetService<SpacebarDbContext>();
var stickers = db.Stickers
.AsNoTracking()
.IgnoreAutoIncludes()
.AsAsyncEnumerable();
await foreach (var sticker in stickers) {
yield return new() {
Id = sticker.Id,
Name = sticker.Name,
Description = sticker.Description,
Available = sticker.Available,
Tags = sticker.Tags,
PackId = sticker.PackId,
GuildId = sticker.GuildId,
UserId = sticker.UserId,
Type = sticker.Type,
FormatType = sticker.FormatType,
};
}
}
}

View File

@@ -0,0 +1,59 @@
using System.Configuration;
using System.Net.Http.Headers;
using ArcaneLibs.Extensions.Streams;
using ImageMagick;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Build.Globbing;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Internal;
using Spacebar.AdminApi.TestClient.Services.Services;
namespace Spacebar.AdminApi.TestClient.Services.Controllers;
[ApiController]
public class GetImageController(LruFileCache lfc, IFileSource fs, DiscordImageResizeService dirs) : ControllerBase {
[HttpGet("/avatars/{_:required}")]
[HttpGet("/emojis/{emoji_id:required}.{ext:required}")]
[HttpGet("/stickers/{sticker_id:required}.{ext:required}")]
[HttpGet("/avatars/{user_id:required}/{avatar_hash:required}.{ext:required}")]
[HttpGet("/banners/{user_id:required}/{user_banner:required}.{ext:required}")]
public async Task<IActionResult> GetImage(string? ext) {
var originalKey = fs.BaseUrl + Request.Path;
var cacheKey = Request.Path + Request.QueryString;
DiscordImageResizeParams resizeParams = new() {
Size = Request.Query.ContainsKey("size") && uint.TryParse(Request.Query["size"], out uint size) ? size : null,
Quality = Request.Query.ContainsKey("quality") && Enum.TryParse<DiscordImageResizeQuality>(Request.Query["quality"], true, out var quality) ? quality : DiscordImageResizeQuality.High,
KeepAspectRatio = !Request.Query.ContainsKey("keepAspectRatio") || !bool.TryParse(Request.Query["keepAspectRatio"], out bool kar) || kar,
Passthrough = Request.Query.ContainsKey("passthrough") && bool.TryParse(Request.Query["passthrough"], out bool pt) && pt,
Animated = Request.Query.ContainsKey("animated") && bool.TryParse(Request.Query["animated"], out bool an) && an,
SpacebarAllowUpscale = Request.Query.ContainsKey("allowUpscale") && bool.TryParse(Request.Query["allowUpscale"], out bool au) && au,
SpacebarOptimiseGif = Request.Query.ContainsKey("optimiseGif") && bool.TryParse(Request.Query["optimiseGif"], out bool og) && og
};
var entry = await lfc.GetOrAdd(cacheKey, async () => {
var original = await fs.GetFile(Request.Path);
if (Request.Query.Any()) {
using var img = await original.ToMagickImageCollectionAsync();
dirs.Apply(img, resizeParams);
var outStream = new MemoryStream();
await img.WriteAsync(outStream, img.First().Format);
outStream.Position = 0;
return new LruFileCache.Entry() {
Data = outStream.ReadToEnd().ToArray(),
MimeType = original.MimeType
};
}
return new LruFileCache.Entry() {
Data = original.Stream.ReadToEnd().ToArray(),
MimeType = original.MimeType
};
});
// byte array with mime type result
return new FileContentResult(entry.Data, entry.MimeType);
}
}

View File

@@ -0,0 +1,149 @@
using System.Configuration;
using System.Net.Http.Headers;
using ArcaneLibs.Extensions.Streams;
using ImageMagick;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Build.Globbing;
using Spacebar.AdminApi.TestClient.Services.Services;
namespace Spacebar.AdminApi.TestClient.Services.Controllers;
[ApiController]
public class IsPixelArtController(LruFileCache lfc, IFileSource fs, PixelArtDetectionService pads, DiscordImageResizeService dirs) : ControllerBase {
private static readonly LruCache<bool> _isPixelArtCache = new(100_000);
private static readonly LruFileCache _edgeCache = new(100_000_000);
[HttpGet("/isPixelArt/{*_:required}")]
public async Task<bool> IsPixelArt() {
return await _isPixelArtCache.GetOrAddAsync(Request.Path.ToString(), async () => {
await using var original = await fs.GetFile(Request.Path.ToString().Replace("/isPixelArt", ""));
using var img = await original.ToMagickImageCollectionAsync();
return pads.IsPixelArt(img);
});
}
[HttpGet("/isCartoonArt/{*_:required}")]
public async Task<bool> IsCartoonArt() {
return await _isPixelArtCache.GetOrAddAsync(Request.Path.ToString(), async () => {
await using var original = await fs.GetFile(Request.Path.ToString().Replace("/isCartoonArt", ""));
using var img = await original.ToMagickImageCollectionAsync();
return pads.IsCartoonArt(img);
});
}
[HttpGet("/edges/{*_:required}")]
public async Task<FileContentResult> GetEdges([FromQuery] string applyMode = "pre") {
return await _edgeCache.GetOrAdd(Request.Path.ToString() + Request.QueryString, async () => {
var original = await fs.GetFile(Request.Path.ToString().Replace("/edges", ""));
DiscordImageResizeParams resizeParams = new() {
Size = Request.Query.ContainsKey("size") && uint.TryParse(Request.Query["size"], out uint size) ? size : null,
Quality = Request.Query.ContainsKey("quality") && Enum.TryParse<DiscordImageResizeQuality>(Request.Query["quality"], true, out var quality) ? quality : DiscordImageResizeQuality.High,
KeepAspectRatio = !Request.Query.ContainsKey("keepAspectRatio") || !bool.TryParse(Request.Query["keepAspectRatio"], out bool kar) || kar,
Passthrough = Request.Query.ContainsKey("passthrough") && bool.TryParse(Request.Query["passthrough"], out bool pt) && pt,
Animated = Request.Query.ContainsKey("animated") && bool.TryParse(Request.Query["animated"], out bool an) && an,
SpacebarAllowUpscale = Request.Query.ContainsKey("allowUpscale") && bool.TryParse(Request.Query["allowUpscale"], out bool au) && au,
SpacebarOptimiseGif = Request.Query.ContainsKey("optimiseGif") && bool.TryParse(Request.Query["optimiseGif"], out bool og) && og
};
double radius = 1;
if (Request.Query.ContainsKey("radius")) double.TryParse(Request.Query["radius"], out radius);
var img = await original.ToMagickImageCollectionAsync();
// if (applyMode == "pre") img = dirs.Apply(img,resizeParams);
int inFrames = img.Count;
using var edged = pads.RenderEdges(img, radius);
// if (applyMode == "post") img = dirs.Apply(img,resizeParams);
Console.WriteLine($"Generated edges for {Request.Path}, radius={radius}, inFrames={inFrames}, outFrames={edged.Count}");
return new LruFileCache.Entry {
Data = edged.ToByteArray(),
// MimeType = Mimes.GetMime(img.First().Format),
MimeType = "image/apng"
};
}).ContinueWith(t => File(t.Result.Data, t.Result.MimeType));
}
[HttpGet("/posterize/{*_:required}")]
public async Task<FileContentResult> Posterize() {
return await _edgeCache.GetOrAdd(Request.Path.ToString() + Request.QueryString, async () => {
var original = await fs.GetFile(Request.Path.ToString().Replace("/posterize", ""));
DiscordImageResizeParams resizeParams = new() {
Size = Request.Query.ContainsKey("size") && uint.TryParse(Request.Query["size"], out uint size) ? size : null,
Quality = Request.Query.ContainsKey("quality") && Enum.TryParse<DiscordImageResizeQuality>(Request.Query["quality"], true, out var quality) ? quality : DiscordImageResizeQuality.High,
KeepAspectRatio = !Request.Query.ContainsKey("keepAspectRatio") || !bool.TryParse(Request.Query["keepAspectRatio"], out bool kar) || kar,
Passthrough = Request.Query.ContainsKey("passthrough") && bool.TryParse(Request.Query["passthrough"], out bool pt) && pt,
Animated = Request.Query.ContainsKey("animated") && bool.TryParse(Request.Query["animated"], out bool an) && an,
SpacebarAllowUpscale = Request.Query.ContainsKey("allowUpscale") && bool.TryParse(Request.Query["allowUpscale"], out bool au) && au
};
double radius = 1;
if (Request.Query.ContainsKey("radius")) double.TryParse(Request.Query["radius"], out radius);
var img = await original.ToMagickImageCollectionAsync();
int inFrames = img.Count;
// using var edged = pads.RenderEdges(img, radius);
foreach (var frame in img) {
// get major color count
frame.ColorFuzz = new Percentage(25);
frame.Posterize(16, DitherMethod.No);
frame.ColorFuzz = new Percentage(0);
}
if(resizeParams.Size.HasValue)
img = dirs.Apply(img,resizeParams);
Console.WriteLine($"Generated edges for {Request.Path}, radius={radius}, inFrames={inFrames}, outFrames={img.Count}");
return new LruFileCache.Entry {
Data = img.ToByteArray(),
// MimeType = Mimes.GetMime(img.First().Format),
MimeType = "image/apng"
};
}).ContinueWith(t => File(t.Result.Data, t.Result.MimeType));
}
[HttpGet("/colorFuzz/{*_:required}")]
public async Task<FileContentResult> ColorFuzz() {
return await _edgeCache.GetOrAdd(Request.Path.ToString() + Request.QueryString, async () => {
var original = await fs.GetFile(Request.Path.ToString().Replace("/colorFuzz", ""));
DiscordImageResizeParams resizeParams = new() {
Size = Request.Query.ContainsKey("size") && uint.TryParse(Request.Query["size"], out uint size) ? size : null,
Quality = Request.Query.ContainsKey("quality") && Enum.TryParse<DiscordImageResizeQuality>(Request.Query["quality"], true, out var quality) ? quality : DiscordImageResizeQuality.High,
KeepAspectRatio = !Request.Query.ContainsKey("keepAspectRatio") || !bool.TryParse(Request.Query["keepAspectRatio"], out bool kar) || kar,
Passthrough = Request.Query.ContainsKey("passthrough") && bool.TryParse(Request.Query["passthrough"], out bool pt) && pt,
Animated = Request.Query.ContainsKey("animated") && bool.TryParse(Request.Query["animated"], out bool an) && an,
SpacebarAllowUpscale = Request.Query.ContainsKey("allowUpscale") && bool.TryParse(Request.Query["allowUpscale"], out bool au) && au
};
double colorFuzz = 1;
if (Request.Query.ContainsKey("colorFuzz")) double.TryParse(Request.Query["colorFuzz"], out colorFuzz);
var img = await original.ToMagickImageCollectionAsync();
int inFrames = img.Count;
// using var edged = pads.RenderEdges(img, radius);
foreach (var frame in img) {
// get major color count
frame.ColorFuzz = new Percentage(colorFuzz);
}
if(resizeParams.Size.HasValue)
img = dirs.Apply(img,resizeParams);
Console.WriteLine($"Generated colorFuzz for {Request.Path}, fuzz={colorFuzz}, inFrames={inFrames}, outFrames={img.Count}");
return new LruFileCache.Entry {
Data = img.ToByteArray(),
// MimeType = Mimes.GetMime(img.First().Format),
MimeType = "image/apng"
};
}).ContinueWith(t => File(t.Result.Data, t.Result.MimeType));
}
}

View File

@@ -0,0 +1,20 @@
using ImageMagick;
namespace Spacebar.AdminApi.TestClient.Services;
public static class Mimes {
private static string PrintLogged(string msg, string mime) {
Console.WriteLine($"{msg}: {mime}");
return mime;
}
public static string GetMime(MagickFormat fmt) => fmt switch {
MagickFormat.Png => "image/png",
MagickFormat.Jpeg => "image/jpeg",
MagickFormat.Gif => "image/gif",
MagickFormat.Bmp => "image/bmp",
MagickFormat.Tiff => "image/tiff",
MagickFormat.WebP => "image/webp",
_ => PrintLogged("Unknown mime for format " + fmt.ToString() + "!", "application/octet-stream")
};
}

View File

@@ -0,0 +1,66 @@
using ImageMagick;
using Spacebar.AdminApi.TestClient.Services;
using Spacebar.AdminApi.TestClient.Services.Services;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddSingleton<IFileSource>(new ProxyFileSource("http://cdn.old.server.spacebar.chat"));
builder.Services.AddSingleton<LruFileCache>(new LruFileCache(1*1024*1024*1024));
builder.Services.AddSingleton<PixelArtDetectionService>();
builder.Services.AddSingleton<DiscordImageResizeService>();
builder.Services.AddControllers();
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) app.MapOpenApi();
app.UseAuthorization();
app.MapControllers();
app.Use((context, next) => {
context.Response.Headers["Access-Control-Allow-Origin"] = "*";
context.Response.Headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS";
context.Response.Headers["Access-Control-Allow-Headers"] = "*, Authorization";
if (context.Request.Method == "OPTIONS") {
context.Response.StatusCode = 200;
return Task.CompletedTask;
}
return next();
});
// fallback to proxy in case we dont have a specific endpoint...
app.MapFallback("{*_}",async context => {
var client = new StreamingHttpClient();
var requestMessage = new HttpRequestMessage(
new HttpMethod(context.Request.Method),
"http://cdn.old.server.spacebar.chat" + context.Request.Path + context.Request.QueryString
) {
Content = new StreamContent(context.Request.Body)
};
Console.WriteLine(requestMessage.RequestUri);
foreach (var header in context.Request.Headers)
if (header.Key is not ("Accept-Encoding" or "Host"))
requestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray());
var responseMessage = await client.SendUnhandledAsync(requestMessage, CancellationToken.None);
context.Response.StatusCode = (int)responseMessage.StatusCode;
foreach (var header in responseMessage.Headers) context.Response.Headers[header.Key] = header.Value.ToArray();
foreach (var header in responseMessage.Content.Headers) context.Response.Headers[header.Key] = header.Value.ToArray();
await responseMessage.Content.CopyToAsync(context.Response.Body);
});
Console.WriteLine("Pre-initializing Magick.NET...");
MagickNET.Initialize();
StreamingHttpClient.LogRequests = false;
app.Run();

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5114",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"LD_LIBRARY_PATH": "/home/Rory/git/spacebar/server-master/extra/admin-api/Spacebar.Cdn/result-lib/lib/"
}
}
}
}

View File

@@ -0,0 +1,54 @@
using System.Runtime.Serialization;
using ImageMagick;
using Microsoft.AspNetCore.Mvc;
namespace Spacebar.AdminApi.TestClient.Services.Services;
public class DiscordImageResizeParams {
public uint? Size { get; set; }
public DiscordImageResizeQuality Quality { get; set; } = DiscordImageResizeQuality.High;
public bool KeepAspectRatio { get; set; } = true;
public bool Passthrough { get; set; } = true;
public bool Animated { get; set; } = true;
public bool SpacebarAllowUpscale { get; set; } = false;
public bool SpacebarOptimiseGif {get;set;} = true;
}
public enum DiscordImageResizeQuality {
[EnumMember(Value = "low")] Low,
[EnumMember(Value = "high")] High,
[EnumMember(Value = "lossless")] Lossless
}
public class DiscordImageResizeService(PixelArtDetectionService pads) {
public MagickImageCollection Apply(MagickImageCollection img, DiscordImageResizeParams resizeParams) {
if (resizeParams.Passthrough) return img;
if (img.First().Format == MagickFormat.Gif) {
Console.WriteLine("Coalescing gif for resize");
img.Coalesce();
}
if (resizeParams.Size.HasValue) {
if (resizeParams.Size > 4096)
resizeParams.Size = 4096;
if (img.Max(x => Math.Max(x.Height, x.Width)) > resizeParams.Size || resizeParams.SpacebarAllowUpscale) {
Parallel.ForEach(img, new ParallelOptions(){MaxDegreeOfParallelism = 8}, frame => {
if (resizeParams.Size.HasValue) {
uint oldWidth = frame.Width, oldHeight = frame.Height;
frame.Resize(resizeParams.Size.Value, resizeParams.Size.Value, pads.IsPixelArt(frame) ? FilterType.Point : FilterType.Gaussian);
Console.WriteLine($"Resized frame from {oldWidth}x{oldHeight} to {frame.Width}x{frame.Height}: {img.IndexOf(frame)}/{img.Count}");
}
});
}
}
if (img.First().Format == MagickFormat.Gif && resizeParams.SpacebarOptimiseGif) {
Console.WriteLine("Optimizing gif after resize");
img.OptimizePlus();
}
return img;
}
}

View File

@@ -0,0 +1,50 @@
using ImageMagick;
namespace Spacebar.AdminApi.TestClient.Services.Services;
public interface IFileSource {
public string BaseUrl { get; }
public Task<FileInfo> GetFile(string path, CancellationToken? cancellationToken = null);
}
public class FileInfo : IDisposable, IAsyncDisposable {
public string MimeType { get; set; }
public Stream Stream { get; set; }
protected virtual void Dispose(bool disposing) {
if (disposing) {
Stream.Dispose();
}
}
public void Dispose() {
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual async ValueTask DisposeAsyncCore() => await Stream.DisposeAsync();
public async ValueTask DisposeAsync() {
await DisposeAsyncCore();
GC.SuppressFinalize(this);
}
public async Task<MagickImageCollection> ToMagickImageCollectionAsync() {
var ms = new MemoryStream();
Stream.Position = 0;
await Stream.CopyToAsync(ms);
ms.Position = 0;
var img = MimeType switch {
"image/apng" => new MagickImageCollection(ms, MagickFormat.APng),
_ => new MagickImageCollection(ms)
};
// if (img.First().Format == MagickFormat.Png) {
// img.Dispose();
// ms.Position = 0;
// img = new MagickImageCollection(ms, MagickFormat.APng);
// }
return img;
}
}

View File

@@ -0,0 +1,58 @@
namespace Spacebar.AdminApi.TestClient.Services.Services;
public class LruFileCache(int maxSizeBytes) {
private readonly Dictionary<string, Entry> _entries = new();
public async Task<Entry?> GetOrAdd(string key, Func<Task<Entry>> factory) {
if (_entries.TryGetValue(key, out var entry)) {
entry.LastAccessed = DateTimeOffset.UtcNow;
return entry;
}
entry = await factory();
if (entry.Data.Length > 0)
_entries[key] = entry;
if (_entries.Sum(kv => kv.Value.Data.Length) > maxSizeBytes) {
var oldestKey = _entries.OrderBy(kv => kv.Value.LastAccessed).First().Key;
_entries.Remove(oldestKey);
}
return entry;
}
public class Entry {
public DateTimeOffset LastAccessed { get; set; }
public byte[] Data { get; set; }
public string MimeType { get; set; }
}
}
public class LruCache<T>(int maxItems) {
private readonly Dictionary<string, CacheItem> _items = new();
public async Task<T?> GetOrAddAsync(string key, Func<Task<T>> factory) {
if (_items.TryGetValue(key, out var cacheItem)) {
cacheItem.LastAccessed = DateTimeOffset.UtcNow;
return cacheItem.Value;
}
var value = await factory();
_items[key] = new CacheItem {
Value = value,
LastAccessed = DateTimeOffset.UtcNow
};
if (_items.Count > maxItems) {
var oldestKey = _items.OrderBy(kv => kv.Value.LastAccessed).First().Key;
_items.Remove(oldestKey);
}
return value;
}
private class CacheItem {
public T Value { get; set; }
public DateTimeOffset LastAccessed { get; set; }
}
}

View File

@@ -0,0 +1,80 @@
using ImageMagick;
namespace Spacebar.AdminApi.TestClient.Services.Services;
public class PixelArtDetectionService {
public bool IsPixelArt<T>(IMagickImage<T> img) where T : struct, IConvertible {
// Simple heuristic: if the image has a limited color palette and sharp edges, consider it pixel art
var colorCount = img.Histogram().Count;
var edgeThreshold = 20; // Arbitrary threshold for edge detection
using var edgeImg = img.Clone();
// noise reduction
edgeImg.MedianFilter();
edgeImg.Edge(edgeThreshold);
var edgePixels = edgeImg.Histogram().Count(kv => kv.Value > 0);
bool isPixelArt = colorCount < 64 && edgePixels > (img.Width * img.Height) / 10;
Console.WriteLine($"IsPixelArt check: colors={colorCount}, edgePixels={edgePixels}, width={img.Width}, height={img.Height} => isPixelArt={isPixelArt}");
return isPixelArt;
}
public bool IsCartoonArt<T>(IMagickImage<T> img) where T : struct, IConvertible {
// Simple heuristic: if the image has a limited color palette and smooth edges, consider it cartoon art
var colorCount = img.Histogram().Count;
var edgeThreshold = 5; // Lower threshold for smoother edges
using var edgeImg = img.Clone();
// noise reduction
edgeImg.MedianFilter();
edgeImg.Edge(edgeThreshold);
var edgePixels = edgeImg.Histogram().Count(kv => kv.Value > 0);
bool isCartoonArt = colorCount < 128 && edgePixels < (img.Width * img.Height) / 20;
Console.WriteLine($"IsCartoonArt check: colors={colorCount}, edgePixels={edgePixels}, width={img.Width}, height={img.Height} => isCartoonArt={isCartoonArt}");
return isCartoonArt;
}
public IMagickImage<T> RenderEdges<T>(IMagickImage<T> img, double radius = 1) where T : struct, IConvertible {
var edgeImg = img.Clone();
// edgeImg.Edge(radius);
edgeImg.WaveletDenoise(new Percentage(50));
edgeImg.CannyEdge(radius, sigma: 0.75, lower: new Percentage(10), upper: new Percentage(30));
return edgeImg;
}
public bool IsPixelArt(MagickImageCollection img) {
Console.WriteLine($"Checking IsPixelArt for image with {img.Count} frames");
return img.All(IsPixelArt);
}
public bool IsCartoonArt(MagickImageCollection img) {
Console.WriteLine($"Checking IsCartoonArt for image with {img.Count} frames");
return img.All(IsCartoonArt);
}
public MagickImageCollection RenderEdges(MagickImageCollection img, double radius = 1) {
var edges = img.Clone();
if (edges.Count == 1) {
edges[0] = RenderEdges(edges[0], radius);
// RenderEdges(edges[0], radius);
return (MagickImageCollection)edges;
}
Parallel.ForEach(img, frame => {
var t = new Thread(() => {
var edged = RenderEdges(frame, radius);
var idx = img.IndexOf(frame);
// while (edges.Count < idx) {
// Console.WriteLine($"Waiting to insert edge frame {idx}/{img.Count} (radius={radius}, size={frame.Width}x{frame.Height})");
// Thread.Sleep(1000);
// }
//
lock (edges) edges[idx] = edged;
//
Console.WriteLine($"Edged frame {idx}/{img.Count} (radius={radius}, size={frame.Width}x{frame.Height})");
});
t.Start();
t.Join();
});
return (MagickImageCollection)edges;
}
}

View File

@@ -0,0 +1,29 @@
namespace Spacebar.AdminApi.TestClient.Services.Services;
public class ProxyFileSource(string baseUrl) : IFileSource {
private static LruFileCache _cache = new(100 * 1024 * 1024); // 100 MB
private readonly StreamingHttpClient _httpClient = new() {
BaseAddress = new Uri(baseUrl)
};
public string BaseUrl => baseUrl;
public async Task<FileInfo> GetFile(string path, CancellationToken? cancellationToken = null) {
var res = await _cache.GetOrAdd(path, async () => {
var res = await _httpClient.SendUnhandledAsync(new(HttpMethod.Get, path), cancellationToken);
res.EnsureSuccessStatusCode();
var ms = new MemoryStream();
await res.Content.CopyToAsync(ms);
return new LruFileCache.Entry {
Data = ms.ToArray(),
MimeType = res.Content.Headers.ContentType?.MediaType ?? "application/octet-stream"
};
});
return new() {
Stream = new MemoryStream(res.Data),
MimeType = res.MimeType
};
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ArcaneLibs" Version="1.0.0-preview.20251207-164820" />
<!-- <PackageReference Include="Magick.NET-Q16-HDRI-AnyCPU" Version="14.10.1" />-->
<PackageReference Include="Magick.NET-Q16-HDRI-OpenMP-x64" Version="14.10.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Spacebar.Db\Spacebar.Db.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,304 @@
#define SINGLE_HTTPCLIENT // Use a single HttpClient instance for all MatrixHttpClient instances
// #define SYNC_HTTPCLIENT // Only allow one request as a time, for debugging
using System.Data;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Net.Http.Headers;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using ArcaneLibs;
using ArcaneLibs.Extensions;
namespace Spacebar.AdminApi.TestClient.Services;
#if SINGLE_HTTPCLIENT
// TODO: Add URI wrapper for
public class StreamingHttpClient {
private static readonly HttpClient Client;
static StreamingHttpClient() {
try {
var handler = new SocketsHttpHandler {
PooledConnectionLifetime = TimeSpan.FromMinutes(15),
MaxConnectionsPerServer = 4096,
EnableMultipleHttp2Connections = true
};
Client = new HttpClient(handler) {
DefaultRequestVersion = new Version(3, 0),
Timeout = TimeSpan.FromDays(1)
};
}
catch (PlatformNotSupportedException e) {
Console.WriteLine("Failed to create HttpClient with connection pooling, continuing without connection pool!");
Console.WriteLine("Original exception (safe to ignore!):");
Console.WriteLine(e);
Client = new HttpClient {
DefaultRequestVersion = new Version(3, 0)
};
}
catch (Exception e) {
Console.WriteLine("Failed to create HttpClient:");
Console.WriteLine(e);
throw;
}
}
#if SYNC_HTTPCLIENT
internal SemaphoreSlim _rateLimitSemaphore { get; } = new(1, 1);
#endif
public static bool LogRequests = true;
public Dictionary<string, string> AdditionalQueryParameters { get; set; } = new();
public Uri? BaseAddress { get; set; }
// default headers, not bound to client
public HttpRequestHeaders DefaultRequestHeaders { get; set; } =
typeof(HttpRequestHeaders).GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, null, [], null)?.Invoke([]) as HttpRequestHeaders ??
throw new InvalidOperationException("Failed to create HttpRequestHeaders");
private static JsonSerializerOptions GetJsonSerializerOptions(JsonSerializerOptions? options = null) {
options ??= new JsonSerializerOptions();
// options.Converters.Add(new JsonFloatStringConverter());
// options.Converters.Add(new JsonDoubleStringConverter());
// options.Converters.Add(new JsonDecimalStringConverter());
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
return options;
}
public async Task<HttpResponseMessage> SendUnhandledAsync(HttpRequestMessage request, CancellationToken? cancellationToken) {
if (request.RequestUri is null) throw new NullReferenceException("RequestUri is null");
// if (!request.RequestUri.IsAbsoluteUri)
request.RequestUri = request.RequestUri.EnsureAbsolute(BaseAddress!);
var swWait = Stopwatch.StartNew();
#if SYNC_HTTPCLIENT
await _rateLimitSemaphore.WaitAsync(cancellationToken);
#endif
if (request.RequestUri is null) throw new NullReferenceException("RequestUri is null");
if (!request.RequestUri.IsAbsoluteUri)
request.RequestUri = new Uri(BaseAddress ?? throw new InvalidOperationException("Relative URI passed, but no BaseAddress is specified!"), request.RequestUri);
swWait.Stop();
var swExec = Stopwatch.StartNew();
foreach (var (key, value) in AdditionalQueryParameters) request.RequestUri = request.RequestUri.AddQuery(key, value);
foreach (var (key, value) in DefaultRequestHeaders) {
if (request.Headers.Contains(key)) continue;
request.Headers.Add(key, value);
}
request.Options.Set(new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingResponse"), true);
if (LogRequests)
Console.WriteLine("Sending " + request.Summarise(includeHeaders: true, includeQuery: true, includeContentIfText: false, hideHeaders: ["Accept"]));
HttpResponseMessage? responseMessage;
try {
responseMessage = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken ?? CancellationToken.None);
}
catch (Exception e) {
if (e is TaskCanceledException or TimeoutException) {
if (request.Method == HttpMethod.Get && !(cancellationToken?.IsCancellationRequested ?? false)) {
await Task.Delay(Random.Shared.Next(500, 2500), cancellationToken ?? CancellationToken.None);
request.ResetSendStatus();
return await SendAsync(request, cancellationToken ?? CancellationToken.None);
}
}
else if (!e.ToString().StartsWith("TypeError: NetworkError"))
Console.WriteLine(
$"Failed to send request {request.Method} {BaseAddress}{request.RequestUri} ({Util.BytesToString(request.Content?.Headers.ContentLength ?? 0)}):\n{e}");
throw;
}
#if SYNC_HTTPCLIENT
finally {
_rateLimitSemaphore.Release();
}
#endif
// Console.WriteLine($"Sending {request.Method} {request.RequestUri} ({Util.BytesToString(request.Content?.Headers.ContentLength ?? 0)}) -> {(int)responseMessage.StatusCode} {responseMessage.StatusCode} ({Util.BytesToString(responseMessage.GetContentLength())}, WAIT={swWait.ElapsedMilliseconds}ms, EXEC={swExec.ElapsedMilliseconds}ms)");
if (LogRequests)
Console.WriteLine("Received " + responseMessage.Summarise(includeHeaders: true, includeContentIfText: false, hideHeaders: [
"Server",
"Date",
"Transfer-Encoding",
"Connection",
"Vary",
"Content-Length",
"Access-Control-Allow-Origin",
"Access-Control-Allow-Methods",
"Access-Control-Allow-Headers",
"Access-Control-Expose-Headers",
"Cache-Control",
"Cross-Origin-Resource-Policy",
"X-Content-Security-Policy",
"Referrer-Policy",
"X-Robots-Tag",
"Content-Security-Policy"
]));
return responseMessage;
}
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) {
var responseMessage = await SendUnhandledAsync(request, cancellationToken);
if (responseMessage.IsSuccessStatusCode) return responseMessage;
//retry on gateway timeout
// if (responseMessage.StatusCode == HttpStatusCode.GatewayTimeout) {
// request.ResetSendStatus();
// return await SendAsync(request, cancellationToken);
// }
//error handling
var content = await responseMessage.Content.ReadAsStringAsync(cancellationToken);
if (content.Length == 0)
throw new DataException("Content was empty");
// throw new MatrixException() {
// ErrorCode = "M_UNKNOWN",
// Error = "Unknown error, server returned no content"
// };
// if (!content.StartsWith('{')) throw new InvalidDataException("Encountered invalid data:\n" + content);
if (!content.TrimStart().StartsWith('{')) {
responseMessage.EnsureSuccessStatusCode();
throw new InvalidDataException("Encountered invalid data:\n" + content);
}
//we have a matrix error
throw new Exception("Unknown http exception");
// MatrixException? ex;
// try {
// ex = JsonSerializer.Deserialize<MatrixException>(content);
// }
// catch (JsonException e) {
// throw new LibMatrixException() {
// ErrorCode = "M_INVALID_JSON",
// Error = e.Message + "\nBody:\n" + await responseMessage.Content.ReadAsStringAsync(cancellationToken)
// };
// }
//
// Debug.Assert(ex != null, nameof(ex) + " != null");
// ex.RawContent = content;
// // Console.WriteLine($"Failed to send request: {ex}");
// if (ex.RetryAfterMs is null) throw ex!;
// //we have a ratelimit error
// await Task.Delay(ex.RetryAfterMs.Value, cancellationToken);
request.ResetSendStatus();
return await SendAsync(request, cancellationToken);
}
// GetAsync
public Task<HttpResponseMessage> GetAsync([StringSyntax("Uri")] string? requestUri, CancellationToken? cancellationToken = null) =>
SendAsync(new HttpRequestMessage(HttpMethod.Get, requestUri), cancellationToken ?? CancellationToken.None);
// GetFromJsonAsync
public async Task<T?> TryGetFromJsonAsync<T>(string requestUri, JsonSerializerOptions? options = null, CancellationToken cancellationToken = default) {
try {
return await GetFromJsonAsync<T>(requestUri, options, cancellationToken);
}
catch (JsonException e) {
Console.WriteLine($"Failed to deserialize response from {requestUri}: {e.Message}");
return default;
}
catch (HttpRequestException e) {
Console.WriteLine($"Failed to get {requestUri}: {e.Message}");
return default;
}
}
public async Task<T> GetFromJsonAsync<T>(string requestUri, JsonSerializerOptions? options = null, CancellationToken cancellationToken = default) {
options = GetJsonSerializerOptions(options);
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var response = await SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
return await JsonSerializer.DeserializeAsync<T>(responseStream, options, cancellationToken) ??
throw new InvalidOperationException("Failed to deserialize response");
}
// GetStreamAsync
public async Task<Stream> GetStreamAsync(string requestUri, CancellationToken cancellationToken = default) {
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var response = await SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStreamAsync(cancellationToken);
}
public async Task<HttpResponseMessage> PutAsJsonAsync<T>([StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, T value, JsonSerializerOptions? options = null,
CancellationToken cancellationToken = default) where T : notnull {
options = GetJsonSerializerOptions(options);
var request = new HttpRequestMessage(HttpMethod.Put, requestUri);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Content = new StringContent(JsonSerializer.Serialize(value, value.GetType(), options),
Encoding.UTF8, "application/json");
return await SendAsync(request, cancellationToken);
}
public async Task<HttpResponseMessage> PostAsJsonAsync<T>([StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, T value, JsonSerializerOptions? options = null,
CancellationToken cancellationToken = default) where T : notnull {
options ??= new JsonSerializerOptions();
// options.Converters.Add(new JsonFloatStringConverter());
// options.Converters.Add(new JsonDoubleStringConverter());
// options.Converters.Add(new JsonDecimalStringConverter());
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Content = new StringContent(JsonSerializer.Serialize(value, value.GetType(), options),
Encoding.UTF8, "application/json");
return await SendAsync(request, cancellationToken);
}
public async IAsyncEnumerable<T?> GetAsyncEnumerableFromJsonAsync<T>([StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, JsonSerializerOptions? options = null) {
options = GetJsonSerializerOptions(options);
var res = await GetAsync(requestUri);
options.PropertyNameCaseInsensitive = true;
var result = JsonSerializer.DeserializeAsyncEnumerable<T>(await res.Content.ReadAsStreamAsync(), options);
await foreach (var resp in result) yield return resp;
}
public static async Task<bool> CheckSuccessStatus(string url) {
//cors causes failure, try to catch
try {
var resp = await Client.GetAsync(url);
return resp.IsSuccessStatusCode;
}
catch (Exception e) {
Console.WriteLine($"Failed to check success status: {e.Message}");
return false;
}
}
public async Task<HttpResponseMessage> PostAsync(string uri, HttpContent? content, CancellationToken cancellationToken = default) {
var request = new HttpRequestMessage(HttpMethod.Post, uri) {
Content = content
};
return await SendAsync(request, cancellationToken);
}
public async Task<HttpResponseMessage> DeleteAsync(string url) {
var request = new HttpRequestMessage(HttpMethod.Delete, url);
return await SendAsync(request);
}
public async Task<HttpResponseMessage> DeleteAsJsonAsync<T>(string url, T payload) {
var request = new HttpRequestMessage(HttpMethod.Delete, url) {
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
};
return await SendAsync(request);
}
public async Task<HttpResponseMessage> PatchAsJsonAsync<T>(string url, T payload) {
var request = new HttpRequestMessage(new HttpMethod("PATCH"), url) {
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
};
return await SendAsync(request);
}
}
#endif

View File

@@ -0,0 +1,17 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore.Routing.EndpointMiddleware": "Information",
"Microsoft.AspNetCore.Routing.EndpointRoutingMiddleware": "Information",
"Microsoft.AspNetCore": "Trace", //Warning
"Microsoft.AspNetCore.Mvc": "Warning", //Warning
"Microsoft.AspNetCore.HostFiltering": "Warning", //Warning
"Microsoft.AspNetCore.Cors": "Warning", //Warning
"Microsoft.AspNetCore.server.Kestrel": "Information",
"Microsoft.AspNetCore.Routing.Matching.DfaMatcher": "Information",
// "Microsoft.EntityFrameworkCore": "Warning"
"Microsoft.EntityFrameworkCore.Database.Command": "Debug"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1 @@
/nix/store/xm08aqdd7pxcdhm0ak6aqb1v7hw5q6ri-gcc-14.3.0-lib

View File

@@ -0,0 +1 @@
/nix/store/iayix7sf8n2mcjihl16xxpppv6j2syjm-clr-6.4.3

View File

@@ -0,0 +1 @@
/nix/store/xm08aqdd7pxcdhm0ak6aqb1v7hw5q6ri-gcc-14.3.0-lib

View File

@@ -0,0 +1,62 @@
@page "/StickerCdnTest"
@using System.Net.Http.Headers
@using Spacebar.AdminApi.Models
@using Spacebar.AdminApi.TestClient.Services
@inject Config Config
<h3>StickerCdnTest</h3>
<table>
@foreach (var sticker in Stickers.OrderBy(x=>x.GuildId).ThenBy(x=>x.Id))
{
<tr>
<td>
<img src="@($"{Config.CdnUrl}/stickers/{sticker.Id}.png?optimiseGif=false&applyMode=post")" alt="@sticker.Name" width="128" height="128" />
</td>
<td>
<img src="@($"{Config.CdnUrl}/edges/stickers/{sticker.Id}.png?optimiseGif=false&applyMode=post")" alt="@sticker.Name" width="128" height="128" />
</td>
<td>
PA: @sticker.IsPixelArt
<br/>
CA: @sticker.IsCartoonArt
<br/>
@sticker.Name (ID: @sticker.Id)
</td>
</tr>
}
</table>
@code {
private List<Sticker> Stickers { get; set; } = new();
protected override async Task OnInitializedAsync()
{
var hc = new HttpClient();
hc.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Config.AccessToken);
var response = await hc.GetAsync(Config.AdminUrl + "/_spacebar/admin/media/sticker/");
if (!response.IsSuccessStatusCode) throw new Exception(await response.Content.ReadAsStringAsync());
var content = response.Content.ReadFromJsonAsAsyncEnumerable<Sticker>();
var tasks = new List<Task>();
var ss = new SemaphoreSlim(4, 4);
await foreach (var sticker in content) {
await ss.WaitAsync();
tasks.Add(Task.Run(async () => {
var isPixelArtTask = hc.GetFromJsonAsync<bool>($"{Config.CdnUrl}/isPixelArt/stickers/{sticker.Id}.gif");
var isCartoonArtTask = hc.GetFromJsonAsync<bool>($"{Config.CdnUrl}/isPixelArt/stickers/{sticker.Id}.gif");
sticker.IsPixelArt = await isPixelArtTask;
sticker.IsCartoonArt = await isCartoonArtTask;
Console.WriteLine($"Sticker: {sticker!.Id} - {sticker.Name}, PixelArt: {sticker.IsPixelArt}, CartoonArt: {sticker.IsCartoonArt}");
Stickers.Add(sticker!);
StateHasChanged();
ss.Release();
}));
}
await Task.WhenAll(tasks);
StateHasChanged();
}
private class Sticker : StickerModel {
public bool IsPixelArt { get; set; }
public bool IsCartoonArt { get; set; }
}
}

BIN
flake.lock generated

Binary file not shown.