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