Replace AssetDelivery API with Thumbnails API for Discord RPC images (#4947)

* replace assetdelivery with thumbnails for rpc

* update GetThumbnailUrlAsync logging

* fix build error
This commit is contained in:
Matt 2025-03-28 19:33:51 +00:00 committed by GitHub
parent 055695e014
commit c1842c0443
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 513 additions and 92 deletions

View File

@ -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<HttpResponseMessage> 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<HttpResponseMessage> 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<T?> GetFromJsonWithRetriesAsync<T>(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<T>(stream, cancellationToken: token);
}
public static async Task<T?> PostFromJsonWithRetriesAsync<T>(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<T>(stream, cancellationToken: token);
}
}
}

View File

@ -13,6 +13,12 @@ namespace Bloxstrap.Integrations
private DiscordRPC.RichPresence? _currentPresence;
private DiscordRPC.RichPresence? _originalPresence;
private FixedSizeList<ThumbnailCacheEntry> _thumbnailCache = new FixedSizeList<ThumbnailCacheEntry>(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<Models.BloxstrapRPC.RichPresence>();
}
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 == "<reset>")
_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 == "<reset>")
_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<ThumbnailRequest>
{
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<Models.BloxstrapRPC.RichPresence>();
}
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 == "<reset>")
_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 == "<reset>")
_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)"
}

View File

@ -0,0 +1,8 @@
namespace Bloxstrap.Models.APIs.Roblox
{
internal class ThumbnailBatchResponse
{
[JsonPropertyName("data")]
public ThumbnailResponse[] Data { get; set; } = Array.Empty<ThumbnailResponse>();
}
}

View File

@ -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; }
/// <summary>
/// TODO: make this an enum
/// List of valid types can be found at https://thumbnails.roblox.com//docs/index.html
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; } = "Avatar";
/// <summary>
/// List of valid sizes can be found at https://thumbnails.roblox.com//docs/index.html
/// </summary>
[JsonPropertyName("size")]
public string Size { get; set; } = "30x30";
/// <summary>
/// TODO: make this an enum
/// List of valid types can be found at https://thumbnails.roblox.com//docs/index.html
/// </summary>
[JsonPropertyName("format")]
public string Format { get; set; } = "Png";
[JsonPropertyName("isCircular")]
public bool IsCircular { get; set; } = true;
}
}

View File

@ -5,13 +5,31 @@
/// </summary>
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; }
/// <summary>
/// Valid states:
/// - Error
/// - Completed
/// - InReview
/// - Pending
/// - Blocked
/// - TemporarilyUnavailable
/// </summary>
[JsonPropertyName("state")]
public string State { get; set; } = null!;
[JsonPropertyName("imageUrl")]
public string ImageUrl { get; set; } = null!;
public string? ImageUrl { get; set; } = null!;
}
}

View File

@ -0,0 +1,8 @@
namespace Bloxstrap.Models
{
internal class ThumbnailCacheEntry
{
public ulong Id { get; set; }
public string Url { get; set; } = string.Empty;
}
}

View File

@ -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<T> : List<T>
{
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);
}
}
}

View File

@ -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
/// <remarks>
/// Returned array may contain null values
/// </remarks>
public static async Task<string?[]> GetThumbnailUrlsAsync(List<ThumbnailRequest> 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<ThumbnailBatchResponse>("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<string?> 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<ThumbnailBatchResponse>("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;
}
}
}