mirror of
https://gitlab.com/Kwoth/nadekobot.git
synced 2025-09-11 17:58:26 -04:00
Abstract away cache. 2 implementations: redis and memory
This commit is contained in:
@@ -1,297 +1,111 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using NadekoBot.Common.Yml;
|
||||
using Newtonsoft.Json;
|
||||
using StackExchange.Redis;
|
||||
using NadekoBot.Common.Configs;
|
||||
|
||||
namespace NadekoBot.Services;
|
||||
|
||||
public sealed class RedisImagesCache : IImageCache, IReadyExecutor
|
||||
public sealed class ImagesConfig : ConfigServiceBase<ImageUrls>
|
||||
{
|
||||
public enum ImageKeys
|
||||
private const string PATH = "data/images.yml";
|
||||
|
||||
private static readonly TypedKey<ImageUrls> _changeKey =
|
||||
new("config.images.updated");
|
||||
|
||||
public override string Name
|
||||
=> "images";
|
||||
|
||||
public ImagesConfig(IConfigSeria serializer, IPubSub pubSub)
|
||||
: base(PATH, serializer, pubSub, _changeKey)
|
||||
{
|
||||
CoinHeads,
|
||||
CoinTails,
|
||||
Dice,
|
||||
SlotBg,
|
||||
SlotEmojis,
|
||||
Currency,
|
||||
RategirlMatrix,
|
||||
RategirlDot,
|
||||
RipOverlay,
|
||||
RipBg,
|
||||
XpBg
|
||||
}
|
||||
}
|
||||
|
||||
public interface IImageCache
|
||||
{
|
||||
Task<byte[]?> GetHeadsImageAsync();
|
||||
Task<byte[]?> GetTailsImageAsync();
|
||||
Task<byte[]?> GetCurrencyImageAsync();
|
||||
Task<byte[]?> GetXpBackgroundImageAsync();
|
||||
Task<byte[]?> GetRategirlBgAsync();
|
||||
Task<byte[]?> GetRategirlDotAsync();
|
||||
Task<byte[]?> GetDiceAsync(int num);
|
||||
Task<byte[]?> GetSlotEmojiAsync(int number);
|
||||
Task<byte[]?> GetSlotBgAsync();
|
||||
Task<byte[]?> GetRipBgAsync();
|
||||
Task<byte[]?> GetRipOverlayAsync();
|
||||
}
|
||||
|
||||
public sealed class ImageCache : IImageCache, INService
|
||||
{
|
||||
private readonly IBotCache _cache;
|
||||
private readonly ImagesConfig _ic;
|
||||
private readonly Random _rng;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
|
||||
public ImageCache(
|
||||
IBotCache cache,
|
||||
ImagesConfig ic,
|
||||
IHttpClientFactory httpFactory)
|
||||
{
|
||||
_cache = cache;
|
||||
_ic = ic;
|
||||
_httpFactory = httpFactory;
|
||||
_rng = new NadekoRandom();
|
||||
}
|
||||
|
||||
private const string BASE_PATH = "data/";
|
||||
private const string CARDS_PATH = $"{BASE_PATH}images/cards";
|
||||
private static TypedKey<byte[]> GetImageKey(Uri url)
|
||||
=> new($"image:{url}");
|
||||
|
||||
private IDatabase Db
|
||||
=> _con.GetDatabase();
|
||||
|
||||
public ImageUrls ImageUrls { get; private set; }
|
||||
|
||||
public IReadOnlyList<byte[]> Heads
|
||||
=> GetByteArrayData(ImageKeys.CoinHeads);
|
||||
|
||||
public IReadOnlyList<byte[]> Tails
|
||||
=> GetByteArrayData(ImageKeys.CoinTails);
|
||||
|
||||
public IReadOnlyList<byte[]> Dice
|
||||
=> GetByteArrayData(ImageKeys.Dice);
|
||||
|
||||
public IReadOnlyList<byte[]> SlotEmojis
|
||||
=> GetByteArrayData(ImageKeys.SlotEmojis);
|
||||
|
||||
public IReadOnlyList<byte[]> Currency
|
||||
=> GetByteArrayData(ImageKeys.Currency);
|
||||
|
||||
public byte[] SlotBackground
|
||||
=> GetByteData(ImageKeys.SlotBg);
|
||||
|
||||
public byte[] RategirlMatrix
|
||||
=> GetByteData(ImageKeys.RategirlMatrix);
|
||||
|
||||
public byte[] RategirlDot
|
||||
=> GetByteData(ImageKeys.RategirlDot);
|
||||
|
||||
public byte[] XpBackground
|
||||
=> GetByteData(ImageKeys.XpBg);
|
||||
|
||||
public byte[] Rip
|
||||
=> GetByteData(ImageKeys.RipBg);
|
||||
|
||||
public byte[] RipOverlay
|
||||
=> GetByteData(ImageKeys.RipOverlay);
|
||||
|
||||
private readonly ConnectionMultiplexer _con;
|
||||
private readonly IBotCredentials _creds;
|
||||
private readonly HttpClient _http;
|
||||
private readonly string _imagesPath;
|
||||
|
||||
public RedisImagesCache(ConnectionMultiplexer con, IBotCredentials creds)
|
||||
{
|
||||
_con = con;
|
||||
_creds = creds;
|
||||
_http = new();
|
||||
_imagesPath = Path.Combine(BASE_PATH, "images.yml");
|
||||
|
||||
Migrate();
|
||||
|
||||
ImageUrls = Yaml.Deserializer.Deserialize<ImageUrls>(File.ReadAllText(_imagesPath));
|
||||
}
|
||||
|
||||
public byte[] GetCard(string key)
|
||||
// since cards are always local for now, don't cache them
|
||||
=> File.ReadAllBytes(Path.Join(CARDS_PATH, key + ".jpg"));
|
||||
|
||||
public async Task OnReadyAsync()
|
||||
{
|
||||
if (await AllKeysExist())
|
||||
return;
|
||||
|
||||
await Reload();
|
||||
}
|
||||
|
||||
private void Migrate()
|
||||
{
|
||||
// migrate to yml
|
||||
if (File.Exists(Path.Combine(BASE_PATH, "images.json")))
|
||||
{
|
||||
var oldFilePath = Path.Combine(BASE_PATH, "images.json");
|
||||
var backupFilePath = Path.Combine(BASE_PATH, "images.json.backup");
|
||||
|
||||
var oldData = JsonConvert.DeserializeObject<OldImageUrls>(File.ReadAllText(oldFilePath));
|
||||
|
||||
if (oldData is not null)
|
||||
private async Task<byte[]?> GetImageDataAsync(Uri url)
|
||||
=> await _cache.GetOrAddAsync(
|
||||
GetImageKey(url),
|
||||
async () =>
|
||||
{
|
||||
var newData = new ImageUrls
|
||||
{
|
||||
Coins =
|
||||
new()
|
||||
{
|
||||
Heads =
|
||||
oldData.Coins.Heads.Length == 1
|
||||
&& oldData.Coins.Heads[0].ToString()
|
||||
== "https://nadeko-pictures.nyc3.digitaloceanspaces.com/other/coins/heads.png"
|
||||
? new[] { new Uri("https://cdn.nadeko.bot/coins/heads3.png") }
|
||||
: oldData.Coins.Heads,
|
||||
Tails = oldData.Coins.Tails.Length == 1
|
||||
&& oldData.Coins.Tails[0].ToString()
|
||||
== "https://nadeko-pictures.nyc3.digitaloceanspaces.com/other/coins/tails.png"
|
||||
? new[] { new Uri("https://cdn.nadeko.bot/coins/tails3.png") }
|
||||
: oldData.Coins.Tails
|
||||
},
|
||||
Dice = oldData.Dice.Map(x => x.ToNewCdn()),
|
||||
Currency = oldData.Currency.Map(x => x.ToNewCdn()),
|
||||
Rategirl =
|
||||
new()
|
||||
{
|
||||
Dot = oldData.Rategirl.Dot.ToNewCdn(),
|
||||
Matrix = oldData.Rategirl.Matrix.ToNewCdn()
|
||||
},
|
||||
Rip = new()
|
||||
{
|
||||
Bg = oldData.Rip.Bg.ToNewCdn(),
|
||||
Overlay = oldData.Rip.Overlay.ToNewCdn()
|
||||
},
|
||||
Slots = new()
|
||||
{
|
||||
Bg = new("https://cdn.nadeko.bot/slots/slots_bg.png"),
|
||||
Emojis = new[]
|
||||
{
|
||||
"https://cdn.nadeko.bot/slots/0.png", "https://cdn.nadeko.bot/slots/1.png",
|
||||
"https://cdn.nadeko.bot/slots/2.png", "https://cdn.nadeko.bot/slots/3.png",
|
||||
"https://cdn.nadeko.bot/slots/4.png", "https://cdn.nadeko.bot/slots/5.png"
|
||||
}.Map(x => new Uri(x))
|
||||
},
|
||||
Xp = new()
|
||||
{
|
||||
Bg = oldData.Xp.Bg.ToNewCdn()
|
||||
},
|
||||
Version = 2
|
||||
};
|
||||
|
||||
File.Move(oldFilePath, backupFilePath, true);
|
||||
File.WriteAllText(_imagesPath, Yaml.Serializer.Serialize(newData));
|
||||
}
|
||||
}
|
||||
|
||||
// removed numbers from slots
|
||||
var localImageUrls = Yaml.Deserializer.Deserialize<ImageUrls>(File.ReadAllText(_imagesPath));
|
||||
if (localImageUrls.Version == 2)
|
||||
{
|
||||
localImageUrls.Version = 3;
|
||||
File.WriteAllText(_imagesPath, Yaml.Serializer.Serialize(localImageUrls));
|
||||
}
|
||||
|
||||
if (localImageUrls.Version == 3)
|
||||
{
|
||||
localImageUrls.Version = 4;
|
||||
if (localImageUrls.Xp?.Bg.ToString() == "https://cdn.nadeko.bot/other/xp/bg.png")
|
||||
localImageUrls.Xp.Bg = new("https://cdn.nadeko.bot/other/xp/bg_k.png");
|
||||
|
||||
File.WriteAllText(_imagesPath, Yaml.Serializer.Serialize(localImageUrls));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Reload()
|
||||
{
|
||||
ImageUrls = Yaml.Deserializer.Deserialize<ImageUrls>(await File.ReadAllTextAsync(_imagesPath));
|
||||
foreach (var key in GetAllKeys())
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case ImageKeys.CoinHeads:
|
||||
await Load(key, ImageUrls.Coins.Heads);
|
||||
break;
|
||||
case ImageKeys.CoinTails:
|
||||
await Load(key, ImageUrls.Coins.Tails);
|
||||
break;
|
||||
case ImageKeys.Dice:
|
||||
await Load(key, ImageUrls.Dice);
|
||||
break;
|
||||
case ImageKeys.SlotBg:
|
||||
await Load(key, ImageUrls.Slots.Bg);
|
||||
break;
|
||||
case ImageKeys.SlotEmojis:
|
||||
await Load(key, ImageUrls.Slots.Emojis);
|
||||
break;
|
||||
case ImageKeys.Currency:
|
||||
await Load(key, ImageUrls.Currency);
|
||||
break;
|
||||
case ImageKeys.RategirlMatrix:
|
||||
await Load(key, ImageUrls.Rategirl.Matrix);
|
||||
break;
|
||||
case ImageKeys.RategirlDot:
|
||||
await Load(key, ImageUrls.Rategirl.Dot);
|
||||
break;
|
||||
case ImageKeys.RipOverlay:
|
||||
await Load(key, ImageUrls.Rip.Overlay);
|
||||
break;
|
||||
case ImageKeys.RipBg:
|
||||
await Load(key, ImageUrls.Rip.Bg);
|
||||
break;
|
||||
case ImageKeys.XpBg:
|
||||
await Load(key, ImageUrls.Xp.Bg);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Load(ImageKeys key, Uri uri)
|
||||
{
|
||||
var data = await GetImageData(uri);
|
||||
if (data is null)
|
||||
return;
|
||||
|
||||
await Db.StringSetAsync(GetRedisKey(key), data);
|
||||
}
|
||||
|
||||
private async Task Load(ImageKeys key, Uri[] uris)
|
||||
{
|
||||
await Db.KeyDeleteAsync(GetRedisKey(key));
|
||||
var imageData = await uris.Select(GetImageData).WhenAll();
|
||||
var vals = imageData.Where(x => x is not null).Select(x => (RedisValue)x).ToArray();
|
||||
|
||||
await Db.ListRightPushAsync(GetRedisKey(key), vals);
|
||||
|
||||
if (uris.Length != vals.Length)
|
||||
{
|
||||
Log.Information(
|
||||
"{Loaded}/{Max} URIs for the key '{ImageKey}' have been loaded.\n"
|
||||
+ "Some of the supplied URIs are either unavailable or invalid",
|
||||
vals.Length,
|
||||
uris.Length,
|
||||
key);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<byte[]> GetImageData(Uri uri)
|
||||
{
|
||||
if (uri.IsFile)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(uri.LocalPath);
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var bytes = await http.GetByteArrayAsync(url);
|
||||
return bytes;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed reading image bytes from uri: {Uri}", uri.ToString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
},
|
||||
expiry: 48.Hours());
|
||||
|
||||
try
|
||||
{
|
||||
return await _http.GetByteArrayAsync(uri);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Image url you provided is not a valid image: {Uri}", uri.ToString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> AllKeysExist()
|
||||
private async Task<byte[]?> GetRandomImageDataAsync(Uri[] urls)
|
||||
{
|
||||
var tasks = await GetAllKeys().Select(x => Db.KeyExistsAsync(GetRedisKey(x))).WhenAll();
|
||||
if (urls.Length == 0)
|
||||
return null;
|
||||
|
||||
return tasks.All(exist => exist);
|
||||
var url = urls[_rng.Next(0, urls.Length)];
|
||||
|
||||
var data = await GetImageDataAsync(url);
|
||||
return data;
|
||||
}
|
||||
|
||||
private IEnumerable<ImageKeys> GetAllKeys()
|
||||
=> Enum.GetValues<ImageKeys>();
|
||||
public Task<byte[]?> GetHeadsImageAsync()
|
||||
=> GetRandomImageDataAsync(_ic.Data.Coins.Heads);
|
||||
|
||||
private byte[][] GetByteArrayData(ImageKeys key)
|
||||
=> Db.ListRange(GetRedisKey(key)).Map(x => (byte[])x);
|
||||
public Task<byte[]?> GetTailsImageAsync()
|
||||
=> GetRandomImageDataAsync(_ic.Data.Coins.Tails);
|
||||
|
||||
private byte[] GetByteData(ImageKeys key)
|
||||
=> Db.StringGet(GetRedisKey(key));
|
||||
public Task<byte[]?> GetCurrencyImageAsync()
|
||||
=> GetRandomImageDataAsync(_ic.Data.Currency);
|
||||
|
||||
private RedisKey GetRedisKey(ImageKeys key)
|
||||
=> _creds.RedisKey() + "_image_" + key;
|
||||
}
|
||||
public Task<byte[]?> GetXpBackgroundImageAsync()
|
||||
=> GetImageDataAsync(_ic.Data.Xp.Bg);
|
||||
|
||||
public Task<byte[]?> GetRategirlBgAsync()
|
||||
=> GetImageDataAsync(_ic.Data.Rategirl.Matrix);
|
||||
|
||||
public Task<byte[]?> GetRategirlDotAsync()
|
||||
=> GetImageDataAsync(_ic.Data.Rategirl.Dot);
|
||||
|
||||
public Task<byte[]?> GetDiceAsync(int num)
|
||||
=> GetImageDataAsync(_ic.Data.Dice[num]);
|
||||
|
||||
public Task<byte[]?> GetSlotEmojiAsync(int number)
|
||||
=> GetImageDataAsync(_ic.Data.Slots.Emojis[number]);
|
||||
|
||||
public Task<byte[]?> GetSlotBgAsync()
|
||||
=> GetImageDataAsync(_ic.Data.Slots.Bg);
|
||||
|
||||
public Task<byte[]?> GetRipBgAsync()
|
||||
=> GetImageDataAsync(_ic.Data.Rip.Bg);
|
||||
|
||||
public Task<byte[]?> GetRipOverlayAsync()
|
||||
=> GetImageDataAsync(_ic.Data.Rip.Overlay);
|
||||
}
|
||||
|
Reference in New Issue
Block a user