From c1842c044346bee6448eef06bc1ea04cda17cf49 Mon Sep 17 00:00:00 2001 From: Matt <97983689+bluepilledgreat@users.noreply.github.com> Date: Fri, 28 Mar 2025 19:33:51 +0000 Subject: [PATCH] Replace AssetDelivery API with Thumbnails API for Discord RPC images (#4947) * replace assetdelivery with thumbnails for rpc * update GetThumbnailUrlAsync logging * fix build error --- Bloxstrap/Extensions/HttpClientEx.cs | 81 +++++ Bloxstrap/Integrations/DiscordRichPresence.cs | 326 +++++++++++++----- .../APIs/Roblox/ThumbnailBatchResponse.cs | 8 + .../Models/APIs/Roblox/ThumbnailRequest.cs | 34 ++ .../Models/APIs/Roblox/ThumbnailResponse.cs | 20 +- Bloxstrap/Models/ThumbnailCacheEntry.cs | 8 + Bloxstrap/Utility/FixedCapacityList.cs | 25 ++ Bloxstrap/Utility/Thumbnails.cs | 103 ++++++ 8 files changed, 513 insertions(+), 92 deletions(-) create mode 100644 Bloxstrap/Extensions/HttpClientEx.cs create mode 100644 Bloxstrap/Models/APIs/Roblox/ThumbnailBatchResponse.cs create mode 100644 Bloxstrap/Models/APIs/Roblox/ThumbnailRequest.cs create mode 100644 Bloxstrap/Models/ThumbnailCacheEntry.cs create mode 100644 Bloxstrap/Utility/FixedCapacityList.cs create mode 100644 Bloxstrap/Utility/Thumbnails.cs diff --git a/Bloxstrap/Extensions/HttpClientEx.cs b/Bloxstrap/Extensions/HttpClientEx.cs new file mode 100644 index 0000000..00680fa --- /dev/null +++ b/Bloxstrap/Extensions/HttpClientEx.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Bloxstrap.Extensions +{ + internal static class HttpClientEx + { + public static async Task GetWithRetriesAsync(this HttpClient client, string url, int retries, CancellationToken token) + { + HttpResponseMessage response = null!; + + for (int i = 1; i <= retries; i++) + { + try + { + response = await client.GetAsync(url, token); + } + catch (TaskCanceledException) + { + throw; + } + catch (Exception ex) + { + App.Logger.WriteException("HttpClientEx::GetWithRetriesAsync", ex); + + if (i == retries) + throw; + } + } + + return response; + } + + public static async Task PostWithRetriesAsync(this HttpClient client, string url, HttpContent? content, int retries, CancellationToken token) + { + HttpResponseMessage response = null!; + + for (int i = 1; i <= retries; i++) + { + try + { + response = await client.PostAsync(url, content, token); + } + catch (TaskCanceledException) + { + throw; + } + catch (Exception ex) + { + App.Logger.WriteException("HttpClientEx::PostWithRetriesAsync", ex); + + if (i == retries) + throw; + } + } + + return response; + } + + public static async Task GetFromJsonWithRetriesAsync(this HttpClient client, string url, int retries, CancellationToken token) where T : class + { + HttpResponseMessage response = await GetWithRetriesAsync(client, url, retries, token); + response.EnsureSuccessStatusCode(); + + using var stream = await response.Content.ReadAsStreamAsync(token); + return await JsonSerializer.DeserializeAsync(stream, cancellationToken: token); + } + + public static async Task PostFromJsonWithRetriesAsync(this HttpClient client, string url, HttpContent? content, int retries, CancellationToken token) where T : class + { + HttpResponseMessage response = await PostWithRetriesAsync(client, url, content, retries, token); + response.EnsureSuccessStatusCode(); + + using var stream = await response.Content.ReadAsStreamAsync(token); + return await JsonSerializer.DeserializeAsync(stream, cancellationToken: token); + } + } +} diff --git a/Bloxstrap/Integrations/DiscordRichPresence.cs b/Bloxstrap/Integrations/DiscordRichPresence.cs index 8d140f9..744c085 100644 --- a/Bloxstrap/Integrations/DiscordRichPresence.cs +++ b/Bloxstrap/Integrations/DiscordRichPresence.cs @@ -13,6 +13,12 @@ namespace Bloxstrap.Integrations private DiscordRPC.RichPresence? _currentPresence; private DiscordRPC.RichPresence? _originalPresence; + private FixedSizeList _thumbnailCache = new FixedSizeList(20); + + private ulong? _smallImgBeingFetched = null; + private ulong? _largeImgBeingFetched = null; + private CancellationTokenSource? _fetchThumbnailsToken; + private bool _visible = true; public DiscordRichPresence(ActivityWatcher activityWatcher) @@ -69,101 +75,239 @@ namespace Bloxstrap.Integrations } else if (message.Command == "SetRichPresence") { - Models.BloxstrapRPC.RichPresence? presenceData; - - try - { - presenceData = message.Data.Deserialize(); - } - catch (Exception) - { - App.Logger.WriteLine(LOG_IDENT, "Failed to parse message! (JSON deserialization threw an exception)"); - return; - } - - if (presenceData is null) - { - App.Logger.WriteLine(LOG_IDENT, "Failed to parse message! (JSON deserialization returned null)"); - return; - } - - if (presenceData.Details is not null) - { - if (presenceData.Details.Length > 128) - App.Logger.WriteLine(LOG_IDENT, $"Details cannot be longer than 128 characters"); - else if (presenceData.Details == "") - _currentPresence.Details = _originalPresence.Details; - else - _currentPresence.Details = presenceData.Details; - } - - if (presenceData.State is not null) - { - if (presenceData.State.Length > 128) - App.Logger.WriteLine(LOG_IDENT, $"State cannot be longer than 128 characters"); - else if (presenceData.State == "") - _currentPresence.State = _originalPresence.State; - else - _currentPresence.State = presenceData.State; - } - - if (presenceData.TimestampStart == 0) - _currentPresence.Timestamps.Start = null; - else if (presenceData.TimestampStart is not null) - _currentPresence.Timestamps.StartUnixMilliseconds = presenceData.TimestampStart * 1000; - - if (presenceData.TimestampEnd == 0) - _currentPresence.Timestamps.End = null; - else if (presenceData.TimestampEnd is not null) - _currentPresence.Timestamps.EndUnixMilliseconds = presenceData.TimestampEnd * 1000; - - if (presenceData.SmallImage is not null) - { - if (presenceData.SmallImage.Clear) - { - _currentPresence.Assets.SmallImageKey = ""; - } - else if (presenceData.SmallImage.Reset) - { - _currentPresence.Assets.SmallImageText = _originalPresence.Assets.SmallImageText; - _currentPresence.Assets.SmallImageKey = _originalPresence.Assets.SmallImageKey; - } - else - { - if (presenceData.SmallImage.AssetId is not null) - _currentPresence.Assets.SmallImageKey = $"https://assetdelivery.roblox.com/v1/asset/?id={presenceData.SmallImage.AssetId}"; - - if (presenceData.SmallImage.HoverText is not null) - _currentPresence.Assets.SmallImageText = presenceData.SmallImage.HoverText; - } - } - - if (presenceData.LargeImage is not null) - { - if (presenceData.LargeImage.Clear) - { - _currentPresence.Assets.LargeImageKey = ""; - } - else if (presenceData.LargeImage.Reset) - { - _currentPresence.Assets.LargeImageText = _originalPresence.Assets.LargeImageText; - _currentPresence.Assets.LargeImageKey = _originalPresence.Assets.LargeImageKey; - } - else - { - if (presenceData.LargeImage.AssetId is not null) - _currentPresence.Assets.LargeImageKey = $"https://assetdelivery.roblox.com/v1/asset/?id={presenceData.LargeImage.AssetId}"; - - if (presenceData.LargeImage.HoverText is not null) - _currentPresence.Assets.LargeImageText = presenceData.LargeImage.HoverText; - } - } + ProcessSetRichPresence(message, implicitUpdate); } if (implicitUpdate) UpdatePresence(); } + private void AddToThumbnailCache(ulong id, string? url) + { + if (url != null) + _thumbnailCache.Add(new ThumbnailCacheEntry { Id = id, Url = url }); + } + + private async Task UpdatePresenceIconsAsync(ulong? smallImg, ulong? largeImg, bool implicitUpdate, CancellationToken token) + { + Debug.Assert(smallImg != null || largeImg != null); + + if (smallImg != null && largeImg != null) + { + string?[] urls = await Thumbnails.GetThumbnailUrlsAsync(new List + { + new ThumbnailRequest + { + TargetId = (ulong)smallImg, + Type = "Asset", + Size = "512x512", + IsCircular = false + }, + new ThumbnailRequest + { + TargetId = (ulong)largeImg, + Type = "Asset", + Size = "512x512", + IsCircular = false + } + }, token); + + string? smallUrl = urls[0]; + string? largeUrl = urls[1]; + + AddToThumbnailCache((ulong)smallImg, smallUrl); + AddToThumbnailCache((ulong)largeImg, largeUrl); + + if (_currentPresence != null) + { + _currentPresence.Assets.SmallImageKey = smallUrl; + _currentPresence.Assets.LargeImageKey = largeUrl; + } + } + else if (smallImg != null) + { + string? url = await Thumbnails.GetThumbnailUrlAsync(new ThumbnailRequest + { + TargetId = (ulong)smallImg, + Type = "Asset", + Size = "512x512", + IsCircular = false + }, token); + + AddToThumbnailCache((ulong)smallImg, url); + + if (_currentPresence != null) + _currentPresence.Assets.SmallImageKey = url; + } + else if (largeImg != null) + { + string? url = await Thumbnails.GetThumbnailUrlAsync(new ThumbnailRequest + { + TargetId = (ulong)largeImg, + Type = "Asset", + Size = "512x512", + IsCircular = false + }, token); + + AddToThumbnailCache((ulong)largeImg, url); + + if (_currentPresence != null) + _currentPresence.Assets.LargeImageKey = url; + } + + _smallImgBeingFetched = null; + _largeImgBeingFetched = null; + + if (implicitUpdate) + UpdatePresence(); + } + + private void ProcessSetRichPresence(Message message, bool implicitUpdate) + { + const string LOG_IDENT = "DiscordRichPresence::ProcessSetRichPresence"; + Models.BloxstrapRPC.RichPresence? presenceData; + + Debug.Assert(_currentPresence is not null); + Debug.Assert(_originalPresence is not null); + + if (_fetchThumbnailsToken != null) + { + _fetchThumbnailsToken.Cancel(); + _fetchThumbnailsToken = null; + } + + try + { + presenceData = message.Data.Deserialize(); + } + catch (Exception) + { + App.Logger.WriteLine(LOG_IDENT, "Failed to parse message! (JSON deserialization threw an exception)"); + return; + } + + if (presenceData is null) + { + App.Logger.WriteLine(LOG_IDENT, "Failed to parse message! (JSON deserialization returned null)"); + return; + } + + if (presenceData.Details is not null) + { + if (presenceData.Details.Length > 128) + App.Logger.WriteLine(LOG_IDENT, $"Details cannot be longer than 128 characters"); + else if (presenceData.Details == "") + _currentPresence.Details = _originalPresence.Details; + else + _currentPresence.Details = presenceData.Details; + } + + if (presenceData.State is not null) + { + if (presenceData.State.Length > 128) + App.Logger.WriteLine(LOG_IDENT, $"State cannot be longer than 128 characters"); + else if (presenceData.State == "") + _currentPresence.State = _originalPresence.State; + else + _currentPresence.State = presenceData.State; + } + + if (presenceData.TimestampStart == 0) + _currentPresence.Timestamps.Start = null; + else if (presenceData.TimestampStart is not null) + _currentPresence.Timestamps.StartUnixMilliseconds = presenceData.TimestampStart * 1000; + + if (presenceData.TimestampEnd == 0) + _currentPresence.Timestamps.End = null; + else if (presenceData.TimestampEnd is not null) + _currentPresence.Timestamps.EndUnixMilliseconds = presenceData.TimestampEnd * 1000; + + // set these to start fetching + ulong? smallImgFetch = null; + ulong? largeImgFetch = null; + + if (presenceData.SmallImage is not null) + { + if (presenceData.SmallImage.Clear) + { + _currentPresence.Assets.SmallImageKey = ""; + _smallImgBeingFetched = null; + } + else if (presenceData.SmallImage.Reset) + { + _currentPresence.Assets.SmallImageText = _originalPresence.Assets.SmallImageText; + _currentPresence.Assets.SmallImageKey = _originalPresence.Assets.SmallImageKey; + _smallImgBeingFetched = null; + } + else + { + if (presenceData.SmallImage.AssetId is not null) + { + ThumbnailCacheEntry? entry = _thumbnailCache.FirstOrDefault(x => x.Id == presenceData.SmallImage.AssetId); + + if (entry == null) + { + smallImgFetch = presenceData.SmallImage.AssetId; + } + else + { + _currentPresence.Assets.SmallImageKey = entry.Url; + _smallImgBeingFetched = null; + } + } + + if (presenceData.SmallImage.HoverText is not null) + _currentPresence.Assets.SmallImageText = presenceData.SmallImage.HoverText; + } + } + + if (presenceData.LargeImage is not null) + { + if (presenceData.LargeImage.Clear) + { + _currentPresence.Assets.LargeImageKey = ""; + _largeImgBeingFetched = null; + } + else if (presenceData.LargeImage.Reset) + { + _currentPresence.Assets.LargeImageText = _originalPresence.Assets.LargeImageText; + _currentPresence.Assets.LargeImageKey = _originalPresence.Assets.LargeImageKey; + _largeImgBeingFetched = null; + } + else + { + if (presenceData.LargeImage.AssetId is not null) + { + ThumbnailCacheEntry? entry = _thumbnailCache.FirstOrDefault(x => x.Id == presenceData.LargeImage.AssetId); + + if (entry == null) + { + largeImgFetch = presenceData.LargeImage.AssetId; + } + else + { + _currentPresence.Assets.LargeImageKey = entry.Url; + _largeImgBeingFetched = null; + } + } + + if (presenceData.LargeImage.HoverText is not null) + _currentPresence.Assets.LargeImageText = presenceData.LargeImage.HoverText; + } + } + + if (smallImgFetch != null) + _smallImgBeingFetched = smallImgFetch; + if (largeImgFetch != null) + _largeImgBeingFetched = largeImgFetch; + + if (_smallImgBeingFetched != null || _largeImgBeingFetched != null) + { + _fetchThumbnailsToken = new CancellationTokenSource(); + Task.Run(() => UpdatePresenceIconsAsync(_smallImgBeingFetched, _largeImgBeingFetched, implicitUpdate, _fetchThumbnailsToken.Token)); + } + } + public void SetVisibility(bool visible) { App.Logger.WriteLine("DiscordRichPresence::SetVisibility", $"Setting presence visibility ({visible})"); @@ -225,13 +369,13 @@ namespace Bloxstrap.Integrations var universeDetails = activity.UniverseDetails!; - icon = universeDetails.Thumbnail.ImageUrl; + icon = universeDetails.Thumbnail.ImageUrl!; if (App.Settings.Prop.ShowAccountOnRichPresence) { var userDetails = await UserDetails.Fetch(activity.UserId); - smallImage = userDetails.Thumbnail.ImageUrl; + smallImage = userDetails.Thumbnail.ImageUrl!; smallImageText = $"Playing on {userDetails.Data.DisplayName} (@{userDetails.Data.Name})"; // i.e. "axell (@Axelan_se)" } diff --git a/Bloxstrap/Models/APIs/Roblox/ThumbnailBatchResponse.cs b/Bloxstrap/Models/APIs/Roblox/ThumbnailBatchResponse.cs new file mode 100644 index 0000000..9d7485f --- /dev/null +++ b/Bloxstrap/Models/APIs/Roblox/ThumbnailBatchResponse.cs @@ -0,0 +1,8 @@ +namespace Bloxstrap.Models.APIs.Roblox +{ + internal class ThumbnailBatchResponse + { + [JsonPropertyName("data")] + public ThumbnailResponse[] Data { get; set; } = Array.Empty(); + } +} diff --git a/Bloxstrap/Models/APIs/Roblox/ThumbnailRequest.cs b/Bloxstrap/Models/APIs/Roblox/ThumbnailRequest.cs new file mode 100644 index 0000000..b3779e0 --- /dev/null +++ b/Bloxstrap/Models/APIs/Roblox/ThumbnailRequest.cs @@ -0,0 +1,34 @@ +namespace Bloxstrap.Models.APIs.Roblox +{ + internal class ThumbnailRequest + { + [JsonPropertyName("requestId")] + public string? RequestId { get; set; } + + [JsonPropertyName("targetId")] + public ulong TargetId { get; set; } + + /// + /// TODO: make this an enum + /// List of valid types can be found at https://thumbnails.roblox.com//docs/index.html + /// + [JsonPropertyName("type")] + public string Type { get; set; } = "Avatar"; + + /// + /// List of valid sizes can be found at https://thumbnails.roblox.com//docs/index.html + /// + [JsonPropertyName("size")] + public string Size { get; set; } = "30x30"; + + /// + /// TODO: make this an enum + /// List of valid types can be found at https://thumbnails.roblox.com//docs/index.html + /// + [JsonPropertyName("format")] + public string Format { get; set; } = "Png"; + + [JsonPropertyName("isCircular")] + public bool IsCircular { get; set; } = true; + } +} diff --git a/Bloxstrap/Models/APIs/Roblox/ThumbnailResponse.cs b/Bloxstrap/Models/APIs/Roblox/ThumbnailResponse.cs index 213083c..bc10ba5 100644 --- a/Bloxstrap/Models/APIs/Roblox/ThumbnailResponse.cs +++ b/Bloxstrap/Models/APIs/Roblox/ThumbnailResponse.cs @@ -5,13 +5,31 @@ /// public class ThumbnailResponse { + [JsonPropertyName("requestId")] + public string RequestId { get; set; } = null!; + + [JsonPropertyName("errorCode")] + public int ErrorCode { get; set; } = 0; + + [JsonPropertyName("errorMessage")] + public string? ErrorMessage { get; set; } = null; + [JsonPropertyName("targetId")] public long TargetId { get; set; } + /// + /// Valid states: + /// - Error + /// - Completed + /// - InReview + /// - Pending + /// - Blocked + /// - TemporarilyUnavailable + /// [JsonPropertyName("state")] public string State { get; set; } = null!; [JsonPropertyName("imageUrl")] - public string ImageUrl { get; set; } = null!; + public string? ImageUrl { get; set; } = null!; } } diff --git a/Bloxstrap/Models/ThumbnailCacheEntry.cs b/Bloxstrap/Models/ThumbnailCacheEntry.cs new file mode 100644 index 0000000..3ce6204 --- /dev/null +++ b/Bloxstrap/Models/ThumbnailCacheEntry.cs @@ -0,0 +1,8 @@ +namespace Bloxstrap.Models +{ + internal class ThumbnailCacheEntry + { + public ulong Id { get; set; } + public string Url { get; set; } = string.Empty; + } +} diff --git a/Bloxstrap/Utility/FixedCapacityList.cs b/Bloxstrap/Utility/FixedCapacityList.cs new file mode 100644 index 0000000..4ea7862 --- /dev/null +++ b/Bloxstrap/Utility/FixedCapacityList.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Bloxstrap.Utility +{ + internal class FixedSizeList : List + { + public int MaxSize { get; } + + public FixedSizeList(int size) + { + MaxSize = size; + } + + public new void Add(T item) + { + if (Count >= MaxSize) + RemoveAt(Count - 1); + base.Add(item); + } + } +} diff --git a/Bloxstrap/Utility/Thumbnails.cs b/Bloxstrap/Utility/Thumbnails.cs new file mode 100644 index 0000000..00ca77a --- /dev/null +++ b/Bloxstrap/Utility/Thumbnails.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Bloxstrap.Utility +{ + internal static class Thumbnails + { + // TODO: remove requests from list once they're finished or failed + /// + /// Returned array may contain null values + /// + public static async Task GetThumbnailUrlsAsync(List requests, CancellationToken token) + { + const string LOG_IDENT = "Thumbnails::GetThumbnailUrlsAsync"; + const int RETRIES = 5; + const int RETRY_TIME_INCREMENT = 500; // ms + + string?[] urls = new string?[requests.Count]; + + // assign unique request ids to each request + for (int i = 0; i < requests.Count; i++) + requests[i].RequestId = i.ToString(); + + var payload = new StringContent(JsonSerializer.Serialize(requests)); + + ThumbnailResponse[] response = null!; + + for (int i = 1; i <= RETRIES; i++) + { + var json = await App.HttpClient.PostFromJsonWithRetriesAsync("https://thumbnails.roblox.com/v1/batch", payload, 3, token); + if (json == null) + throw new InvalidHTTPResponseException("Deserialised ThumbnailBatchResponse is null"); + + response = json.Data; + + bool finished = response.All(x => x.State != "Pending"); + if (finished) + break; + + if (i == RETRIES) + App.Logger.WriteLine(LOG_IDENT, "Ran out of retries"); + else + await Task.Delay(RETRY_TIME_INCREMENT * i, token); + } + + foreach (var item in response) + { + if (item.State == "Pending") + App.Logger.WriteLine(LOG_IDENT, $"{item.TargetId} is still pending"); + else if (item.State == "Error") + App.Logger.WriteLine(LOG_IDENT, $"{item.TargetId} got error code {item.ErrorCode} ({item.ErrorMessage})"); + else if (item.State != "Completed") + App.Logger.WriteLine(LOG_IDENT, $"{item.TargetId} got \"{item.State}\""); + + urls[int.Parse(item.RequestId)] = item.ImageUrl; + } + + return urls; + } + + public static async Task GetThumbnailUrlAsync(ThumbnailRequest request, CancellationToken token) + { + const string LOG_IDENT = "Thumbnails::GetThumbnailUrlAsync"; + const int RETRIES = 5; + const int RETRY_TIME_INCREMENT = 500; // ms + + request.RequestId = "0"; + + var payload = new StringContent(JsonSerializer.Serialize(new ThumbnailRequest[] { request })); + + ThumbnailResponse response = null!; + + for (int i = 1; i <= RETRIES; i++) + { + var json = await App.HttpClient.PostFromJsonWithRetriesAsync("https://thumbnails.roblox.com/v1/batch", payload, 3, token); + if (json == null) + throw new InvalidHTTPResponseException("Deserialised ThumbnailBatchResponse is null"); + + response = json.Data[0]; + + if (response.State != "Pending") + break; + + if (i == RETRIES) + App.Logger.WriteLine(LOG_IDENT, "Ran out of retries"); + else + await Task.Delay(RETRY_TIME_INCREMENT * i, token); + } + + if (response.State == "Pending") + App.Logger.WriteLine(LOG_IDENT, $"{response.TargetId} is still pending"); + else if (response.State == "Error") + App.Logger.WriteLine(LOG_IDENT, $"{response.TargetId} got error code {response.ErrorCode} ({response.ErrorMessage})"); + else if (response.State != "Completed") + App.Logger.WriteLine(LOG_IDENT, $"{response.TargetId} got \"{response.State}\""); + + return response.ImageUrl; + } + } +}