mirror of
https://github.com/spacebarchat/server.git
synced 2026-04-26 10:57:31 +00:00
CDN-CS stuff
This commit is contained in:
@@ -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
|
||||
19
extra/admin-api/Spacebar.AdminApi.Models/StickerModel.cs
Normal file
19
extra/admin-api/Spacebar.AdminApi.Models/StickerModel.cs
Normal 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>();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
20
extra/admin-api/Spacebar.Cdn/Mimes.cs
Normal file
20
extra/admin-api/Spacebar.Cdn/Mimes.cs
Normal 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")
|
||||
};
|
||||
}
|
||||
66
extra/admin-api/Spacebar.Cdn/Program.cs
Normal file
66
extra/admin-api/Spacebar.Cdn/Program.cs
Normal 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();
|
||||
15
extra/admin-api/Spacebar.Cdn/Properties/launchSettings.json
Normal file
15
extra/admin-api/Spacebar.Cdn/Properties/launchSettings.json
Normal 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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
50
extra/admin-api/Spacebar.Cdn/Services/IFileSource.cs
Normal file
50
extra/admin-api/Spacebar.Cdn/Services/IFileSource.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
58
extra/admin-api/Spacebar.Cdn/Services/LruFileCache.cs
Normal file
58
extra/admin-api/Spacebar.Cdn/Services/LruFileCache.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
29
extra/admin-api/Spacebar.Cdn/Services/ProxyFileSource.cs
Normal file
29
extra/admin-api/Spacebar.Cdn/Services/ProxyFileSource.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
20
extra/admin-api/Spacebar.Cdn/Spacebar.Cdn.csproj
Normal file
20
extra/admin-api/Spacebar.Cdn/Spacebar.Cdn.csproj
Normal 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>
|
||||
304
extra/admin-api/Spacebar.Cdn/StreamingHttpClient.cs
Normal file
304
extra/admin-api/Spacebar.Cdn/StreamingHttpClient.cs
Normal 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
|
||||
17
extra/admin-api/Spacebar.Cdn/appsettings.Development.json
Normal file
17
extra/admin-api/Spacebar.Cdn/appsettings.Development.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
extra/admin-api/Spacebar.Cdn/appsettings.json
Normal file
9
extra/admin-api/Spacebar.Cdn/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
1
extra/admin-api/Spacebar.Cdn/gcc-lib-lib
Symbolic link
1
extra/admin-api/Spacebar.Cdn/gcc-lib-lib
Symbolic link
@@ -0,0 +1 @@
|
||||
/nix/store/xm08aqdd7pxcdhm0ak6aqb1v7hw5q6ri-gcc-14.3.0-lib
|
||||
1
extra/admin-api/Spacebar.Cdn/opencl-lib
Symbolic link
1
extra/admin-api/Spacebar.Cdn/opencl-lib
Symbolic link
@@ -0,0 +1 @@
|
||||
/nix/store/iayix7sf8n2mcjihl16xxpppv6j2syjm-clr-6.4.3
|
||||
1
extra/admin-api/Spacebar.Cdn/result-lib
Symbolic link
1
extra/admin-api/Spacebar.Cdn/result-lib
Symbolic link
@@ -0,0 +1 @@
|
||||
/nix/store/xm08aqdd7pxcdhm0ak6aqb1v7hw5q6ri-gcc-14.3.0-lib
|
||||
@@ -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
BIN
flake.lock
generated
Binary file not shown.
Reference in New Issue
Block a user