From 18d43f9afa0954fe3b57725a3d00aa8b83590077 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 17:01:10 +0200 Subject: [PATCH] fix(emotes): survive BetterTTV's shared-emote endpoint going private /emotes/shared/top now answers 403 with {"message":"unauthorized"}. Nothing checked the status code, so that object was handed to a List deserializer, which threw on the very first character on every plugin start. The throw escaped the whole loader, so the 65 global emotes -- fetched successfully one call earlier -- were discarded along with it, and State went back to Unloaded, arming the same failure for the next trigger. A failed page now stops paging and keeps what the global endpoint returned. The global call itself still throws when it fails, because without it there is nothing to keep. Two smaller hazards on the same path: an empty page would have made Last() throw rather than end the loop, and a null deserialization result was dereferenced outright. Both end the loop now. Logged as a warning, not an error. This is a third-party endpoint changing its access policy, not a fault in the plugin, and it does not need a stack trace in the log at every launch. --- HellionChat/EmoteCache.cs | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/HellionChat/EmoteCache.cs b/HellionChat/EmoteCache.cs index 0af31b7..260da90 100644 --- a/HellionChat/EmoteCache.cs +++ b/HellionChat/EmoteCache.cs @@ -125,6 +125,15 @@ public static class EmoteCache try { var global = await Client.GetAsync(GlobalEmotes, ct); + if (!global.IsSuccessStatusCode) + { + // Nothing usable at all -- let the catch below reset to Unloaded + // so a later trigger can retry. + throw new HttpRequestException( + $"BetterTTV global emotes returned {(int)global.StatusCode}." + ); + } + var globalList = await global.Content.ReadAsStringAsync(ct); foreach (var emote in JsonSerializer.Deserialize(globalList)!) @@ -135,9 +144,27 @@ public static class EmoteCache for (var i = 0; i < 15; i++) { var top = await Client.GetAsync(Top100Emotes.Format(BetterTTV, lastId), ct); + + // The shared-emote endpoint went behind authentication and now + // answers 403 with a JSON object. Deserializing that as a list + // threw on every single start, and took the global emotes -- which + // still work -- down with it. The global set is the useful half + // anyway, so a failure here stops paging instead of the load. + if (!top.IsSuccessStatusCode) + { + Plugin.LogProxy.Warning( + $"BetterTTV shared emotes unavailable ({(int)top.StatusCode}); " + + "continuing with global emotes only." + ); + break; + } + var topList = await top.Content.ReadAsStringAsync(ct); - var jsonList = JsonSerializer.Deserialize>(topList)!; + var jsonList = JsonSerializer.Deserialize>(topList); + if (jsonList is not { Count: > 0 }) + break; + // BetterTTV occasionally returns entries with a null Code; // skip them so a single bad row doesn't break the whole cache. foreach (var emote in jsonList) @@ -147,7 +174,7 @@ public static class EmoteCache ) Cache.TryAdd(emote.Emote.Code, emote.Emote); - lastId = jsonList.Last().Id; + lastId = jsonList[^1].Id; } SortedCodeArray = Cache.Keys.Order().ToArray();