1use std::sync::Arc;
15
16use async_trait::async_trait;
17use log::{debug, info, warn};
18use tauri::{Emitter, Listener, Manager};
19
20use crate::repository::types::RepoError;
21use crate::repository::MediaRepository;
22use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
23
24#[async_trait]
29pub trait FavoriteSink: Send + Sync {
30 async fn push_favorite(&self, item_id: &str, is_favorite: bool) -> Result<(), RepoError>;
31}
32
33#[async_trait]
34impl<T: MediaRepository + ?Sized> FavoriteSink for T {
35 async fn push_favorite(&self, item_id: &str, is_favorite: bool) -> Result<(), RepoError> {
36 if is_favorite {
37 self.mark_favorite(item_id).await
38 } else {
39 self.unmark_favorite(item_id).await
40 }
41 }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct PendingFavorite {
47 pub item_id: String,
48 pub is_favorite: bool,
49}
50
51async fn read_pending(
53 db: &Arc<RusqliteService>,
54 user_id: &str,
55) -> Result<Vec<PendingFavorite>, String> {
56 db.query_many(
57 Query::with_params(
58 "SELECT item_id, is_favorite FROM user_data \
59 WHERE user_id = ? AND pending_sync = 1 AND is_favorite IS NOT NULL",
60 vec![QueryParam::String(user_id.to_string())],
61 ),
62 |row| {
63 Ok(PendingFavorite {
64 item_id: row.get::<_, String>(0)?,
65 is_favorite: row.get::<_, Option<i32>>(1)?.unwrap_or(0) != 0,
66 })
67 },
68 )
69 .await
70}
71
72pub async fn drain_pending_favorites(
80 db: &Arc<RusqliteService>,
81 sink: &dyn FavoriteSink,
82 user_id: &str,
83) -> Result<Vec<String>, String> {
84 let pending = read_pending(db, user_id).await?;
85 if pending.is_empty() {
86 return Ok(Vec::new());
87 }
88
89 info!(
90 "[Favorites] Pushing {} favourite change(s) queued while offline",
91 pending.len()
92 );
93
94 let mut pushed = Vec::new();
95 for change in pending {
96 match sink
97 .push_favorite(&change.item_id, change.is_favorite)
98 .await
99 {
100 Ok(()) => {
101 let cleared = db
102 .execute(Query::with_params(
103 "UPDATE user_data SET pending_sync = 0, synced_at = ? \
104 WHERE user_id = ? AND item_id = ?",
105 vec![
106 QueryParam::String(chrono::Utc::now().to_rfc3339()),
107 QueryParam::String(user_id.to_string()),
108 QueryParam::String(change.item_id.clone()),
109 ],
110 ))
111 .await;
112
113 match cleared {
114 Ok(_) => pushed.push(change.item_id),
115 Err(e) => warn!(
118 "[Favorites] Pushed {} but could not clear pending_sync: {}",
119 change.item_id, e
120 ),
121 }
122 }
123 Err(e) => {
124 debug!(
126 "[Favorites] Deferring {}, server rejected the push: {:?}",
127 change.item_id, e
128 );
129 }
130 }
131 }
132
133 Ok(pushed)
134}
135
136pub fn spawn_favorites_drain(app: tauri::AppHandle) {
144 let handle = app.clone();
145 app.listen("connectivity:reconnected", move |_event| {
146 let app = handle.clone();
147 tauri::async_runtime::spawn(async move {
148 if let Err(e) = run_drain(&app).await {
149 warn!("[Favorites] Drain skipped: {}", e);
150 }
151 });
152 });
153}
154
155async fn run_drain(app: &tauri::AppHandle) -> Result<(), String> {
156 let db_service: Arc<RusqliteService> = {
157 let db = app.state::<crate::commands::storage::DatabaseWrapper>();
158 let database = db.0.lock().map_err(|e| e.to_string())?;
159 Arc::new(database.service())
160 };
161
162 let (repo, user_id) = {
163 let manager = app.state::<crate::commands::repository::RepositoryManagerWrapper>();
164 let handles = manager.0.handles();
165 let Some(handle) = handles.first() else {
166 return Ok(());
168 };
169 let repo = manager.0.get(handle).ok_or("Repository not found")?;
170 let user_id = repo.user_id().to_string();
171 (repo, user_id)
172 };
173
174 let pushed = drain_pending_favorites(&db_service, repo.as_ref(), &user_id).await?;
175
176 if !pushed.is_empty() {
177 let event = crate::commands::repository::FavoritesChangedEvent { item_ids: pushed };
178 if let Err(e) = app.emit(crate::commands::repository::FAVORITES_CHANGED_EVENT, &event) {
179 warn!("[Favorites] Failed to emit change event: {}", e);
180 }
181 }
182
183 Ok(())
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189 use crate::utils::lock::MutexSafe;
190 use rusqlite::Connection;
191 use std::sync::Mutex;
192
193 struct RecordingSink {
195 calls: Mutex<Vec<(String, bool)>>,
196 fail_for: Option<String>,
197 }
198
199 impl RecordingSink {
200 fn new() -> Self {
201 Self {
202 calls: Mutex::new(Vec::new()),
203 fail_for: None,
204 }
205 }
206
207 fn failing_for(item_id: &str) -> Self {
208 Self {
209 calls: Mutex::new(Vec::new()),
210 fail_for: Some(item_id.to_string()),
211 }
212 }
213
214 fn calls(&self) -> Vec<(String, bool)> {
215 let mut calls = self.calls.lock_safe().clone();
216 calls.sort();
217 calls
218 }
219 }
220
221 #[async_trait]
222 impl FavoriteSink for RecordingSink {
223 async fn push_favorite(&self, item_id: &str, is_favorite: bool) -> Result<(), RepoError> {
224 if self.fail_for.as_deref() == Some(item_id) {
225 return Err(RepoError::Offline);
226 }
227 self.calls
228 .lock()
229 .unwrap()
230 .push((item_id.to_string(), is_favorite));
231 Ok(())
232 }
233 }
234
235 fn test_db() -> Arc<RusqliteService> {
236 let conn = Connection::open_in_memory().unwrap();
237 conn.execute_batch(
238 r#"
239 CREATE TABLE user_data (
240 user_id TEXT NOT NULL,
241 item_id TEXT NOT NULL,
242 is_favorite INTEGER,
243 synced_at TEXT,
244 pending_sync INTEGER DEFAULT 0,
245 PRIMARY KEY (user_id, item_id)
246 );
247 "#,
248 )
249 .unwrap();
250 Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
251 }
252
253 async fn seed(db: &Arc<RusqliteService>, rows: &[(&str, &str, i32, i32)]) {
254 for (user, item, fav, pending) in rows {
255 db.execute(Query::with_params(
256 "INSERT INTO user_data (user_id, item_id, is_favorite, pending_sync) \
257 VALUES (?, ?, ?, ?)",
258 vec![
259 QueryParam::String(user.to_string()),
260 QueryParam::String(item.to_string()),
261 QueryParam::Int(*fav),
262 QueryParam::Int(*pending),
263 ],
264 ))
265 .await
266 .unwrap();
267 }
268 }
269
270 async fn pending_flag(db: &Arc<RusqliteService>, item_id: &str) -> Option<i32> {
271 db.query_optional(
272 Query::with_params(
273 "SELECT pending_sync FROM user_data WHERE item_id = ?",
274 vec![QueryParam::String(item_id.to_string())],
275 ),
276 |row| row.get::<_, Option<i32>>(0),
277 )
278 .await
279 .unwrap()
280 .flatten()
281 }
282
283 #[tokio::test]
288 async fn test_drain_pushes_pending_favorites_and_clears_the_flag() {
289 let db = test_db();
290 seed(
291 &db,
292 &[
293 ("u1", "marked-offline", 1, 1),
294 ("u1", "unmarked-offline", 0, 1),
295 ("u1", "already-synced", 1, 0),
297 ],
298 )
299 .await;
300
301 let sink = RecordingSink::new();
302 let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
303
304 assert_eq!(
305 sink.calls(),
306 vec![
307 ("marked-offline".to_string(), true),
308 ("unmarked-offline".to_string(), false),
309 ],
310 "both pending changes push, with their direction preserved"
311 );
312 assert_eq!(pushed.len(), 2);
313 assert_eq!(pending_flag(&db, "marked-offline").await, Some(0));
314 assert_eq!(pending_flag(&db, "unmarked-offline").await, Some(0));
315 }
316
317 #[tokio::test]
322 async fn test_drain_leaves_failed_pushes_pending() {
323 let db = test_db();
324 seed(&db, &[("u1", "ok", 1, 1), ("u1", "boom", 1, 1)]).await;
325
326 let sink = RecordingSink::failing_for("boom");
327 let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
328
329 assert_eq!(pushed, vec!["ok".to_string()]);
330 assert_eq!(pending_flag(&db, "ok").await, Some(0));
331 assert_eq!(
332 pending_flag(&db, "boom").await,
333 Some(1),
334 "a failed push must stay queued for the next reconnect"
335 );
336 }
337
338 #[tokio::test]
342 async fn test_drain_only_touches_the_given_user() {
343 let db = test_db();
344 seed(&db, &[("u1", "mine", 1, 1), ("u2", "theirs", 1, 1)]).await;
345
346 let sink = RecordingSink::new();
347 let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
348
349 assert_eq!(pushed, vec!["mine".to_string()]);
350 assert_eq!(pending_flag(&db, "theirs").await, Some(1));
351 }
352
353 #[tokio::test]
358 async fn test_drain_is_a_noop_when_nothing_is_pending() {
359 let db = test_db();
360 seed(&db, &[("u1", "synced", 1, 0)]).await;
361
362 let sink = RecordingSink::new();
363 let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
364
365 assert!(pushed.is_empty());
366 assert!(sink.calls().is_empty());
367 }
368}