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    USER_AGENT_BROWSER, USER_AGENT_STEAM,
9};
10use crate::utils::http_client::HTTP_CLIENT;
11use serde::{Deserialize, Serialize};
12use serde_json::{json, Value};
13use std::time::Duration;
14
15// === API DE USUÁRIO - IMPORTAÇÃO DE BIBLIOTECA E CONQUISTAS (Requer API Key) ===
16
17/// Estrutura auxiliar para representar um jogo na biblioteca Steam.
18#[derive(Debug, Deserialize, Serialize)]
19pub struct SteamGame {
20    pub appid: u32,
21    pub name: String,
22    pub playtime_forever: i32, // Minutos
23    pub img_icon_url: Option<String>,
24    #[serde(default)]
25    pub rtime_last_played: i64,
26}
27
28#[derive(Debug, Deserialize, Serialize)]
29struct SteamResponseData {
30    game_count: u32,
31    games: Vec<SteamGame>,
32}
33
34#[derive(Debug, Deserialize, Serialize)]
35struct SteamApiResponse {
36    response: SteamResponseData,
37}
38
39/// Estrutura auxiliar para obter conquistas de um jogo.
40#[derive(Debug, Deserialize, Serialize, Clone)]
41pub struct SteamAchievement {
42    pub apiname: String,
43    pub achieved: i32,
44    pub unlocktime: i64,
45    pub name: Option<String>,
46    pub description: Option<String>,
47}
48
49#[derive(Debug, Deserialize)]
50struct PlayerStats {
51    achievements: Option<Vec<SteamAchievement>>,
52}
53
54#[derive(Debug, Deserialize)]
55struct PlayerStatsResponse {
56    playerstats: PlayerStats,
57}
58
59#[derive(Debug, Deserialize)]
60struct RecentGamesResponse {
61    response: RecentGamesData,
62}
63
64#[derive(Debug, Deserialize)]
65struct RecentGamesData {
66    games: Option<Vec<SteamGame>>,
67}
68
69/// Lista todos os jogos da biblioteca de um usuário Steam via API.
70///
71/// Obtém os jogos do usuário na Steam, com informações de tempo jogado e último horário de jogo.
72///
73/// **Parâmetros:**
74/// - `api_key`: Chave da API Steam (pública)
75/// - `steam_id`: ID do usuário Steam
76///
77/// **Retorno:** Vetor de SteamGame ou erro de rede
78pub async fn list_steam_games(api_key: &str, steam_id: &str) -> Result<Vec<SteamGame>, String> {
79    let url = format!(
80        "https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key={}&steamid={}&format=json&include_appinfo=true&include_played_free_games=true",
81        api_key, steam_id
82    );
83
84    let res = HTTP_CLIENT
85        .get(&url)
86        .send()
87        .await
88        .map_err(|e| e.to_string())?;
89
90    if !res.status().is_success() {
91        return Err(format!("Erro Steam API (OwnedGames): {}", res.status()));
92    }
93
94    let api_data: SteamApiResponse = res.json().await.map_err(|e| format!("JSON Error: {}", e))?;
95    Ok(api_data.response.games)
96}
97
98/// Busca jogos jogados nas últimas 2 semanas
99pub async fn get_recently_played_games(
100    api_key: &str,
101    steam_id: &str,
102) -> Result<Vec<SteamGame>, String> {
103    let url = format!(
104        "https://api.steampowered.com/IPlayerService/GetRecentlyPlayedGames/v0001/?key={}&steamid={}&format=json&count=10",
105        api_key, steam_id
106    );
107
108    let res = crate::utils::http_client::HTTP_CLIENT
109        .get(&url)
110        .send()
111        .await
112        .map_err(|e| e.to_string())?;
113
114    if !res.status().is_success() {
115        return Err(format!("Erro Steam Recent Games: {}", res.status()));
116    }
117
118    let data: RecentGamesResponse = res.json().await.map_err(|e| e.to_string())?;
119    Ok(data.response.games.unwrap_or_default())
120}
121
122/// Busca conquistas do jogador num jogo específico
123pub async fn get_player_achievements(
124    api_key: &str,
125    steam_id: &str,
126    app_id: u32,
127) -> Result<Vec<SteamAchievement>, String> {
128    // Usa l=brazilian para tentar obter os nomes traduzidos se disponíveis
129    let url = format!(
130        "https://api.steampowered.com/ISteamUserStats/GetPlayerAchievements/v0001/?appid={}&key={}&steamid={}&l=brazilian",
131        app_id, api_key, steam_id
132    );
133
134    let res = crate::utils::http_client::HTTP_CLIENT
135        .get(&url)
136        .send()
137        .await
138        .map_err(|e| e.to_string())?;
139
140    // Jogos sem conquistas retornam 400 ou erro, e são tratados como lista vazia
141    if !res.status().is_success() {
142        return Ok(vec![]);
143    }
144
145    let data: Result<PlayerStatsResponse, _> = res.json().await;
146    match data {
147        Ok(d) => Ok(d.playerstats.achievements.unwrap_or_default()),
148        Err(_) => Ok(vec![]), // Falha no parse (jogo sem conquistas públicas)
149    }
150}
151
152//  === API DA LOJA - METADADOS, REVIEWS E CONTEÚDO ADULTO (Pública) ===
153
154/// Detalhes da loja Steam para um aplicativo (jogo).
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct SteamStoreData {
157    pub name: String,
158    pub is_free: bool,
159    pub short_description: String,
160    pub header_image: String,
161    pub website: Option<String>,
162    pub release_date: Option<String>,
163    pub content_descriptors: ContentDescriptors,
164    pub categories: Vec<Category>,
165    pub genres: Vec<Genre>,
166    pub required_age: u32,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct ContentDescriptors {
171    pub ids: Vec<u32>,
172    pub notes: Option<String>,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct Category {
177    pub id: u32,
178    pub description: String,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct Genre {
183    pub id: String,
184    pub description: String,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct SteamReviewSummary {
189    pub review_score: u32,
190    pub review_score_desc: String,
191    pub total_positive: u32,
192    pub total_negative: u32,
193    pub total_reviews: u32,
194}
195
196/// Resultado mínimo de busca por nome na Store Search pública da Steam.
197///
198/// Contém apenas os campos necessários para resolver o `app_id` de um jogo
199/// a partir do nome — os demais campos do endpoint (preço, plataformas,
200/// metascore) já são obtidos de outras fontes integradas ao Playlite
201/// (ITAD, PCGamingWiki, RAWG) e por isso são intencionalmente descartados.
202#[derive(Debug, Deserialize)]
203pub struct SteamSearchItem {
204    pub id: u32,
205    pub name: String,
206    #[serde(rename = "type")]
207    pub item_type: String,
208}
209
210#[derive(Debug, Deserialize)]
211struct SteamSearchResponse {
212    items: Vec<SteamSearchItem>,
213}
214
215/// Busca jogos na Steam Store por nome (endpoint público, sem API key).
216///
217/// Usado para resolver o `app_id` de jogos importados de outras plataformas
218/// (Epic, GOG etc.), quando o `app_id` não é conhecido diretamente.
219///
220/// Filtra automaticamente itens do tipo `"sub"` (pacotes/bundles), que não
221/// são jogos individuais e não servem como alvo de correlação. Itens do tipo
222/// `"app"` ainda podem incluir DLCs, trilhas sonoras e edições especiais —
223/// a desambiguação desses casos é responsabilidade de quem chama esta função.
224pub async fn search_app_by_name(name: &str) -> Result<Vec<SteamSearchItem>, String> {
225    crate::services::rate_limiter::STEAM_LIMITER
226        .run(|| async {
227            let url = format!(
228                "{}?term={}&l=english&cc=BR",
229                STEAM_STORE_SEARCH_URL,
230                urlencoding::encode(name)
231            );
232
233            let res = HTTP_CLIENT
234                .get(&url)
235                .header("User-Agent", USER_AGENT_BROWSER)
236                .timeout(Duration::from_secs(STEAM_STORE_TIMEOUT_SECS))
237                .send()
238                .await
239                .map_err(|e| format!("Erro requisição Steam Store Search: {}", e))?;
240
241            if !res.status().is_success() {
242                return Err(format!("Steam Store Search API Error: {}", res.status()));
243            }
244
245            let data: SteamSearchResponse = res
246                .json()
247                .await
248                .map_err(|e| format!("Erro ao parsear resposta Steam Store Search: {}", e))?;
249
250            Ok(data
251                .items
252                .into_iter()
253                .filter(|item| item.item_type != "sub")
254                .collect())
255        })
256        .await
257}
258
259/// Busca detalhes da loja (Conteúdo adulto, descrição, imagens).
260/// Retorna Option porque o jogo pode não existir na loja (removido/banido).
261pub async fn get_app_details(app_id: &str) -> Result<Option<SteamStoreData>, String> {
262    crate::services::rate_limiter::STEAM_LIMITER.run(|| async {
263        // Filtra apenas os campos necessários
264        let url = format!(
265            "{}?appids={}&filters=basic,content_descriptors,categories,genres,release_date",
266            STEAM_STORE_API_URL, app_id
267        );
268
269        let response = HTTP_CLIENT
270            .get(&url)
271            .timeout(Duration::from_secs(STEAM_STORE_TIMEOUT_SECS))
272            .send()
273            .await
274            .map_err(|e| format!("Erro requisição Steam Store: {}", e))?;
275
276        if !response.status().is_success() {
277            return Err(format!("Steam Store API Error: {}", response.status()));
278        }
279
280        let json: Value = response.json().await.map_err(|e| e.to_string())?;
281
282        // Navega no JSON dinâmico (Chave é o AppID)
283        if let Some(app_wrapper) = json.get(app_id) {
284            if let Some(success) = app_wrapper.get("success").and_then(|v| v.as_bool()) {
285                if success {
286                    if let Some(data) = app_wrapper.get("data") {
287                        let name = data
288                            .get("name")
289                            .and_then(|v| v.as_str())
290                            .unwrap_or("")
291                            .to_string();
292                        let is_free = data
293                            .get("is_free")
294                            .and_then(|v| v.as_bool())
295                            .unwrap_or(false);
296                        let short_description = data
297                            .get("short_description")
298                            .and_then(|v| v.as_str())
299                            .unwrap_or("")
300                            .to_string();
301                        let header_image = data
302                            .get("header_image")
303                            .and_then(|v| v.as_str())
304                            .unwrap_or("")
305                            .to_string();
306                        let website = data
307                            .get("website")
308                            .and_then(|v| v.as_str())
309                            .map(|s| s.to_string());
310                        let release_date = data
311                            .get("release_date")
312                            .and_then(|v| v.get("date"))
313                            .and_then(|v| v.as_str())
314                            .map(|s| s.to_string());
315                        let required_age = data
316                            .get("required_age")
317                            .and_then(|v| v.as_u64())
318                            .unwrap_or(0) as u32;
319                        let content_descriptors: ContentDescriptors = serde_json::from_value(
320                            data.get("content_descriptors")
321                                .cloned()
322                                .unwrap_or(json!({"ids": [], "notes": null})),
323                        )
324                            .unwrap_or(ContentDescriptors {
325                                ids: vec![],
326                                notes: None,
327                            });
328                        let categories: Vec<Category> = serde_json::from_value(
329                            data.get("categories").cloned().unwrap_or(json!([])),
330                        )
331                            .unwrap_or_default();
332                        let genres: Vec<Genre> =
333                            serde_json::from_value(data.get("genres").cloned().unwrap_or(json!([])))
334                                .unwrap_or_default();
335
336                        return Ok(Some(SteamStoreData {
337                            name,
338                            is_free,
339                            short_description,
340                            header_image,
341                            website,
342                            release_date,
343                            content_descriptors,
344                            categories,
345                            genres,
346                            required_age,
347                        }));
348                    }
349                }
350            }
351        }
352
353        Ok(None)
354    }).await
355}
356
357/// Busca o resumo das avaliações (Reviews)
358pub async fn get_app_reviews(app_id: &str) -> Result<Option<SteamReviewSummary>, String> {
359    crate::services::rate_limiter::STEAM_LIMITER.run(|| async {
360        let url = format!(
361            "{}/{}?json=1&language=all&purchase_type=all",
362            REVIEW_API_URL, app_id
363        );
364
365        let response = HTTP_CLIENT
366            .get(&url)
367            .header("User-Agent", USER_AGENT_STEAM)
368            .send()
369            .await
370            .map_err(|e| e.to_string())?;
371
372        let json: Value = response.json().await.map_err(|e| e.to_string())?;
373
374        if let Some(success) = json.get("success").and_then(|v| v.as_i64()) {
375            if success == 1 {
376                if let Some(summary) = json.get("query_summary") {
377                    let total_reviews = summary
378                        .get("total_reviews")
379                        .and_then(|v| v.as_u64())
380                        .unwrap_or(0) as u32;
381                    let review_score_desc = summary
382                        .get("review_score_desc")
383                        .and_then(|v| v.as_str())
384                        .unwrap_or("No Reviews")
385                        .to_string();
386
387                    return Ok(Some(SteamReviewSummary {
388                        review_score: summary
389                            .get("review_score")
390                            .and_then(|v| v.as_u64())
391                            .unwrap_or(0) as u32,
392                        review_score_desc,
393                        total_positive: summary
394                            .get("total_positive")
395                            .and_then(|v| v.as_u64())
396                            .unwrap_or(0) as u32,
397                        total_negative: summary
398                            .get("total_negative")
399                            .and_then(|v| v.as_u64())
400                            .unwrap_or(0) as u32,
401                        total_reviews,
402                    }));
403                }
404            }
405        }
406
407        Ok(None)
408    }).await
409}
410
411/// Detecta conteúdo explícito (sexual) baseado exclusivamente em critérios da Steam
412///
413/// Retorna:
414/// - bool: se é conteúdo explícito
415/// - Vec<String>: flags descritivas encontradas
416pub fn detect_adult_content(data: &SteamStoreData) -> (bool, Vec<String>) {
417    let mut flags = Vec::new();
418    let mut is_explicit = false;
419
420    // 1. Content Descriptors (fonte mais confiável)
421    for id in &data.content_descriptors.ids {
422        match id {
423            // Sexual Content
424            2 => {
425                is_explicit = true;
426                flags.push("sexual content".to_string());
427            }
428
429            // Nudity
430            3 => {
431                is_explicit = true;
432                flags.push("nudity".to_string());
433            }
434
435            // Informativos (não explícitos)
436            1 => flags.push("violence".to_string()),
437            4 => flags.push("gore".to_string()),
438            5 => flags.push("adult themes".to_string()),
439
440            _ => {}
441        }
442    }
443
444    // 2. Notes (geralmente usadas para Adult Only Sexual Content)
445    if let Some(notes) = &data.content_descriptors.notes {
446        let notes_lower = notes.to_lowercase();
447        let explicit_keywords = [
448            "adult only sexual content",
449            "explicit sexual",
450            "pornographic",
451            "sexual acts",
452            "graphic sexual",
453        ];
454
455        for keyword in explicit_keywords {
456            if notes_lower.contains(keyword) {
457                is_explicit = true;
458                flags.push(keyword.to_string());
459            }
460        }
461    }
462
463    // 3. Tags / gêneros como fallback fraco
464    for genre in &data.genres {
465        let desc = genre.description.to_lowercase();
466        let explicit_genre_keywords = [
467            "hentai",
468            "nsfw",
469            "eroge",
470            "porn",
471            "sexual content",
472            "adult only",
473        ];
474
475        for keyword in explicit_genre_keywords {
476            if desc.contains(keyword) {
477                is_explicit = true;
478                flags.push(keyword.to_string());
479            }
480        }
481    }
482
483    // 4. Normalização
484    flags.sort();
485    flags.dedup();
486
487    (is_explicit, flags)
488}