Skip to main content

jellytau_lib/storage/
mod.rs

1//! Offline storage module using SQLite
2//!
3//! Provides local caching of Jellyfin metadata, download management,
4//! and offline mutation queue for sync-back operations.
5
6pub mod db_service;
7pub mod models;
8pub mod schema;
9
10use crate::utils::lock::MutexSafe;
11use std::path::PathBuf;
12use std::sync::{Arc, Mutex};
13
14use log::{debug, error, info};
15use rusqlite::{Connection, Result as SqliteResult};
16
17pub use db_service::{DatabaseService, RusqliteService};
18use schema::MIGRATIONS;
19
20/// Database connection wrapper with thread-safe access
21pub struct Database {
22    conn: Arc<Mutex<Connection>>,
23    path: PathBuf,
24}
25
26impl Database {
27    /// Open or create the database at a specific path
28    pub fn open(path: &PathBuf) -> SqliteResult<Self> {
29        // Ensure parent directory exists
30        if let Some(parent) = path.parent() {
31            std::fs::create_dir_all(parent).ok();
32        }
33
34        let conn = Connection::open(path)?;
35
36        // Enable foreign keys
37        conn.execute_batch("PRAGMA foreign_keys = ON;")?;
38
39        // Enable WAL mode for better concurrent access
40        conn.execute_batch("PRAGMA journal_mode = WAL;")?;
41
42        let db = Self {
43            conn: Arc::new(Mutex::new(conn)),
44            path: path.clone(),
45        };
46
47        // Run migrations
48        db.migrate()?;
49
50        Ok(db)
51    }
52
53    /// Open an in-memory database (for testing)
54    #[cfg(test)]
55    pub fn open_in_memory() -> SqliteResult<Self> {
56        let conn = Connection::open_in_memory()?;
57
58        // Enable foreign keys
59        conn.execute_batch("PRAGMA foreign_keys = ON;")?;
60
61        let db = Self {
62            conn: Arc::new(Mutex::new(conn)),
63            path: PathBuf::from(":memory:"),
64        };
65
66        // Run migrations
67        db.migrate()?;
68
69        Ok(db)
70    }
71
72    /// Get connection (for testing)
73    #[cfg(test)]
74    pub fn connection(&self) -> Arc<Mutex<Connection>> {
75        Arc::clone(&self.conn)
76    }
77
78    /// Run all pending migrations
79    pub fn migrate(&self) -> SqliteResult<()> {
80        info!("Starting database migrations...");
81        let conn = self.conn.lock_safe();
82
83        // Create migrations table if it doesn't exist
84        debug!("Creating _migrations table if it doesn't exist...");
85        match conn.execute(
86            "CREATE TABLE IF NOT EXISTS _migrations (
87                id INTEGER PRIMARY KEY,
88                name TEXT NOT NULL UNIQUE,
89                applied_at TEXT DEFAULT CURRENT_TIMESTAMP
90            )",
91            [],
92        ) {
93            Ok(_) => debug!("_migrations table ready"),
94            Err(e) => {
95                error!("Failed to create _migrations table: {}", e);
96                return Err(e);
97            }
98        }
99
100        // Get applied migrations
101        debug!("Querying applied migrations...");
102        let mut stmt = conn.prepare("SELECT name FROM _migrations")?;
103        let applied: Vec<String> = stmt
104            .query_map([], |row: &rusqlite::Row| row.get(0))?
105            .filter_map(|r| r.ok())
106            .collect();
107        debug!("Found {} applied migrations", applied.len());
108
109        // Apply pending migrations
110        for (name, sql) in MIGRATIONS {
111            if !applied.contains(&name.to_string()) {
112                info!("Applying migration: {}", name);
113                match conn.execute_batch(sql) {
114                    Ok(_) => {
115                        info!("Successfully applied migration: {}", name);
116                        match conn.execute("INSERT INTO _migrations (name) VALUES (?1)", [name]) {
117                            Ok(_) => debug!("Recorded migration: {}", name),
118                            Err(e) => {
119                                error!("Failed to record migration {}: {}", name, e);
120                                return Err(e);
121                            }
122                        }
123                    }
124                    Err(e) => {
125                        error!("Failed to apply migration {}: {}", name, e);
126                        return Err(e);
127                    }
128                }
129            } else {
130                debug!("Skipping already applied migration: {}", name);
131            }
132        }
133
134        info!("All migrations completed successfully");
135        Ok(())
136    }
137
138    /// Get a database service for async-safe operations
139    ///
140    /// This wraps all blocking database operations in spawn_blocking to prevent
141    /// freezing the async runtime.
142    pub fn service(&self) -> RusqliteService {
143        RusqliteService::new(Arc::clone(&self.conn))
144    }
145
146    /// Get the database file path
147    pub fn path(&self) -> &PathBuf {
148        &self.path
149    }
150
151    /// Get database file size in bytes
152    pub fn file_size(&self) -> Option<u64> {
153        std::fs::metadata(&self.path).ok().map(|m| m.len())
154    }
155}
156
157// TRACES: UR-002, UR-012, UR-019, UR-025 | DR-012 | UT-014, UT-015, UT-016, UT-017, UT-018, UT-019, UT-020, UT-021, UT-022, UT-023, UT-025
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use rusqlite::params;
162
163    #[test]
164    fn test_open_in_memory() {
165        let db = Database::open_in_memory().unwrap();
166        assert_eq!(db.path().to_str(), Some(":memory:"));
167    }
168
169    #[test]
170    fn test_migrations_run() {
171        let db = Database::open_in_memory().unwrap();
172        let conn = db.connection();
173        let conn = conn.lock_safe();
174
175        // Check that tables exist
176        let mut stmt = conn
177            .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='items'")
178            .unwrap();
179        let exists: Option<String> = stmt.query_row([], |row: &rusqlite::Row| row.get(0)).ok();
180        assert!(exists.is_some());
181    }
182
183    #[test]
184    fn test_all_tables_created() {
185        let db = Database::open_in_memory().unwrap();
186        let conn = db.connection();
187        let conn = conn.lock_safe();
188
189        let expected_tables = [
190            "servers",
191            "users",
192            "libraries",
193            "items",
194            "media_streams",
195            "user_data",
196            "downloads",
197            "sync_queue",
198            "thumbnails",
199            "playlists",
200            "playlist_items",
201            "genres",
202        ];
203
204        for table in expected_tables {
205            let exists: Option<String> = conn
206                .query_row(
207                    "SELECT name FROM sqlite_master WHERE type='table' AND name=?1",
208                    [table],
209                    |row: &rusqlite::Row| row.get(0),
210                )
211                .ok();
212            assert!(exists.is_some(), "Table '{}' should exist", table);
213        }
214    }
215
216    #[test]
217    fn test_fts_table_created() {
218        let db = Database::open_in_memory().unwrap();
219        let conn = db.connection();
220        let conn = conn.lock_safe();
221
222        let exists: Option<String> = conn
223            .query_row(
224                "SELECT name FROM sqlite_master WHERE type='table' AND name='items_fts'",
225                [],
226                |row: &rusqlite::Row| row.get(0),
227            )
228            .ok();
229        assert!(exists.is_some(), "FTS table 'items_fts' should exist");
230    }
231
232    #[test]
233    fn test_server_crud() {
234        let db = Database::open_in_memory().unwrap();
235        let conn = db.connection();
236        let conn = conn.lock_safe();
237
238        // Insert a server
239        conn.execute(
240            "INSERT INTO servers (id, name, url, version) VALUES (?1, ?2, ?3, ?4)",
241            params!["server1", "My Server", "http://localhost:8096", "10.8.0"],
242        )
243        .unwrap();
244
245        // Read it back
246        let (name, url): (String, String) = conn
247            .query_row(
248                "SELECT name, url FROM servers WHERE id = ?1",
249                ["server1"],
250                |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
251            )
252            .unwrap();
253        assert_eq!(name, "My Server");
254        assert_eq!(url, "http://localhost:8096");
255
256        // Update it
257        conn.execute(
258            "UPDATE servers SET name = ?1 WHERE id = ?2",
259            params!["Updated Server", "server1"],
260        )
261        .unwrap();
262
263        let name: String = conn
264            .query_row(
265                "SELECT name FROM servers WHERE id = ?1",
266                ["server1"],
267                |row: &rusqlite::Row| row.get(0),
268            )
269            .unwrap();
270        assert_eq!(name, "Updated Server");
271
272        // Delete it
273        conn.execute("DELETE FROM servers WHERE id = ?1", ["server1"])
274            .unwrap();
275
276        let count: i32 = conn
277            .query_row("SELECT COUNT(*) FROM servers", [], |row: &rusqlite::Row| {
278                row.get(0)
279            })
280            .unwrap();
281        assert_eq!(count, 0);
282    }
283
284    #[test]
285    fn test_user_crud() {
286        let db = Database::open_in_memory().unwrap();
287        let conn = db.connection();
288        let conn = conn.lock_safe();
289
290        // Create a server first (foreign key)
291        conn.execute(
292            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
293            params!["server1", "Test Server", "http://localhost:8096"],
294        )
295        .unwrap();
296
297        // Insert a user
298        conn.execute(
299            "INSERT INTO users (id, server_id, username, is_active)
300             VALUES (?1, ?2, ?3, ?4)",
301            params!["user1", "server1", "admin", 1],
302        )
303        .unwrap();
304
305        // Read it back
306        let (username, is_active): (String, i32) = conn
307            .query_row(
308                "SELECT username, is_active FROM users WHERE id = ?1",
309                ["user1"],
310                |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
311            )
312            .unwrap();
313        assert_eq!(username, "admin");
314        assert_eq!(is_active, 1);
315
316        // Update is_active
317        conn.execute("UPDATE users SET is_active = 0 WHERE id = ?1", ["user1"])
318            .unwrap();
319
320        let is_active: i32 = conn
321            .query_row(
322                "SELECT is_active FROM users WHERE id = ?1",
323                ["user1"],
324                |row: &rusqlite::Row| row.get(0),
325            )
326            .unwrap();
327        assert_eq!(is_active, 0);
328    }
329
330    #[test]
331    fn test_cascade_delete_server_removes_users() {
332        let db = Database::open_in_memory().unwrap();
333        let conn = db.connection();
334        let conn = conn.lock_safe();
335
336        // Create server and user
337        conn.execute(
338            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
339            params!["server1", "Test Server", "http://localhost:8096"],
340        )
341        .unwrap();
342
343        conn.execute(
344            "INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
345            params!["user1", "server1", "admin"],
346        )
347        .unwrap();
348
349        // Verify user exists
350        let count: i32 = conn
351            .query_row(
352                "SELECT COUNT(*) FROM users WHERE server_id = ?1",
353                ["server1"],
354                |row: &rusqlite::Row| row.get(0),
355            )
356            .unwrap();
357        assert_eq!(count, 1);
358
359        // Delete server
360        conn.execute("DELETE FROM servers WHERE id = ?1", ["server1"])
361            .unwrap();
362
363        // User should be deleted via CASCADE
364        let count: i32 = conn
365            .query_row("SELECT COUNT(*) FROM users", [], |row: &rusqlite::Row| {
366                row.get(0)
367            })
368            .unwrap();
369        assert_eq!(count, 0);
370    }
371
372    #[test]
373    fn test_item_insert_and_fts_search() {
374        let db = Database::open_in_memory().unwrap();
375        let conn = db.connection();
376        let conn = conn.lock_safe();
377
378        // Create server first
379        conn.execute(
380            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
381            params!["server1", "Test Server", "http://localhost:8096"],
382        )
383        .unwrap();
384
385        // Insert an item
386        conn.execute(
387            "INSERT INTO items (id, server_id, name, item_type, overview, album_name, artists)
388             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
389            params![
390                "item1",
391                "server1",
392                "Bohemian Rhapsody",
393                "Audio",
394                "A legendary rock song",
395                "A Night at the Opera",
396                "[\"Queen\"]"
397            ],
398        )
399        .unwrap();
400
401        // Search via FTS
402        let mut stmt = conn
403            .prepare(
404                "SELECT i.name FROM items i
405                 JOIN items_fts ON i.rowid = items_fts.rowid
406                 WHERE items_fts MATCH ?1",
407            )
408            .unwrap();
409
410        // Search by song name
411        let result: Option<String> = stmt
412            .query_row(["Bohemian"], |row: &rusqlite::Row| row.get(0))
413            .ok();
414        assert_eq!(result, Some("Bohemian Rhapsody".to_string()));
415
416        // Search by album name
417        let result: Option<String> = stmt
418            .query_row(["Opera"], |row: &rusqlite::Row| row.get(0))
419            .ok();
420        assert_eq!(result, Some("Bohemian Rhapsody".to_string()));
421
422        // Search by artist
423        let result: Option<String> = stmt
424            .query_row(["Queen"], |row: &rusqlite::Row| row.get(0))
425            .ok();
426        assert_eq!(result, Some("Bohemian Rhapsody".to_string()));
427    }
428
429    #[test]
430    fn test_user_data_playback_position() {
431        let db = Database::open_in_memory().unwrap();
432        let conn = db.connection();
433        let conn = conn.lock_safe();
434
435        // Setup: server, user, item
436        conn.execute(
437            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
438            params!["server1", "Test", "http://localhost"],
439        )
440        .unwrap();
441
442        conn.execute(
443            "INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
444            params!["user1", "server1", "admin"],
445        )
446        .unwrap();
447
448        conn.execute(
449            "INSERT INTO items (id, server_id, name, item_type) VALUES (?1, ?2, ?3, ?4)",
450            params!["item1", "server1", "Test Movie", "Movie"],
451        )
452        .unwrap();
453
454        // Insert user data with playback position
455        conn.execute(
456            "INSERT INTO user_data (user_id, item_id, playback_position_ticks, is_played)
457             VALUES (?1, ?2, ?3, ?4)",
458            params!["user1", "item1", 12345678900_i64, 0],
459        )
460        .unwrap();
461
462        // Read back
463        let (position, is_played): (i64, i32) = conn
464            .query_row(
465                "SELECT playback_position_ticks, is_played FROM user_data
466                 WHERE user_id = ?1 AND item_id = ?2",
467                ["user1", "item1"],
468                |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
469            )
470            .unwrap();
471        assert_eq!(position, 12345678900);
472        assert_eq!(is_played, 0);
473
474        // Update to mark as played
475        conn.execute(
476            "UPDATE user_data SET is_played = 1, playback_position_ticks = 0
477             WHERE user_id = ?1 AND item_id = ?2",
478            ["user1", "item1"],
479        )
480        .unwrap();
481
482        let is_played: i32 = conn
483            .query_row(
484                "SELECT is_played FROM user_data WHERE user_id = ?1 AND item_id = ?2",
485                ["user1", "item1"],
486                |row: &rusqlite::Row| row.get(0),
487            )
488            .unwrap();
489        assert_eq!(is_played, 1);
490    }
491
492    #[test]
493    fn test_sync_queue_operations() {
494        let db = Database::open_in_memory().unwrap();
495        let conn = db.connection();
496        let conn = conn.lock_safe();
497
498        // Setup
499        conn.execute(
500            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
501            params!["server1", "Test", "http://localhost"],
502        )
503        .unwrap();
504
505        conn.execute(
506            "INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
507            params!["user1", "server1", "admin"],
508        )
509        .unwrap();
510
511        // Queue a sync operation
512        conn.execute(
513            "INSERT INTO sync_queue (user_id, operation, item_id, payload, status)
514             VALUES (?1, ?2, ?3, ?4, ?5)",
515            params![
516                "user1",
517                "mark_favorite",
518                "item123",
519                r#"{"favorite": true}"#,
520                "pending"
521            ],
522        )
523        .unwrap();
524
525        // Get pending operations
526        let mut stmt = conn
527            .prepare("SELECT operation, item_id FROM sync_queue WHERE status = 'pending'")
528            .unwrap();
529
530        let ops: Vec<(String, String)> = stmt
531            .query_map([], |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)))
532            .unwrap()
533            .filter_map(|r| r.ok())
534            .collect();
535
536        assert_eq!(ops.len(), 1);
537        assert_eq!(ops[0].0, "mark_favorite");
538        assert_eq!(ops[0].1, "item123");
539
540        // Mark as completed
541        conn.execute(
542            "UPDATE sync_queue SET status = 'completed' WHERE item_id = ?1",
543            ["item123"],
544        )
545        .unwrap();
546
547        let pending_count: i32 = conn
548            .query_row(
549                "SELECT COUNT(*) FROM sync_queue WHERE status = 'pending'",
550                [],
551                |row: &rusqlite::Row| row.get(0),
552            )
553            .unwrap();
554        assert_eq!(pending_count, 0);
555    }
556
557    #[test]
558    fn test_downloads_table() {
559        let db = Database::open_in_memory().unwrap();
560        let conn = db.connection();
561        let conn = conn.lock_safe();
562
563        // Setup
564        conn.execute(
565            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
566            params!["server1", "Test", "http://localhost"],
567        )
568        .unwrap();
569
570        conn.execute(
571            "INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
572            params!["user1", "server1", "admin"],
573        )
574        .unwrap();
575
576        conn.execute(
577            "INSERT INTO items (id, server_id, name, item_type) VALUES (?1, ?2, ?3, ?4)",
578            params!["item1", "server1", "Test Song", "Audio"],
579        )
580        .unwrap();
581
582        // Queue a download
583        conn.execute(
584            "INSERT INTO downloads (item_id, user_id, file_path, status, progress)
585             VALUES (?1, ?2, ?3, ?4, ?5)",
586            params!["item1", "user1", "/data/downloads/test.mp3", "pending", 0.0],
587        )
588        .unwrap();
589
590        // Update progress
591        conn.execute(
592            "UPDATE downloads SET status = 'downloading', progress = 0.5
593             WHERE item_id = ?1",
594            ["item1"],
595        )
596        .unwrap();
597
598        let (status, progress): (String, f64) = conn
599            .query_row(
600                "SELECT status, progress FROM downloads WHERE item_id = ?1",
601                ["item1"],
602                |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
603            )
604            .unwrap();
605        assert_eq!(status, "downloading");
606        assert!((progress - 0.5).abs() < 0.001);
607
608        // Complete download
609        conn.execute(
610            "UPDATE downloads SET status = 'completed', progress = 1.0
611             WHERE item_id = ?1",
612            ["item1"],
613        )
614        .unwrap();
615
616        let status: String = conn
617            .query_row(
618                "SELECT status FROM downloads WHERE item_id = ?1",
619                ["item1"],
620                |row: &rusqlite::Row| row.get(0),
621            )
622            .unwrap();
623        assert_eq!(status, "completed");
624    }
625
626    #[test]
627    fn test_migrations_idempotent() {
628        let db = Database::open_in_memory().unwrap();
629
630        // Run migrations again - should not fail
631        let result = db.migrate();
632        assert!(result.is_ok());
633
634        // Tables should still exist
635        let conn = db.connection();
636        let conn = conn.lock_safe();
637
638        let count: i32 = conn
639            .query_row(
640                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='items'",
641                [],
642                |row: &rusqlite::Row| row.get(0),
643            )
644            .unwrap();
645        assert_eq!(count, 1);
646    }
647
648    #[test]
649    fn test_global_active_user_deactivation() {
650        let db = Database::open_in_memory().unwrap();
651        let conn = db.connection();
652        let conn = conn.lock_safe();
653
654        // Create two servers
655        conn.execute(
656            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
657            params!["server1", "Server 1", "http://server1.com"],
658        )
659        .unwrap();
660
661        conn.execute(
662            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
663            params!["server2", "Server 2", "http://server2.com"],
664        )
665        .unwrap();
666
667        // Create users on different servers
668        conn.execute(
669            "INSERT INTO users (id, server_id, username, is_active, last_login_at)
670             VALUES (?1, ?2, ?3, 1, '2024-01-01 10:00:00')",
671            params!["user1", "server1", "admin"],
672        )
673        .unwrap();
674
675        conn.execute(
676            "INSERT INTO users (id, server_id, username, is_active, last_login_at)
677             VALUES (?1, ?2, ?3, 1, '2024-01-01 11:00:00')",
678            params!["user2", "server2", "admin"],
679        )
680        .unwrap();
681
682        // Initially both users are active (simulating the old bug)
683        let active_count: i32 = conn
684            .query_row(
685                "SELECT COUNT(*) FROM users WHERE is_active = 1",
686                [],
687                |row: &rusqlite::Row| row.get(0),
688            )
689            .unwrap();
690        assert_eq!(active_count, 2);
691
692        // Now simulate setting user1 as active (global deactivation)
693        conn.execute("UPDATE users SET is_active = 0", []).unwrap();
694        conn.execute(
695            "UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?1",
696            ["user1"],
697        )
698        .unwrap();
699
700        // Only one user should be active now
701        let active_count: i32 = conn
702            .query_row(
703                "SELECT COUNT(*) FROM users WHERE is_active = 1",
704                [],
705                |row: &rusqlite::Row| row.get(0),
706            )
707            .unwrap();
708        assert_eq!(active_count, 1);
709
710        // And it should be user1
711        let active_user: String = conn
712            .query_row(
713                "SELECT id FROM users WHERE is_active = 1",
714                [],
715                |row: &rusqlite::Row| row.get(0),
716            )
717            .unwrap();
718        assert_eq!(active_user, "user1");
719    }
720
721    #[test]
722    fn test_active_session_query_ordering() {
723        let db = Database::open_in_memory().unwrap();
724        let conn = db.connection();
725        let conn = conn.lock_safe();
726
727        // Create server
728        conn.execute(
729            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
730            params!["server1", "Test Server", "http://localhost:8096"],
731        )
732        .unwrap();
733
734        // Create users with different login times
735        conn.execute(
736            "INSERT INTO users (id, server_id, username, is_active, last_login_at)
737             VALUES (?1, ?2, ?3, 0, '2024-01-01 10:00:00')",
738            params!["user1", "server1", "old_user"],
739        )
740        .unwrap();
741
742        conn.execute(
743            "INSERT INTO users (id, server_id, username, is_active, last_login_at)
744             VALUES (?1, ?2, ?3, 1, '2024-01-01 12:00:00')",
745            params!["user2", "server1", "recent_user"],
746        )
747        .unwrap();
748
749        // Query for active user ordered by last_login_at DESC (simulating storage_get_active_session)
750        let (user_id, username): (String, String) = conn
751            .query_row(
752                "SELECT u.id, u.username FROM users u
753                 JOIN servers s ON u.server_id = s.id
754                 WHERE u.is_active = 1
755                 ORDER BY u.last_login_at DESC
756                 LIMIT 1",
757                [],
758                |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
759            )
760            .unwrap();
761
762        assert_eq!(user_id, "user2");
763        assert_eq!(username, "recent_user");
764    }
765}