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/// How long a connection retries a locked database before erroring — covers a
21/// reader meeting a WAL checkpoint, or the writer meeting a reader's snapshot.
22const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
23
24/// How many read-only connections serve queries alongside the writer. Reads
25/// are short; a few cover a library page's parallel fetches plus background
26/// work without holding many file handles.
27const READER_CONNECTIONS: usize = 3;
28
29/// The database: opened once at startup and owned for the life of the app.
30///
31/// All access goes through [`Database::service`], which hands out clones of one
32/// [`RusqliteService`] — a writer thread plus a pool of read-only connections
33/// (see `db_service`). Nothing else opens the database file.
34pub struct Database {
35    /// The read-write connection. Owned by the service's writer thread; kept
36    /// here only for migrations (which run before that thread starts taking
37    /// work) and for tests.
38    #[cfg_attr(not(test), allow(dead_code))]
39    conn: Arc<Mutex<Connection>>,
40    service: RusqliteService,
41    path: PathBuf,
42}
43
44impl Database {
45    /// Open or create the database at a specific path
46    pub fn open(path: &PathBuf) -> SqliteResult<Self> {
47        // Ensure parent directory exists
48        if let Some(parent) = path.parent() {
49            std::fs::create_dir_all(parent).ok();
50        }
51
52        let conn = Connection::open(path)?;
53
54        // Enable foreign keys
55        conn.execute_batch("PRAGMA foreign_keys = ON;")?;
56
57        // WAL lets the reader connections run alongside the writer.
58        conn.execute_batch("PRAGMA journal_mode = WAL;")?;
59        // In WAL mode NORMAL is corruption-safe and skips the fsync FULL pays on
60        // every commit (a power cut can lose the last commits, an app crash
61        // cannot). On Android flash that fsync dominated every small write.
62        conn.execute_batch("PRAGMA synchronous = NORMAL;")?;
63        conn.busy_timeout(BUSY_TIMEOUT)?;
64
65        let conn = Arc::new(Mutex::new(conn));
66        Self::migrate_connection(&conn, MIGRATIONS)?;
67
68        // Planner statistics. Without them SQLite guesses between indexes, and
69        // guessed badly for the listing query (see 08-database-design.md →
70        // "Listing query shape"). `optimize` only analyses what is missing or
71        // stale; `analysis_limit` bounds each table's scan so this stays in the
72        // milliseconds on a large catalogue. Failure is not fatal.
73        if let Err(e) = conn
74            .lock_safe()
75            .execute_batch("PRAGMA analysis_limit = 400; PRAGMA optimize = 0x10002;")
76        {
77            error!("PRAGMA optimize failed: {}", e);
78        }
79
80        // Readers open after migrations, so they only ever see the final schema.
81        let readers = (0..READER_CONNECTIONS)
82            .map(|_| Self::open_reader(path))
83            .collect::<SqliteResult<Vec<_>>>()?;
84
85        Ok(Self {
86            service: RusqliteService::with_readers(Arc::clone(&conn), readers),
87            conn,
88            path: path.clone(),
89        })
90    }
91
92    /// A connection that can only read. `query_only` makes an accidental write
93    /// routed to the pool fail loudly instead of racing the writer.
94    fn open_reader(path: &PathBuf) -> SqliteResult<Connection> {
95        let conn = Connection::open(path)?;
96        conn.busy_timeout(BUSY_TIMEOUT)?;
97        conn.execute_batch("PRAGMA query_only = ON;")?;
98        Ok(conn)
99    }
100
101    /// Open an in-memory database (for testing)
102    #[cfg(test)]
103    pub fn open_in_memory() -> SqliteResult<Self> {
104        let conn = Connection::open_in_memory()?;
105
106        // Enable foreign keys
107        conn.execute_batch("PRAGMA foreign_keys = ON;")?;
108
109        let conn = Arc::new(Mutex::new(conn));
110        Self::migrate_connection(&conn, MIGRATIONS)?;
111
112        // An in-memory database cannot be shared between connections, so this
113        // one has no reader pool: reads go through the writer.
114        Ok(Self {
115            service: RusqliteService::new(Arc::clone(&conn)),
116            conn,
117            path: PathBuf::from(":memory:"),
118        })
119    }
120
121    /// Get connection (for testing)
122    #[cfg(test)]
123    pub fn connection(&self) -> Arc<Mutex<Connection>> {
124        Arc::clone(&self.conn)
125    }
126
127    /// Re-run all migrations against an open database (tests only; `open` runs
128    /// them before the service starts).
129    #[cfg(test)]
130    pub fn migrate(&self) -> SqliteResult<()> {
131        self.migrate_with(MIGRATIONS)
132    }
133
134    /// Test seam: inject a failing migration. See [`Self::migrate_connection`].
135    #[cfg(test)]
136    fn migrate_with(&self, migrations: &[(&str, &str)]) -> SqliteResult<()> {
137        Self::migrate_connection(&self.conn, migrations)
138    }
139
140    /// Apply `migrations` in order, skipping ones `_migrations` already records.
141    ///
142    /// **Each migration is one transaction, and the `_migrations` row is written
143    /// inside it.** SQLite autocommits every statement otherwise, so a migration
144    /// that failed partway — low disk, an OOM kill, the process dying mid-boot —
145    /// used to leave its earlier statements applied while recording nothing.
146    /// `execute_batch` aborts on the first error, so the retry on the next launch
147    /// then failed at statement 1 ("duplicate column name") and kept failing
148    /// forever; `Database::open` turns that into a panic, so the app never
149    /// started again and the only fix was clearing app data. Committing the
150    /// schema change and the bookkeeping together makes a migration all-or-nothing
151    /// and a retry always safe.
152    ///
153    /// Every migration is pure DDL/DML, which SQLite runs transactionally — a
154    /// `PRAGMA` or `VACUUM` added to one would not roll back and must not be.
155    ///
156    /// Runs on the bare connection, before the writer thread takes it, so
157    /// tests can also inject a failing migration through `migrate_with`.
158    ///
159    /// TRACES: UR-002 | DR-012 | UT-014
160    fn migrate_connection(
161        conn: &Mutex<Connection>,
162        migrations: &[(&str, &str)],
163    ) -> SqliteResult<()> {
164        info!("Starting database migrations...");
165        let conn = conn.lock_safe();
166
167        // Create migrations table if it doesn't exist
168        debug!("Creating _migrations table if it doesn't exist...");
169        match conn.execute(
170            "CREATE TABLE IF NOT EXISTS _migrations (
171                id INTEGER PRIMARY KEY,
172                name TEXT NOT NULL UNIQUE,
173                applied_at TEXT DEFAULT CURRENT_TIMESTAMP
174            )",
175            [],
176        ) {
177            Ok(_) => debug!("_migrations table ready"),
178            Err(e) => {
179                error!("Failed to create _migrations table: {}", e);
180                return Err(e);
181            }
182        }
183
184        // Get applied migrations
185        debug!("Querying applied migrations...");
186        let mut stmt = conn.prepare("SELECT name FROM _migrations")?;
187        let applied: Vec<String> = stmt
188            .query_map([], |row: &rusqlite::Row| row.get(0))?
189            .filter_map(|r| r.ok())
190            .collect();
191        debug!("Found {} applied migrations", applied.len());
192
193        // Apply pending migrations
194        for (name, sql) in migrations {
195            if applied.contains(&name.to_string()) {
196                debug!("Skipping already applied migration: {}", name);
197                continue;
198            }
199
200            info!("Applying migration: {}", name);
201
202            // `unchecked_transaction` because the connection is reached through a
203            // shared guard rather than `&mut`. Dropping the transaction without
204            // committing rolls it back, which is exactly what the `?`s below do.
205            let tx = conn.unchecked_transaction()?;
206
207            if let Err(e) = tx.execute_batch(sql) {
208                error!("Failed to apply migration {} (rolled back): {}", name, e);
209                return Err(e);
210            }
211            if let Err(e) = tx.execute("INSERT INTO _migrations (name) VALUES (?1)", [name]) {
212                error!("Failed to record migration {} (rolled back): {}", name, e);
213                return Err(e);
214            }
215            tx.commit()?;
216
217            info!("Successfully applied migration: {}", name);
218        }
219
220        info!("All migrations completed successfully");
221        Ok(())
222    }
223
224    /// A handle to the database service. Cheap: every call returns a clone of
225    /// the same writer thread and reader pool.
226    pub fn service(&self) -> RusqliteService {
227        self.service.clone()
228    }
229
230    /// Get the database file path
231    pub fn path(&self) -> &PathBuf {
232        &self.path
233    }
234
235    /// Get database file size in bytes
236    pub fn file_size(&self) -> Option<u64> {
237        std::fs::metadata(&self.path).ok().map(|m| m.len())
238    }
239}
240
241// 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
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use rusqlite::params;
246
247    #[test]
248    fn test_open_in_memory() {
249        let db = Database::open_in_memory().unwrap();
250        assert_eq!(db.path().to_str(), Some(":memory:"));
251    }
252
253    /// A migration that dies partway must leave *nothing* behind.
254    ///
255    /// SQLite autocommits each statement, so before migrations were wrapped in a
256    /// transaction the first `ADD COLUMN` of a failing batch stuck while the
257    /// `_migrations` row was never written. `execute_batch` aborts on the first
258    /// error, so the retry on the next launch failed at statement 1 with
259    /// "duplicate column name" and kept failing forever — and `Database::open`
260    /// panics on that, so the app never started again.
261    ///
262    /// TRACES: UR-002 | DR-012 | UT-014
263    #[test]
264    fn test_failed_migration_rolls_back_and_stays_retryable() {
265        let db = Database::open_in_memory().unwrap();
266
267        // Statement 1 succeeds, statement 2 fails, statement 3 never runs.
268        let poisoned = &[(
269            "900_partially_failing",
270            "ALTER TABLE downloads ADD COLUMN audit_a TEXT;
271             ALTER TABLE downloads ADD COLUMN audit_b TEXT FROM NOWHERE;
272             ALTER TABLE downloads ADD COLUMN audit_c TEXT;",
273        )][..];
274
275        let first = db.migrate_with(poisoned).unwrap_err();
276
277        // Nothing from the batch may survive, or the retry cannot re-run it.
278        assert!(
279            !has_column(&db, "downloads", "audit_a"),
280            "statement 1 of a failed migration was left applied: the batch did not roll back"
281        );
282        assert!(!has_column(&db, "downloads", "audit_c"));
283
284        // And it must not be recorded as applied.
285        let recorded: i64 = db
286            .connection()
287            .lock_safe()
288            .query_row(
289                "SELECT COUNT(*) FROM _migrations WHERE name = ?1",
290                ["900_partially_failing"],
291                |r| r.get(0),
292            )
293            .unwrap();
294        assert_eq!(recorded, 0, "a failed migration must not be recorded");
295
296        // The retry must fail the same way it did the first time — reaching the
297        // real error — rather than tripping over its own leftovers.
298        let second = db.migrate_with(poisoned).unwrap_err();
299        assert!(
300            !second.to_string().contains("duplicate column"),
301            "the retry hit leftovers from the failed run instead of the real error: {second}"
302        );
303        assert_eq!(first.to_string(), second.to_string());
304
305        // A corrected migration under the same name then applies cleanly.
306        let fixed = &[(
307            "900_partially_failing",
308            "ALTER TABLE downloads ADD COLUMN audit_a TEXT;
309             ALTER TABLE downloads ADD COLUMN audit_c TEXT;",
310        )][..];
311        db.migrate_with(fixed).unwrap();
312        assert!(has_column(&db, "downloads", "audit_a"));
313        assert!(has_column(&db, "downloads", "audit_c"));
314    }
315
316    /// A committed migration is recorded, so it is never applied twice.
317    ///
318    /// TRACES: UR-002 | DR-012 | UT-014
319    #[test]
320    fn test_successful_migration_is_recorded_in_the_same_transaction() {
321        let db = Database::open_in_memory().unwrap();
322        let m = &[(
323            "901_adds_a_column",
324            "ALTER TABLE downloads ADD COLUMN audit_d TEXT;",
325        )][..];
326
327        db.migrate_with(m).unwrap();
328        // Re-running must be a no-op, not a "duplicate column" failure.
329        db.migrate_with(m).unwrap();
330        assert!(has_column(&db, "downloads", "audit_d"));
331    }
332
333    fn has_column(db: &Database, table: &str, column: &str) -> bool {
334        let conn = db.connection();
335        let conn = conn.lock_safe();
336        let mut stmt = conn
337            .prepare(&format!("PRAGMA table_info({table})"))
338            .unwrap();
339        let mut names = stmt
340            .query_map([], |row| row.get::<_, String>(1))
341            .unwrap()
342            .filter_map(|r| r.ok());
343        names.any(|n| n == column)
344    }
345
346    #[test]
347    fn test_migrations_run() {
348        let db = Database::open_in_memory().unwrap();
349        let conn = db.connection();
350        let conn = conn.lock_safe();
351
352        // Check that tables exist
353        let mut stmt = conn
354            .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='items'")
355            .unwrap();
356        let exists: Option<String> = stmt.query_row([], |row: &rusqlite::Row| row.get(0)).ok();
357        assert!(exists.is_some());
358    }
359
360    #[test]
361    fn test_all_tables_created() {
362        let db = Database::open_in_memory().unwrap();
363        let conn = db.connection();
364        let conn = conn.lock_safe();
365
366        let expected_tables = [
367            "servers",
368            "users",
369            "libraries",
370            "items",
371            "media_streams",
372            "user_data",
373            "downloads",
374            "sync_queue",
375            "thumbnails",
376            "playlists",
377            "playlist_items",
378            "genres",
379        ];
380
381        for table in expected_tables {
382            let exists: Option<String> = conn
383                .query_row(
384                    "SELECT name FROM sqlite_master WHERE type='table' AND name=?1",
385                    [table],
386                    |row: &rusqlite::Row| row.get(0),
387                )
388                .ok();
389            assert!(exists.is_some(), "Table '{}' should exist", table);
390        }
391    }
392
393    #[test]
394    fn test_fts_table_created() {
395        let db = Database::open_in_memory().unwrap();
396        let conn = db.connection();
397        let conn = conn.lock_safe();
398
399        let exists: Option<String> = conn
400            .query_row(
401                "SELECT name FROM sqlite_master WHERE type='table' AND name='items_fts'",
402                [],
403                |row: &rusqlite::Row| row.get(0),
404            )
405            .ok();
406        assert!(exists.is_some(), "FTS table 'items_fts' should exist");
407    }
408
409    #[test]
410    fn test_server_crud() {
411        let db = Database::open_in_memory().unwrap();
412        let conn = db.connection();
413        let conn = conn.lock_safe();
414
415        // Insert a server
416        conn.execute(
417            "INSERT INTO servers (id, name, url, version) VALUES (?1, ?2, ?3, ?4)",
418            params!["server1", "My Server", "http://localhost:8096", "10.8.0"],
419        )
420        .unwrap();
421
422        // Read it back
423        let (name, url): (String, String) = conn
424            .query_row(
425                "SELECT name, url FROM servers WHERE id = ?1",
426                ["server1"],
427                |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
428            )
429            .unwrap();
430        assert_eq!(name, "My Server");
431        assert_eq!(url, "http://localhost:8096");
432
433        // Update it
434        conn.execute(
435            "UPDATE servers SET name = ?1 WHERE id = ?2",
436            params!["Updated Server", "server1"],
437        )
438        .unwrap();
439
440        let name: String = conn
441            .query_row(
442                "SELECT name FROM servers WHERE id = ?1",
443                ["server1"],
444                |row: &rusqlite::Row| row.get(0),
445            )
446            .unwrap();
447        assert_eq!(name, "Updated Server");
448
449        // Delete it
450        conn.execute("DELETE FROM servers WHERE id = ?1", ["server1"])
451            .unwrap();
452
453        let count: i32 = conn
454            .query_row("SELECT COUNT(*) FROM servers", [], |row: &rusqlite::Row| {
455                row.get(0)
456            })
457            .unwrap();
458        assert_eq!(count, 0);
459    }
460
461    #[test]
462    fn test_user_crud() {
463        let db = Database::open_in_memory().unwrap();
464        let conn = db.connection();
465        let conn = conn.lock_safe();
466
467        // Create a server first (foreign key)
468        conn.execute(
469            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
470            params!["server1", "Test Server", "http://localhost:8096"],
471        )
472        .unwrap();
473
474        // Insert a user
475        conn.execute(
476            "INSERT INTO users (id, server_id, username, is_active)
477             VALUES (?1, ?2, ?3, ?4)",
478            params!["user1", "server1", "admin", 1],
479        )
480        .unwrap();
481
482        // Read it back
483        let (username, is_active): (String, i32) = conn
484            .query_row(
485                "SELECT username, is_active FROM users WHERE id = ?1",
486                ["user1"],
487                |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
488            )
489            .unwrap();
490        assert_eq!(username, "admin");
491        assert_eq!(is_active, 1);
492
493        // Update is_active
494        conn.execute("UPDATE users SET is_active = 0 WHERE id = ?1", ["user1"])
495            .unwrap();
496
497        let is_active: i32 = conn
498            .query_row(
499                "SELECT is_active FROM users WHERE id = ?1",
500                ["user1"],
501                |row: &rusqlite::Row| row.get(0),
502            )
503            .unwrap();
504        assert_eq!(is_active, 0);
505    }
506
507    #[test]
508    fn test_cascade_delete_server_removes_users() {
509        let db = Database::open_in_memory().unwrap();
510        let conn = db.connection();
511        let conn = conn.lock_safe();
512
513        // Create server and user
514        conn.execute(
515            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
516            params!["server1", "Test Server", "http://localhost:8096"],
517        )
518        .unwrap();
519
520        conn.execute(
521            "INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
522            params!["user1", "server1", "admin"],
523        )
524        .unwrap();
525
526        // Verify user exists
527        let count: i32 = conn
528            .query_row(
529                "SELECT COUNT(*) FROM users WHERE server_id = ?1",
530                ["server1"],
531                |row: &rusqlite::Row| row.get(0),
532            )
533            .unwrap();
534        assert_eq!(count, 1);
535
536        // Delete server
537        conn.execute("DELETE FROM servers WHERE id = ?1", ["server1"])
538            .unwrap();
539
540        // User should be deleted via CASCADE
541        let count: i32 = conn
542            .query_row("SELECT COUNT(*) FROM users", [], |row: &rusqlite::Row| {
543                row.get(0)
544            })
545            .unwrap();
546        assert_eq!(count, 0);
547    }
548
549    #[test]
550    fn test_item_insert_and_fts_search() {
551        let db = Database::open_in_memory().unwrap();
552        let conn = db.connection();
553        let conn = conn.lock_safe();
554
555        // Create server first
556        conn.execute(
557            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
558            params!["server1", "Test Server", "http://localhost:8096"],
559        )
560        .unwrap();
561
562        // Insert an item
563        conn.execute(
564            "INSERT INTO items (id, server_id, name, item_type, overview, album_name, artists)
565             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
566            params![
567                "item1",
568                "server1",
569                "Bohemian Rhapsody",
570                "Audio",
571                "A legendary rock song",
572                "A Night at the Opera",
573                "[\"Queen\"]"
574            ],
575        )
576        .unwrap();
577
578        // Search via FTS
579        let mut stmt = conn
580            .prepare(
581                "SELECT i.name FROM items i
582                 JOIN items_fts ON i.rowid = items_fts.rowid
583                 WHERE items_fts MATCH ?1",
584            )
585            .unwrap();
586
587        // Search by song name
588        let result: Option<String> = stmt
589            .query_row(["Bohemian"], |row: &rusqlite::Row| row.get(0))
590            .ok();
591        assert_eq!(result, Some("Bohemian Rhapsody".to_string()));
592
593        // Search by album name
594        let result: Option<String> = stmt
595            .query_row(["Opera"], |row: &rusqlite::Row| row.get(0))
596            .ok();
597        assert_eq!(result, Some("Bohemian Rhapsody".to_string()));
598
599        // Search by artist
600        let result: Option<String> = stmt
601            .query_row(["Queen"], |row: &rusqlite::Row| row.get(0))
602            .ok();
603        assert_eq!(result, Some("Bohemian Rhapsody".to_string()));
604    }
605
606    #[test]
607    fn test_user_data_playback_position() {
608        let db = Database::open_in_memory().unwrap();
609        let conn = db.connection();
610        let conn = conn.lock_safe();
611
612        // Setup: server, user, item
613        conn.execute(
614            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
615            params!["server1", "Test", "http://localhost"],
616        )
617        .unwrap();
618
619        conn.execute(
620            "INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
621            params!["user1", "server1", "admin"],
622        )
623        .unwrap();
624
625        conn.execute(
626            "INSERT INTO items (id, server_id, name, item_type) VALUES (?1, ?2, ?3, ?4)",
627            params!["item1", "server1", "Test Movie", "Movie"],
628        )
629        .unwrap();
630
631        // Insert user data with playback position
632        conn.execute(
633            "INSERT INTO user_data (user_id, item_id, playback_position_ticks, is_played)
634             VALUES (?1, ?2, ?3, ?4)",
635            params!["user1", "item1", 12345678900_i64, 0],
636        )
637        .unwrap();
638
639        // Read back
640        let (position, is_played): (i64, i32) = conn
641            .query_row(
642                "SELECT playback_position_ticks, is_played FROM user_data
643                 WHERE user_id = ?1 AND item_id = ?2",
644                ["user1", "item1"],
645                |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
646            )
647            .unwrap();
648        assert_eq!(position, 12345678900);
649        assert_eq!(is_played, 0);
650
651        // Update to mark as played
652        conn.execute(
653            "UPDATE user_data SET is_played = 1, playback_position_ticks = 0
654             WHERE user_id = ?1 AND item_id = ?2",
655            ["user1", "item1"],
656        )
657        .unwrap();
658
659        let is_played: i32 = conn
660            .query_row(
661                "SELECT is_played FROM user_data WHERE user_id = ?1 AND item_id = ?2",
662                ["user1", "item1"],
663                |row: &rusqlite::Row| row.get(0),
664            )
665            .unwrap();
666        assert_eq!(is_played, 1);
667    }
668
669    #[test]
670    fn test_sync_queue_operations() {
671        let db = Database::open_in_memory().unwrap();
672        let conn = db.connection();
673        let conn = conn.lock_safe();
674
675        // Setup
676        conn.execute(
677            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
678            params!["server1", "Test", "http://localhost"],
679        )
680        .unwrap();
681
682        conn.execute(
683            "INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
684            params!["user1", "server1", "admin"],
685        )
686        .unwrap();
687
688        // Queue a sync operation
689        conn.execute(
690            "INSERT INTO sync_queue (user_id, operation, item_id, payload, status)
691             VALUES (?1, ?2, ?3, ?4, ?5)",
692            params![
693                "user1",
694                "mark_favorite",
695                "item123",
696                r#"{"favorite": true}"#,
697                "pending"
698            ],
699        )
700        .unwrap();
701
702        // Get pending operations
703        let mut stmt = conn
704            .prepare("SELECT operation, item_id FROM sync_queue WHERE status = 'pending'")
705            .unwrap();
706
707        let ops: Vec<(String, String)> = stmt
708            .query_map([], |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)))
709            .unwrap()
710            .filter_map(|r| r.ok())
711            .collect();
712
713        assert_eq!(ops.len(), 1);
714        assert_eq!(ops[0].0, "mark_favorite");
715        assert_eq!(ops[0].1, "item123");
716
717        // Mark as completed
718        conn.execute(
719            "UPDATE sync_queue SET status = 'completed' WHERE item_id = ?1",
720            ["item123"],
721        )
722        .unwrap();
723
724        let pending_count: i32 = conn
725            .query_row(
726                "SELECT COUNT(*) FROM sync_queue WHERE status = 'pending'",
727                [],
728                |row: &rusqlite::Row| row.get(0),
729            )
730            .unwrap();
731        assert_eq!(pending_count, 0);
732    }
733
734    #[test]
735    fn test_downloads_table() {
736        let db = Database::open_in_memory().unwrap();
737        let conn = db.connection();
738        let conn = conn.lock_safe();
739
740        // Setup
741        conn.execute(
742            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
743            params!["server1", "Test", "http://localhost"],
744        )
745        .unwrap();
746
747        conn.execute(
748            "INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
749            params!["user1", "server1", "admin"],
750        )
751        .unwrap();
752
753        conn.execute(
754            "INSERT INTO items (id, server_id, name, item_type) VALUES (?1, ?2, ?3, ?4)",
755            params!["item1", "server1", "Test Song", "Audio"],
756        )
757        .unwrap();
758
759        // Queue a download
760        conn.execute(
761            "INSERT INTO downloads (item_id, user_id, file_path, status, progress)
762             VALUES (?1, ?2, ?3, ?4, ?5)",
763            params!["item1", "user1", "/data/downloads/test.mp3", "pending", 0.0],
764        )
765        .unwrap();
766
767        // Update progress
768        conn.execute(
769            "UPDATE downloads SET status = 'downloading', progress = 0.5
770             WHERE item_id = ?1",
771            ["item1"],
772        )
773        .unwrap();
774
775        let (status, progress): (String, f64) = conn
776            .query_row(
777                "SELECT status, progress FROM downloads WHERE item_id = ?1",
778                ["item1"],
779                |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
780            )
781            .unwrap();
782        assert_eq!(status, "downloading");
783        assert!((progress - 0.5).abs() < 0.001);
784
785        // Complete download
786        conn.execute(
787            "UPDATE downloads SET status = 'completed', progress = 1.0
788             WHERE item_id = ?1",
789            ["item1"],
790        )
791        .unwrap();
792
793        let status: String = conn
794            .query_row(
795                "SELECT status FROM downloads WHERE item_id = ?1",
796                ["item1"],
797                |row: &rusqlite::Row| row.get(0),
798            )
799            .unwrap();
800        assert_eq!(status, "completed");
801    }
802
803    #[test]
804    fn test_migrations_idempotent() {
805        let db = Database::open_in_memory().unwrap();
806
807        // Run migrations again - should not fail
808        let result = db.migrate();
809        assert!(result.is_ok());
810
811        // Tables should still exist
812        let conn = db.connection();
813        let conn = conn.lock_safe();
814
815        let count: i32 = conn
816            .query_row(
817                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='items'",
818                [],
819                |row: &rusqlite::Row| row.get(0),
820            )
821            .unwrap();
822        assert_eq!(count, 1);
823    }
824
825    #[test]
826    fn test_global_active_user_deactivation() {
827        let db = Database::open_in_memory().unwrap();
828        let conn = db.connection();
829        let conn = conn.lock_safe();
830
831        // Create two servers
832        conn.execute(
833            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
834            params!["server1", "Server 1", "http://server1.com"],
835        )
836        .unwrap();
837
838        conn.execute(
839            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
840            params!["server2", "Server 2", "http://server2.com"],
841        )
842        .unwrap();
843
844        // Create users on different servers
845        conn.execute(
846            "INSERT INTO users (id, server_id, username, is_active, last_login_at)
847             VALUES (?1, ?2, ?3, 1, '2024-01-01 10:00:00')",
848            params!["user1", "server1", "admin"],
849        )
850        .unwrap();
851
852        conn.execute(
853            "INSERT INTO users (id, server_id, username, is_active, last_login_at)
854             VALUES (?1, ?2, ?3, 1, '2024-01-01 11:00:00')",
855            params!["user2", "server2", "admin"],
856        )
857        .unwrap();
858
859        // Initially both users are active (simulating the old bug)
860        let active_count: i32 = conn
861            .query_row(
862                "SELECT COUNT(*) FROM users WHERE is_active = 1",
863                [],
864                |row: &rusqlite::Row| row.get(0),
865            )
866            .unwrap();
867        assert_eq!(active_count, 2);
868
869        // Now simulate setting user1 as active (global deactivation)
870        conn.execute("UPDATE users SET is_active = 0", []).unwrap();
871        conn.execute(
872            "UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?1",
873            ["user1"],
874        )
875        .unwrap();
876
877        // Only one user should be active now
878        let active_count: i32 = conn
879            .query_row(
880                "SELECT COUNT(*) FROM users WHERE is_active = 1",
881                [],
882                |row: &rusqlite::Row| row.get(0),
883            )
884            .unwrap();
885        assert_eq!(active_count, 1);
886
887        // And it should be user1
888        let active_user: String = conn
889            .query_row(
890                "SELECT id FROM users WHERE is_active = 1",
891                [],
892                |row: &rusqlite::Row| row.get(0),
893            )
894            .unwrap();
895        assert_eq!(active_user, "user1");
896    }
897
898    #[test]
899    fn test_active_session_query_ordering() {
900        let db = Database::open_in_memory().unwrap();
901        let conn = db.connection();
902        let conn = conn.lock_safe();
903
904        // Create server
905        conn.execute(
906            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
907            params!["server1", "Test Server", "http://localhost:8096"],
908        )
909        .unwrap();
910
911        // Create users with different login times
912        conn.execute(
913            "INSERT INTO users (id, server_id, username, is_active, last_login_at)
914             VALUES (?1, ?2, ?3, 0, '2024-01-01 10:00:00')",
915            params!["user1", "server1", "old_user"],
916        )
917        .unwrap();
918
919        conn.execute(
920            "INSERT INTO users (id, server_id, username, is_active, last_login_at)
921             VALUES (?1, ?2, ?3, 1, '2024-01-01 12:00:00')",
922            params!["user2", "server1", "recent_user"],
923        )
924        .unwrap();
925
926        // Query for active user ordered by last_login_at DESC (simulating storage_get_active_session)
927        let (user_id, username): (String, String) = conn
928            .query_row(
929                "SELECT u.id, u.username FROM users u
930                 JOIN servers s ON u.server_id = s.id
931                 WHERE u.is_active = 1
932                 ORDER BY u.last_login_at DESC
933                 LIMIT 1",
934                [],
935                |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
936            )
937            .unwrap();
938
939        assert_eq!(user_id, "user2");
940        assert_eq!(username, "recent_user");
941    }
942
943    /// A read must not queue behind a long write.
944    ///
945    /// The database is in WAL mode precisely so readers can run alongside a
946    /// writer, but every query used to go through one connection behind one
947    /// mutex — so a big catalog-cache transaction stalled every library page,
948    /// thumbnail lookup and settings read in the app until it committed.
949    ///
950    /// TRACES: UR-002 | DR-012 | UT-014
951    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
952    async fn reads_do_not_wait_for_an_in_flight_write() {
953        use crate::storage::db_service::{DatabaseService, Query};
954        use std::time::{Duration, Instant};
955
956        let dir = tempfile::tempdir().unwrap();
957        let db = Database::open(&dir.path().join("jellytau.db")).unwrap();
958        let service = db.service();
959
960        let writer = service.clone();
961        let write = tokio::spawn(async move {
962            writer
963                .transaction(|_tx| {
964                    std::thread::sleep(Duration::from_millis(600));
965                    Ok(())
966                })
967                .await
968        });
969        // Let the write take the connection first.
970        tokio::time::sleep(Duration::from_millis(100)).await;
971
972        let started = Instant::now();
973        let servers: i64 = service
974            .query_one(Query::new("SELECT COUNT(*) FROM servers"), |r| r.get(0))
975            .await
976            .unwrap();
977        let waited = started.elapsed();
978
979        assert_eq!(servers, 0);
980        assert!(
981            waited < Duration::from_millis(250),
982            "a read waited {waited:?} for an unrelated write to commit"
983        );
984        write.await.unwrap().unwrap();
985    }
986
987    /// Commit cost: in WAL mode `synchronous = NORMAL` is corruption-safe and
988    /// skips the per-commit fsync that FULL (the default) pays — on Android
989    /// flash that is the dominant cost of every small write. A busy timeout
990    /// lets the reader connections ride out a checkpoint instead of failing.
991    ///
992    /// TRACES: UR-002 | DR-012 | UT-014
993    #[test]
994    fn open_configures_wal_for_interactive_use() {
995        let dir = tempfile::tempdir().unwrap();
996        let db = Database::open(&dir.path().join("jellytau.db")).unwrap();
997        let conn = db.connection();
998        let conn = conn.lock_safe();
999
1000        let mode: String = conn
1001            .query_row("PRAGMA journal_mode", [], |r| r.get(0))
1002            .unwrap();
1003        let synchronous: i64 = conn
1004            .query_row("PRAGMA synchronous", [], |r| r.get(0))
1005            .unwrap();
1006        let busy_timeout: i64 = conn
1007            .query_row("PRAGMA busy_timeout", [], |r| r.get(0))
1008            .unwrap();
1009
1010        assert_eq!(mode, "wal");
1011        assert_eq!(synchronous, 1, "expected synchronous = NORMAL");
1012        assert!(busy_timeout > 0, "expected a busy timeout");
1013    }
1014
1015    /// The planner gets statistics: the app used to never run `ANALYZE`, so
1016    /// SQLite guessed between indexes — and for the listing query guessed the
1017    /// `server_id` index, which every row shares, turning an index lookup into
1018    /// a walk of the whole catalogue. `PRAGMA optimize` at open refreshes
1019    /// whatever statistics are missing or stale, bounded by `analysis_limit`.
1020    ///
1021    /// TRACES: UR-002 | DR-012 | UT-014
1022    #[test]
1023    fn open_gives_the_planner_statistics() {
1024        let dir = tempfile::tempdir().unwrap();
1025        let path = dir.path().join("jellytau.db");
1026        {
1027            let db = Database::open(&path).unwrap();
1028            let conn = db.connection();
1029            let conn = conn.lock_safe();
1030            conn.execute_batch(
1031                "INSERT INTO servers (id, name, url) VALUES ('s', 'S', 'http://s');",
1032            )
1033            .unwrap();
1034            for i in 0..2000 {
1035                conn.execute(
1036                    "INSERT INTO items (id, server_id, name, item_type) VALUES (?1, 's', 'n', 'Audio')",
1037                    [format!("i{i}")],
1038                )
1039                .unwrap();
1040            }
1041        }
1042
1043        let db = Database::open(&path).unwrap();
1044        let conn = db.connection();
1045        let conn = conn.lock_safe();
1046        let analysed: i64 = conn
1047            .query_row(
1048                "SELECT COUNT(*) FROM sqlite_master WHERE name = 'sqlite_stat1'",
1049                [],
1050                |r| r.get(0),
1051            )
1052            .unwrap();
1053        assert_eq!(analysed, 1, "the planner has no statistics");
1054        let items_stats: i64 = conn
1055            .query_row(
1056                "SELECT COUNT(*) FROM sqlite_stat1 WHERE tbl = 'items'",
1057                [],
1058                |r| r.get(0),
1059            )
1060            .unwrap();
1061        assert!(items_stats > 0, "no statistics for items");
1062    }
1063
1064    /// Writes a phone-sized catalogue to `$JELLYTAU_BENCH_DB` for timing
1065    /// queries with the `sqlite3` CLI. Not a test; run explicitly with
1066    /// `--ignored`.
1067    #[test]
1068    #[ignore]
1069    fn write_bench_database() {
1070        let Ok(path) = std::env::var("JELLYTAU_BENCH_DB") else {
1071            return;
1072        };
1073        let _ = std::fs::remove_file(&path);
1074        let db = Database::open(&PathBuf::from(&path)).unwrap();
1075        let conn = db.connection();
1076        let conn = conn.lock_safe();
1077        conn.execute_batch(
1078            "BEGIN;
1079             INSERT INTO servers (id, name, url) VALUES ('srv', 'S', 'http://s');
1080             INSERT INTO users (id, server_id, username) VALUES ('u', 'srv', 'u');
1081             INSERT INTO libraries (id, server_id, name, collection_type) VALUES ('tv', 'srv', 'TV', 'tvshows');
1082             INSERT INTO libraries (id, server_id, name, collection_type) VALUES ('music', 'srv', 'Music', 'music');",
1083        )
1084        .unwrap();
1085        let now = "2026-09-23T00:00:00Z";
1086        let mut item = conn
1087            .prepare(
1088                "INSERT INTO items (id, server_id, library_id, parent_id, name, sort_name, item_type,
1089                                    series_id, season_id, album_id, synced_at)
1090                 VALUES (?1, 'srv', ?2, ?3, ?4, ?4, ?5, ?6, ?7, ?8, ?9)",
1091            )
1092            .unwrap();
1093        let none: Option<String> = None;
1094        for s in 0..300 {
1095            let series = format!("series-{s}");
1096            item.execute(rusqlite::params![
1097                series,
1098                "tv",
1099                none,
1100                format!("Show {s}"),
1101                "Series",
1102                none,
1103                none,
1104                none,
1105                now
1106            ])
1107            .unwrap();
1108            for n in 0..11 {
1109                let season = format!("{series}-s{n}");
1110                item.execute(rusqlite::params![
1111                    season,
1112                    "tv",
1113                    series,
1114                    format!("Season {n}"),
1115                    "Season",
1116                    series,
1117                    none,
1118                    none,
1119                    now
1120                ])
1121                .unwrap();
1122                for e in 0..24 {
1123                    let ep = format!("{season}-e{e}");
1124                    item.execute(rusqlite::params![
1125                        ep,
1126                        "tv",
1127                        season,
1128                        format!("A Title {e}"),
1129                        "Episode",
1130                        series,
1131                        season,
1132                        none,
1133                        now
1134                    ])
1135                    .unwrap();
1136                    conn.execute(
1137                        "INSERT INTO user_data (user_id, item_id, playback_position_ticks) VALUES ('u', ?1, 5)",
1138                        [&ep],
1139                    )
1140                    .unwrap();
1141                }
1142            }
1143        }
1144        for a in 0..2000 {
1145            let album = format!("album-{a}");
1146            item.execute(rusqlite::params![
1147                album,
1148                "music",
1149                none,
1150                format!("Album {a}"),
1151                "MusicAlbum",
1152                none,
1153                none,
1154                none,
1155                now
1156            ])
1157            .unwrap();
1158            for t in 0..12 {
1159                item.execute(rusqlite::params![
1160                    format!("{album}-t{t}"),
1161                    "music",
1162                    album,
1163                    format!("Track {t}"),
1164                    "Audio",
1165                    none,
1166                    none,
1167                    album,
1168                    now
1169                ])
1170                .unwrap();
1171            }
1172        }
1173        for d in 0..400 {
1174            conn.execute(
1175                "INSERT INTO downloads (item_id, user_id, file_path, status) VALUES (?1, 'u', '/x', 'completed')",
1176                [format!("series-{}-s1-e{}", d % 300, d % 24)],
1177            )
1178            .unwrap();
1179        }
1180        drop(item);
1181        // No ANALYZE: the app never runs it, so the planner works without stats.
1182        conn.execute_batch("COMMIT;").unwrap();
1183    }
1184}