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