From 654fcc29b5d2284d301df3f63e9db35c21ec63cf Mon Sep 17 00:00:00 2001 From: Rory& Date: Sun, 11 Jan 2026 16:31:22 +0100 Subject: [PATCH] CDN-CS stuff --- .husky/pre-commit | 4 +- .../Spacebar.AdminApi.Models/StickerModel.cs | 19 ++ .../Controllers/Media/StickerController.cs | 39 +++ .../Controllers/GetImageController.cs | 59 ++++ .../Internal/GetImageController.cs | 149 +++++++++ extra/admin-api/Spacebar.Cdn/Mimes.cs | 20 ++ extra/admin-api/Spacebar.Cdn/Program.cs | 66 ++++ .../Properties/launchSettings.json | 15 + .../Services/DiscordImageResizeService.cs | 54 ++++ .../Spacebar.Cdn/Services/IFileSource.cs | 50 +++ .../Spacebar.Cdn/Services/LruFileCache.cs | 58 ++++ .../Services/PixelArtDetectionService.cs | 80 +++++ .../Spacebar.Cdn/Services/ProxyFileSource.cs | 29 ++ .../Spacebar.Cdn/Spacebar.Cdn.csproj | 20 ++ .../Spacebar.Cdn/StreamingHttpClient.cs | 304 ++++++++++++++++++ .../Spacebar.Cdn/appsettings.Development.json | 17 + extra/admin-api/Spacebar.Cdn/appsettings.json | 9 + extra/admin-api/Spacebar.Cdn/gcc-lib-lib | 1 + extra/admin-api/Spacebar.Cdn/opencl-lib | 1 + extra/admin-api/Spacebar.Cdn/result-lib | 1 + .../Pages/Media/StickerCdnTest.razor | 62 ++++ flake.lock | Bin 1497 -> 1497 bytes 22 files changed, 1055 insertions(+), 2 deletions(-) create mode 100644 extra/admin-api/Spacebar.AdminApi.Models/StickerModel.cs create mode 100644 extra/admin-api/Spacebar.AdminApi/Controllers/Media/StickerController.cs create mode 100644 extra/admin-api/Spacebar.Cdn/Controllers/GetImageController.cs create mode 100644 extra/admin-api/Spacebar.Cdn/Controllers/Internal/GetImageController.cs create mode 100644 extra/admin-api/Spacebar.Cdn/Mimes.cs create mode 100644 extra/admin-api/Spacebar.Cdn/Program.cs create mode 100644 extra/admin-api/Spacebar.Cdn/Properties/launchSettings.json create mode 100644 extra/admin-api/Spacebar.Cdn/Services/DiscordImageResizeService.cs create mode 100644 extra/admin-api/Spacebar.Cdn/Services/IFileSource.cs create mode 100644 extra/admin-api/Spacebar.Cdn/Services/LruFileCache.cs create mode 100644 extra/admin-api/Spacebar.Cdn/Services/PixelArtDetectionService.cs create mode 100644 extra/admin-api/Spacebar.Cdn/Services/ProxyFileSource.cs create mode 100644 extra/admin-api/Spacebar.Cdn/Spacebar.Cdn.csproj create mode 100644 extra/admin-api/Spacebar.Cdn/StreamingHttpClient.cs create mode 100644 extra/admin-api/Spacebar.Cdn/appsettings.Development.json create mode 100644 extra/admin-api/Spacebar.Cdn/appsettings.json create mode 120000 extra/admin-api/Spacebar.Cdn/gcc-lib-lib create mode 120000 extra/admin-api/Spacebar.Cdn/opencl-lib create mode 120000 extra/admin-api/Spacebar.Cdn/result-lib create mode 100644 extra/admin-api/Utilities/Spacebar.AdminApi.TestClient/Pages/Media/StickerCdnTest.razor diff --git a/.husky/pre-commit b/.husky/pre-commit index 8047c2c55..bc8bed76b 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -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 \ No newline at end of file diff --git a/extra/admin-api/Spacebar.AdminApi.Models/StickerModel.cs b/extra/admin-api/Spacebar.AdminApi.Models/StickerModel.cs new file mode 100644 index 000000000..e0914eaa9 --- /dev/null +++ b/extra/admin-api/Spacebar.AdminApi.Models/StickerModel.cs @@ -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 StickerPacks { get; set; } = new List(); + // public virtual User? User { get; set; } + // public virtual ICollection Messages { get; set; } = new List(); +} \ No newline at end of file diff --git a/extra/admin-api/Spacebar.AdminApi/Controllers/Media/StickerController.cs b/extra/admin-api/Spacebar.AdminApi/Controllers/Media/StickerController.cs new file mode 100644 index 000000000..25070052a --- /dev/null +++ b/extra/admin-api/Spacebar.AdminApi/Controllers/Media/StickerController.cs @@ -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 logger, SpacebarDbContext db, RabbitMQService mq, AuthenticationService auth, IServiceProvider sp) : ControllerBase { + [HttpGet("")] + public async IAsyncEnumerable GetStickers() { + (await auth.GetCurrentUser(Request)).GetRights().AssertHasAllRights(SpacebarRights.Rights.OPERATOR); + + // var db2 = sp.CreateScope().ServiceProvider.GetService(); + 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, + }; + } + } +} \ No newline at end of file diff --git a/extra/admin-api/Spacebar.Cdn/Controllers/GetImageController.cs b/extra/admin-api/Spacebar.Cdn/Controllers/GetImageController.cs new file mode 100644 index 000000000..1de9f6323 --- /dev/null +++ b/extra/admin-api/Spacebar.Cdn/Controllers/GetImageController.cs @@ -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 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(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); + } +} \ No newline at end of file diff --git a/extra/admin-api/Spacebar.Cdn/Controllers/Internal/GetImageController.cs b/extra/admin-api/Spacebar.Cdn/Controllers/Internal/GetImageController.cs new file mode 100644 index 000000000..7d4ef0874 --- /dev/null +++ b/extra/admin-api/Spacebar.Cdn/Controllers/Internal/GetImageController.cs @@ -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 _isPixelArtCache = new(100_000); + private static readonly LruFileCache _edgeCache = new(100_000_000); + + [HttpGet("/isPixelArt/{*_:required}")] + public async Task 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 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 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(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 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(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 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(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)); + } +} \ No newline at end of file diff --git a/extra/admin-api/Spacebar.Cdn/Mimes.cs b/extra/admin-api/Spacebar.Cdn/Mimes.cs new file mode 100644 index 000000000..508e9a3b6 --- /dev/null +++ b/extra/admin-api/Spacebar.Cdn/Mimes.cs @@ -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") + }; +} \ No newline at end of file diff --git a/extra/admin-api/Spacebar.Cdn/Program.cs b/extra/admin-api/Spacebar.Cdn/Program.cs new file mode 100644 index 000000000..34dc8b13d --- /dev/null +++ b/extra/admin-api/Spacebar.Cdn/Program.cs @@ -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(new ProxyFileSource("http://cdn.old.server.spacebar.chat")); +builder.Services.AddSingleton(new LruFileCache(1*1024*1024*1024)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +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(); \ No newline at end of file diff --git a/extra/admin-api/Spacebar.Cdn/Properties/launchSettings.json b/extra/admin-api/Spacebar.Cdn/Properties/launchSettings.json new file mode 100644 index 000000000..5205c88e7 --- /dev/null +++ b/extra/admin-api/Spacebar.Cdn/Properties/launchSettings.json @@ -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/" + } + } + } +} diff --git a/extra/admin-api/Spacebar.Cdn/Services/DiscordImageResizeService.cs b/extra/admin-api/Spacebar.Cdn/Services/DiscordImageResizeService.cs new file mode 100644 index 000000000..06f5d4f5e --- /dev/null +++ b/extra/admin-api/Spacebar.Cdn/Services/DiscordImageResizeService.cs @@ -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; + } +} \ No newline at end of file diff --git a/extra/admin-api/Spacebar.Cdn/Services/IFileSource.cs b/extra/admin-api/Spacebar.Cdn/Services/IFileSource.cs new file mode 100644 index 000000000..e10328727 --- /dev/null +++ b/extra/admin-api/Spacebar.Cdn/Services/IFileSource.cs @@ -0,0 +1,50 @@ +using ImageMagick; + +namespace Spacebar.AdminApi.TestClient.Services.Services; + +public interface IFileSource { + public string BaseUrl { get; } + public Task 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 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; + } +} \ No newline at end of file diff --git a/extra/admin-api/Spacebar.Cdn/Services/LruFileCache.cs b/extra/admin-api/Spacebar.Cdn/Services/LruFileCache.cs new file mode 100644 index 000000000..3a25b1e4a --- /dev/null +++ b/extra/admin-api/Spacebar.Cdn/Services/LruFileCache.cs @@ -0,0 +1,58 @@ +namespace Spacebar.AdminApi.TestClient.Services.Services; + +public class LruFileCache(int maxSizeBytes) { + private readonly Dictionary _entries = new(); + + public async Task GetOrAdd(string key, Func> 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(int maxItems) { + private readonly Dictionary _items = new(); + + public async Task GetOrAddAsync(string key, Func> 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; } + } +} \ No newline at end of file diff --git a/extra/admin-api/Spacebar.Cdn/Services/PixelArtDetectionService.cs b/extra/admin-api/Spacebar.Cdn/Services/PixelArtDetectionService.cs new file mode 100644 index 000000000..204b578e3 --- /dev/null +++ b/extra/admin-api/Spacebar.Cdn/Services/PixelArtDetectionService.cs @@ -0,0 +1,80 @@ +using ImageMagick; + +namespace Spacebar.AdminApi.TestClient.Services.Services; + +public class PixelArtDetectionService { + public bool IsPixelArt(IMagickImage 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(IMagickImage 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 RenderEdges(IMagickImage 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; + } +} \ No newline at end of file diff --git a/extra/admin-api/Spacebar.Cdn/Services/ProxyFileSource.cs b/extra/admin-api/Spacebar.Cdn/Services/ProxyFileSource.cs new file mode 100644 index 000000000..bc6ab2ae0 --- /dev/null +++ b/extra/admin-api/Spacebar.Cdn/Services/ProxyFileSource.cs @@ -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 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 + }; + } +} \ No newline at end of file diff --git a/extra/admin-api/Spacebar.Cdn/Spacebar.Cdn.csproj b/extra/admin-api/Spacebar.Cdn/Spacebar.Cdn.csproj new file mode 100644 index 000000000..3591a69a0 --- /dev/null +++ b/extra/admin-api/Spacebar.Cdn/Spacebar.Cdn.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/extra/admin-api/Spacebar.Cdn/StreamingHttpClient.cs b/extra/admin-api/Spacebar.Cdn/StreamingHttpClient.cs new file mode 100644 index 000000000..8090c0532 --- /dev/null +++ b/extra/admin-api/Spacebar.Cdn/StreamingHttpClient.cs @@ -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 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 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("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 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(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 GetAsync([StringSyntax("Uri")] string? requestUri, CancellationToken? cancellationToken = null) => + SendAsync(new HttpRequestMessage(HttpMethod.Get, requestUri), cancellationToken ?? CancellationToken.None); + + // GetFromJsonAsync + public async Task TryGetFromJsonAsync(string requestUri, JsonSerializerOptions? options = null, CancellationToken cancellationToken = default) { + try { + return await GetFromJsonAsync(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 GetFromJsonAsync(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(responseStream, options, cancellationToken) ?? + throw new InvalidOperationException("Failed to deserialize response"); + } + + // GetStreamAsync + public async Task 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 PutAsJsonAsync([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 PostAsJsonAsync([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 GetAsyncEnumerableFromJsonAsync([StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, JsonSerializerOptions? options = null) { + options = GetJsonSerializerOptions(options); + var res = await GetAsync(requestUri); + options.PropertyNameCaseInsensitive = true; + var result = JsonSerializer.DeserializeAsyncEnumerable(await res.Content.ReadAsStreamAsync(), options); + await foreach (var resp in result) yield return resp; + } + + public static async Task 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 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 DeleteAsync(string url) { + var request = new HttpRequestMessage(HttpMethod.Delete, url); + return await SendAsync(request); + } + + public async Task DeleteAsJsonAsync(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 PatchAsJsonAsync(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 \ No newline at end of file diff --git a/extra/admin-api/Spacebar.Cdn/appsettings.Development.json b/extra/admin-api/Spacebar.Cdn/appsettings.Development.json new file mode 100644 index 000000000..dc0a4c8cc --- /dev/null +++ b/extra/admin-api/Spacebar.Cdn/appsettings.Development.json @@ -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" + } + } +} diff --git a/extra/admin-api/Spacebar.Cdn/appsettings.json b/extra/admin-api/Spacebar.Cdn/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/extra/admin-api/Spacebar.Cdn/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/extra/admin-api/Spacebar.Cdn/gcc-lib-lib b/extra/admin-api/Spacebar.Cdn/gcc-lib-lib new file mode 120000 index 000000000..84ce47f59 --- /dev/null +++ b/extra/admin-api/Spacebar.Cdn/gcc-lib-lib @@ -0,0 +1 @@ +/nix/store/xm08aqdd7pxcdhm0ak6aqb1v7hw5q6ri-gcc-14.3.0-lib \ No newline at end of file diff --git a/extra/admin-api/Spacebar.Cdn/opencl-lib b/extra/admin-api/Spacebar.Cdn/opencl-lib new file mode 120000 index 000000000..0c99ced3d --- /dev/null +++ b/extra/admin-api/Spacebar.Cdn/opencl-lib @@ -0,0 +1 @@ +/nix/store/iayix7sf8n2mcjihl16xxpppv6j2syjm-clr-6.4.3 \ No newline at end of file diff --git a/extra/admin-api/Spacebar.Cdn/result-lib b/extra/admin-api/Spacebar.Cdn/result-lib new file mode 120000 index 000000000..84ce47f59 --- /dev/null +++ b/extra/admin-api/Spacebar.Cdn/result-lib @@ -0,0 +1 @@ +/nix/store/xm08aqdd7pxcdhm0ak6aqb1v7hw5q6ri-gcc-14.3.0-lib \ No newline at end of file diff --git a/extra/admin-api/Utilities/Spacebar.AdminApi.TestClient/Pages/Media/StickerCdnTest.razor b/extra/admin-api/Utilities/Spacebar.AdminApi.TestClient/Pages/Media/StickerCdnTest.razor new file mode 100644 index 000000000..3a82563f6 --- /dev/null +++ b/extra/admin-api/Utilities/Spacebar.AdminApi.TestClient/Pages/Media/StickerCdnTest.razor @@ -0,0 +1,62 @@ +@page "/StickerCdnTest" +@using System.Net.Http.Headers +@using Spacebar.AdminApi.Models +@using Spacebar.AdminApi.TestClient.Services +@inject Config Config +

