Skip to main content

jellytau_lib/commands/storage/
mod.rs

1//! Tauri commands for database/storage operations
2//!
3//! TRACES: UR-002, UR-011, UR-012, UR-017, UR-019, UR-025, UR-047 | IR-013 | DR-012, DR-013, DR-022, DR-060
4
5use std::sync::{Arc, Mutex};
6
7use log::{debug, error, info, warn};
8use serde::{Deserialize, Serialize};
9use tauri::State;
10
11use crate::credentials::CredentialStore;
12use crate::storage::db_service::{DatabaseService, Query, QueryParam};
13use crate::storage::Database;
14use crate::thumbnail::ThumbnailCache;
15
16use super::SmartCacheWrapper;
17
18// Cohesive command clusters in their own submodules, re-exported so the command
19// names remain at `commands::storage::*` (invoke_handler unchanged).
20mod people;
21mod series_prefs;
22mod thumbnails;
23pub use people::*;
24pub use series_prefs::*;
25pub use thumbnails::*;
26
27/// Wrapper for thread-safe database access
28pub struct DatabaseWrapper(pub Mutex<Database>);
29
30/// Wrapper for thread-safe credential store access
31pub struct CredentialStoreWrapper(pub Mutex<CredentialStore>);
32
33/// Wrapper for thread-safe thumbnail cache access
34pub struct ThumbnailCacheWrapper(pub Arc<ThumbnailCache>);
35
36/// Server info returned to frontend
37#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
38pub struct ServerInfo {
39    pub id: String,
40    pub name: String,
41    pub url: String,
42    pub version: Option<String>,
43}
44
45/// User info returned to frontend
46#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct UserInfo {
49    pub id: String,
50    pub server_id: String,
51    pub username: String,
52    pub is_active: bool,
53}
54
55/// Active session info (for session restoration)
56#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct ActiveSession {
59    pub user_id: String,
60    pub username: String,
61    pub server_id: String,
62    pub server_url: String,
63    pub server_name: String,
64    pub access_token: String,
65}
66
67/// Security status info
68#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct SecurityStatus {
71    pub using_keyring: bool,
72    pub storage_type: String,
73}
74
75/// Initialize the database and run migrations
76#[tauri::command]
77#[specta::specta]
78pub fn storage_init(db: State<DatabaseWrapper>) -> Result<String, String> {
79    let database = db.0.lock().map_err(|e| e.to_string())?;
80    Ok(database.path().to_string_lossy().to_string())
81}
82
83/// A playable URL for a downloaded file on disk.
84///
85/// Local media is served over a loopback HTTP server rather than handed to the
86/// webview as a `file://`/asset URL, because the asset protocol cannot stream a
87/// large file — it answers a range-less request with the whole thing, which
88/// Chromium abandons. See `media_server` for why real HTTP is used.
89///
90/// The returned URL carries the server's per-session token, so it is only valid
91/// for this run of the app and must not be persisted.
92///
93/// TRACES: UR-071 | DR-137
94#[tauri::command]
95#[specta::specta]
96pub fn media_local_url(
97    server: State<crate::media_server::MediaServerWrapper>,
98    path: String,
99) -> Result<String, String> {
100    server
101        .0
102        .as_ref()
103        .map(|s| s.url_for(&path))
104        .ok_or_else(|| "Local media server is not running".to_string())
105}
106
107/// Get storage directory path (parent directory of the database file)
108#[tauri::command]
109#[specta::specta]
110pub fn storage_get_path(db: State<DatabaseWrapper>) -> Result<String, String> {
111    let database = db.0.lock().map_err(|e| e.to_string())?;
112    let db_path = database.path();
113
114    // Return the parent directory instead of the database file path
115    let storage_dir = db_path
116        .parent()
117        .ok_or_else(|| "Database path has no parent directory".to_string())?;
118
119    Ok(storage_dir.to_string_lossy().to_string())
120}
121
122/// Get database file size in bytes
123#[tauri::command]
124#[specta::specta]
125pub fn storage_get_size(db: State<DatabaseWrapper>) -> Result<Option<u64>, String> {
126    let database = db.0.lock().map_err(|e| e.to_string())?;
127    Ok(database.file_size())
128}
129
130/// Get security status (keyring vs encrypted file fallback)
131#[tauri::command]
132#[specta::specta]
133pub fn storage_get_security_status(
134    creds: State<CredentialStoreWrapper>,
135) -> Result<SecurityStatus, String> {
136    let store = creds.0.lock().map_err(|e| e.to_string())?;
137    let using_keyring = store.is_using_keyring();
138    Ok(SecurityStatus {
139        using_keyring,
140        storage_type: if using_keyring {
141            "system_keyring".to_string()
142        } else {
143            "encrypted_file".to_string()
144        },
145    })
146}
147
148/// Save a server connection
149/// Uses INSERT ... ON CONFLICT to avoid triggering CASCADE DELETE on users
150#[tauri::command]
151#[specta::specta]
152pub async fn storage_save_server(
153    db: State<'_, DatabaseWrapper>,
154    id: String,
155    name: String,
156    url: String,
157    version: Option<String>,
158) -> Result<(), String> {
159    let db_service = {
160        let database = db.0.lock().map_err(|e| e.to_string())?;
161        Arc::new(database.service())
162    };
163
164    // IMPORTANT: Use ON CONFLICT ... DO UPDATE instead of INSERT OR REPLACE
165    // INSERT OR REPLACE triggers DELETE + INSERT, which cascades to delete all users!
166    let query = Query::with_params(
167        "INSERT INTO servers (id, name, url, version, last_connected_at)
168         VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
169         ON CONFLICT(id) DO UPDATE SET
170            name = excluded.name,
171            url = excluded.url,
172            version = excluded.version,
173            last_connected_at = CURRENT_TIMESTAMP",
174        vec![
175            QueryParam::String(id),
176            QueryParam::String(name),
177            QueryParam::String(url),
178            version.map(QueryParam::String).unwrap_or(QueryParam::Null),
179        ],
180    );
181
182    db_service.execute(query).await.map_err(|e| e.to_string())?;
183
184    Ok(())
185}
186
187/// Get all saved servers
188#[tauri::command]
189#[specta::specta]
190pub async fn storage_get_servers(
191    db: State<'_, DatabaseWrapper>,
192) -> Result<Vec<ServerInfo>, String> {
193    let db_service = {
194        let database = db.0.lock().map_err(|e| e.to_string())?;
195        Arc::new(database.service())
196    };
197
198    let query =
199        Query::new("SELECT id, name, url, version FROM servers ORDER BY last_connected_at DESC");
200
201    let servers = db_service
202        .query_many(query, |row| {
203            Ok(ServerInfo {
204                id: row.get(0)?,
205                name: row.get(1)?,
206                url: row.get(2)?,
207                version: row.get(3)?,
208            })
209        })
210        .await
211        .map_err(|e| e.to_string())?;
212
213    Ok(servers)
214}
215
216/// Delete a server and all associated data
217#[tauri::command]
218#[specta::specta]
219pub async fn storage_delete_server(
220    db: State<'_, DatabaseWrapper>,
221    creds: State<'_, CredentialStoreWrapper>,
222    server_id: String,
223) -> Result<(), String> {
224    let db_service = {
225        let database = db.0.lock().map_err(|e| e.to_string())?;
226        Arc::new(database.service())
227    };
228
229    // Get all user IDs for this server to delete their tokens
230    let user_query = Query::with_params(
231        "SELECT id FROM users WHERE server_id = ?",
232        vec![QueryParam::String(server_id.clone())],
233    );
234
235    let user_ids: Vec<String> = db_service
236        .query_many(user_query, |row| row.get(0))
237        .await
238        .map_err(|e| e.to_string())?;
239
240    // Delete tokens from secure storage
241    {
242        let store = creds.0.lock().map_err(|e| e.to_string())?;
243        for user_id in user_ids {
244            let _ = store.delete_token(&user_id); // Ignore errors, user might not have token
245        }
246    } // Drop store lock here
247
248    // Delete server (cascades to users via foreign key)
249    let delete_query = Query::with_params(
250        "DELETE FROM servers WHERE id = ?",
251        vec![QueryParam::String(server_id)],
252    );
253
254    db_service
255        .execute(delete_query)
256        .await
257        .map_err(|e| e.to_string())?;
258
259    Ok(())
260}
261
262/// Save a user account (token stored in secure storage, not database)
263#[tauri::command]
264#[specta::specta]
265pub async fn storage_save_user(
266    db: State<'_, DatabaseWrapper>,
267    creds: State<'_, CredentialStoreWrapper>,
268    id: String,
269    server_id: String,
270    username: String,
271    access_token: Option<String>,
272) -> Result<bool, String> {
273    info!(
274        "storage_save_user called: id={}, server_id={}, username={}",
275        id, server_id, username
276    );
277
278    let (db_service, db_path) = {
279        let database = db.0.lock().map_err(|e| {
280            error!("Failed to lock database: {}", e);
281            e.to_string()
282        })?;
283        let path = database.path().to_path_buf();
284        (Arc::new(database.service()), path)
285    };
286
287    // Save user metadata to database (without token)
288    // IMPORTANT: Use ON CONFLICT ... DO UPDATE instead of INSERT OR REPLACE
289    // INSERT OR REPLACE triggers DELETE + INSERT, which cascades to delete user_data!
290    debug!("Executing INSERT INTO users with ON CONFLICT...");
291    let insert_query = Query::with_params(
292        "INSERT INTO users (id, server_id, username, last_login_at)
293         VALUES (?, ?, ?, CURRENT_TIMESTAMP)
294         ON CONFLICT(id) DO UPDATE SET
295            server_id = excluded.server_id,
296            username = excluded.username,
297            last_login_at = CURRENT_TIMESTAMP",
298        vec![
299            QueryParam::String(id.clone()),
300            QueryParam::String(server_id),
301            QueryParam::String(username),
302        ],
303    );
304
305    db_service.execute(insert_query).await.map_err(|e| {
306        error!("Failed to save user: {}", e);
307        e.to_string()
308    })?;
309    info!("User saved to database successfully");
310
311    // Verify the user was actually saved
312    let verify_query = Query::with_params(
313        "SELECT COUNT(*) FROM users WHERE id = ?",
314        vec![QueryParam::String(id.clone())],
315    );
316    let verify_count: i32 = db_service
317        .query_one(verify_query, |row| row.get(0))
318        .await
319        .unwrap_or(-1);
320    debug!("VERIFY: {} users with id={} after insert", verify_count, id);
321    debug!("Database path: {:?}", db_path);
322
323    // Force WAL checkpoint to ensure user data is persisted to disk
324    let checkpoint_query = Query::new("PRAGMA wal_checkpoint(PASSIVE)");
325    match db_service.execute(checkpoint_query).await {
326        Ok(_) => debug!("WAL checkpoint completed after save_user"),
327        Err(e) => warn!("WAL checkpoint failed: {}", e),
328    }
329
330    // Save token to secure storage if provided
331    let using_keyring = if let Some(token) = access_token {
332        let store = creds.0.lock().map_err(|e| e.to_string())?;
333        let result = store.save_token(&id, &token).map_err(|e| e.to_string())?;
334        let is_keyring = matches!(result, crate::credentials::CredentialResult::Keyring);
335        is_keyring
336    } else {
337        // No token provided, check current status
338        let store = creds.0.lock().map_err(|e| e.to_string())?;
339        store.is_using_keyring()
340    };
341
342    // Return whether we're using the secure keyring
343    Ok(using_keyring)
344}
345
346/// Get users for a server
347#[tauri::command]
348#[specta::specta]
349pub async fn storage_get_users(
350    db: State<'_, DatabaseWrapper>,
351    server_id: String,
352) -> Result<Vec<UserInfo>, String> {
353    let db_service = {
354        let database = db.0.lock().map_err(|e| e.to_string())?;
355        Arc::new(database.service())
356    };
357
358    let query = Query::with_params(
359        "SELECT id, server_id, username, is_active FROM users
360         WHERE server_id = ? ORDER BY last_login_at DESC",
361        vec![QueryParam::String(server_id)],
362    );
363
364    let users = db_service
365        .query_many(query, |row| {
366            Ok(UserInfo {
367                id: row.get(0)?,
368                server_id: row.get(1)?,
369                username: row.get(2)?,
370                is_active: row.get::<_, i32>(3)? != 0,
371            })
372        })
373        .await
374        .map_err(|e| e.to_string())?;
375
376    Ok(users)
377}
378
379/// Set a user as active (and deactivate all other users globally)
380#[tauri::command]
381#[specta::specta]
382pub async fn storage_set_active_user(
383    db: State<'_, DatabaseWrapper>,
384    user_id: String,
385    _server_id: String,
386) -> Result<(), String> {
387    info!("storage_set_active_user called: user_id={}", user_id);
388
389    let (db_service, db_path) = {
390        let database = db.0.lock().map_err(|e| e.to_string())?;
391        let path = database.path().to_path_buf();
392        (Arc::new(database.service()), path)
393    };
394
395    // Deactivate ALL users globally (since we only connect to one server at a time)
396    let deactivate_query = Query::new("UPDATE users SET is_active = 0");
397    db_service
398        .execute(deactivate_query)
399        .await
400        .map_err(|e| e.to_string())?;
401
402    // Activate the specified user and update last_login_at
403    let activate_query = Query::with_params(
404        "UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?",
405        vec![QueryParam::String(user_id.clone())],
406    );
407    let rows_affected = db_service
408        .execute(activate_query)
409        .await
410        .map_err(|e| e.to_string())?;
411
412    debug!("storage_set_active_user: {} rows affected", rows_affected);
413
414    if rows_affected == 0 {
415        warn!("No user found with id={} to set as active!", user_id);
416    }
417
418    // Verify the user is now active
419    let verify_query = Query::new("SELECT COUNT(*) FROM users WHERE is_active = 1");
420    let verify_count: i32 = db_service
421        .query_one(verify_query, |row| row.get(0))
422        .await
423        .unwrap_or(-1);
424    debug!("VERIFY: {} active users after set_active", verify_count);
425    debug!("Database path: {:?}", db_path);
426
427    // Force WAL checkpoint to ensure data is persisted to disk
428    let checkpoint_query = Query::new("PRAGMA wal_checkpoint(PASSIVE)");
429    match db_service.execute(checkpoint_query).await {
430        Ok(_) => debug!("WAL checkpoint completed"),
431        Err(e) => warn!("WAL checkpoint failed: {}", e),
432    }
433
434    Ok(())
435}
436
437/// Get the active user for a server
438#[tauri::command]
439#[specta::specta]
440pub async fn storage_get_active_user(
441    db: State<'_, DatabaseWrapper>,
442    server_id: String,
443) -> Result<Option<UserInfo>, String> {
444    let db_service = {
445        let database = db.0.lock().map_err(|e| e.to_string())?;
446        Arc::new(database.service())
447    };
448
449    let query = Query::with_params(
450        "SELECT id, server_id, username, is_active FROM users
451         WHERE server_id = ? AND is_active = 1",
452        vec![QueryParam::String(server_id)],
453    );
454
455    db_service
456        .query_optional(query, |row| {
457            Ok(UserInfo {
458                id: row.get(0)?,
459                server_id: row.get(1)?,
460                username: row.get(2)?,
461                is_active: true,
462            })
463        })
464        .await
465        .map_err(|e| e.to_string())
466}
467
468/// Get the active session (user + server + token) for session restoration
469#[tauri::command]
470#[specta::specta]
471pub async fn storage_get_active_session(
472    db: State<'_, DatabaseWrapper>,
473    creds: State<'_, CredentialStoreWrapper>,
474) -> Result<Option<ActiveSession>, String> {
475    info!("storage_get_active_session called");
476
477    let (db_service, db_path) = {
478        let database = db.0.lock().map_err(|e| e.to_string())?;
479        let path = database.path().to_path_buf();
480        (Arc::new(database.service()), path)
481    };
482
483    // Debug: count total users and active users
484    let total_query = Query::new("SELECT COUNT(*) FROM users");
485    let total_users: i32 = db_service
486        .query_one(total_query, |row| row.get(0))
487        .await
488        .unwrap_or(-1);
489
490    let active_query = Query::new("SELECT COUNT(*) FROM users WHERE is_active = 1");
491    let active_users: i32 = db_service
492        .query_one(active_query, |row| row.get(0))
493        .await
494        .unwrap_or(-1);
495
496    debug!(
497        "Database state: {} total users, {} active users",
498        total_users, active_users
499    );
500    debug!("Database path: {:?}", db_path);
501
502    // Find active user with their server info, ordered by most recently logged in
503    let session_query = Query::new(
504        "SELECT u.id, u.username, u.server_id, s.url, s.name
505         FROM users u
506         JOIN servers s ON u.server_id = s.id
507         WHERE u.is_active = 1
508         ORDER BY u.last_login_at DESC
509         LIMIT 1",
510    );
511
512    let result = db_service
513        .query_optional(session_query, |row| {
514            Ok((
515                row.get::<_, String>(0)?,
516                row.get::<_, String>(1)?,
517                row.get::<_, String>(2)?,
518                row.get::<_, String>(3)?,
519                row.get::<_, String>(4)?,
520            ))
521        })
522        .await
523        .map_err(|e| e.to_string())?;
524
525    match result {
526        Some((user_id, username, server_id, server_url, server_name)) => {
527            info!("Found active user: {} ({})", username, user_id);
528            // Get token from secure storage
529            let store = creds.0.lock().map_err(|e| e.to_string())?;
530            match store.get_token(&user_id) {
531                Ok(access_token) => {
532                    debug!("Successfully retrieved token from secure storage");
533                    Ok(Some(ActiveSession {
534                        user_id,
535                        username,
536                        server_id,
537                        server_url,
538                        server_name,
539                        access_token,
540                    }))
541                }
542                Err(e) => {
543                    // Token not found or error - session is invalid
544                    warn!("Failed to get token from secure storage: {:?}", e);
545                    Ok(None)
546                }
547            }
548        }
549        None => {
550            info!("No active user found in database");
551            Ok(None)
552        }
553    }
554}
555
556/// Get user's access token from secure storage
557#[tauri::command]
558#[specta::specta]
559pub fn storage_get_access_token(
560    creds: State<CredentialStoreWrapper>,
561    user_id: String,
562) -> Result<Option<String>, String> {
563    let store = creds.0.lock().map_err(|e| e.to_string())?;
564    match store.get_token(&user_id) {
565        Ok(token) => Ok(Some(token)),
566        Err(crate::credentials::CredentialError::NotFound) => Ok(None),
567        Err(e) => Err(e.to_string()),
568    }
569}
570
571/// Delete a user account and their token from secure storage
572#[tauri::command]
573#[specta::specta]
574pub async fn storage_delete_user(
575    db: State<'_, DatabaseWrapper>,
576    creds: State<'_, CredentialStoreWrapper>,
577    user_id: String,
578) -> Result<(), String> {
579    info!("storage_delete_user called: user_id={}", user_id);
580    debug!("STACK TRACE: This is where the user is being deleted!");
581
582    // Delete token from secure storage first
583    {
584        let store = creds.0.lock().map_err(|e| e.to_string())?;
585        let _ = store.delete_token(&user_id); // Ignore errors, token might not exist
586    }
587
588    // Delete user from database
589    let db_service = {
590        let database = db.0.lock().map_err(|e| e.to_string())?;
591        Arc::new(database.service())
592    };
593
594    let query = Query::with_params(
595        "DELETE FROM users WHERE id = ?",
596        vec![QueryParam::String(user_id)],
597    );
598
599    db_service.execute(query).await.map_err(|e| e.to_string())?;
600
601    info!("User deleted successfully");
602    Ok(())
603}
604
605/// Playback progress info
606#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
607#[serde(rename_all = "camelCase")]
608pub struct PlaybackProgress {
609    pub item_id: String,
610    /// Resume position in milliseconds. Stored as Jellyfin ticks in the DB;
611    /// converted here so the frontend never sees ticks.
612    pub position_ms: i64,
613    pub is_played: bool,
614    pub is_favorite: bool,
615    pub play_count: i32,
616}
617
618/// Update playback progress in local database
619/// This stores the progress locally for offline access and "continue watching"
620#[tauri::command]
621#[specta::specta]
622pub async fn storage_update_playback_progress(
623    db: State<'_, DatabaseWrapper>,
624    user_id: String,
625    item_id: String,
626    position_ms: i64,
627) -> Result<(), String> {
628    // The frontend speaks milliseconds; ticks are a Jellyfin storage detail that
629    // stays on this side of the boundary. 10_000 ticks = 1 ms.
630    let position_ticks = position_ms * 10_000;
631    let db_service = {
632        let database = db.0.lock().map_err(|e| e.to_string())?;
633        Arc::new(database.service())
634    };
635
636    // Note: user_id and item_id are Jellyfin IDs (strings)
637    // The user_data table uses them directly as strings, not foreign keys to integer IDs
638    // Insert or update playback position
639    let query = Query::with_params(
640        "INSERT INTO user_data (user_id, item_id, playback_position_ticks, last_played_at, pending_sync)
641         VALUES (?, ?, ?, CURRENT_TIMESTAMP, 1)
642         ON CONFLICT(user_id, item_id) DO UPDATE SET
643            playback_position_ticks = excluded.playback_position_ticks,
644            last_played_at = excluded.last_played_at,
645            pending_sync = 1",
646        vec![
647            QueryParam::String(user_id),
648            QueryParam::String(item_id.clone()),
649            QueryParam::Int64(position_ticks),
650        ],
651    );
652
653    match db_service.execute(query).await {
654        Ok(_) => Ok(()),
655        Err(e) if e.contains("constraint") || e.contains("UNIQUE") => {
656            // Foreign key constraint failed - this can happen if:
657            // 1. The item isn't synced locally yet (item_id not in items table)
658            // 2. Database migration 003 hasn't been applied (old database)
659            //
660            // In either case, we silently succeed - playback progress will still
661            // be reported to the server, and local progress will be tracked
662            // once the item is synced.
663            debug!(
664                "Skipping local playback progress for item {} (not cached locally)",
665                item_id
666            );
667            Ok(())
668        }
669        Err(e) => Err(format!("Failed to update playback progress: {}", e)),
670    }
671}
672
673/// Update playback progress with context in local database
674/// This stores the progress along with playback context (container vs single)
675#[tauri::command]
676#[specta::specta]
677pub async fn storage_update_playback_context(
678    db: State<'_, DatabaseWrapper>,
679    user_id: String,
680    item_id: String,
681    position_ms: i64,
682    context_type: Option<String>,
683    context_id: Option<String>,
684) -> Result<(), String> {
685    use crate::storage::db_service::{Query, QueryParam};
686
687    // Milliseconds in, Jellyfin ticks stored. 10_000 ticks = 1 ms.
688    let position_ticks = position_ms * 10_000;
689    let db_service = {
690        let database = db.0.lock().map_err(|e| e.to_string())?;
691        Arc::new(database.service())
692    };
693
694    let query = Query::with_params(
695        "INSERT INTO user_data (user_id, item_id, playback_position_ticks, last_played_at,
696                                playback_context_type, playback_context_id, pending_sync)
697         VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?, ?, 1)
698         ON CONFLICT(user_id, item_id) DO UPDATE SET
699            playback_position_ticks = excluded.playback_position_ticks,
700            last_played_at = excluded.last_played_at,
701            playback_context_type = excluded.playback_context_type,
702            playback_context_id = excluded.playback_context_id,
703            pending_sync = 1",
704        vec![
705            QueryParam::String(user_id.clone()),
706            QueryParam::String(item_id.clone()),
707            QueryParam::Int64(position_ticks),
708            context_type
709                .map(QueryParam::String)
710                .unwrap_or(QueryParam::Null),
711            context_id
712                .map(QueryParam::String)
713                .unwrap_or(QueryParam::Null),
714        ],
715    );
716
717    match db_service.execute(query).await {
718        Ok(_) => Ok(()),
719        Err(e) if e.contains("constraint") || e.contains("UNIQUE") => {
720            debug!(
721                "Skipping local playback context for item {} (not cached locally)",
722                item_id
723            );
724            Ok(())
725        }
726        Err(e) => Err(format!("Failed to update playback context: {}", e)),
727    }
728}
729
730/// Mark item as played in local database
731#[tauri::command]
732#[specta::specta]
733pub async fn storage_mark_played(
734    db: State<'_, DatabaseWrapper>,
735    smart_cache: State<'_, SmartCacheWrapper>,
736    user_id: String,
737    item_id: String,
738) -> Result<(), String> {
739    let db_service = {
740        let database = db.0.lock().map_err(|e| e.to_string())?;
741        Arc::new(database.service())
742    };
743
744    // Get album_id for this item (if it's an audio track)
745    let album_query = Query::with_params(
746        "SELECT album_id, item_type FROM items WHERE id = ?",
747        vec![QueryParam::String(item_id.clone())],
748    );
749
750    let album_info: Option<(Option<String>, String)> = db_service
751        .query_optional(album_query, |row| Ok((row.get(0)?, row.get(1)?)))
752        .await
753        .unwrap_or(None);
754
755    // Track play in SmartCache (for audio tracks with albums)
756    if let Some((Some(album_id), item_type)) = album_info.clone() {
757        if item_type == "Audio" {
758            // Clone cache to avoid holding lock across async operations
759            let should_cache = {
760                let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
761                cache.track_play(&item_id, Some(&album_id));
762                cache.should_cache_album(&album_id)
763            };
764
765            // Check if we should cache this album
766            if let Some(true) = should_cache {
767                info!("Album affinity threshold reached for album {}!", album_id);
768
769                // Auto-queue remaining album tracks for download
770                // Get tracks from this album that aren't already downloaded
771                let tracks_query = Query::with_params(
772                    "SELECT i.id, i.name, i.artists, i.album_name
773                     FROM items i
774                     LEFT JOIN downloads d ON d.item_id = i.id AND d.user_id = ?
775                     WHERE i.album_id = ? AND i.item_type = 'Audio'
776                       AND (d.id IS NULL OR d.status NOT IN ('completed', 'downloading', 'pending'))
777                     ORDER BY i.index_number",
778                    vec![
779                        QueryParam::String(user_id.clone()),
780                        QueryParam::String(album_id.clone()),
781                    ],
782                );
783
784                let tracks: Vec<(String, String, Option<String>, Option<String>)> = db_service
785                    .query_many(tracks_query, |row| {
786                        Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
787                    })
788                    .await
789                    .unwrap_or_else(|e| {
790                        warn!("Failed to query album tracks: {}", e);
791                        Vec::new()
792                    });
793
794                if !tracks.is_empty() {
795                    info!(
796                        "Auto-queueing {} tracks from album for download",
797                        tracks.len()
798                    );
799
800                    // Queue each track with high priority (50) and mark as auto-downloaded
801                    for (track_id, track_name, artist_name, album_name) in tracks {
802                        // Generate a sanitized file path (simplified version)
803                        let sanitized_name = track_name
804                            .chars()
805                            .map(|c| {
806                                if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' {
807                                    c
808                                } else {
809                                    '_'
810                                }
811                            })
812                            .collect::<String>();
813                        let file_path = format!("downloads/{}/{}.mp3", album_id, sanitized_name);
814
815                        let insert_query = Query::with_params(
816                            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name, download_source)
817                             VALUES (?, ?, ?, 'pending', 50, CURRENT_TIMESTAMP, ?, ?, ?, 'auto')
818                             ON CONFLICT(item_id, user_id) DO UPDATE SET
819                               priority = 50,
820                               status = 'pending',
821                               download_source = 'auto'",
822                            vec![
823                                QueryParam::String(track_id.clone()),
824                                QueryParam::String(user_id.clone()),
825                                QueryParam::String(file_path),
826                                QueryParam::String(track_name),
827                                artist_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
828                                album_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
829                            ],
830                        );
831
832                        if let Err(e) = db_service.execute(insert_query).await {
833                            warn!("Failed to queue track {}: {}", track_id, e);
834                        }
835                    }
836
837                    info!("Album tracks queued for automatic download");
838                } else {
839                    info!("All album tracks already downloaded or queued");
840                }
841            }
842        }
843    }
844
845    let query = Query::with_params(
846        "INSERT INTO user_data (user_id, item_id, is_played, play_count, last_played_at, pending_sync)
847         VALUES (?, ?, 1, 1, CURRENT_TIMESTAMP, 1)
848         ON CONFLICT(user_id, item_id) DO UPDATE SET
849            is_played = 1,
850            play_count = play_count + 1,
851            last_played_at = CURRENT_TIMESTAMP,
852            pending_sync = 1",
853        vec![QueryParam::String(user_id), QueryParam::String(item_id.clone())],
854    );
855
856    match db_service.execute(query).await {
857        Ok(_) => Ok(()),
858        Err(e) if e.contains("constraint") => {
859            // Foreign key constraint failed - item not cached locally
860            debug!(
861                "Skipping local mark_played for item {} (not cached locally)",
862                item_id
863            );
864            Ok(())
865        }
866        Err(e) => Err(e.to_string()),
867    }
868}
869
870/// Set the watched flag locally for an item **and everything inside it**.
871///
872/// This backs the watched toggle, and is deliberately separate from
873/// [`storage_mark_played`] — which reports a single track/episode finishing and
874/// increments `play_count` — because the toggle has two directions and applies
875/// to containers.
876///
877/// The recursion is what makes the toggle honest offline. Jellyfin applies
878/// `POST`/`DELETE /PlayedItems/{id}` recursively over a season or series, so
879/// online the server fixes up the children on the next read; with no server to
880/// ask, marking a season watched would otherwise tick the season and leave every
881/// episode inside it unwatched. Targets are drawn from `items` by the same link
882/// columns the rest of the offline layer uses, so an id that is not cached
883/// selects nothing and the statement is a no-op rather than a foreign-key error.
884///
885/// Un-marking clears the resume position too, matching the server, so an item
886/// un-marked offline does not come back offering to resume from a position it is
887/// no longer meant to have.
888///
889/// `pending_sync = 1` hands the rows to the sync drain.
890///
891/// TRACES: UR-073 | DR-158
892#[tauri::command]
893#[specta::specta]
894pub async fn storage_set_watched(
895    db: State<'_, DatabaseWrapper>,
896    user_id: String,
897    item_id: String,
898    watched: bool,
899) -> Result<(), String> {
900    let db_service = {
901        let database = db.0.lock().map_err(|e| e.to_string())?;
902        Arc::new(database.service())
903    };
904
905    // The item itself plus its descendants: a season's episodes reach it by
906    // season_id, a series' by series_id, its seasons by parent_id, an album's
907    // tracks by album_id.
908    let targets = "SELECT id FROM items
909                   WHERE id = ? OR parent_id = ? OR album_id = ?
910                      OR season_id = ? OR series_id = ?";
911
912    let sql = if watched {
913        format!(
914            "INSERT INTO user_data (user_id, item_id, is_played, play_count, last_played_at, pending_sync)
915             SELECT ?, id, 1, 1, CURRENT_TIMESTAMP, 1 FROM ({targets})
916             ON CONFLICT(user_id, item_id) DO UPDATE SET
917                is_played = 1,
918                play_count = MAX(user_data.play_count, 1),
919                last_played_at = CURRENT_TIMESTAMP,
920                pending_sync = 1"
921        )
922    } else {
923        format!(
924            "INSERT INTO user_data (user_id, item_id, is_played, play_count, playback_position_ticks, pending_sync)
925             SELECT ?, id, 0, 0, 0, 1 FROM ({targets})
926             ON CONFLICT(user_id, item_id) DO UPDATE SET
927                is_played = 0,
928                play_count = 0,
929                playback_position_ticks = 0,
930                pending_sync = 1"
931        )
932    };
933
934    let query = Query::with_params(
935        sql,
936        vec![
937            QueryParam::String(user_id),
938            QueryParam::String(item_id.clone()),
939            QueryParam::String(item_id.clone()),
940            QueryParam::String(item_id.clone()),
941            QueryParam::String(item_id.clone()),
942            QueryParam::String(item_id.clone()),
943        ],
944    );
945
946    db_service.execute(query).await.map_err(|e| e.to_string())?;
947    Ok(())
948}
949
950/// Get playback progress for an item
951#[tauri::command]
952#[specta::specta]
953pub async fn storage_get_playback_progress(
954    db: State<'_, DatabaseWrapper>,
955    user_id: String,
956    item_id: String,
957) -> Result<Option<PlaybackProgress>, String> {
958    let db_service = {
959        let database = db.0.lock().map_err(|e| e.to_string())?;
960        Arc::new(database.service())
961    };
962
963    let query = Query::with_params(
964        "SELECT item_id, playback_position_ticks, is_played, is_favorite, play_count
965         FROM user_data WHERE user_id = ? AND item_id = ?",
966        vec![QueryParam::String(user_id), QueryParam::String(item_id)],
967    );
968
969    db_service
970        .query_optional(query, |row| {
971            let position_ticks: i64 = row.get(1)?;
972            Ok(PlaybackProgress {
973                item_id: row.get(0)?,
974                position_ms: position_ticks / 10_000,
975                is_played: row.get::<_, i32>(2)? != 0,
976                is_favorite: row.get::<_, i32>(3)? != 0,
977                play_count: row.get(4)?,
978            })
979        })
980        .await
981        .map_err(|e| e.to_string())
982}
983
984/// Mark pending sync as completed for an item
985#[tauri::command]
986#[specta::specta]
987pub async fn storage_mark_synced(
988    db: State<'_, DatabaseWrapper>,
989    user_id: String,
990    item_id: String,
991) -> Result<(), String> {
992    let db_service = {
993        let database = db.0.lock().map_err(|e| e.to_string())?;
994        Arc::new(database.service())
995    };
996
997    let query = Query::with_params(
998        "UPDATE user_data SET pending_sync = 0, synced_at = CURRENT_TIMESTAMP
999         WHERE user_id = ? AND item_id = ?",
1000        vec![QueryParam::String(user_id), QueryParam::String(item_id)],
1001    );
1002
1003    db_service.execute(query).await.map_err(|e| e.to_string())?;
1004
1005    Ok(())
1006}
1007
1008/// Toggle favorite status for an item in local database
1009/// This updates the is_favorite field and marks it for sync to Jellyfin
1010#[tauri::command]
1011#[specta::specta]
1012pub async fn storage_toggle_favorite(
1013    db: State<'_, DatabaseWrapper>,
1014    user_id: String,
1015    item_id: String,
1016    is_favorite: bool,
1017) -> Result<bool, String> {
1018    let db_service = {
1019        let database = db.0.lock().map_err(|e| e.to_string())?;
1020        Arc::new(database.service())
1021    };
1022
1023    // Insert or update favorite status
1024    let query = Query::with_params(
1025        "INSERT INTO user_data (user_id, item_id, is_favorite, pending_sync)
1026         VALUES (?, ?, ?, 1)
1027         ON CONFLICT(user_id, item_id) DO UPDATE SET
1028            is_favorite = excluded.is_favorite,
1029            pending_sync = 1",
1030        vec![
1031            QueryParam::String(user_id),
1032            QueryParam::String(item_id.clone()),
1033            QueryParam::Int(is_favorite as i32),
1034        ],
1035    );
1036
1037    match db_service.execute(query).await {
1038        Ok(_) => Ok(is_favorite),
1039        Err(e) if e.contains("constraint") => {
1040            // Foreign key constraint failed - item not cached locally
1041            // Still return the requested state, server update will work separately
1042            debug!(
1043                "Skipping local favorite toggle for item {} (not cached locally)",
1044                item_id
1045            );
1046            Ok(is_favorite)
1047        }
1048        Err(e) => Err(format!("Failed to toggle favorite: {}", e)),
1049    }
1050}
1051
1052// =============================================================================
1053// Offline Data Queries
1054// =============================================================================
1055
1056/// Cached library info returned to frontend
1057#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
1058#[serde(rename_all = "camelCase")]
1059pub struct CachedLibrary {
1060    pub id: String,
1061    pub server_id: String,
1062    pub name: String,
1063    pub collection_type: Option<String>,
1064    pub image_tag: Option<String>,
1065}
1066
1067/// Cached media item returned to frontend
1068#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
1069#[serde(rename_all = "camelCase")]
1070pub struct CachedItem {
1071    pub id: String,
1072    pub name: String,
1073    pub item_type: String,
1074    pub parent_id: Option<String>,
1075    pub library_id: Option<String>,
1076    pub overview: Option<String>,
1077    pub genres: Option<String>,
1078    pub runtime_ticks: Option<i64>,
1079    pub production_year: Option<i32>,
1080    pub community_rating: Option<f64>,
1081    pub official_rating: Option<String>,
1082    pub primary_image_tag: Option<String>,
1083    // Music-specific
1084    pub album_id: Option<String>,
1085    pub album_name: Option<String>,
1086    pub album_artist: Option<String>,
1087    pub artists: Option<String>,
1088    pub index_number: Option<i32>,
1089    // TV-specific
1090    pub series_id: Option<String>,
1091    pub series_name: Option<String>,
1092    pub season_id: Option<String>,
1093    pub season_name: Option<String>,
1094    pub parent_index_number: Option<i32>,
1095}
1096
1097/// Get cached libraries for a server
1098#[tauri::command]
1099#[specta::specta]
1100pub async fn storage_get_libraries(
1101    db: State<'_, DatabaseWrapper>,
1102    server_id: String,
1103) -> Result<Vec<CachedLibrary>, String> {
1104    let db_service = {
1105        let database = db.0.lock().map_err(|e| e.to_string())?;
1106        Arc::new(database.service())
1107    };
1108
1109    let query = Query::with_params(
1110        "SELECT id, server_id, name, collection_type, image_tag
1111         FROM libraries
1112         WHERE server_id = ?
1113         ORDER BY sort_order ASC, name ASC",
1114        vec![QueryParam::String(server_id)],
1115    );
1116
1117    let libraries = db_service
1118        .query_many(query, |row| {
1119            Ok(CachedLibrary {
1120                id: row.get(0)?,
1121                server_id: row.get(1)?,
1122                name: row.get(2)?,
1123                collection_type: row.get(3)?,
1124                image_tag: row.get(4)?,
1125            })
1126        })
1127        .await
1128        .map_err(|e| e.to_string())?;
1129
1130    Ok(libraries)
1131}
1132
1133fn row_to_cached_item(row: &rusqlite::Row) -> rusqlite::Result<CachedItem> {
1134    Ok(CachedItem {
1135        id: row.get(0)?,
1136        name: row.get(1)?,
1137        item_type: row.get(2)?,
1138        parent_id: row.get(3)?,
1139        library_id: row.get(4)?,
1140        overview: row.get(5)?,
1141        genres: row.get(6)?,
1142        runtime_ticks: row.get(7)?,
1143        production_year: row.get(8)?,
1144        community_rating: row.get(9)?,
1145        official_rating: row.get(10)?,
1146        primary_image_tag: row.get(11)?,
1147        album_id: row.get(12)?,
1148        album_name: row.get(13)?,
1149        album_artist: row.get(14)?,
1150        artists: row.get(15)?,
1151        index_number: row.get(16)?,
1152        series_id: row.get(17)?,
1153        series_name: row.get(18)?,
1154        season_id: row.get(19)?,
1155        season_name: row.get(20)?,
1156        parent_index_number: row.get(21)?,
1157    })
1158}
1159
1160/// Get cached items with optional filtering
1161#[tauri::command]
1162#[specta::specta]
1163pub async fn storage_get_items(
1164    db: State<'_, DatabaseWrapper>,
1165    server_id: String,
1166    parent_id: Option<String>,
1167    library_id: Option<String>,
1168    item_type: Option<String>,
1169    limit: Option<i32>,
1170    offset: Option<i32>,
1171) -> Result<Vec<CachedItem>, String> {
1172    let db_service = {
1173        let database = db.0.lock().map_err(|e| e.to_string())?;
1174        Arc::new(database.service())
1175    };
1176
1177    // Build base query
1178    let base_select = "SELECT id, name, item_type, parent_id, library_id, overview, genres,
1179                runtime_ticks, production_year, community_rating, official_rating,
1180                primary_image_tag, album_id, album_name, album_artist, artists,
1181                index_number, series_id, series_name, season_id, season_name,
1182                parent_index_number
1183         FROM items
1184         WHERE server_id = ?";
1185
1186    let mut conditions = Vec::new();
1187    let mut params = vec![QueryParam::String(server_id)];
1188
1189    if let Some(pid) = parent_id {
1190        conditions.push("parent_id = ?");
1191        params.push(QueryParam::String(pid));
1192    }
1193
1194    if let Some(lid) = library_id {
1195        conditions.push("library_id = ?");
1196        params.push(QueryParam::String(lid));
1197    }
1198
1199    if let Some(itype) = item_type {
1200        conditions.push("item_type = ?");
1201        params.push(QueryParam::String(itype));
1202    }
1203
1204    let mut sql = base_select.to_string();
1205    for cond in &conditions {
1206        sql.push_str(&format!(" AND {}", cond));
1207    }
1208    sql.push_str(" ORDER BY sort_name ASC, name ASC");
1209
1210    if let Some(lim) = limit {
1211        sql.push_str(&format!(" LIMIT {}", lim));
1212    }
1213    if let Some(off) = offset {
1214        sql.push_str(&format!(" OFFSET {}", off));
1215    }
1216
1217    let query = Query::with_params(sql, params);
1218
1219    let items = db_service
1220        .query_many(query, row_to_cached_item)
1221        .await
1222        .map_err(|e| e.to_string())?;
1223
1224    Ok(items)
1225}
1226
1227/// Get a single cached item by ID
1228#[tauri::command]
1229#[specta::specta]
1230pub async fn storage_get_item(
1231    db: State<'_, DatabaseWrapper>,
1232    item_id: String,
1233) -> Result<Option<CachedItem>, String> {
1234    let db_service = {
1235        let database = db.0.lock().map_err(|e| e.to_string())?;
1236        Arc::new(database.service())
1237    };
1238
1239    let query = Query::with_params(
1240        "SELECT id, name, item_type, parent_id, library_id, overview, genres,
1241                runtime_ticks, production_year, community_rating, official_rating,
1242                primary_image_tag, album_id, album_name, album_artist, artists,
1243                index_number, series_id, series_name, season_id, season_name,
1244                parent_index_number
1245         FROM items WHERE id = ?",
1246        vec![QueryParam::String(item_id)],
1247    );
1248
1249    db_service
1250        .query_optional(query, row_to_cached_item)
1251        .await
1252        .map_err(|e| e.to_string())
1253}
1254
1255/// Search cached items using FTS
1256#[tauri::command]
1257#[specta::specta]
1258pub async fn storage_search_items(
1259    db: State<'_, DatabaseWrapper>,
1260    server_id: String,
1261    query: String,
1262    limit: Option<i32>,
1263) -> Result<Vec<CachedItem>, String> {
1264    let db_service = {
1265        let database = db.0.lock().map_err(|e| e.to_string())?;
1266        Arc::new(database.service())
1267    };
1268
1269    // Escape FTS special characters and add prefix matching
1270    let fts_query = format!("{}*", query.replace('"', "\"\""));
1271
1272    let limit_clause = limit.map(|l| format!(" LIMIT {}", l)).unwrap_or_default();
1273
1274    let sql = format!(
1275        "SELECT i.id, i.name, i.item_type, i.parent_id, i.library_id, i.overview, i.genres,
1276                i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
1277                i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
1278                i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
1279                i.parent_index_number
1280         FROM items i
1281         JOIN items_fts fts ON fts.rowid = i.rowid
1282         WHERE i.server_id = ? AND items_fts MATCH ?
1283         ORDER BY rank{}",
1284        limit_clause
1285    );
1286
1287    let query_obj = Query::with_params(
1288        sql,
1289        vec![QueryParam::String(server_id), QueryParam::String(fts_query)],
1290    );
1291
1292    let items = db_service
1293        .query_many(query_obj, row_to_cached_item)
1294        .await
1295        .map_err(|e| e.to_string())?;
1296
1297    Ok(items)
1298}
1299
1300/// Save a library to the cache
1301#[tauri::command]
1302#[specta::specta]
1303pub async fn storage_save_library(
1304    db: State<'_, DatabaseWrapper>,
1305    id: String,
1306    server_id: String,
1307    name: String,
1308    collection_type: Option<String>,
1309    image_tag: Option<String>,
1310    sort_order: Option<i32>,
1311) -> Result<(), String> {
1312    let db_service = {
1313        let database = db.0.lock().map_err(|e| e.to_string())?;
1314        Arc::new(database.service())
1315    };
1316
1317    let query = Query::with_params(
1318        "INSERT OR REPLACE INTO libraries (id, server_id, name, collection_type, image_tag, sort_order, synced_at)
1319         VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
1320        vec![
1321            QueryParam::String(id),
1322            QueryParam::String(server_id),
1323            QueryParam::String(name),
1324            collection_type.map(QueryParam::String).unwrap_or(QueryParam::Null),
1325            image_tag.map(QueryParam::String).unwrap_or(QueryParam::Null),
1326            QueryParam::Int(sort_order.unwrap_or(0)),
1327        ],
1328    );
1329
1330    db_service.execute(query).await.map_err(|e| e.to_string())?;
1331    Ok(())
1332}
1333
1334/// Save an item to the cache
1335#[tauri::command]
1336#[specta::specta]
1337pub async fn storage_save_item(
1338    db: State<'_, DatabaseWrapper>,
1339    item: CachedItem,
1340    server_id: String,
1341) -> Result<(), String> {
1342    let db_service = {
1343        let database = db.0.lock().map_err(|e| e.to_string())?;
1344        Arc::new(database.service())
1345    };
1346
1347    // Generate sort_name from name (remove leading "The ", "A ", etc.)
1348    let sort_name = item
1349        .name
1350        .strip_prefix("The ")
1351        .or_else(|| item.name.strip_prefix("A "))
1352        .or_else(|| item.name.strip_prefix("An "))
1353        .unwrap_or(&item.name)
1354        .to_string();
1355
1356    let query = Query::with_params(
1357        "INSERT OR REPLACE INTO items (
1358            id, server_id, library_id, parent_id, name, sort_name, item_type,
1359            overview, genres, runtime_ticks, production_year, community_rating,
1360            official_rating, primary_image_tag, album_id, album_name, album_artist,
1361            artists, index_number, series_id, series_name, season_id, season_name,
1362            parent_index_number, synced_at
1363         ) VALUES (
1364            ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
1365            ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP
1366         )",
1367        vec![
1368            QueryParam::String(item.id),
1369            QueryParam::String(server_id),
1370            item.library_id
1371                .map(QueryParam::String)
1372                .unwrap_or(QueryParam::Null),
1373            item.parent_id
1374                .map(QueryParam::String)
1375                .unwrap_or(QueryParam::Null),
1376            QueryParam::String(item.name),
1377            QueryParam::String(sort_name),
1378            QueryParam::String(item.item_type),
1379            item.overview
1380                .map(QueryParam::String)
1381                .unwrap_or(QueryParam::Null),
1382            item.genres
1383                .map(QueryParam::String)
1384                .unwrap_or(QueryParam::Null),
1385            item.runtime_ticks
1386                .map(QueryParam::Int64)
1387                .unwrap_or(QueryParam::Null),
1388            item.production_year
1389                .map(QueryParam::Int)
1390                .unwrap_or(QueryParam::Null),
1391            item.community_rating
1392                .map(QueryParam::Float)
1393                .unwrap_or(QueryParam::Null),
1394            item.official_rating
1395                .map(QueryParam::String)
1396                .unwrap_or(QueryParam::Null),
1397            item.primary_image_tag
1398                .map(QueryParam::String)
1399                .unwrap_or(QueryParam::Null),
1400            item.album_id
1401                .map(QueryParam::String)
1402                .unwrap_or(QueryParam::Null),
1403            item.album_name
1404                .map(QueryParam::String)
1405                .unwrap_or(QueryParam::Null),
1406            item.album_artist
1407                .map(QueryParam::String)
1408                .unwrap_or(QueryParam::Null),
1409            item.artists
1410                .map(QueryParam::String)
1411                .unwrap_or(QueryParam::Null),
1412            item.index_number
1413                .map(QueryParam::Int)
1414                .unwrap_or(QueryParam::Null),
1415            item.series_id
1416                .map(QueryParam::String)
1417                .unwrap_or(QueryParam::Null),
1418            item.series_name
1419                .map(QueryParam::String)
1420                .unwrap_or(QueryParam::Null),
1421            item.season_id
1422                .map(QueryParam::String)
1423                .unwrap_or(QueryParam::Null),
1424            item.season_name
1425                .map(QueryParam::String)
1426                .unwrap_or(QueryParam::Null),
1427            item.parent_index_number
1428                .map(QueryParam::Int)
1429                .unwrap_or(QueryParam::Null),
1430        ],
1431    );
1432
1433    db_service.execute(query).await.map_err(|e| e.to_string())?;
1434    Ok(())
1435}
1436
1437/// Get count of pending sync operations for a user
1438#[tauri::command]
1439#[specta::specta]
1440pub async fn storage_get_pending_sync_count(
1441    db: State<'_, DatabaseWrapper>,
1442    user_id: String,
1443) -> Result<i32, String> {
1444    let db_service = {
1445        let database = db.0.lock().map_err(|e| e.to_string())?;
1446        Arc::new(database.service())
1447    };
1448
1449    let query = Query::with_params(
1450        "SELECT COUNT(*) FROM user_data WHERE user_id = ? AND pending_sync = 1",
1451        vec![QueryParam::String(user_id)],
1452    );
1453
1454    let count: i32 = db_service
1455        .query_one(query, |row| row.get(0))
1456        .await
1457        .map_err(|e| e.to_string())?;
1458
1459    Ok(count)
1460}
1461
1462#[cfg(test)]
1463mod tests {
1464    use super::*;
1465
1466    #[test]
1467    fn test_server_info_serialization() {
1468        let server = ServerInfo {
1469            id: "server-123".to_string(),
1470            name: "My Server".to_string(),
1471            url: "https://jellyfin.example.com".to_string(),
1472            version: Some("10.8.0".to_string()),
1473        };
1474
1475        let json = serde_json::to_string(&server);
1476        assert!(json.is_ok());
1477        let serialized = json.unwrap();
1478        assert!(serialized.contains("server-123"));
1479        assert!(serialized.contains("My Server"));
1480    }
1481
1482    #[test]
1483    fn test_server_info_without_version() {
1484        let server = ServerInfo {
1485            id: "server-456".to_string(),
1486            name: "Test Server".to_string(),
1487            url: "https://test.local".to_string(),
1488            version: None,
1489        };
1490
1491        let json = serde_json::to_string(&server).unwrap();
1492        assert!(json.contains("null") || json.contains("\"version\":null"));
1493    }
1494
1495    #[test]
1496    fn test_server_info_roundtrip() {
1497        let original = ServerInfo {
1498            id: "srv-999".to_string(),
1499            name: "Production".to_string(),
1500            url: "https://prod.jellyfin.example.com:8096".to_string(),
1501            version: Some("10.9.0".to_string()),
1502        };
1503
1504        let json = serde_json::to_string(&original).unwrap();
1505        let deserialized: ServerInfo = serde_json::from_str(&json).unwrap();
1506
1507        assert_eq!(original.id, deserialized.id);
1508        assert_eq!(original.name, deserialized.name);
1509        assert_eq!(original.url, deserialized.url);
1510        assert_eq!(original.version, deserialized.version);
1511    }
1512
1513    #[test]
1514    fn test_user_info_serialization() {
1515        let user = UserInfo {
1516            id: "user-123".to_string(),
1517            server_id: "server-456".to_string(),
1518            username: "john_doe".to_string(),
1519            is_active: true,
1520        };
1521
1522        let json = serde_json::to_string(&user);
1523        assert!(json.is_ok());
1524        let serialized = json.unwrap();
1525        assert!(serialized.contains("user-123"));
1526        assert!(serialized.contains("john_doe"));
1527    }
1528
1529    #[test]
1530    fn test_user_info_inactive() {
1531        let user = UserInfo {
1532            id: "user-inactive".to_string(),
1533            server_id: "server-789".to_string(),
1534            username: "jane_doe".to_string(),
1535            is_active: false,
1536        };
1537
1538        let json = serde_json::to_string(&user).unwrap();
1539        assert!(json.contains("false"));
1540
1541        let deserialized: UserInfo = serde_json::from_str(&json).unwrap();
1542        assert!(!deserialized.is_active);
1543    }
1544
1545    #[test]
1546    fn test_active_session_serialization() {
1547        let session = ActiveSession {
1548            user_id: "user-001".to_string(),
1549            username: "alice".to_string(),
1550            server_id: "server-001".to_string(),
1551            server_url: "https://jellyfin.example.com".to_string(),
1552            server_name: "Home Jellyfin".to_string(),
1553            access_token: "very-long-token-string-abc123".to_string(),
1554        };
1555
1556        let json = serde_json::to_string(&session);
1557        assert!(json.is_ok());
1558        let serialized = json.unwrap();
1559        assert!(serialized.contains("alice"));
1560        assert!(serialized.contains("Home Jellyfin"));
1561    }
1562
1563    #[test]
1564    fn test_active_session_roundtrip() {
1565        let original = ActiveSession {
1566            user_id: "u999".to_string(),
1567            username: "testuser".to_string(),
1568            server_id: "s999".to_string(),
1569            server_url: "https://test.example.com:8096".to_string(),
1570            server_name: "Test Server".to_string(),
1571            access_token: "token-xyz".to_string(),
1572        };
1573
1574        let json = serde_json::to_string(&original).unwrap();
1575        let deserialized: ActiveSession = serde_json::from_str(&json).unwrap();
1576
1577        assert_eq!(original.user_id, deserialized.user_id);
1578        assert_eq!(original.username, deserialized.username);
1579        assert_eq!(original.access_token, deserialized.access_token);
1580    }
1581
1582    #[test]
1583    fn test_security_status_with_keyring() {
1584        let status = SecurityStatus {
1585            using_keyring: true,
1586            storage_type: "system_keyring".to_string(),
1587        };
1588
1589        let json = serde_json::to_string(&status).unwrap();
1590        assert!(json.contains("true"));
1591        assert!(json.contains("system_keyring"));
1592    }
1593
1594    #[test]
1595    fn test_security_status_with_encrypted_file() {
1596        let status = SecurityStatus {
1597            using_keyring: false,
1598            storage_type: "encrypted_file".to_string(),
1599        };
1600
1601        let json = serde_json::to_string(&status).unwrap();
1602        assert!(json.contains("false"));
1603        assert!(json.contains("encrypted_file"));
1604    }
1605
1606    #[test]
1607    fn test_playback_progress_serialization() {
1608        let progress = PlaybackProgress {
1609            item_id: "item-123".to_string(),
1610            position_ms: 150_000_000,
1611            is_played: true,
1612            is_favorite: false,
1613            play_count: 3,
1614        };
1615
1616        let json = serde_json::to_string(&progress);
1617        assert!(json.is_ok());
1618        let serialized = json.unwrap();
1619        assert!(serialized.contains("item-123"));
1620        assert!(serialized.contains("150000000"));
1621    }
1622
1623    #[test]
1624    fn test_playback_progress_played_status() {
1625        let progress = PlaybackProgress {
1626            item_id: "item-456".to_string(),
1627            position_ms: 0,
1628            is_played: true,
1629            is_favorite: true,
1630            play_count: 1,
1631        };
1632
1633        let json = serde_json::to_string(&progress).unwrap();
1634        let deserialized: PlaybackProgress = serde_json::from_str(&json).unwrap();
1635
1636        assert!(deserialized.is_played);
1637        assert!(deserialized.is_favorite);
1638        assert_eq!(deserialized.play_count, 1);
1639    }
1640
1641    #[test]
1642    fn test_playback_progress_not_played() {
1643        let progress = PlaybackProgress {
1644            item_id: "item-789".to_string(),
1645            position_ms: 30_000_000,
1646            is_played: false,
1647            is_favorite: false,
1648            play_count: 0,
1649        };
1650
1651        let json = serde_json::to_string(&progress).unwrap();
1652        let deserialized: PlaybackProgress = serde_json::from_str(&json).unwrap();
1653
1654        assert!(!deserialized.is_played);
1655        assert_eq!(deserialized.play_count, 0);
1656    }
1657
1658    #[test]
1659    fn test_database_wrapper_structure() {
1660        // Verify DatabaseWrapper can be created and holds Mutex<Database>
1661        assert!(std::mem::size_of::<DatabaseWrapper>() > 0);
1662    }
1663
1664    #[test]
1665    fn test_credential_store_wrapper_structure() {
1666        // Verify CredentialStoreWrapper can be created
1667        assert!(std::mem::size_of::<CredentialStoreWrapper>() > 0);
1668    }
1669
1670    #[test]
1671    fn test_thumbnail_cache_wrapper_structure() {
1672        // Verify ThumbnailCacheWrapper holds Arc<ThumbnailCache>
1673        assert!(std::mem::size_of::<ThumbnailCacheWrapper>() > 0);
1674    }
1675
1676    #[test]
1677    fn test_user_info_camel_case() {
1678        let user = UserInfo {
1679            id: "u1".to_string(),
1680            server_id: "s1".to_string(),
1681            username: "user1".to_string(),
1682            is_active: true,
1683        };
1684
1685        let json = serde_json::to_string(&user).unwrap();
1686        // Verify camelCase serialization
1687        assert!(json.contains("serverId"));
1688        assert!(json.contains("isActive"));
1689    }
1690
1691    #[test]
1692    fn test_active_session_camel_case() {
1693        let session = ActiveSession {
1694            user_id: "u1".to_string(),
1695            username: "user1".to_string(),
1696            server_id: "s1".to_string(),
1697            server_url: "url1".to_string(),
1698            server_name: "name1".to_string(),
1699            access_token: "token1".to_string(),
1700        };
1701
1702        let json = serde_json::to_string(&session).unwrap();
1703        // Verify camelCase serialization
1704        assert!(json.contains("userId"));
1705        assert!(json.contains("serverId"));
1706        assert!(json.contains("serverUrl"));
1707        assert!(json.contains("serverName"));
1708        assert!(json.contains("accessToken"));
1709    }
1710
1711    #[test]
1712    fn test_playback_progress_camel_case() {
1713        let progress = PlaybackProgress {
1714            item_id: "i1".to_string(),
1715            position_ms: 100,
1716            is_played: true,
1717            is_favorite: false,
1718            play_count: 1,
1719        };
1720
1721        let json = serde_json::to_string(&progress).unwrap();
1722        // Verify camelCase serialization
1723        assert!(json.contains("itemId"));
1724        assert!(json.contains("positionMs"));
1725        assert!(json.contains("isPlayed"));
1726        assert!(json.contains("isFavorite"));
1727        assert!(json.contains("playCount"));
1728    }
1729}