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]
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#[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 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#[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#[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#[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 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#[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#[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 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 {
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); }
270 } 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#[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 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 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 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 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 let store = creds.0.lock().map_err(|e| e.to_string())?;
363 store.is_using_keyring()
364 };
365
366 Ok(using_keyring)
368}
369
370#[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#[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 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 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 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 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#[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#[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 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 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 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 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#[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#[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 {
608 let store = creds.0.lock().map_err(|e| e.to_string())?;
609 let _ = store.delete_token(&user_id); }
611
612 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#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
631#[serde(rename_all = "camelCase")]
632pub struct PlaybackProgress {
633 pub item_id: String,
634 pub position_ms: i64,
637 pub is_played: bool,
638 pub is_favorite: bool,
639 pub play_count: i32,
640}
641
642#[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 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 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 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#[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 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#[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 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 if let Some((Some(album_id), item_type)) = album_info.clone() {
781 if item_type == "Audio" {
782 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 if let Some(true) = should_cache {
791 info!("Album affinity threshold reached for album {}!", album_id);
792
793 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 for (track_id, track_name, artist_name, album_name) in tracks {
826 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 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#[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 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#[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#[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#[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 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 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#[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#[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 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 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#[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#[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 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#[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#[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 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#[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#[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 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#[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 assert!(std::mem::size_of::<DatabaseWrapper>() > 0);
1686 }
1687
1688 #[test]
1689 fn test_credential_store_wrapper_structure() {
1690 assert!(std::mem::size_of::<CredentialStoreWrapper>() > 0);
1692 }
1693
1694 #[test]
1695 fn test_thumbnail_cache_wrapper_structure() {
1696 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 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 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 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}