StickerCdnTest

+ + + @foreach (var sticker in Stickers.OrderBy(x=>x.GuildId).ThenBy(x=>x.Id)) + { + + + + + + } +
+ @sticker.Name + + @sticker.Name + + PA: @sticker.IsPixelArt +
+ CA: @sticker.IsCartoonArt +
+ @sticker.Name (ID: @sticker.Id) +
+ +@code { + private List 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(); + var tasks = new List(); + var ss = new SemaphoreSlim(4, 4); + await foreach (var sticker in content) { + await ss.WaitAsync(); + tasks.Add(Task.Run(async () => { + var isPixelArtTask = hc.GetFromJsonAsync($"{Config.CdnUrl}/isPixelArt/stickers/{sticker.Id}.gif"); + var isCartoonArtTask = hc.GetFromJsonAsync($"{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; } + } +} \ No newline at end of file diff --git a/flake.lock b/flake.lock index d4decccc25c903258dacfe1fdfc18205b5a1be87..1805babff0a79df9347aededb17419fdddfa0e63 100644 GIT binary patch delta 119 zcmcb~eUp2G789F=rICrD`NRpb+9??sWr2}q=7~x9PT3Yo2KuE25l&`hNNkRTm zA;xK5j#-s{8K&XoljpODP3~hd)-X1)G*3)4O|~>KHBYomv9L%nH%v>kG)^>0v@kZa XurM@BOG-^mN}POvQF${D^I0YU#TO*r delta 119 zcmcb~eUp2G789GHp_z$+<-`fH+NNdR<(5V{hVGRid0F{BRj!HYUX`X{rnxR|$=T*c zKAC~xQNjLs!I{bBljpODP3~hd)<{lFF)~UuPqDN#OH55PHBC)UOfoPtNwqMsFg7u= XurxF=PBk$}G?;vVQF${D^I0YU-1H>B