First working POC
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
//! Database service abstraction layer
|
||||
//!
|
||||
//! This module provides an async database interface that abstracts away
|
||||
//! the underlying database implementation. This makes it easy to:
|
||||
//! - Switch between sync (rusqlite) and async (tokio-rusqlite) implementations
|
||||
//! - Prevent blocking the async runtime with synchronous database calls
|
||||
//! - Test with different database backends
|
||||
//! - Migrate to other database systems in the future
|
||||
|
||||
use async_trait::async_trait;
|
||||
use rusqlite::{params_from_iter, Connection, Result as SqliteResult, Row};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Database query result type
|
||||
pub type DbResult<T> = Result<T, String>;
|
||||
|
||||
/// Represents a database query that can be executed
|
||||
#[derive(Clone)]
|
||||
pub struct Query {
|
||||
pub sql: String,
|
||||
pub params: Vec<QueryParam>,
|
||||
}
|
||||
|
||||
/// Query parameter types supported by the database
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum QueryParam {
|
||||
String(String),
|
||||
Int(i32),
|
||||
Int64(i64),
|
||||
Float(f64),
|
||||
#[allow(dead_code)]
|
||||
Bool(bool),
|
||||
Null,
|
||||
}
|
||||
|
||||
impl Query {
|
||||
pub fn new(sql: impl Into<String>) -> Self {
|
||||
Self {
|
||||
sql: sql.into(),
|
||||
params: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_params(sql: impl Into<String>, params: Vec<QueryParam>) -> Self {
|
||||
Self {
|
||||
sql: sql.into(),
|
||||
params,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Database service trait - abstraction over database operations
|
||||
#[async_trait]
|
||||
pub trait DatabaseService: Send + Sync {
|
||||
/// Execute a query that doesn't return results (INSERT, UPDATE, DELETE)
|
||||
async fn execute(&self, query: Query) -> DbResult<usize>;
|
||||
|
||||
/// Execute a batch of SQL statements (for migrations)
|
||||
#[allow(dead_code)]
|
||||
async fn execute_batch(&self, sql: &str) -> DbResult<()>;
|
||||
|
||||
/// Query a single row
|
||||
async fn query_one<T, F>(&self, query: Query, mapper: F) -> DbResult<T>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: Fn(&Row) -> SqliteResult<T> + Send + 'static;
|
||||
|
||||
/// Query a single optional row
|
||||
async fn query_optional<T, F>(&self, query: Query, mapper: F) -> DbResult<Option<T>>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: Fn(&Row) -> SqliteResult<T> + Send + 'static;
|
||||
|
||||
/// Query multiple rows
|
||||
async fn query_many<T, F>(&self, query: Query, mapper: F) -> DbResult<Vec<T>>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: Fn(&Row) -> SqliteResult<T> + Send + 'static;
|
||||
|
||||
/// Run a transaction with multiple operations
|
||||
async fn transaction<F, T>(&self, f: F) -> DbResult<T>
|
||||
where
|
||||
F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
|
||||
T: Send + 'static;
|
||||
|
||||
/// Get the row ID of the most recent successful INSERT
|
||||
async fn last_insert_rowid(&self) -> DbResult<i64>;
|
||||
}
|
||||
|
||||
/// Transaction handle for batching multiple operations
|
||||
pub struct Transaction<'a> {
|
||||
conn: &'a Connection,
|
||||
}
|
||||
|
||||
impl<'a> Transaction<'a> {
|
||||
pub fn new(conn: &'a Connection) -> Self {
|
||||
Self { conn }
|
||||
}
|
||||
|
||||
pub fn execute(&mut self, query: Query) -> DbResult<usize> {
|
||||
execute_query(self.conn, query)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rusqlite-based database service implementation
|
||||
///
|
||||
/// This implementation wraps synchronous rusqlite operations in tokio::task::spawn_blocking
|
||||
/// to prevent blocking the async runtime.
|
||||
pub struct RusqliteService {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl RusqliteService {
|
||||
pub fn new(conn: Arc<Mutex<Connection>>) -> Self {
|
||||
Self { conn }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DatabaseService for RusqliteService {
|
||||
async fn execute(&self, query: Query) -> DbResult<usize> {
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
execute_query(&conn, query)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {}", e))?
|
||||
}
|
||||
|
||||
async fn execute_batch(&self, sql: &str) -> DbResult<()> {
|
||||
let conn = Arc::clone(&self.conn);
|
||||
let sql = sql.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
conn.execute_batch(&sql)
|
||||
.map_err(|e| format!("Execute batch failed: {}", e))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {}", e))?
|
||||
}
|
||||
|
||||
async fn query_one<T, F>(&self, query: Query, mapper: F) -> DbResult<T>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
|
||||
{
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
query_one(&conn, query, mapper)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {}", e))?
|
||||
}
|
||||
|
||||
async fn query_optional<T, F>(&self, query: Query, mapper: F) -> DbResult<Option<T>>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
|
||||
{
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
query_optional(&conn, query, mapper)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {}", e))?
|
||||
}
|
||||
|
||||
async fn query_many<T, F>(&self, query: Query, mapper: F) -> DbResult<Vec<T>>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
|
||||
{
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
query_many(&conn, query, mapper)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {}", e))?
|
||||
}
|
||||
|
||||
async fn transaction<F, T>(&self, f: F) -> DbResult<T>
|
||||
where
|
||||
F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
|
||||
T: Send + 'static,
|
||||
{
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
|
||||
conn.execute("BEGIN TRANSACTION", [])
|
||||
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
|
||||
|
||||
let mut transaction = Transaction::new(&conn);
|
||||
let result = f(&mut transaction);
|
||||
|
||||
match result {
|
||||
Ok(value) => {
|
||||
conn.execute("COMMIT", [])
|
||||
.map_err(|e| format!("Failed to commit transaction: {}", e))?;
|
||||
Ok(value)
|
||||
}
|
||||
Err(e) => {
|
||||
conn.execute("ROLLBACK", [])
|
||||
.map_err(|e| format!("Failed to rollback transaction: {}", e))?;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {}", e))?
|
||||
}
|
||||
|
||||
async fn last_insert_rowid(&self) -> DbResult<i64> {
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {}", e))?
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for executing queries synchronously
|
||||
|
||||
fn execute_query(conn: &Connection, query: Query) -> DbResult<usize> {
|
||||
let params = convert_params(&query.params);
|
||||
conn.execute(&query.sql, params_from_iter(params.iter()))
|
||||
.map_err(|e| format!("Execute failed: {}", e))
|
||||
}
|
||||
|
||||
fn query_one<T, F>(conn: &Connection, query: Query, mapper: F) -> DbResult<T>
|
||||
where
|
||||
F: Fn(&Row) -> SqliteResult<T>,
|
||||
{
|
||||
let params = convert_params(&query.params);
|
||||
conn.query_row(&query.sql, params_from_iter(params.iter()), mapper)
|
||||
.map_err(|e| format!("Query one failed: {}", e))
|
||||
}
|
||||
|
||||
fn query_optional<T, F>(conn: &Connection, query: Query, mapper: F) -> DbResult<Option<T>>
|
||||
where
|
||||
F: Fn(&Row) -> SqliteResult<T>,
|
||||
{
|
||||
match query_one(conn, query, mapper) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(e) if e.contains("Query returned no rows") || e.contains("QueryReturnedNoRows") => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn query_many<T, F>(conn: &Connection, query: Query, mapper: F) -> DbResult<Vec<T>>
|
||||
where
|
||||
F: Fn(&Row) -> SqliteResult<T>,
|
||||
{
|
||||
let params = convert_params(&query.params);
|
||||
let mut stmt = conn
|
||||
.prepare(&query.sql)
|
||||
.map_err(|e| format!("Prepare failed: {}", e))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params_from_iter(params.iter()), mapper)
|
||||
.map_err(|e| format!("Query map failed: {}", e))?;
|
||||
|
||||
rows.collect::<SqliteResult<Vec<T>>>()
|
||||
.map_err(|e| format!("Collect failed: {}", e))
|
||||
}
|
||||
|
||||
/// Convert QueryParam to rusqlite::types::Value
|
||||
fn convert_params(params: &[QueryParam]) -> Vec<rusqlite::types::Value> {
|
||||
params
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
QueryParam::String(s) => rusqlite::types::Value::Text(s.clone()),
|
||||
QueryParam::Int(i) => rusqlite::types::Value::Integer(*i as i64),
|
||||
QueryParam::Int64(i) => rusqlite::types::Value::Integer(*i),
|
||||
QueryParam::Float(f) => rusqlite::types::Value::Real(*f),
|
||||
QueryParam::Bool(b) => rusqlite::types::Value::Integer(if *b { 1 } else { 0 }),
|
||||
QueryParam::Null => rusqlite::types::Value::Null,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_query() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
.unwrap();
|
||||
|
||||
let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
|
||||
|
||||
let query = Query::with_params(
|
||||
"INSERT INTO test (name) VALUES (?)",
|
||||
vec![QueryParam::String("Alice".to_string())],
|
||||
);
|
||||
|
||||
let rows = service.execute(query).await.unwrap();
|
||||
assert_eq!(rows, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_query_one() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO test (name) VALUES ('Bob')", [])
|
||||
.unwrap();
|
||||
|
||||
let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
|
||||
|
||||
let query = Query::new("SELECT name FROM test WHERE id = 1");
|
||||
let name: String = service
|
||||
.query_one(query, |row| row.get(0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(name, "Bob");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_query_many() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO test (name) VALUES ('Alice')", [])
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO test (name) VALUES ('Bob')", [])
|
||||
.unwrap();
|
||||
|
||||
let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
|
||||
|
||||
let query = Query::new("SELECT name FROM test ORDER BY id");
|
||||
let names: Vec<String> = service
|
||||
.query_many(query, |row| row.get(0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(names, vec!["Alice", "Bob"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_query_optional() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
.unwrap();
|
||||
|
||||
let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
|
||||
|
||||
let query = Query::new("SELECT name FROM test WHERE id = 999");
|
||||
let result: Option<String> = service
|
||||
.query_optional(query, |row| row.get(0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_transaction() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
.unwrap();
|
||||
|
||||
let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
|
||||
|
||||
let result = service
|
||||
.transaction(|tx| {
|
||||
tx.execute(Query::with_params(
|
||||
"INSERT INTO test (name) VALUES (?)",
|
||||
vec![QueryParam::String("Alice".to_string())],
|
||||
))?;
|
||||
tx.execute(Query::with_params(
|
||||
"INSERT INTO test (name) VALUES (?)",
|
||||
vec![QueryParam::String("Bob".to_string())],
|
||||
))?;
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify both rows were inserted
|
||||
let query = Query::new("SELECT COUNT(*) FROM test");
|
||||
let count: i32 = service.query_one(query, |row| row.get(0)).await.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
//! Offline storage module using SQLite
|
||||
//!
|
||||
//! Provides local caching of Jellyfin metadata, download management,
|
||||
//! and offline mutation queue for sync-back operations.
|
||||
|
||||
pub mod db_service;
|
||||
pub mod models;
|
||||
pub mod schema;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use log::{debug, error, info};
|
||||
use rusqlite::{Connection, Result as SqliteResult};
|
||||
|
||||
use schema::MIGRATIONS;
|
||||
pub use db_service::{DatabaseService, RusqliteService};
|
||||
|
||||
/// Database connection wrapper with thread-safe access
|
||||
pub struct Database {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Open or create the database at a specific path
|
||||
pub fn open(path: &PathBuf) -> SqliteResult<Self> {
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
|
||||
let conn = Connection::open(path)?;
|
||||
|
||||
// Enable foreign keys
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||
|
||||
// Enable WAL mode for better concurrent access
|
||||
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
|
||||
|
||||
let db = Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
path: path.clone(),
|
||||
};
|
||||
|
||||
// Run migrations
|
||||
db.migrate()?;
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// Open an in-memory database (for testing)
|
||||
#[cfg(test)]
|
||||
pub fn open_in_memory() -> SqliteResult<Self> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
|
||||
// Enable foreign keys
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||
|
||||
let db = Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
path: PathBuf::from(":memory:"),
|
||||
};
|
||||
|
||||
// Run migrations
|
||||
db.migrate()?;
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// Get connection (for testing)
|
||||
#[cfg(test)]
|
||||
pub fn connection(&self) -> Arc<Mutex<Connection>> {
|
||||
Arc::clone(&self.conn)
|
||||
}
|
||||
|
||||
/// Run all pending migrations
|
||||
pub fn migrate(&self) -> SqliteResult<()> {
|
||||
info!("Starting database migrations...");
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
// Create migrations table if it doesn't exist
|
||||
debug!("Creating _migrations table if it doesn't exist...");
|
||||
match conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS _migrations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
applied_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)",
|
||||
[],
|
||||
) {
|
||||
Ok(_) => debug!("_migrations table ready"),
|
||||
Err(e) => {
|
||||
error!("Failed to create _migrations table: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Get applied migrations
|
||||
debug!("Querying applied migrations...");
|
||||
let mut stmt = conn.prepare("SELECT name FROM _migrations")?;
|
||||
let applied: Vec<String> = stmt
|
||||
.query_map([], |row: &rusqlite::Row| row.get(0))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
debug!("Found {} applied migrations", applied.len());
|
||||
|
||||
// Apply pending migrations
|
||||
for (name, sql) in MIGRATIONS {
|
||||
if !applied.contains(&name.to_string()) {
|
||||
info!("Applying migration: {}", name);
|
||||
match conn.execute_batch(sql) {
|
||||
Ok(_) => {
|
||||
info!("Successfully applied migration: {}", name);
|
||||
match conn.execute(
|
||||
"INSERT INTO _migrations (name) VALUES (?1)",
|
||||
[name],
|
||||
) {
|
||||
Ok(_) => debug!("Recorded migration: {}", name),
|
||||
Err(e) => {
|
||||
error!("Failed to record migration {}: {}", name, e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to apply migration {}: {}", name, e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debug!("Skipping already applied migration: {}", name);
|
||||
}
|
||||
}
|
||||
|
||||
info!("All migrations completed successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a database service for async-safe operations
|
||||
///
|
||||
/// This wraps all blocking database operations in spawn_blocking to prevent
|
||||
/// freezing the async runtime.
|
||||
pub fn service(&self) -> RusqliteService {
|
||||
RusqliteService::new(Arc::clone(&self.conn))
|
||||
}
|
||||
|
||||
/// Get the database file path
|
||||
pub fn path(&self) -> &PathBuf {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Get database file size in bytes
|
||||
pub fn file_size(&self) -> Option<u64> {
|
||||
std::fs::metadata(&self.path).ok().map(|m| m.len())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::params;
|
||||
|
||||
#[test]
|
||||
fn test_open_in_memory() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
assert_eq!(db.path().to_str(), Some(":memory:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrations_run() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// Check that tables exist
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='items'")
|
||||
.unwrap();
|
||||
let exists: Option<String> = stmt.query_row([], |row: &rusqlite::Row| row.get(0)).ok();
|
||||
assert!(exists.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_tables_created() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let expected_tables = [
|
||||
"servers",
|
||||
"users",
|
||||
"libraries",
|
||||
"items",
|
||||
"media_streams",
|
||||
"user_data",
|
||||
"downloads",
|
||||
"sync_queue",
|
||||
"thumbnails",
|
||||
"playlists",
|
||||
"playlist_items",
|
||||
];
|
||||
|
||||
for table in expected_tables {
|
||||
let exists: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?1",
|
||||
[table],
|
||||
|row: &rusqlite::Row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
assert!(exists.is_some(), "Table '{}' should exist", table);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fts_table_created() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let exists: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='items_fts'",
|
||||
[],
|
||||
|row: &rusqlite::Row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
assert!(exists.is_some(), "FTS table 'items_fts' should exist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_crud() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// Insert a server
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url, version) VALUES (?1, ?2, ?3, ?4)",
|
||||
params!["server1", "My Server", "http://localhost:8096", "10.8.0"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Read it back
|
||||
let (name, url): (String, String) = conn
|
||||
.query_row(
|
||||
"SELECT name, url FROM servers WHERE id = ?1",
|
||||
["server1"],
|
||||
|row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(name, "My Server");
|
||||
assert_eq!(url, "http://localhost:8096");
|
||||
|
||||
// Update it
|
||||
conn.execute(
|
||||
"UPDATE servers SET name = ?1 WHERE id = ?2",
|
||||
params!["Updated Server", "server1"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let name: String = conn
|
||||
.query_row("SELECT name FROM servers WHERE id = ?1", ["server1"], |row: &rusqlite::Row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(name, "Updated Server");
|
||||
|
||||
// Delete it
|
||||
conn.execute("DELETE FROM servers WHERE id = ?1", ["server1"])
|
||||
.unwrap();
|
||||
|
||||
let count: i32 = conn
|
||||
.query_row("SELECT COUNT(*) FROM servers", [], |row: &rusqlite::Row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_crud() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// Create a server first (foreign key)
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
|
||||
params!["server1", "Test Server", "http://localhost:8096"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Insert a user
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username, is_active)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params!["user1", "server1", "admin", 1],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Read it back
|
||||
let (username, is_active): (String, i32) = conn
|
||||
.query_row(
|
||||
"SELECT username, is_active FROM users WHERE id = ?1",
|
||||
["user1"],
|
||||
|row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(username, "admin");
|
||||
assert_eq!(is_active, 1);
|
||||
|
||||
// Update is_active
|
||||
conn.execute(
|
||||
"UPDATE users SET is_active = 0 WHERE id = ?1",
|
||||
["user1"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let is_active: i32 = conn
|
||||
.query_row("SELECT is_active FROM users WHERE id = ?1", ["user1"], |row: &rusqlite::Row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(is_active, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cascade_delete_server_removes_users() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// Create server and user
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
|
||||
params!["server1", "Test Server", "http://localhost:8096"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
|
||||
params!["user1", "server1", "admin"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Verify user exists
|
||||
let count: i32 = conn
|
||||
.query_row("SELECT COUNT(*) FROM users WHERE server_id = ?1", ["server1"], |row: &rusqlite::Row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
// Delete server
|
||||
conn.execute("DELETE FROM servers WHERE id = ?1", ["server1"])
|
||||
.unwrap();
|
||||
|
||||
// User should be deleted via CASCADE
|
||||
let count: i32 = conn
|
||||
.query_row("SELECT COUNT(*) FROM users", [], |row: &rusqlite::Row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_item_insert_and_fts_search() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// Create server first
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
|
||||
params!["server1", "Test Server", "http://localhost:8096"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Insert an item
|
||||
conn.execute(
|
||||
"INSERT INTO items (id, server_id, name, item_type, overview, album_name, artists)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
"item1",
|
||||
"server1",
|
||||
"Bohemian Rhapsody",
|
||||
"Audio",
|
||||
"A legendary rock song",
|
||||
"A Night at the Opera",
|
||||
"[\"Queen\"]"
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Search via FTS
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT i.name FROM items i
|
||||
JOIN items_fts ON i.rowid = items_fts.rowid
|
||||
WHERE items_fts MATCH ?1",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Search by song name
|
||||
let result: Option<String> = stmt
|
||||
.query_row(["Bohemian"], |row: &rusqlite::Row| row.get(0))
|
||||
.ok();
|
||||
assert_eq!(result, Some("Bohemian Rhapsody".to_string()));
|
||||
|
||||
// Search by album name
|
||||
let result: Option<String> = stmt
|
||||
.query_row(["Opera"], |row: &rusqlite::Row| row.get(0))
|
||||
.ok();
|
||||
assert_eq!(result, Some("Bohemian Rhapsody".to_string()));
|
||||
|
||||
// Search by artist
|
||||
let result: Option<String> = stmt
|
||||
.query_row(["Queen"], |row: &rusqlite::Row| row.get(0))
|
||||
.ok();
|
||||
assert_eq!(result, Some("Bohemian Rhapsody".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_data_playback_position() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// Setup: server, user, item
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
|
||||
params!["server1", "Test", "http://localhost"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
|
||||
params!["user1", "server1", "admin"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO items (id, server_id, name, item_type) VALUES (?1, ?2, ?3, ?4)",
|
||||
params!["item1", "server1", "Test Movie", "Movie"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Insert user data with playback position
|
||||
conn.execute(
|
||||
"INSERT INTO user_data (user_id, item_id, playback_position_ticks, is_played)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params!["user1", "item1", 12345678900_i64, 0],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Read back
|
||||
let (position, is_played): (i64, i32) = conn
|
||||
.query_row(
|
||||
"SELECT playback_position_ticks, is_played FROM user_data
|
||||
WHERE user_id = ?1 AND item_id = ?2",
|
||||
["user1", "item1"],
|
||||
|row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(position, 12345678900);
|
||||
assert_eq!(is_played, 0);
|
||||
|
||||
// Update to mark as played
|
||||
conn.execute(
|
||||
"UPDATE user_data SET is_played = 1, playback_position_ticks = 0
|
||||
WHERE user_id = ?1 AND item_id = ?2",
|
||||
["user1", "item1"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let is_played: i32 = conn
|
||||
.query_row(
|
||||
"SELECT is_played FROM user_data WHERE user_id = ?1 AND item_id = ?2",
|
||||
["user1", "item1"],
|
||||
|row: &rusqlite::Row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(is_played, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_queue_operations() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// Setup
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
|
||||
params!["server1", "Test", "http://localhost"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
|
||||
params!["user1", "server1", "admin"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Queue a sync operation
|
||||
conn.execute(
|
||||
"INSERT INTO sync_queue (user_id, operation, item_id, payload, status)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
"user1",
|
||||
"mark_favorite",
|
||||
"item123",
|
||||
r#"{"favorite": true}"#,
|
||||
"pending"
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Get pending operations
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT operation, item_id FROM sync_queue WHERE status = 'pending'")
|
||||
.unwrap();
|
||||
|
||||
let ops: Vec<(String, String)> = stmt
|
||||
.query_map([], |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)))
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
assert_eq!(ops.len(), 1);
|
||||
assert_eq!(ops[0].0, "mark_favorite");
|
||||
assert_eq!(ops[0].1, "item123");
|
||||
|
||||
// Mark as completed
|
||||
conn.execute(
|
||||
"UPDATE sync_queue SET status = 'completed' WHERE item_id = ?1",
|
||||
["item123"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pending_count: i32 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sync_queue WHERE status = 'pending'",
|
||||
[],
|
||||
|row: &rusqlite::Row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(pending_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_downloads_table() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// Setup
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
|
||||
params!["server1", "Test", "http://localhost"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
|
||||
params!["user1", "server1", "admin"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO items (id, server_id, name, item_type) VALUES (?1, ?2, ?3, ?4)",
|
||||
params!["item1", "server1", "Test Song", "Audio"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Queue a download
|
||||
conn.execute(
|
||||
"INSERT INTO downloads (item_id, user_id, file_path, status, progress)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params!["item1", "user1", "/data/downloads/test.mp3", "pending", 0.0],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Update progress
|
||||
conn.execute(
|
||||
"UPDATE downloads SET status = 'downloading', progress = 0.5
|
||||
WHERE item_id = ?1",
|
||||
["item1"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (status, progress): (String, f64) = conn
|
||||
.query_row(
|
||||
"SELECT status, progress FROM downloads WHERE item_id = ?1",
|
||||
["item1"],
|
||||
|row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(status, "downloading");
|
||||
assert!((progress - 0.5).abs() < 0.001);
|
||||
|
||||
// Complete download
|
||||
conn.execute(
|
||||
"UPDATE downloads SET status = 'completed', progress = 1.0
|
||||
WHERE item_id = ?1",
|
||||
["item1"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let status: String = conn
|
||||
.query_row(
|
||||
"SELECT status FROM downloads WHERE item_id = ?1",
|
||||
["item1"],
|
||||
|row: &rusqlite::Row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(status, "completed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrations_idempotent() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
|
||||
// Run migrations again - should not fail
|
||||
let result = db.migrate();
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Tables should still exist
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let count: i32 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='items'",
|
||||
[],
|
||||
|row: &rusqlite::Row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_active_user_deactivation() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// Create two servers
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
|
||||
params!["server1", "Server 1", "http://server1.com"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
|
||||
params!["server2", "Server 2", "http://server2.com"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Create users on different servers
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username, is_active, last_login_at)
|
||||
VALUES (?1, ?2, ?3, 1, '2024-01-01 10:00:00')",
|
||||
params!["user1", "server1", "admin"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username, is_active, last_login_at)
|
||||
VALUES (?1, ?2, ?3, 1, '2024-01-01 11:00:00')",
|
||||
params!["user2", "server2", "admin"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Initially both users are active (simulating the old bug)
|
||||
let active_count: i32 = conn
|
||||
.query_row("SELECT COUNT(*) FROM users WHERE is_active = 1", [], |row: &rusqlite::Row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(active_count, 2);
|
||||
|
||||
// Now simulate setting user1 as active (global deactivation)
|
||||
conn.execute("UPDATE users SET is_active = 0", [])
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?1",
|
||||
["user1"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Only one user should be active now
|
||||
let active_count: i32 = conn
|
||||
.query_row("SELECT COUNT(*) FROM users WHERE is_active = 1", [], |row: &rusqlite::Row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(active_count, 1);
|
||||
|
||||
// And it should be user1
|
||||
let active_user: String = conn
|
||||
.query_row(
|
||||
"SELECT id FROM users WHERE is_active = 1",
|
||||
[],
|
||||
|row: &rusqlite::Row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(active_user, "user1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_active_session_query_ordering() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// Create server
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
|
||||
params!["server1", "Test Server", "http://localhost:8096"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Create users with different login times
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username, is_active, last_login_at)
|
||||
VALUES (?1, ?2, ?3, 0, '2024-01-01 10:00:00')",
|
||||
params!["user1", "server1", "old_user"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username, is_active, last_login_at)
|
||||
VALUES (?1, ?2, ?3, 1, '2024-01-01 12:00:00')",
|
||||
params!["user2", "server1", "recent_user"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Query for active user ordered by last_login_at DESC (simulating storage_get_active_session)
|
||||
let (user_id, username): (String, String) = conn
|
||||
.query_row(
|
||||
"SELECT u.id, u.username FROM users u
|
||||
JOIN servers s ON u.server_id = s.id
|
||||
WHERE u.is_active = 1
|
||||
ORDER BY u.last_login_at DESC
|
||||
LIMIT 1",
|
||||
[],
|
||||
|row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(user_id, "user2");
|
||||
assert_eq!(username, "recent_user");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Database model structs
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Playlist item entry
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlaylistItem {
|
||||
pub id: Option<i64>,
|
||||
pub playlist_id: String,
|
||||
pub item_id: String,
|
||||
pub sort_order: i32,
|
||||
pub added_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
//! Database schema and migrations
|
||||
|
||||
/// List of migrations to apply in order.
|
||||
/// Each migration is a tuple of (name, sql).
|
||||
pub const MIGRATIONS: &[(&str, &str)] = &[
|
||||
("001_initial_schema", MIGRATION_001),
|
||||
("002_remove_access_token", MIGRATION_002),
|
||||
("003_relax_user_data_constraints", MIGRATION_003),
|
||||
("004_enhance_downloads", MIGRATION_004),
|
||||
("005_relax_downloads_fk", MIGRATION_005),
|
||||
("006_downloads_metadata", MIGRATION_006),
|
||||
("007_cache_metadata", MIGRATION_007),
|
||||
("008_video_downloads", MIGRATION_008),
|
||||
("009_people_tables", MIGRATION_009),
|
||||
("010_playback_context", MIGRATION_010),
|
||||
("011_user_player_settings", MIGRATION_011),
|
||||
("012_download_source", MIGRATION_012),
|
||||
("013_downloads_item_status_index", MIGRATION_013),
|
||||
("014_series_audio_preferences", MIGRATION_014),
|
||||
];
|
||||
|
||||
/// Initial schema migration
|
||||
const MIGRATION_001: &str = r#"
|
||||
-- Jellyfin servers the user has connected to
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
version TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
last_connected_at TEXT
|
||||
);
|
||||
|
||||
-- User accounts on Jellyfin servers
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
username TEXT NOT NULL,
|
||||
access_token TEXT,
|
||||
is_active INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login_at TEXT,
|
||||
UNIQUE(server_id, username)
|
||||
);
|
||||
|
||||
-- Libraries/views from Jellyfin
|
||||
CREATE TABLE IF NOT EXISTS libraries (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
collection_type TEXT,
|
||||
image_tag TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
synced_at TEXT,
|
||||
UNIQUE(server_id, id)
|
||||
);
|
||||
|
||||
-- Media items (movies, shows, episodes, albums, songs, artists)
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
library_id TEXT REFERENCES libraries(id) ON DELETE SET NULL,
|
||||
parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
|
||||
|
||||
-- Core metadata
|
||||
name TEXT NOT NULL,
|
||||
sort_name TEXT,
|
||||
original_title TEXT,
|
||||
item_type TEXT NOT NULL, -- Movie, Series, Episode, MusicAlbum, Audio, MusicArtist, etc.
|
||||
|
||||
-- Media info
|
||||
overview TEXT,
|
||||
tagline TEXT,
|
||||
genres TEXT, -- JSON array
|
||||
tags TEXT, -- JSON array
|
||||
studios TEXT, -- JSON array
|
||||
|
||||
-- For episodes
|
||||
series_id TEXT,
|
||||
series_name TEXT,
|
||||
season_id TEXT,
|
||||
season_name TEXT,
|
||||
index_number INTEGER, -- Episode number
|
||||
parent_index_number INTEGER, -- Season number
|
||||
|
||||
-- For music
|
||||
album_id TEXT,
|
||||
album_name TEXT,
|
||||
album_artist TEXT,
|
||||
artists TEXT, -- JSON array
|
||||
|
||||
-- Dates
|
||||
premiere_date TEXT,
|
||||
production_year INTEGER,
|
||||
date_created TEXT,
|
||||
|
||||
-- Runtime (ticks)
|
||||
runtime_ticks INTEGER,
|
||||
|
||||
-- Images
|
||||
primary_image_tag TEXT,
|
||||
backdrop_image_tags TEXT, -- JSON array
|
||||
|
||||
-- Ratings
|
||||
community_rating REAL,
|
||||
official_rating TEXT,
|
||||
|
||||
-- Sync metadata
|
||||
synced_at TEXT,
|
||||
etag TEXT,
|
||||
|
||||
UNIQUE(server_id, id)
|
||||
);
|
||||
|
||||
-- Full-text search index for items
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5(
|
||||
name,
|
||||
overview,
|
||||
album_name,
|
||||
album_artist,
|
||||
artists,
|
||||
series_name,
|
||||
content='items',
|
||||
content_rowid='rowid'
|
||||
);
|
||||
|
||||
-- Triggers to keep FTS index in sync
|
||||
CREATE TRIGGER IF NOT EXISTS items_ai AFTER INSERT ON items BEGIN
|
||||
INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
|
||||
VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS items_ad AFTER DELETE ON items BEGIN
|
||||
INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
|
||||
VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS items_au AFTER UPDATE ON items BEGIN
|
||||
INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
|
||||
VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
|
||||
INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
|
||||
VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
|
||||
END;
|
||||
|
||||
-- Media streams (audio/subtitle tracks)
|
||||
CREATE TABLE IF NOT EXISTS media_streams (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
stream_index INTEGER NOT NULL,
|
||||
stream_type TEXT NOT NULL, -- Audio, Subtitle, Video
|
||||
codec TEXT,
|
||||
language TEXT,
|
||||
display_title TEXT,
|
||||
is_default INTEGER DEFAULT 0,
|
||||
is_forced INTEGER DEFAULT 0,
|
||||
is_external INTEGER DEFAULT 0,
|
||||
path TEXT, -- For external subtitles
|
||||
UNIQUE(item_id, stream_index)
|
||||
);
|
||||
|
||||
-- User-specific data (watch progress, favorites)
|
||||
CREATE TABLE IF NOT EXISTS user_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
|
||||
-- Playback state
|
||||
playback_position_ticks INTEGER DEFAULT 0,
|
||||
play_count INTEGER DEFAULT 0,
|
||||
is_played INTEGER DEFAULT 0,
|
||||
is_favorite INTEGER DEFAULT 0,
|
||||
|
||||
-- Timestamps
|
||||
last_played_at TEXT,
|
||||
|
||||
-- Sync status
|
||||
synced_at TEXT,
|
||||
pending_sync INTEGER DEFAULT 0, -- 1 if local changes need sync
|
||||
|
||||
UNIQUE(user_id, item_id)
|
||||
);
|
||||
|
||||
-- Downloaded media files
|
||||
CREATE TABLE IF NOT EXISTS downloads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
-- File info
|
||||
file_path TEXT NOT NULL,
|
||||
file_size INTEGER,
|
||||
mime_type TEXT,
|
||||
|
||||
-- Download state
|
||||
status TEXT DEFAULT 'pending', -- pending, downloading, completed, failed, paused
|
||||
progress REAL DEFAULT 0, -- 0.0 to 1.0
|
||||
|
||||
-- Transcoding options used
|
||||
bitrate INTEGER,
|
||||
container TEXT,
|
||||
|
||||
-- Timestamps
|
||||
queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
|
||||
-- Error tracking
|
||||
error_message TEXT,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
|
||||
UNIQUE(item_id, user_id)
|
||||
);
|
||||
|
||||
-- Offline mutation queue (changes to sync back to server)
|
||||
CREATE TABLE IF NOT EXISTS sync_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
-- Operation details
|
||||
operation TEXT NOT NULL, -- mark_played, mark_favorite, update_progress, etc.
|
||||
item_id TEXT,
|
||||
payload TEXT, -- JSON data for the operation
|
||||
|
||||
-- Queue state
|
||||
status TEXT DEFAULT 'pending', -- pending, processing, completed, failed
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
|
||||
-- Timestamps
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
processed_at TEXT,
|
||||
|
||||
-- Error tracking
|
||||
error_message TEXT
|
||||
);
|
||||
|
||||
-- Cached thumbnails
|
||||
CREATE TABLE IF NOT EXISTS thumbnails (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
image_type TEXT NOT NULL, -- Primary, Backdrop, Thumb, Logo, etc.
|
||||
image_tag TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(item_id, image_type, image_tag)
|
||||
);
|
||||
|
||||
-- User playlists (local + synced)
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
is_local INTEGER DEFAULT 0, -- 1 for local-only playlists
|
||||
jellyfin_id TEXT, -- NULL for local-only
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
-- Playlist items
|
||||
CREATE TABLE IF NOT EXISTS playlist_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL,
|
||||
added_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(playlist_id, item_id)
|
||||
);
|
||||
|
||||
-- Indexes for common queries
|
||||
CREATE INDEX IF NOT EXISTS idx_items_server ON items(server_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_library ON items(library_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_type ON items(item_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_album ON items(album_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_series ON items(series_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_data_user ON user_data(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_data_item ON user_data(item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_queue_status ON sync_queue(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_thumbnails_item ON thumbnails(item_id);
|
||||
"#;
|
||||
|
||||
/// Migration to remove access_token column from users table
|
||||
/// Tokens are now stored in the system keyring (or encrypted file fallback)
|
||||
const MIGRATION_002: &str = r#"
|
||||
-- Remove access_token column from users table
|
||||
-- Tokens are now stored in secure storage (system keyring)
|
||||
|
||||
-- SQLite doesn't support DROP COLUMN in older versions, so we recreate the table
|
||||
CREATE TABLE IF NOT EXISTS users_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
username TEXT NOT NULL,
|
||||
is_active INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login_at TEXT,
|
||||
UNIQUE(server_id, username)
|
||||
);
|
||||
|
||||
-- Copy existing data (excluding access_token)
|
||||
INSERT OR IGNORE INTO users_new (id, server_id, username, is_active, created_at, last_login_at)
|
||||
SELECT id, server_id, username, is_active, created_at, last_login_at FROM users;
|
||||
|
||||
-- Drop old table and rename new one
|
||||
DROP TABLE IF EXISTS users;
|
||||
ALTER TABLE users_new RENAME TO users;
|
||||
"#;
|
||||
|
||||
/// Migration to relax foreign key constraints on user_data table
|
||||
/// Allows tracking playback progress for items not yet synced to local database
|
||||
const MIGRATION_003: &str = r#"
|
||||
-- Recreate user_data table without foreign key constraint on item_id
|
||||
-- This allows tracking playback progress for items that haven't been synced locally yet
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_data_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
item_id TEXT NOT NULL, -- No foreign key constraint - item may not be synced yet
|
||||
|
||||
-- Playback state
|
||||
playback_position_ticks INTEGER DEFAULT 0,
|
||||
play_count INTEGER DEFAULT 0,
|
||||
is_played INTEGER DEFAULT 0,
|
||||
is_favorite INTEGER DEFAULT 0,
|
||||
|
||||
-- Timestamps
|
||||
last_played_at TEXT,
|
||||
|
||||
-- Sync status
|
||||
synced_at TEXT,
|
||||
pending_sync INTEGER DEFAULT 0, -- 1 if local changes need sync
|
||||
|
||||
UNIQUE(user_id, item_id)
|
||||
);
|
||||
|
||||
-- Copy existing data
|
||||
INSERT OR IGNORE INTO user_data_new (id, user_id, item_id, playback_position_ticks, play_count, is_played, is_favorite, last_played_at, synced_at, pending_sync)
|
||||
SELECT id, user_id, item_id, playback_position_ticks, play_count, is_played, is_favorite, last_played_at, synced_at, pending_sync FROM user_data;
|
||||
|
||||
-- Drop old table and rename new one
|
||||
DROP TABLE IF EXISTS user_data;
|
||||
ALTER TABLE user_data_new RENAME TO user_data;
|
||||
|
||||
-- Recreate index
|
||||
CREATE INDEX IF NOT EXISTS idx_user_data_user ON user_data(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_data_item ON user_data(item_id);
|
||||
"#;
|
||||
|
||||
/// Migration to enhance downloads table with priority and bytes_downloaded
|
||||
const MIGRATION_004: &str = r#"
|
||||
-- Add priority column for queue ordering
|
||||
ALTER TABLE downloads ADD COLUMN priority INTEGER DEFAULT 0;
|
||||
|
||||
-- Add bytes_downloaded for resume support
|
||||
ALTER TABLE downloads ADD COLUMN bytes_downloaded INTEGER DEFAULT 0;
|
||||
|
||||
-- Create index for efficient queue processing (priority DESC, FIFO within same priority)
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_queue
|
||||
ON downloads(status, priority DESC, queued_at ASC)
|
||||
WHERE status IN ('pending', 'downloading');
|
||||
"#;
|
||||
|
||||
/// Migration to relax foreign key constraint on downloads.item_id
|
||||
/// Allows downloading items that haven't been synced to local database yet
|
||||
const MIGRATION_005: &str = r#"
|
||||
-- Recreate downloads table without foreign key constraint on item_id
|
||||
-- This allows downloading items that haven't been synced locally yet
|
||||
|
||||
CREATE TABLE IF NOT EXISTS downloads_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id TEXT NOT NULL, -- No foreign key constraint - item may not be synced yet
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
-- File info
|
||||
file_path TEXT NOT NULL,
|
||||
file_size INTEGER,
|
||||
mime_type TEXT,
|
||||
|
||||
-- Download state
|
||||
status TEXT DEFAULT 'pending', -- pending, downloading, completed, failed, paused
|
||||
progress REAL DEFAULT 0, -- 0.0 to 1.0
|
||||
|
||||
-- Transcoding options used
|
||||
bitrate INTEGER,
|
||||
container TEXT,
|
||||
|
||||
-- Timestamps
|
||||
queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
|
||||
-- Error tracking
|
||||
error_message TEXT,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
|
||||
-- Priority and progress tracking (from migration 004)
|
||||
priority INTEGER DEFAULT 0,
|
||||
bytes_downloaded INTEGER DEFAULT 0,
|
||||
|
||||
UNIQUE(item_id, user_id)
|
||||
);
|
||||
|
||||
-- Copy existing data
|
||||
INSERT OR IGNORE INTO downloads_new (
|
||||
id, item_id, user_id, file_path, file_size, mime_type, status, progress,
|
||||
bitrate, container, queued_at, started_at, completed_at, error_message,
|
||||
retry_count, priority, bytes_downloaded
|
||||
)
|
||||
SELECT
|
||||
id, item_id, user_id, file_path, file_size, mime_type, status, progress,
|
||||
bitrate, container, queued_at, started_at, completed_at, error_message,
|
||||
retry_count, priority, bytes_downloaded
|
||||
FROM downloads;
|
||||
|
||||
-- Drop old table and rename new one
|
||||
DROP TABLE IF EXISTS downloads;
|
||||
ALTER TABLE downloads_new RENAME TO downloads;
|
||||
|
||||
-- Recreate indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_queue
|
||||
ON downloads(status, priority DESC, queued_at ASC)
|
||||
WHERE status IN ('pending', 'downloading');
|
||||
"#;
|
||||
|
||||
/// Migration to store item metadata directly in downloads table
|
||||
/// This eliminates dependency on items table being synced and fixes UUID display issues
|
||||
const MIGRATION_006: &str = r#"
|
||||
-- Add columns to store item metadata directly in downloads
|
||||
-- This ensures correct display even when items aren't synced locally
|
||||
ALTER TABLE downloads ADD COLUMN item_name TEXT;
|
||||
ALTER TABLE downloads ADD COLUMN artist_name TEXT;
|
||||
ALTER TABLE downloads ADD COLUMN album_name TEXT;
|
||||
"#;
|
||||
|
||||
/// Migration to enhance thumbnail caching with LRU eviction support
|
||||
/// - Relaxes foreign key constraint on item_id (allows caching for items not yet synced)
|
||||
/// - Adds last_accessed for LRU eviction
|
||||
/// - Adds file_size for cache limit tracking
|
||||
/// - Creates cache_settings table for configurable limits
|
||||
const MIGRATION_007: &str = r#"
|
||||
-- Recreate thumbnails table without foreign key constraint on item_id
|
||||
-- and add LRU eviction support columns
|
||||
CREATE TABLE IF NOT EXISTS thumbnails_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id TEXT NOT NULL, -- No foreign key constraint - item may not be synced yet
|
||||
image_type TEXT NOT NULL, -- Primary, Backdrop, Thumb, Logo, etc.
|
||||
image_tag TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
file_size INTEGER DEFAULT 0, -- Size in bytes for cache limit tracking
|
||||
cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
last_accessed TEXT DEFAULT CURRENT_TIMESTAMP, -- For LRU eviction
|
||||
UNIQUE(item_id, image_type, image_tag)
|
||||
);
|
||||
|
||||
-- Copy existing data (if any)
|
||||
INSERT OR IGNORE INTO thumbnails_new (id, item_id, image_type, image_tag, file_path, width, height, cached_at)
|
||||
SELECT id, item_id, image_type, image_tag, file_path, width, height, cached_at FROM thumbnails;
|
||||
|
||||
-- Drop old table and rename new one
|
||||
DROP TABLE IF EXISTS thumbnails;
|
||||
ALTER TABLE thumbnails_new RENAME TO thumbnails;
|
||||
|
||||
-- Create indexes for efficient queries
|
||||
CREATE INDEX IF NOT EXISTS idx_thumbnails_item ON thumbnails(item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_thumbnails_lru ON thumbnails(last_accessed ASC);
|
||||
|
||||
-- Cache settings table for configurable limits
|
||||
CREATE TABLE IF NOT EXISTS cache_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Insert default settings
|
||||
INSERT OR IGNORE INTO cache_settings (key, value) VALUES ('image_cache_limit_bytes', '1073741824'); -- 1GB default
|
||||
INSERT OR IGNORE INTO cache_settings (key, value) VALUES ('image_cache_enabled', 'true');
|
||||
"#;
|
||||
|
||||
/// Migration to add video download support and item pinning
|
||||
/// - Adds video-specific metadata columns to downloads (series/episode info, quality preset)
|
||||
/// - Adds media_type to distinguish audio vs video downloads
|
||||
/// - Adds is_pinned column to items table for protecting metadata from cache clear
|
||||
const MIGRATION_008: &str = r#"
|
||||
-- Add video-specific metadata columns to downloads
|
||||
ALTER TABLE downloads ADD COLUMN series_name TEXT;
|
||||
ALTER TABLE downloads ADD COLUMN season_name TEXT;
|
||||
ALTER TABLE downloads ADD COLUMN episode_number INTEGER;
|
||||
ALTER TABLE downloads ADD COLUMN season_number INTEGER;
|
||||
ALTER TABLE downloads ADD COLUMN quality_preset TEXT DEFAULT 'original';
|
||||
ALTER TABLE downloads ADD COLUMN media_type TEXT DEFAULT 'audio';
|
||||
|
||||
-- Add pinning support to items table
|
||||
-- Pinned items are protected from cache clear operations
|
||||
ALTER TABLE items ADD COLUMN is_pinned INTEGER DEFAULT 0;
|
||||
|
||||
-- Index for efficiently finding pinned items
|
||||
CREATE INDEX IF NOT EXISTS idx_items_pinned ON items(is_pinned) WHERE is_pinned = 1;
|
||||
|
||||
-- Index for efficiently querying downloads by series
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_series ON downloads(series_name) WHERE series_name IS NOT NULL;
|
||||
|
||||
-- Index for filtering by media type
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_media_type ON downloads(media_type);
|
||||
"#;
|
||||
|
||||
/// Migration to add people/cast caching support
|
||||
/// - Creates people table for caching actor/director/writer/etc info
|
||||
/// - Creates item_people junction table for many-to-many relationships
|
||||
/// - Adds indexes for efficient queries
|
||||
const MIGRATION_009: &str = r#"
|
||||
-- People table for caching cast/crew members
|
||||
CREATE TABLE IF NOT EXISTS people (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
overview TEXT,
|
||||
primary_image_tag TEXT,
|
||||
premiere_date TEXT, -- Birth date
|
||||
end_date TEXT, -- Death date
|
||||
synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(server_id, id)
|
||||
);
|
||||
|
||||
-- Item-Person association table (many-to-many)
|
||||
-- Stores which people appear in which items, along with role info
|
||||
CREATE TABLE IF NOT EXISTS item_people (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id TEXT NOT NULL,
|
||||
person_id TEXT NOT NULL,
|
||||
server_id TEXT NOT NULL,
|
||||
person_type TEXT NOT NULL, -- Actor, Director, Writer, Producer, Composer, etc.
|
||||
role TEXT, -- Character name for actors
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(item_id, person_id, person_type)
|
||||
);
|
||||
|
||||
-- Indexes for efficient queries
|
||||
CREATE INDEX IF NOT EXISTS idx_people_server ON people(server_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_people_name ON people(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_item_people_item ON item_people(item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_item_people_person ON item_people(person_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_item_people_type ON item_people(person_type);
|
||||
"#;
|
||||
|
||||
/// Migration to add playback context tracking
|
||||
/// - Adds playback_context_type column to track if user played a container or single item
|
||||
/// - Adds playback_context_id column to store the container ID (album/playlist)
|
||||
/// - Adds index for efficient recently played queries
|
||||
const MIGRATION_010: &str = r#"
|
||||
-- Add playback context tracking to user_data
|
||||
-- Tracks whether user played a container (album/playlist) or single item
|
||||
ALTER TABLE user_data ADD COLUMN playback_context_type TEXT;
|
||||
ALTER TABLE user_data ADD COLUMN playback_context_id TEXT;
|
||||
|
||||
-- Index for efficient recently played queries
|
||||
CREATE INDEX IF NOT EXISTS idx_user_data_last_played
|
||||
ON user_data(user_id, last_played_at DESC)
|
||||
WHERE last_played_at IS NOT NULL;
|
||||
"#;
|
||||
|
||||
/// Migration to add user-specific player settings
|
||||
/// - Creates user_player_settings table for autoplay and audio preferences
|
||||
/// - Note: Sleep timer state is NOT persisted (cancelled on app close)
|
||||
/// - Autoplay settings control next episode behavior
|
||||
/// - Audio settings for crossfade, gapless playback, and volume normalization
|
||||
const MIGRATION_011: &str = r#"
|
||||
-- User-specific player settings (autoplay and audio settings)
|
||||
-- Sleep timer is NOT persisted here (maintained in-memory only)
|
||||
CREATE TABLE IF NOT EXISTS user_player_settings (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
-- Autoplay settings
|
||||
autoplay_next_episode INTEGER DEFAULT 1, -- 1 = enabled, 0 = disabled
|
||||
autoplay_countdown_seconds INTEGER DEFAULT 10, -- 5-30 seconds
|
||||
|
||||
-- Audio settings (crossfade, normalization)
|
||||
crossfade_duration REAL DEFAULT 0.0, -- 0-12 seconds
|
||||
gapless_playback INTEGER DEFAULT 1,
|
||||
normalize_volume INTEGER DEFAULT 0,
|
||||
volume_level TEXT DEFAULT 'normal', -- 'loud', 'normal', 'quiet'
|
||||
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Index for efficient user settings lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_user_player_settings_user ON user_player_settings(user_id);
|
||||
"#;
|
||||
|
||||
/// Migration to track download source (user-initiated vs auto-cached)
|
||||
/// - Adds download_source column to distinguish manual downloads from auto-caching
|
||||
/// - Enables color-coded UI display
|
||||
const MIGRATION_012: &str = r#"
|
||||
-- Add download source tracking
|
||||
-- Values: 'user' (explicit download), 'auto' (smart cache/queue precache)
|
||||
ALTER TABLE downloads ADD COLUMN download_source TEXT DEFAULT 'user';
|
||||
|
||||
-- Index for filtering by source
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_source ON downloads(download_source);
|
||||
"#;
|
||||
|
||||
/// Migration to add composite index for offline mode filtering
|
||||
/// - Adds index on (item_id, status) for efficient JOIN queries in OfflineRepository
|
||||
/// - Significantly improves performance when filtering items by download status
|
||||
const MIGRATION_013: &str = r#"
|
||||
-- Add composite index for offline mode filtering
|
||||
-- This speeds up queries that join items with downloads to show only downloaded content
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_item_status ON downloads(item_id, status);
|
||||
"#;
|
||||
|
||||
/// Migration to add series audio track preferences
|
||||
/// - Stores user's preferred audio track per series
|
||||
/// - Matches tracks by display title and language across episodes
|
||||
/// - Falls back to default track if preferred track not found
|
||||
const MIGRATION_014: &str = r#"
|
||||
-- Series-specific audio track preferences
|
||||
-- When user changes audio track for an episode, remember preference for the series
|
||||
CREATE TABLE IF NOT EXISTS series_audio_preferences (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
series_id TEXT NOT NULL,
|
||||
server_id TEXT NOT NULL,
|
||||
|
||||
-- Audio track info for matching across episodes
|
||||
audio_track_display_title TEXT,
|
||||
audio_track_language TEXT,
|
||||
audio_track_index INTEGER,
|
||||
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
PRIMARY KEY (user_id, series_id, server_id)
|
||||
);
|
||||
|
||||
-- Index for efficient lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_series_audio_prefs_user_series
|
||||
ON series_audio_preferences(user_id, series_id);
|
||||
"#;
|
||||
Reference in New Issue
Block a user