Skip to main content

game_manager_lib/commands/recommendation/
core.rs

1//! Comandos Tauri para Sistema de Recomendação v4.0
2//!
3//! Faz JOIN com game_details para obter genres, tags categorizadas e series.
4//! Utiliza abordagem híbrida: perfil do usuário + collaborative filtering.
5//! Permite configuração de filtros (playtime), pesos personalizados, feedback (blacklist).
6//! Retorna razões detalhadas para cada recomendação.
7
8use crate::database::AppState;
9use crate::errors::AppError;
10use crate::models::{Game, Platform};
11use crate::services::recommendation::cf_aggregator::build_cf_candidates;
12use crate::services::recommendation::{
13    calculate_user_profile, parse_release_year, rank_games_collaborative, rank_games_content_based,
14    rank_games_hybrid, GameWithDetails, RecommendationConfig, RecommendationReason, SeriesLimit,
15    UserPreferenceVector, UserSettings,
16};
17use serde::{Deserialize, Serialize};
18use std::collections::HashSet;
19use tauri::{Manager, State};
20
21// === ESTRUTURAS DE DADOS ===
22
23/// Estrutura completa de recomendação para o Frontend
24#[derive(Debug, Serialize)]
25pub struct GameRecommendation {
26    pub game_id: String,
27    pub score: f32,
28    pub reason: RecommendationReason,
29}
30
31/// Struct auxiliar para input de configuração opcional
32#[derive(Debug, Deserialize)]
33pub struct RecommendationOptions {
34    pub min_playtime: Option<i32>,
35    pub max_playtime: Option<i32>,
36    pub limit: usize,
37    pub ignored_game_ids: Option<Vec<String>>, // Blacklist do feedback
38    pub config: Option<RecommendationConfig>,  // Pesos personalizados
39}
40
41// === COMANDOS PRINCIPAIS ===
42
43/// Comando Híbrido Principal (Usado na Playlist e Home)
44///
45/// Retorna recomendações de jogos baseadas em perfil do usuário + collaborative filtering.
46#[tauri::command]
47pub async fn recommend_hybrid_library(
48    app: tauri::AppHandle,
49    state: State<'_, AppState>,
50    options: RecommendationOptions,
51) -> Result<Vec<GameRecommendation>, AppError> {
52    let games_with_details = fetch_all_games_with_details(&state)?;
53    let ignored_ids = create_ignored_set(options.ignored_game_ids.clone());
54    let profile = calculate_user_profile(&games_with_details, &ignored_ids);
55    let (cf_scores, _) = build_cf_candidates(&games_with_details);
56    let candidates = filter_candidates_by_playtime(games_with_details, &options);
57    let config = options.config.unwrap_or_default();
58    let user_settings = load_user_settings(&app);
59
60    let ranked = rank_games_hybrid(
61        &profile,
62        &candidates,
63        &cf_scores,
64        &ignored_ids,
65        config,
66        user_settings,
67    );
68
69    Ok(format_recommendations(ranked, options.limit))
70}
71
72/// Recomendação Content-Based Pura (Biblioteca)
73#[tauri::command]
74pub async fn recommend_from_library(
75    app: tauri::AppHandle,
76    state: State<'_, AppState>,
77    options: RecommendationOptions,
78) -> Result<Vec<GameRecommendation>, AppError> {
79    let games_with_details = fetch_all_games_with_details(&state)?;
80    let ignored_ids = create_ignored_set(options.ignored_game_ids.clone());
81    let profile = calculate_user_profile(&games_with_details, &ignored_ids);
82    let candidates = filter_candidates_by_playtime(games_with_details, &options);
83    let config = options.config.unwrap_or_default();
84    let user_settings = load_user_settings(&app);
85    let ranked = rank_games_content_based(&profile, &candidates, &config, &user_settings);
86
87    Ok(format_recommendations(ranked, options.limit))
88}
89
90/// Recomendação Collaborative Pura
91#[tauri::command]
92pub async fn recommend_collaborative_library(
93    app: tauri::AppHandle,
94    state: State<'_, AppState>,
95    options: RecommendationOptions,
96) -> Result<Vec<GameRecommendation>, AppError> {
97    let games_with_details = fetch_all_games_with_details(&state)?;
98    let ignored_ids = create_ignored_set(options.ignored_game_ids.clone());
99    let (cf_scores, _) = build_cf_candidates(&games_with_details);
100    let candidates = filter_candidates_by_playtime(games_with_details, &options);
101    let user_settings = load_user_settings(&app);
102    let ranked = rank_games_collaborative(&candidates, &cf_scores, &ignored_ids, &user_settings);
103
104    Ok(format_recommendations(ranked, options.limit))
105}
106
107/// Retorna informações sobre o perfil do usuário
108#[tauri::command]
109pub async fn get_user_profile(
110    state: State<'_, AppState>,
111) -> Result<UserPreferenceVector, AppError> {
112    let games_with_details = fetch_all_games_with_details(&state)?;
113    let ignored_ids = HashSet::new();
114    let profile = calculate_user_profile(&games_with_details, &ignored_ids);
115    Ok(profile)
116}
117
118// === FUNÇÕES AUXILIARES ===
119
120/// Carrega as configurações de usuário do arquivo JSON
121fn load_user_settings(app_handle: &tauri::AppHandle) -> UserSettings {
122    // Tentar ler o arquivo de preferências
123    let app_data_dir = match app_handle.path().app_data_dir() {
124        Ok(dir) => dir,
125        Err(_) => return UserSettings::default(),
126    };
127
128    let prefs_path = app_data_dir.join("user_preferences.json");
129
130    if !prefs_path.exists() {
131        return UserSettings::default();
132    }
133
134    let contents = match std::fs::read_to_string(&prefs_path) {
135        Ok(c) => c,
136        Err(_) => return UserSettings::default(),
137    };
138
139    let prefs: serde_json::Value = match serde_json::from_str(&contents) {
140        Ok(p) => p,
141        Err(_) => return UserSettings::default(),
142    };
143
144    let filter_adult = prefs
145        .get("filter_adult_content")
146        .and_then(|v| v.as_bool())
147        .unwrap_or(false);
148
149    let series_limit_str = prefs
150        .get("series_limit")
151        .and_then(|v| v.as_str())
152        .unwrap_or("moderate");
153
154    let series_limit = match series_limit_str {
155        "none" => SeriesLimit::None,
156        "aggressive" => SeriesLimit::Aggressive,
157        _ => SeriesLimit::Moderate,
158    };
159
160    UserSettings {
161        filter_adult_content: filter_adult,
162        series_limit,
163    }
164}
165
166fn fetch_all_games_with_details(state: &AppState) -> Result<Vec<GameWithDetails>, AppError> {
167    let conn = state.games_db.lock()?;
168
169    let mut stmt = conn.prepare(
170        "SELECT
171            g.id, g.name, g.slug, g.playtime, g.favorite, g.user_rating, g.cover_url,
172            g.platform_game_id, g.last_played, g.added_at, g.platform, g.playtime_source, g.alternative_names,
173            gd.genres, gd.steam_app_id, gd.release_date, gd.series, gd.tags
174         FROM games g
175         LEFT JOIN game_details gd ON g.id = gd.game_id
176         ORDER BY g.name ASC",
177    )?;
178
179    let games: Result<Vec<GameWithDetails>, _> = stmt
180        .query_map([], |row| {
181            let alt_names_json: Option<String> = row.get(12)?;
182            let alternative_names = alt_names_json.and_then(|s| serde_json::from_str(&s).ok());
183
184            let game = Game {
185                id: row.get(0)?,
186                name: row.get(1)?,
187                slug: row.get(2)?,
188                playtime: row.get(3)?,
189                favorite: row.get(4)?,
190                user_rating: row.get(5)?,
191                cover_url: row.get(6)?,
192                platform_game_id: row.get(7)?,
193                last_played: row.get(8)?,
194                added_at: row.get(9)?,
195                platform: row.get::<_, String>(10)?.parse().unwrap_or(Platform::Outra),
196                alternative_names,
197                installed: false,
198                import_confidence: None,
199                install_path: None,
200                executable_path: None,
201                launch_args: None,
202                status: None,
203                playtime_source: row
204                    .get::<_, Option<String>>(11)?
205                    .and_then(|s| s.parse().ok()),
206                genres: None,
207                developer: None,
208                is_adult: false,
209            };
210
211            let genres_json: Option<String> = row.get(13)?;
212            let genres: Vec<String> = genres_json
213                .as_ref()
214                .map(|s| {
215                    if let Ok(vec) = serde_json::from_str::<Vec<String>>(s) {
216                        vec
217                    } else {
218                        s.split(',')
219                            .map(|g| g.trim().to_string())
220                            .filter(|g| !g.is_empty())
221                            .collect()
222                    }
223                })
224                .unwrap_or_default();
225
226            let steam_app_id_str: Option<String> = row.get(14)?;
227            let steam_app_id: Option<u32> = steam_app_id_str.and_then(|s| s.parse().ok());
228
229            let release_date: Option<String> = row.get(15)?;
230            let release_year = release_date.and_then(|d| parse_release_year(&d));
231
232            let series: Option<String> = row.get(16)?;
233
234            let tags_json: Option<String> = row.get(17)?;
235            let tags: Vec<crate::models::GameTag> = tags_json
236                .as_ref()
237                .and_then(|s| serde_json::from_str(s).ok())
238                .unwrap_or_default();
239
240            Ok(GameWithDetails {
241                game,
242                genres,
243                tags,
244                series,
245                release_year,
246                steam_app_id,
247            })
248        })?
249        .collect();
250
251    games.map_err(|e| e.into())
252}
253
254fn create_ignored_set(ignored_game_ids: Option<Vec<String>>) -> HashSet<String> {
255    ignored_game_ids.unwrap_or_default().into_iter().collect()
256}
257
258fn filter_candidates_by_playtime(
259    games: Vec<GameWithDetails>,
260    options: &RecommendationOptions,
261) -> Vec<GameWithDetails> {
262    let min = options.min_playtime.unwrap_or(0);
263    let max = options.max_playtime.unwrap_or(999999);
264
265    games
266        .into_iter()
267        .filter(|g| {
268            let pt = g.game.playtime.unwrap_or(0);
269            pt >= min && pt <= max
270        })
271        .collect()
272}
273
274fn format_recommendations(
275    ranked: Vec<(GameWithDetails, f32, RecommendationReason)>,
276    limit: usize,
277) -> Vec<GameRecommendation> {
278    ranked
279        .into_iter()
280        .take(limit)
281        .map(|(g, score, reason)| GameRecommendation {
282            game_id: g.game.id,
283            score,
284            reason,
285        })
286        .collect()
287}