Skip to main content

game_manager_lib/services/integration/
steam_api.rs

1//! Módulo de integração com as APIs Steam.
2//!
3//! Unifica funcionalidades da API de Usuário (autenticada) para obter conquistas
4//! e da API da Loja (pública) para enriquecer metadados (reviews, conteúdo adulto).
5
6use 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// === API DE USUÁRIO - IMPORTAÇÃO DE BIBLIOTECA E CONQUISTAS (Requer API Key) ===
15
16/// Estrutura auxiliar para representar um jogo na biblioteca Steam.
17#[derive(Debug, Deserialize, Serialize)]
18pub struct SteamGame {
19    pub appid: u32,
20    pub name: String,
21    pub playtime_forever: i32, // Minutos
22    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/// Estrutura auxiliar para obter conquistas de um jogo.
39#[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
68/// Lista todos os jogos da biblioteca de um usuário Steam via API.
69///
70/// Obtém os jogos do usuário na Steam, com informações de tempo jogado e último horário de jogo.
71///
72/// **Parâmetros:**
73/// - `api_key`: Chave da API Steam (pública)
74/// - `steam_id`: ID do usuário Steam
75///
76/// **Retorno:** Vetor de SteamGame ou erro de rede
77pub 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
97/// Busca jogos jogados nas últimas 2 semanas
98pub 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
121/// Busca conquistas do jogador num jogo específico
122pub async fn get_player_achievements(
123    api_key: &str,
124    steam_id: &str,
125    app_id: u32,
126) -> Result<Vec<SteamAchievement>, String> {
127    // Usa l=brazilian para tentar obter os nomes traduzidos se disponíveis
128    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    // Jogos sem conquistas retornam 400 ou erro, e são tratados como lista vazia
140    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![]), // Falha no parse (jogo sem conquistas públicas)
148    }
149}
150
151//  === API DA LOJA - METADADOS, REVIEWS E CONTEÚDO ADULTO (Pública) ===
152
153/// Detalhes da loja Steam para um aplicativo (jogo).
154#[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/// Resultado mínimo de busca por nome na Store Search pública da Steam.
196///
197/// Contém apenas os campos necessários para resolver o `app_id` de um jogo
198/// a partir do nome — os demais campos do endpoint (preço, plataformas,
199/// metascore) já são obtidos de outras fontes integradas ao Playlite
200/// (ITAD, PCGamingWiki, RAWG) e por isso são intencionalmente descartados.
201#[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
214/// Busca jogos na Steam Store por nome (endpoint público, sem API key).
215///
216/// Usado para resolver o `app_id` de jogos importados de outras plataformas
217/// (Epic, GOG etc.), quando o `app_id` não é conhecido diretamente.
218///
219/// Filtra automaticamente itens do tipo `"sub"` (pacotes/bundles), que não
220/// são jogos individuais e não servem como alvo de correlação. Itens do tipo
221/// `"app"` ainda podem incluir DLCs, trilhas sonoras e edições especiais —
222/// a desambiguação desses casos é responsabilidade de quem chama esta função.
223pub 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
253/// Busca detalhes da loja (Conteúdo adulto, descrição, imagens).
254/// Retorna Option porque o jogo pode não existir na loja (removido/banido).
255pub async fn get_app_details(app_id: &str) -> Result<Option<SteamStoreData>, String> {
256    // Filtra apenas os campos necessários
257    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    // Navega no JSON dinâmico (Chave é o AppID)
276    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
354/// Busca o resumo das avaliações (Reviews)
355pub 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
407/// Detecta conteúdo explícito (sexual) baseado exclusivamente em critérios da Steam
408///
409/// Retorna:
410/// - bool: se é conteúdo explícito
411/// - Vec<String>: flags descritivas encontradas
412pub fn detect_adult_content(data: &SteamStoreData) -> (bool, Vec<String>) {
413    let mut flags = Vec::new();
414    let mut is_explicit = false;
415
416    // 1. Content Descriptors (fonte mais confiável)
417    for id in &data.content_descriptors.ids {
418        match id {
419            // Sexual Content
420            2 => {
421                is_explicit = true;
422                flags.push("sexual content".to_string());
423            }
424
425            // Nudity
426            3 => {
427                is_explicit = true;
428                flags.push("nudity".to_string());
429            }
430
431            // Informativos (não explícitos)
432            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    // 2. Notes (geralmente usadas para Adult Only Sexual Content)
441    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    // 3. Tags / gêneros como fallback fraco
461    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    // 4. Normalização
482    flags.sort();
483    flags.dedup();
484
485    (is_explicit, flags)
486}