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}