Repository navigation
Expand file tree
/
Copy pathSteamClient.cs
More file actions
383 lines (308 loc) · 12.6 KB
/
Copy pathSteamClient.cs
File metadata and controls
383 lines (308 loc) · 12.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
/*
* Steam Api Client
*
* Copyright (c) 2026 Kyle Givler
* Licensed under the MIT License.
*/
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SteamApiClient.Contracts.SteamApi;
using SteamApiClient.Services;
using SteamApiClient.Settings;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SteamApiClient.HttpClients;
public class SteamClient : ISteamClient
{
private readonly HttpClient _httpClient;
private readonly SteamClientApiOptions _steamOptions;
private readonly ILogger<SteamClient> _logger;
private readonly ICacheService _cache;
private static readonly JsonSerializerOptions _jsonOptions =
new(JsonSerializerDefaults.Web);
private const int STEAM_VANITY_SUCCESS = 1;
private const int STEAM_VANITY_NO_MATCH = 42;
private const int STORE_BROWSE_BATCH_SIZE = 100;
public SteamClient(
HttpClient httpClient,
IOptions<SteamClientApiOptions> steamOptions,
ICacheService cache,
ILogger<SteamClient> logger)
{
_httpClient = httpClient;
_steamOptions = steamOptions.Value;
_cache = cache;
_logger = logger;
// TODO: Move this to a helper or something
_httpClient.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
_httpClient.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue(SteamClientConstants.UserAgent, SteamClientConstants.Version));
_httpClient.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue(SteamClientConstants.UserAgentComment));
}
public async Task<OwnedGames> GetOwnedGames(
long steamId,
bool includeAppInfo = true,
bool includePlayedFreeGames = true,
CancellationToken ct = default)
{
var result = await GetOwnedGamesWithCacheInfo(
steamId,
includeAppInfo,
includePlayedFreeGames,
ct);
return result.OwnedGames;
}
public async Task<OwnedGamesResult> GetOwnedGamesWithCacheInfo(
long steamId,
bool includeAppInfo = true,
bool includePlayedFreeGames = true,
CancellationToken ct = default)
{
var cacheKey = $"owned_v2_{steamId}_{includeAppInfo}_{includePlayedFreeGames}";
var tags = new[] { $"steam_user_{steamId}", "owned_games" };
// checks RAM (L1), falls back to DB (L2) enforces single flight
var result = await _cache.GetOrCreateWithMetadataAsync(cacheKey, async (token) =>
{
// _logger.LogDebug("Cache miss or expired. Fetching OwnedGames from Steam API.");
var url =
$"IPlayerService/GetOwnedGames/v0001/" +
$"?key={_steamOptions.ApiKey}" +
$"&steamid={steamId}" +
$"&format=json" +
$"{(includeAppInfo ? "&include_appinfo=1" : "")}" +
$"{(includePlayedFreeGames ? "&include_played_free_games=1" : "")}" +
$"&l=english";
using var response = await _httpClient.GetAsync(url, token);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"Steam API failure (OwnedGames). StatusCode={StatusCode}",
response.StatusCode);
throw new HttpRequestException(
$"Steam API failed to return owned games. StatusCode={response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync(token);
var parsed = JsonSerializer.Deserialize<OwnedGamesResponse>(json, _jsonOptions);
if (parsed?.Response is null)
{
_logger.LogWarning(
"Steam API invalid JSON (OwnedGames).");
throw new InvalidOperationException("Steam API returned an invalid owned-games response.");
}
return parsed.Response;
}, _steamOptions.Cache.OwnedGames, tags, ct);
return new OwnedGamesResult(result.Value, result.Cache);
}
public async Task<long> GetSteamIdFromVanityUrl(
string vanityUrl,
CancellationToken ct = default)
{
var normalizedVanity = SteamVanityUrlHelper.Normalize(vanityUrl);
return await _cache.CoalesceAsync($"vanity:v2:fill:{normalizedVanity}",
token => ResolveVanityAsync(normalizedVanity, token), ct);
}
private async Task<long> ResolveVanityAsync(string normalizedVanity, CancellationToken ct)
{
var successCacheKey = SteamVanityUrlHelper.BuildCacheKey(normalizedVanity);
var notFoundCacheKey = SteamVanityUrlHelper.BuildNotFoundCacheKey(normalizedVanity);
var tags = new[] { SteamVanityUrlHelper.BuildCacheKey(normalizedVanity), "vanity_urls" };
var cachedSuccess = await _cache.GetAsync<long?>(successCacheKey, ct);
if (cachedSuccess.HasValue)
{
return cachedSuccess.Value;
}
var cachedNotFound = await _cache.GetAsync<bool?>(notFoundCacheKey, ct);
if (cachedNotFound == true)
{
return 0L;
}
// _logger.LogDebug("Cache miss or expired. Resolving VanityUrl from Steam API.");
var encoded = Uri.EscapeDataString(normalizedVanity);
var url =
$"ISteamUser/ResolveVanityURL/v0001/" +
$"?key={_steamOptions.ApiKey}" +
$"&vanityurl={encoded}&format=json";
using var response = await _httpClient.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"Steam API failure (VanityUrl). StatusCode={StatusCode}",
response.StatusCode);
throw new HttpRequestException(
$"Steam API failed to resolve vanity URL. StatusCode={response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync(ct);
var parsed = JsonSerializer.Deserialize<ResolveVanityUrlResponse>(json, _jsonOptions);
var r = parsed?.Response;
if (r is null)
{
_logger.LogWarning(
"Steam API invalid response (VanityUrl).");
throw new InvalidOperationException("Steam API returned an invalid vanity response.");
}
if (r.Success == STEAM_VANITY_NO_MATCH)
{
// _logger.LogInformation("Vanity URL not found.");
await _cache.SetAsync<bool?>(notFoundCacheKey, true, _steamOptions.Cache.VanityNotFound, tags, ct);
return 0L;
}
if (r.Success != STEAM_VANITY_SUCCESS)
{
_logger.LogWarning(
"Steam API failure status (VanityUrl). Success={Success}",
r.Success);
throw new InvalidOperationException(
$"Steam API returned vanity failure status {r.Success}.");
}
var steamId = long.Parse(r.SteamId!);
await _cache.SetAsync<long?>(successCacheKey, steamId, _steamOptions.Cache.VanitySuccess, tags, ct);
return steamId;
}
public async Task InvalidateOwnedGamesCacheAsync(long userId, CancellationToken ct = default)
{
await _cache.InvalidateByTagAsync($"steam_user_{userId}", ct);
}
public async Task<IReadOnlyDictionary<int, SteamDeckCompatibilityCategory>> GetSteamDeckCompatibilityAsync(
IEnumerable<int> appIds,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(appIds);
var ids = appIds
.Where(appId => appId > 0)
.Distinct()
.ToArray();
var result = new Dictionary<int, SteamDeckCompatibilityCategory>();
var uncached = new List<int>();
foreach (var appId in ids)
{
var cached = await _cache.GetAsync<CachedSteamDeckCompatibility>($"steam_deck_compat:{appId}", ct);
if (cached is not null)
{
result[appId] = cached.Category;
}
else
{
uncached.Add(appId);
}
}
foreach (var batch in uncached.Chunk(STORE_BROWSE_BATCH_SIZE))
{
var fetched = await FetchSteamDeckCompatibilityBatchAsync(batch, ct);
if (fetched is null)
{
foreach (var appId in batch)
{
result[appId] = SteamDeckCompatibilityCategory.Unknown;
}
continue;
}
foreach (var appId in batch)
{
if (!fetched.TryGetValue(appId, out var category))
{
result[appId] = SteamDeckCompatibilityCategory.Unknown;
continue;
}
result[appId] = category;
await _cache.SetAsync(
$"steam_deck_compat:{appId}",
new CachedSteamDeckCompatibility(category),
_steamOptions.Cache.SteamDeckCompatibility,
[
"steam_deck_compatibility",
$"app_{appId}"
],
ct);
}
}
return result;
}
private async Task<Dictionary<int, SteamDeckCompatibilityCategory>?> FetchSteamDeckCompatibilityBatchAsync(
IReadOnlyCollection<int> appIds,
CancellationToken ct)
{
var request = new StoreBrowseRequest(
appIds
.Select(appId => new StoreBrowseItemId(appId))
.ToArray(),
new StoreBrowseContext(
Language: "english",
CountryCode: "US",
SteamRealm: 1),
new StoreBrowseDataRequest(
IncludePlatforms: true));
var inputJson = JsonSerializer.Serialize(request, _jsonOptions);
var url =
"IStoreBrowseService/GetItems/v1/" +
$"?key={Uri.EscapeDataString(_steamOptions.ApiKey)}" +
$"&input_json={Uri.EscapeDataString(inputJson)}";
try
{
using var response = await _httpClient.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"Steam API failure (Deck compatibility). " +
"StatusCode={StatusCode}",
response.StatusCode);
return null;
}
var json = await response.Content.ReadAsStringAsync(ct);
var parsed = JsonSerializer.Deserialize<StoreBrowseItemsResponse>(
json,
_jsonOptions);
if (parsed?.Response?.StoreItems is null)
{
_logger.LogWarning(
"Steam API returned an invalid Deck " +
"compatibility response.");
return null;
}
var result = new Dictionary<int, SteamDeckCompatibilityCategory>();
foreach (var item in parsed.Response.StoreItems)
{
var category =
item.Platforms?.SteamDeckCompatCategory switch
{
1 => SteamDeckCompatibilityCategory.Unsupported,
2 => SteamDeckCompatibilityCategory.Playable,
3 => SteamDeckCompatibilityCategory.Verified,
_ => SteamDeckCompatibilityCategory.Unknown
};
result[item.AppId] = category;
}
return result;
}
catch (HttpRequestException exception)
{
_logger.LogWarning(
exception,
"Steam Deck compatibility request failed.");
return null;
}
catch (JsonException exception)
{
_logger.LogWarning(
exception,
"Steam Deck compatibility response could not be parsed.");
return null;
}
}
private sealed record StoreBrowseRequest(
StoreBrowseItemId[] Ids,
StoreBrowseContext Context,
[property: JsonPropertyName("data_request")]
StoreBrowseDataRequest DataRequest);
private sealed record StoreBrowseItemId([property: JsonPropertyName("appid")] int AppId);
private sealed record StoreBrowseContext(string Language,
[property: JsonPropertyName("country_code")]
string CountryCode,
[property: JsonPropertyName("steam_realm")]
int SteamRealm);
private sealed record StoreBrowseDataRequest([property: JsonPropertyName("include_platforms")]
bool IncludePlatforms);
private sealed record CachedSteamDeckCompatibility(SteamDeckCompatibilityCategory Category);
}