game_manager_lib/services/integration/
steam_api.rs1use crate::constants::{
7 REVIEW_API_URL, STEAM_STORE_API_URL, STEAM_STORE_SEARCH_URL, STEAM_STORE_TIMEOUT_SECS,
8};
9use crate::utils::http_client::HTTP_CLIENT;
10use serde::{Deserialize, Serialize};
11use serde_json::{json, Value};
12use std::time::Duration;
13
14#[derive(Debug, Deserialize, Serialize)]
18pub struct SteamGame {
19 pub appid: u32,
20 pub name: String,
21 pub playtime_forever: i32, pub img_icon_url: Option<String>,
23 #[serde(default)]
24 pub rtime_last_played: i64,
25}
26
27#[derive(Debug, Deserialize, Serialize)]
28struct SteamResponseData {
29 game_count: u32,
30 games: Vec<SteamGame>,
31}
32
33#[derive(Debug, Deserialize, Serialize)]
34struct SteamApiResponse {
35 response: SteamResponseData,
36}
37
38#[derive(Debug, Deserialize, Serialize, Clone)]
40pub struct SteamAchievement {
41 pub apiname: String,
42 pub achieved: i32,
43 pub unlocktime: i64,
44 pub name: Option<String>,
45 pub description: Option<String>,
46}
47
48#[derive(Debug, Deserialize)]
49struct PlayerStats {
50 achievements: Option<Vec<SteamAchievement>>,
51}
52
53#[derive(Debug, Deserialize)]
54struct PlayerStatsResponse {
55 playerstats: PlayerStats,
56}
57
58#[derive(Debug, Deserialize)]
59struct RecentGamesResponse {
60 response: RecentGamesData,
61}
62
63#[derive(Debug, Deserialize)]
64struct RecentGamesData {
65 games: Option<Vec<SteamGame>>,
66}
67
68pub async fn list_steam_games(api_key: &str, steam_id: &str) -> Result<Vec<SteamGame>, String> {
78 let url = format!(
79 "https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key={}&steamid={}&format=json&include_appinfo=true&include_played_free_games=true",
80 api_key, steam_id
81 );
82
83 let res = HTTP_CLIENT
84 .get(&url)
85 .send()
86 .await
87 .map_err(|e| e.to_string())?;
88
89 if !res.status().is_success() {
90 return Err(format!("Erro Steam API (OwnedGames): {}", res.status()));
91 }
92
93 let api_data: SteamApiResponse = res.json().await.map_err(|e| format!("JSON Error: {}", e))?;
94 Ok(api_data.response.games)
95}
96
97pub async fn get_recently_played_games(
99 api_key: &str,
100 steam_id: &str,
101) -> Result<Vec<SteamGame>, String> {
102 let url = format!(
103 "https://api.steampowered.com/IPlayerService/GetRecentlyPlayedGames/v0001/?key={}&steamid={}&format=json&count=10",
104 api_key, steam_id
105 );
106
107 let res = crate::utils::http_client::HTTP_CLIENT
108 .get(&url)
109 .send()
110 .await
111 .map_err(|e| e.to_string())?;
112
113 if !res.status().is_success() {
114 return Err(format!("Erro Steam Recent Games: {}", res.status()));
115 }
116
117 let data: RecentGamesResponse = res.json().await.map_err(|e| e.to_string())?;
118 Ok(data.response.games.unwrap_or_default())
119}
120
121pub async fn get_player_achievements(
123 api_key: &str,
124 steam_id: &str,
125 app_id: u32,
126) -> Result<Vec<SteamAchievement>, String> {
127 let url = format!(
129 "https://api.steampowered.com/ISteamUserStats/GetPlayerAchievements/v0001/?appid={}&key={}&steamid={}&l=brazilian",
130 app_id, api_key, steam_id
131 );
132
133 let res = crate::utils::http_client::HTTP_CLIENT
134 .get(&url)
135 .send()
136 .await
137 .map_err(|e| e.to_string())?;
138
139 if !res.status().is_success() {
141 return Ok(vec![]);
142 }
143
144 let data: Result<PlayerStatsResponse, _> = res.json().await;
145 match data {
146 Ok(d) => Ok(d.playerstats.achievements.unwrap_or_default()),
147 Err(_) => Ok(vec![]), }
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct SteamStoreData {
156 pub name: String,
157 pub is_free: bool,
158 pub short_description: String,
159 pub header_image: String,
160 pub website: Option<String>,
161 pub release_date: Option<String>,
162 pub content_descriptors: ContentDescriptors,
163 pub categories: Vec<Category>,
164 pub genres: Vec<Genre>,
165 pub required_age: u32,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct ContentDescriptors {
170 pub ids: Vec<u32>,
171 pub notes: Option<String>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct Category {
176 pub id: u32,
177 pub description: String,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct Genre {
182 pub id: String,
183 pub description: String,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct SteamReviewSummary {
188 pub review_score: u32,
189 pub review_score_desc: String,
190 pub total_positive: u32,
191 pub total_negative: u32,
192 pub total_reviews: u32,
193}
194
195#[derive(Debug, Deserialize)]
202pub struct SteamSearchItem {
203 pub id: u32,
204 pub name: String,
205 #[serde(rename = "type")]
206 pub item_type: String,
207}
208
209#[derive(Debug, Deserialize)]
210struct SteamSearchResponse {
211 items: Vec<SteamSearchItem>,
212}
213
214pub async fn search_app_by_name(name: &str) -> Result<Vec<SteamSearchItem>, String> {
224 let url = format!(
225 "{}?term={}&l=english&",
226 STEAM_STORE_SEARCH_URL,
227 urlencoding::encode(name)
228 );
229
230 let res = HTTP_CLIENT
231 .get(&url)
232 .timeout(Duration::from_secs(STEAM_STORE_TIMEOUT_SECS))
233 .send()
234 .await
235 .map_err(|e| format!("Erro requisição Steam Store Search: {}", e))?;
236
237 if !res.status().is_success() {
238 return Err(format!("Steam Store Search API Error: {}", res.status()));
239 }
240
241 let data: SteamSearchResponse = res
242 .json()
243 .await
244 .map_err(|e| format!("Erro ao parsear resposta Steam Store Search: {}", e))?;
245
246 Ok(data
247 .items
248 .into_iter()
249 .filter(|item| item.item_type != "sub")
250 .collect())
251}
252
253pub async fn get_app_details(app_id: &str) -> Result<Option<SteamStoreData>, String> {
256 let url = format!(
258 "{}?appids={}&l=brazilian&filters=basic,content_descriptors,categories,genres,release_date",
259 STEAM_STORE_API_URL, app_id
260 );
261
262 let response = HTTP_CLIENT
263 .get(&url)
264 .timeout(Duration::from_secs(STEAM_STORE_TIMEOUT_SECS))
265 .send()
266 .await
267 .map_err(|e| format!("Erro requisição Steam Store: {}", e))?;
268
269 if !response.status().is_success() {
270 return Err(format!("Steam Store API Error: {}", response.status()));
271 }
272
273 let json: Value = response.json().await.map_err(|e| e.to_string())?;
274
275 if let Some(app_wrapper) = json.get(app_id) {
277 if let Some(success) = app_wrapper.get("success").and_then(|v| v.as_bool()) {
278 if success {
279 if let Some(data) = app_wrapper.get("data") {
280 let name = data
281 .get("name")
282 .and_then(|v| v.as_str())
283 .unwrap_or("")
284 .to_string();
285 let is_free = data
286 .get("is_free")
287 .and_then(|v| v.as_bool())
288 .unwrap_or(false);
289 let short_description = data
290 .get("short_description")
291 .and_then(|v| v.as_str())
292 .unwrap_or("")
293 .to_string();
294 let header_image = data
295 .get("header_image")
296 .and_then(|v| v.as_str())
297 .unwrap_or("")
298 .to_string();
299 let website = data
300 .get("website")
301 .and_then(|v| v.as_str())
302 .map(|s| s.to_string());
303
304 let release_date = data
305 .get("release_date")
306 .and_then(|v| v.get("date"))
307 .and_then(|v| v.as_str())
308 .map(|s| s.to_string());
309
310 let required_age = data
311 .get("required_age")
312 .and_then(|v| v.as_u64())
313 .unwrap_or(0) as u32;
314
315 let content_descriptors: ContentDescriptors = serde_json::from_value(
316 data.get("content_descriptors")
317 .cloned()
318 .unwrap_or(json!({"ids": [], "notes": null})),
319 )
320 .unwrap_or(ContentDescriptors {
321 ids: vec![],
322 notes: None,
323 });
324
325 let categories: Vec<Category> = serde_json::from_value(
326 data.get("categories").cloned().unwrap_or(json!([])),
327 )
328 .unwrap_or_default();
329
330 let genres: Vec<Genre> =
331 serde_json::from_value(data.get("genres").cloned().unwrap_or(json!([])))
332 .unwrap_or_default();
333
334 return Ok(Some(SteamStoreData {
335 name,
336 is_free,
337 short_description,
338 header_image,
339 website,
340 release_date,
341 content_descriptors,
342 categories,
343 genres,
344 required_age,
345 }));
346 }
347 }
348 }
349 }
350
351 Ok(None)
352}
353
354pub async fn get_app_reviews(app_id: &str) -> Result<Option<SteamReviewSummary>, String> {
356 let url = format!(
357 "{}/{}?json=1&language=all&purchase_type=all",
358 REVIEW_API_URL, app_id
359 );
360
361 let response = HTTP_CLIENT
362 .get(&url)
363 .header("User-Agent", "Valve/Steam HTTP Client 1.0")
364 .send()
365 .await
366 .map_err(|e| e.to_string())?;
367
368 let json: Value = response.json().await.map_err(|e| e.to_string())?;
369
370 if let Some(success) = json.get("success").and_then(|v| v.as_i64()) {
371 if success == 1 {
372 if let Some(summary) = json.get("query_summary") {
373 let total_reviews = summary
374 .get("total_reviews")
375 .and_then(|v| v.as_u64())
376 .unwrap_or(0) as u32;
377
378 let review_score_desc = summary
379 .get("review_score_desc")
380 .and_then(|v| v.as_str())
381 .unwrap_or("No Reviews")
382 .to_string();
383
384 return Ok(Some(SteamReviewSummary {
385 review_score: summary
386 .get("review_score")
387 .and_then(|v| v.as_u64())
388 .unwrap_or(0) as u32,
389 review_score_desc,
390 total_positive: summary
391 .get("total_positive")
392 .and_then(|v| v.as_u64())
393 .unwrap_or(0) as u32,
394 total_negative: summary
395 .get("total_negative")
396 .and_then(|v| v.as_u64())
397 .unwrap_or(0) as u32,
398 total_reviews,
399 }));
400 }
401 }
402 }
403
404 Ok(None)
405}
406
407pub fn detect_adult_content(data: &SteamStoreData) -> (bool, Vec<String>) {
413 let mut flags = Vec::new();
414 let mut is_explicit = false;
415
416 for id in &data.content_descriptors.ids {
418 match id {
419 2 => {
421 is_explicit = true;
422 flags.push("sexual content".to_string());
423 }
424
425 3 => {
427 is_explicit = true;
428 flags.push("nudity".to_string());
429 }
430
431 1 => flags.push("violence".to_string()),
433 4 => flags.push("gore".to_string()),
434 5 => flags.push("adult themes".to_string()),
435
436 _ => {}
437 }
438 }
439
440 if let Some(notes) = &data.content_descriptors.notes {
442 let notes_lower = notes.to_lowercase();
443
444 let explicit_keywords = [
445 "adult only sexual content",
446 "explicit sexual",
447 "pornographic",
448 "sexual acts",
449 "graphic sexual",
450 ];
451
452 for keyword in explicit_keywords {
453 if notes_lower.contains(keyword) {
454 is_explicit = true;
455 flags.push(keyword.to_string());
456 }
457 }
458 }
459
460 for genre in &data.genres {
462 let desc = genre.description.to_lowercase();
463
464 let explicit_genre_keywords = [
465 "hentai",
466 "nsfw",
467 "eroge",
468 "porn",
469 "sexual content",
470 "adult only",
471 ];
472
473 for keyword in explicit_genre_keywords {
474 if desc.contains(keyword) {
475 is_explicit = true;
476 flags.push(keyword.to_string());
477 }
478 }
479 }
480
481 flags.sort();
483 flags.dedup();
484
485 (is_explicit, flags)
486}