Skip to main content

game_manager_lib/commands/recommendation/
analysis.rs

1//! Comandos para análise e debug do sistema de recomendação
2//!
3//! Este módulo fornece comandos Tauri para gerar relatórios de análise
4//! do sistema de recomendação, úteis para debug e ajuste fino dos algoritmos.
5
6use crate::constants::MINUTES_PER_HOUR_F32;
7use crate::database::AppState;
8use crate::errors::AppError;
9use crate::models::Platform;
10use crate::services::recommendation::{
11    calculate_user_profile, export_games_csv, export_report_json, export_report_txt,
12    generate_analysis_report, parse_release_year, GameWithDetails, RecommendationConfig,
13    UserSettings,
14};
15use serde::Serialize;
16use std::collections::HashSet;
17use tauri::{AppHandle, Manager, State};
18
19/// Type alias para dados de recomendação preparados
20type PreparedRecommendationData = (Vec<GameWithDetails>, Vec<GameWithDetails>, HashSet<String>);
21
22/// Resposta do comando de análise
23#[derive(Debug, Serialize)]
24pub struct AnalysisResponse {
25    pub success: bool,
26    pub json_path: Option<String>,
27    pub csv_path: Option<String>,
28    pub txt_path: Option<String>,
29    pub message: String,
30}
31
32/// Gera análise completa do sistema de recomendação
33///
34/// Cria três arquivos:
35/// - `recommendation_analysis_TIMESTAMP.json` - Análise completa em JSON
36/// - `recommendation_analysis_TIMESTAMP.txt` - Relatório legível em texto
37/// - `recommendation_ranking_TIMESTAMP.csv` - Ranking em CSV para Excel
38///
39/// Os arquivos são salvos em `AppData/Local/Playlite/analysis/`
40#[tauri::command]
41pub async fn generate_recommendation_analysis(
42    app: AppHandle,
43    limit: Option<usize>,
44) -> Result<AnalysisResponse, String> {
45    tracing::info!("Gerando análise de recomendação...");
46
47    let analysis_dir = setup_analysis_directory(&app)?;
48    let (json_path, txt_path, csv_path) = create_analysis_file_paths(&analysis_dir)?;
49
50    let state: State<AppState> = app.state();
51    let (candidates_with_details, all_games_with_details, already_played_ids) =
52        fetch_and_prepare_data(&state)?;
53
54    let profile = calculate_user_profile(&all_games_with_details, &HashSet::new());
55    let (cf_scores, _) =
56        crate::services::cf_aggregator::build_cf_candidates(&all_games_with_details);
57
58    let config = RecommendationConfig::default();
59    let user_settings = UserSettings::default();
60
61    let report = generate_analysis_report(
62        &profile,
63        &candidates_with_details,
64        &cf_scores,
65        &already_played_ids,
66        config,
67        user_settings,
68    );
69
70    let limited_report = limit_report(report, limit);
71
72    export_analysis_reports(&limited_report, &json_path, &txt_path, &csv_path)?;
73
74    log_success(&json_path, &txt_path, &csv_path);
75
76    Ok(AnalysisResponse {
77        success: true,
78        json_path: Some(json_path.to_string_lossy().to_string()),
79        txt_path: Some(txt_path.to_string_lossy().to_string()),
80        csv_path: Some(csv_path.to_string_lossy().to_string()),
81        message: format!(
82            "Análise gerada com sucesso! {} jogos analisados.",
83            limited_report.games.len()
84        ),
85    })
86}
87
88// === FUNÇÕES AUXILIARES ===
89
90fn setup_analysis_directory(app: &AppHandle) -> Result<std::path::PathBuf, String> {
91    let analysis_dir = app
92        .path()
93        .app_data_dir()
94        .map_err(|e| format!("Erro ao obter diretório de dados: {}", e))?
95        .join("analysis");
96
97    std::fs::create_dir_all(&analysis_dir)
98        .map_err(|e| format!("Erro ao criar diretório de análise: {}", e))?;
99
100    Ok(analysis_dir)
101}
102
103fn create_analysis_file_paths(
104    analysis_dir: &std::path::Path,
105) -> Result<(std::path::PathBuf, std::path::PathBuf, std::path::PathBuf), String> {
106    let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
107    let json_path = analysis_dir.join(format!("recommendation_analysis_{}.json", timestamp));
108    let txt_path = analysis_dir.join(format!("recommendation_analysis_{}.txt", timestamp));
109    let csv_path = analysis_dir.join(format!("recommendation_ranking_{}.csv", timestamp));
110
111    Ok((json_path, txt_path, csv_path))
112}
113
114fn fetch_and_prepare_data(state: &State<AppState>) -> Result<PreparedRecommendationData, String> {
115    let library_games = crate::commands::games::get_games(state.clone())
116        .map_err(|e| format!("Erro ao buscar jogos da biblioteca: {}", e))?;
117
118    tracing::info!("Total de jogos na biblioteca: {}", library_games.len());
119
120    let already_played_ids: HashSet<String> = library_games
121        .iter()
122        .filter(|g| {
123            let hours = g.playtime.unwrap_or(0) as f32 / MINUTES_PER_HOUR_F32;
124            hours > 5.0 || g.favorite
125        })
126        .map(|g| g.id.clone())
127        .collect();
128
129    let candidate_games: Vec<_> = library_games
130        .iter()
131        .filter(|g| !already_played_ids.contains(&g.id))
132        .cloned()
133        .collect();
134
135    tracing::info!("Candidatos para recomendação: {}", candidate_games.len());
136
137    let candidates_with_details = fetch_games_with_details(&candidate_games, state)
138        .map_err(|e| format!("Erro ao processar candidatos: {}", e))?;
139
140    let all_games_with_details = fetch_games_with_details(&library_games, state)
141        .map_err(|e| format!("Erro ao processar biblioteca completa: {}", e))?;
142
143    Ok((
144        candidates_with_details,
145        all_games_with_details,
146        already_played_ids,
147    ))
148}
149
150fn fetch_games_with_details(
151    _games: &[crate::models::Game],
152    state: &State<AppState>,
153) -> Result<Vec<GameWithDetails>, AppError> {
154    let conn = state.games_db.lock()?;
155
156    let mut stmt = conn.prepare(
157        "SELECT
158            g.id, g.name, g.playtime, g.favorite, g.user_rating, g.cover_url,
159            g.platform_game_id, g.last_played, g.added_at, g.platform,
160            gd.genres, gd.steam_app_id, gd.release_date, gd.series, gd.tags
161         FROM games g
162         LEFT JOIN game_details gd ON g.id = gd.game_id
163         ORDER BY g.name ASC",
164    )?;
165
166    let games_with_details: Result<Vec<GameWithDetails>, _> = stmt
167        .query_map([], |row| {
168            let game = crate::models::Game {
169                id: row.get(0)?,
170                name: row.get(1)?,
171                playtime: row.get(2)?,
172                favorite: row.get(3)?,
173                user_rating: row.get(4)?,
174                cover_url: row.get(5)?,
175                platform_game_id: row.get(6)?,
176                last_played: row.get(7)?,
177                added_at: row.get(8)?,
178                platform: row.get::<_, String>(9)?.parse().unwrap_or(Platform::Outra),
179                // Campos não utilizados
180                genres: None,
181                developer: None,
182                install_path: None,
183                executable_path: None,
184                launch_args: None,
185                status: None,
186                is_adult: false,
187                installed: false,
188                import_confidence: None,
189            };
190
191            let genres_json: Option<String> = row.get(10)?;
192            let genres: Vec<String> = genres_json
193                .as_ref()
194                .map(|s| {
195                    // Tentar parsear como JSON primeiro
196                    if let Ok(vec) = serde_json::from_str::<Vec<String>>(s) {
197                        vec
198                    } else {
199                        // Fallback: parsear como comma-separated string
200                        s.split(',')
201                            .map(|g| g.trim().to_string())
202                            .filter(|g| !g.is_empty())
203                            .collect()
204                    }
205                })
206                .unwrap_or_default();
207
208            let steam_app_id_str: Option<String> = row.get(11)?;
209            let steam_app_id: Option<u32> = steam_app_id_str.and_then(|s| s.parse().ok());
210
211            let release_date: Option<String> = row.get(12)?;
212            let release_year = release_date.and_then(|d| parse_release_year(&d));
213            let series: Option<String> = row.get(13)?;
214
215            // Buscar tags do JSON na coluna tags
216            let tags_json: Option<String> = row.get(14)?;
217            let tags: Vec<crate::models::GameTag> = tags_json
218                .as_ref()
219                .and_then(|s| serde_json::from_str(s).ok())
220                .unwrap_or_default();
221
222            Ok(GameWithDetails {
223                game,
224                genres,
225                tags,
226                series,
227                release_year,
228                steam_app_id,
229            })
230        })?
231        .collect();
232
233    games_with_details.map_err(|e| e.into())
234}
235
236fn limit_report(
237    mut report: crate::services::recommendation::RecommendationAnalysisReport,
238    limit: Option<usize>,
239) -> crate::services::recommendation::RecommendationAnalysisReport {
240    if let Some(limit) = limit {
241        report.games.truncate(limit);
242    }
243    report
244}
245
246fn export_analysis_reports(
247    report: &crate::services::recommendation::RecommendationAnalysisReport,
248    json_path: &std::path::Path,
249    txt_path: &std::path::Path,
250    csv_path: &std::path::Path,
251) -> Result<(), String> {
252    export_report_json(report, json_path.to_str().unwrap())
253        .map_err(|e| format!("Erro ao salvar JSON: {}", e))?;
254
255    export_report_txt(report, txt_path.to_str().unwrap())
256        .map_err(|e| format!("Erro ao salvar TXT: {}", e))?;
257
258    export_games_csv(&report.games, csv_path.to_str().unwrap())
259        .map_err(|e| format!("Erro ao salvar CSV: {}", e))?;
260
261    Ok(())
262}
263
264fn log_success(
265    json_path: &std::path::Path,
266    txt_path: &std::path::Path,
267    csv_path: &std::path::Path,
268) {
269    tracing::info!("Análise gerada com sucesso!");
270    tracing::info!("  JSON: {:?}", json_path);
271    tracing::info!("  TXT:  {:?}", txt_path);
272    tracing::info!("  CSV:  {:?}", csv_path);
273}