game_manager_lib/commands/recommendation/
core.rs1use 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#[derive(Debug, Serialize)]
25pub struct GameRecommendation {
26 pub game_id: String,
27 pub score: f32,
28 pub reason: RecommendationReason,
29}
30
31#[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>>, pub config: Option<RecommendationConfig>, }
40
41#[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#[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#[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#[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
118fn load_user_settings(app_handle: &tauri::AppHandle) -> UserSettings {
122 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}