diff --git a/crates/pinakes-core/Cargo.toml b/crates/pinakes-core/Cargo.toml index 60e6917..98d825b 100644 --- a/crates/pinakes-core/Cargo.toml +++ b/crates/pinakes-core/Cargo.toml @@ -46,5 +46,8 @@ image_hasher = { workspace = true } pinakes-plugin-api.workspace = true wasmtime.workspace = true +[lints] +workspace = true + [dev-dependencies] tempfile = "3.25.0" diff --git a/crates/pinakes-core/src/books.rs b/crates/pinakes-core/src/books.rs index c287a1c..92eb174 100644 --- a/crates/pinakes-core/src/books.rs +++ b/crates/pinakes-core/src/books.rs @@ -132,7 +132,9 @@ pub fn parse_author_file_as(name: &str) -> String { 1 => parts[0].to_string(), _ => { // Last part is surname, rest is given names - let surname = parts.last().unwrap(); + let Some(surname) = parts.last() else { + return String::new(); + }; let given_names = parts[..parts.len() - 1].join(" "); format!("{}, {}", surname, given_names) }, diff --git a/crates/pinakes-core/src/enrichment/musicbrainz.rs b/crates/pinakes-core/src/enrichment/musicbrainz.rs index 77669d2..1b315a6 100644 --- a/crates/pinakes-core/src/enrichment/musicbrainz.rs +++ b/crates/pinakes-core/src/enrichment/musicbrainz.rs @@ -117,7 +117,10 @@ impl MetadataEnricher for MusicBrainzEnricher { return Ok(None); } - let recording = &recordings.unwrap()[0]; + let Some(recordings) = recordings else { + return Ok(None); + }; + let recording = &recordings[0]; let external_id = recording .get("id") .and_then(|id| id.as_str()) diff --git a/crates/pinakes-core/src/enrichment/tmdb.rs b/crates/pinakes-core/src/enrichment/tmdb.rs index ed04da8..3a5575e 100644 --- a/crates/pinakes-core/src/enrichment/tmdb.rs +++ b/crates/pinakes-core/src/enrichment/tmdb.rs @@ -89,7 +89,10 @@ impl MetadataEnricher for TmdbEnricher { return Ok(None); } - let movie = &results.unwrap()[0]; + let Some(results) = results else { + return Ok(None); + }; + let movie = &results[0]; let external_id = match movie.get("id").and_then(|id| id.as_i64()) { Some(id) => id.to_string(), None => return Ok(None), diff --git a/crates/pinakes-core/src/events.rs b/crates/pinakes-core/src/events.rs index 8b896dd..9fd52d8 100644 --- a/crates/pinakes-core/src/events.rs +++ b/crates/pinakes-core/src/events.rs @@ -77,17 +77,22 @@ pub fn detect_events( return Ok(Vec::new()); } - // Sort by date_taken - items.sort_by_key(|a| a.date_taken.unwrap()); + // Sort by date_taken (None < Some, but all are Some after retain) + items.sort_by_key(|a| a.date_taken); let mut events: Vec = Vec::new(); + let Some(first_date) = items[0].date_taken else { + return Ok(Vec::new()); + }; let mut current_event_items: Vec = vec![items[0].id]; - let mut current_start_time = items[0].date_taken.unwrap(); - let mut current_last_time = items[0].date_taken.unwrap(); + let mut current_start_time = first_date; + let mut current_last_time = first_date; let mut current_location = items[0].latitude.zip(items[0].longitude); for item in items.iter().skip(1) { - let item_time = item.date_taken.unwrap(); + let Some(item_time) = item.date_taken else { + continue; + }; let time_gap = (item_time - current_last_time).num_seconds(); // Check time gap @@ -180,15 +185,20 @@ pub fn detect_bursts( return Ok(Vec::new()); } - // Sort by date_taken - items.sort_by_key(|a| a.date_taken.unwrap()); + // Sort by date_taken (None < Some, but all are Some after retain) + items.sort_by_key(|a| a.date_taken); let mut bursts: Vec> = Vec::new(); + let Some(first_date) = items[0].date_taken else { + return Ok(Vec::new()); + }; let mut current_burst: Vec = vec![items[0].id]; - let mut last_time = items[0].date_taken.unwrap(); + let mut last_time = first_date; for item in items.iter().skip(1) { - let item_time = item.date_taken.unwrap(); + let Some(item_time) = item.date_taken else { + continue; + }; let gap = (item_time - last_time).num_seconds(); if gap <= max_gap_secs { diff --git a/crates/pinakes-core/src/links.rs b/crates/pinakes-core/src/links.rs index 986a850..bdcdca7 100644 --- a/crates/pinakes-core/src/links.rs +++ b/crates/pinakes-core/src/links.rs @@ -8,13 +8,26 @@ //! - Link resolution strategies //! - Context extraction for backlink previews -use std::path::Path; +use std::{path::Path, sync::LazyLock}; use regex::Regex; use uuid::Uuid; use crate::model::{LinkType, MarkdownLink, MediaId}; +// Compile regexes once at startup to avoid recompilation on every call +static WIKILINK_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").expect("valid wikilink regex") +}); + +static EMBED_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").expect("valid embed regex") +}); + +static MARKDOWN_LINK_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").expect("valid markdown link regex") +}); + /// Configuration for context extraction around links const CONTEXT_CHARS_BEFORE: usize = 50; const CONTEXT_CHARS_AFTER: usize = 50; @@ -50,13 +63,13 @@ fn extract_wikilinks( source_media_id: MediaId, content: &str, ) -> Vec { - // Match [[...]] - we'll manually filter out embeds that are preceded by ! - let re = Regex::new(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").unwrap(); let mut links = Vec::new(); for (line_num, line) in content.lines().enumerate() { - for cap in re.captures_iter(line) { - let full_match = cap.get(0).unwrap(); + for cap in WIKILINK_RE.captures_iter(line) { + let Some(full_match) = cap.get(0) else { + continue; + }; let match_start = full_match.start(); // Check if preceded by ! (which would make it an embed, not a wikilink) @@ -67,7 +80,10 @@ fn extract_wikilinks( } } - let target = cap.get(1).unwrap().as_str().trim(); + let Some(target_match) = cap.get(1) else { + continue; + }; + let target = target_match.as_str().trim(); let display_text = cap.get(2).map(|m| m.as_str().trim().to_string()); let context = extract_context( @@ -100,13 +116,17 @@ fn extract_embeds( source_media_id: MediaId, content: &str, ) -> Vec { - let re = Regex::new(r"!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").unwrap(); let mut links = Vec::new(); for (line_num, line) in content.lines().enumerate() { - for cap in re.captures_iter(line) { - let full_match = cap.get(0).unwrap(); - let target = cap.get(1).unwrap().as_str().trim(); + for cap in EMBED_RE.captures_iter(line) { + let Some(full_match) = cap.get(0) else { + continue; + }; + let Some(target_match) = cap.get(1) else { + continue; + }; + let target = target_match.as_str().trim(); let display_text = cap.get(2).map(|m| m.as_str().trim().to_string()); let context = extract_context( @@ -139,13 +159,13 @@ fn extract_markdown_links( source_media_id: MediaId, content: &str, ) -> Vec { - // Match [text](path) where path doesn't start with http:// or https:// - let re = Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap(); let mut links = Vec::new(); for (line_num, line) in content.lines().enumerate() { - for cap in re.captures_iter(line) { - let full_match = cap.get(0).unwrap(); + for cap in MARKDOWN_LINK_RE.captures_iter(line) { + let Some(full_match) = cap.get(0) else { + continue; + }; let match_start = full_match.start(); // Skip markdown images: ![alt](image.png) @@ -155,8 +175,14 @@ fn extract_markdown_links( continue; } - let text = cap.get(1).unwrap().as_str().trim(); - let path = cap.get(2).unwrap().as_str().trim(); + let Some(text_match) = cap.get(1) else { + continue; + }; + let Some(path_match) = cap.get(2) else { + continue; + }; + let text = text_match.as_str().trim(); + let path = path_match.as_str().trim(); // Skip external links if path.starts_with("http://") diff --git a/crates/pinakes-core/src/storage/postgres.rs b/crates/pinakes-core/src/storage/postgres.rs index 9f57fb0..b2c934b 100644 --- a/crates/pinakes-core/src/storage/postgres.rs +++ b/crates/pinakes-core/src/storage/postgres.rs @@ -20,6 +20,15 @@ pub struct PostgresBackend { pool: Pool, } +/// Escape special LIKE pattern characters (`%`, `_`, `\`) in user input +/// to prevent wildcard injection. +fn escape_like_pattern(input: &str) -> String { + input + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_") +} + impl PostgresBackend { pub async fn new(config: &PostgresConfig) -> Result { let mut pool_config = PoolConfig::new(); @@ -335,7 +344,7 @@ fn build_search_inner( params.push(Box::new(text.clone())); params.push(Box::new(prefix_query)); - params.push(Box::new(format!("%{}%", text))); + params.push(Box::new(format!("%{}%", escape_like_pattern(&text)))); params.push(Box::new(text.clone())); params.push(Box::new(text.clone())); params.push(Box::new(text.clone())); @@ -377,7 +386,7 @@ fn build_search_inner( params.push(Box::new(term.clone())); params.push(Box::new(term.clone())); params.push(Box::new(term.clone())); - params.push(Box::new(format!("%{}%", term))); + params.push(Box::new(format!("%{}%", escape_like_pattern(&term)))); Ok(format!( "(similarity(COALESCE(title, ''), ${idx_title}) > 0.3 OR \ similarity(COALESCE(artist, ''), ${idx_artist}) > 0.3 OR \ @@ -1086,6 +1095,88 @@ impl StorageBackend for PostgresBackend { Ok(rows) } + async fn batch_update_media( + &self, + ids: &[MediaId], + title: Option<&str>, + artist: Option<&str>, + album: Option<&str>, + genre: Option<&str>, + year: Option, + description: Option<&str>, + ) -> Result { + if ids.is_empty() { + return Ok(0); + } + + // Build SET clause dynamically from provided fields + let mut set_parts = Vec::new(); + let mut params: Vec> = + Vec::new(); + let mut idx = 1; + + if let Some(v) = title { + set_parts.push(format!("title = ${idx}")); + params.push(Box::new(v.to_string())); + idx += 1; + } + if let Some(v) = artist { + set_parts.push(format!("artist = ${idx}")); + params.push(Box::new(v.to_string())); + idx += 1; + } + if let Some(v) = album { + set_parts.push(format!("album = ${idx}")); + params.push(Box::new(v.to_string())); + idx += 1; + } + if let Some(v) = genre { + set_parts.push(format!("genre = ${idx}")); + params.push(Box::new(v.to_string())); + idx += 1; + } + if let Some(v) = year { + set_parts.push(format!("year = ${idx}")); + params.push(Box::new(v)); + idx += 1; + } + if let Some(v) = description { + set_parts.push(format!("description = ${idx}")); + params.push(Box::new(v.to_string())); + idx += 1; + } + + // Always update updated_at + let now = chrono::Utc::now(); + set_parts.push(format!("updated_at = ${idx}")); + params.push(Box::new(now)); + idx += 1; + + if set_parts.len() == 1 { + return Ok(0); + } + + let uuids: Vec = ids.iter().map(|id| id.0).collect(); + let sql = format!( + "UPDATE media_items SET {} WHERE id = ANY(${idx})", + set_parts.join(", ") + ); + params.push(Box::new(uuids)); + + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = params + .iter() + .map(|p| p.as_ref() as &(dyn tokio_postgres::types::ToSql + Sync)) + .collect(); + let rows = client.execute(&sql, ¶m_refs).await?; + Ok(rows) + } + // Tags async fn create_tag( &self, @@ -4186,11 +4277,9 @@ impl StorageBackend for PostgresBackend { ) .await?; - if row.is_none() { + let Some(row) = row else { return Ok(None); - } - - let row = row.unwrap(); + }; // Get authors let author_rows = client @@ -4552,9 +4641,9 @@ impl StorageBackend for PostgresBackend { let rows = if let (Some(i), Some(a), Some(s), Some(p), Some(l)) = (isbn, author, series, publisher, language) { - let author_pattern = format!("%{}%", a); - let series_pattern = format!("%{}%", s); - let publisher_pattern = format!("%{}%", p); + let author_pattern = format!("%{}%", escape_like_pattern(a)); + let series_pattern = format!("%{}%", escape_like_pattern(s)); + let publisher_pattern = format!("%{}%", escape_like_pattern(p)); client .query( "SELECT DISTINCT m.id, m.path, m.file_name, m.media_type, \ diff --git a/crates/pinakes-core/src/storage/sqlite.rs b/crates/pinakes-core/src/storage/sqlite.rs index 6b47a87..4b33ef1 100644 --- a/crates/pinakes-core/src/storage/sqlite.rs +++ b/crates/pinakes-core/src/storage/sqlite.rs @@ -736,7 +736,8 @@ impl StorageBackend for SqliteBackend { item.created_at.to_rfc3339(), item.updated_at.to_rfc3339(), ], - )?; + ) + .map_err(crate::error::db_ctx("insert_media", &item.id))?; Ok(()) }) .await @@ -907,42 +908,45 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let changed = db.execute( - "UPDATE media_items SET path = ?2, file_name = ?3, media_type = ?4, \ - content_hash = ?5, file_size = ?6, title = ?7, artist = ?8, album = \ - ?9, genre = ?10, year = ?11, duration_secs = ?12, description = ?13, \ - thumbnail_path = ?14, file_mtime = ?15, date_taken = ?16, latitude = \ - ?17, longitude = ?18, camera_make = ?19, camera_model = ?20, rating \ - = ?21, perceptual_hash = ?22, updated_at = ?23 WHERE id = ?1", - params![ - item.id.0.to_string(), - item.path.to_string_lossy().as_ref(), - item.file_name, - media_type_to_str(&item.media_type), - item.content_hash.0, - item.file_size as i64, - item.title, - item.artist, - item.album, - item.genre, - item.year, - item.duration_secs, - item.description, - item - .thumbnail_path - .as_ref() - .map(|p| p.to_string_lossy().to_string()), - item.file_mtime, - item.date_taken.as_ref().map(|d| d.to_rfc3339()), - item.latitude, - item.longitude, - item.camera_make, - item.camera_model, - item.rating, - item.perceptual_hash, - item.updated_at.to_rfc3339(), - ], - )?; + let changed = db + .execute( + "UPDATE media_items SET path = ?2, file_name = ?3, media_type = ?4, \ + content_hash = ?5, file_size = ?6, title = ?7, artist = ?8, album \ + = ?9, genre = ?10, year = ?11, duration_secs = ?12, description = \ + ?13, thumbnail_path = ?14, file_mtime = ?15, date_taken = ?16, \ + latitude = ?17, longitude = ?18, camera_make = ?19, camera_model = \ + ?20, rating = ?21, perceptual_hash = ?22, updated_at = ?23 WHERE \ + id = ?1", + params![ + item.id.0.to_string(), + item.path.to_string_lossy().as_ref(), + item.file_name, + media_type_to_str(&item.media_type), + item.content_hash.0, + item.file_size as i64, + item.title, + item.artist, + item.album, + item.genre, + item.year, + item.duration_secs, + item.description, + item + .thumbnail_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()), + item.file_mtime, + item.date_taken.as_ref().map(|d| d.to_rfc3339()), + item.latitude, + item.longitude, + item.camera_make, + item.camera_model, + item.rating, + item.perceptual_hash, + item.updated_at.to_rfc3339(), + ], + ) + .map_err(crate::error::db_ctx("update_media", &item.id))?; if changed == 0 { return Err(PinakesError::NotFound(format!("media item {}", item.id))); } @@ -961,7 +965,8 @@ impl StorageBackend for SqliteBackend { let changed = db .execute("DELETE FROM media_items WHERE id = ?1", params![ id.0.to_string() - ])?; + ]) + .map_err(crate::error::db_ctx("delete_media", id))?; if changed == 0 { return Err(PinakesError::NotFound(format!("media item {id}"))); } @@ -1604,16 +1609,17 @@ impl StorageBackend for SqliteBackend { if ids.is_empty() { return Ok(0); } + let n = ids.len(); let ids: Vec = ids.iter().map(|id| id.0.to_string()).collect(); let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - // Use IN clause for batch delete - much faster than individual deletes - // SQLite has a limit of ~500-1000 items in IN clause, so chunk if needed const CHUNK_SIZE: usize = 500; - db.execute_batch("BEGIN IMMEDIATE")?; + let ctx = format!("{n} items"); + db.execute_batch("BEGIN IMMEDIATE") + .map_err(crate::error::db_ctx("batch_delete_media", &ctx))?; let mut count = 0u64; for chunk in ids.chunks(CHUNK_SIZE) { let placeholders: Vec = @@ -1624,10 +1630,13 @@ impl StorageBackend for SqliteBackend { ); let params: Vec<&dyn rusqlite::ToSql> = chunk.iter().map(|s| s as &dyn rusqlite::ToSql).collect(); - let rows = db.execute(&sql, params.as_slice())?; + let rows = db + .execute(&sql, params.as_slice()) + .map_err(crate::error::db_ctx("batch_delete_media", &ctx))?; count += rows as u64; } - db.execute_batch("COMMIT")?; + db.execute_batch("COMMIT") + .map_err(crate::error::db_ctx("batch_delete_media", &ctx))?; Ok(count) }) .await @@ -1670,6 +1679,113 @@ impl StorageBackend for SqliteBackend { .map_err(|e| PinakesError::Database(e.to_string()))? } + async fn batch_update_media( + &self, + ids: &[MediaId], + title: Option<&str>, + artist: Option<&str>, + album: Option<&str>, + genre: Option<&str>, + year: Option, + description: Option<&str>, + ) -> Result { + if ids.is_empty() { + return Ok(0); + } + let ids: Vec = ids.iter().map(|id| id.0.to_string()).collect(); + let title = title.map(String::from); + let artist = artist.map(String::from); + let album = album.map(String::from); + let genre = genre.map(String::from); + let description = description.map(String::from); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + + // Build SET clause dynamically from provided fields + let mut set_parts = Vec::new(); + let mut params_vec: Vec> = Vec::new(); + let mut idx = 1; + + if let Some(ref v) = title { + set_parts.push(format!("title = ?{idx}")); + params_vec.push(Box::new(v.clone())); + idx += 1; + } + if let Some(ref v) = artist { + set_parts.push(format!("artist = ?{idx}")); + params_vec.push(Box::new(v.clone())); + idx += 1; + } + if let Some(ref v) = album { + set_parts.push(format!("album = ?{idx}")); + params_vec.push(Box::new(v.clone())); + idx += 1; + } + if let Some(ref v) = genre { + set_parts.push(format!("genre = ?{idx}")); + params_vec.push(Box::new(v.clone())); + idx += 1; + } + if let Some(v) = year { + set_parts.push(format!("year = ?{idx}")); + params_vec.push(Box::new(v)); + idx += 1; + } + if let Some(ref v) = description { + set_parts.push(format!("description = ?{idx}")); + params_vec.push(Box::new(v.clone())); + idx += 1; + } + + // Always update updated_at + let now = chrono::Utc::now().to_rfc3339(); + set_parts.push(format!("updated_at = ?{idx}")); + params_vec.push(Box::new(now)); + idx += 1; + + if set_parts.len() == 1 { + // Only updated_at, nothing to change + return Ok(0); + } + + const CHUNK_SIZE: usize = 500; + let ctx = format!("{} items", ids.len()); + db.execute_batch("BEGIN IMMEDIATE") + .map_err(crate::error::db_ctx("batch_update_media", &ctx))?; + + let mut count = 0u64; + for chunk in ids.chunks(CHUNK_SIZE) { + let id_placeholders: Vec = + (0..chunk.len()).map(|i| format!("?{}", idx + i)).collect(); + let sql = format!( + "UPDATE media_items SET {} WHERE id IN ({})", + set_parts.join(", "), + id_placeholders.join(", ") + ); + + let mut all_params: Vec<&dyn rusqlite::ToSql> = + params_vec.iter().map(|p| p.as_ref()).collect(); + let id_params: Vec<&dyn rusqlite::ToSql> = + chunk.iter().map(|s| s as &dyn rusqlite::ToSql).collect(); + all_params.extend(id_params); + + let rows = db + .execute(&sql, all_params.as_slice()) + .map_err(crate::error::db_ctx("batch_update_media", &ctx))?; + count += rows as u64; + } + + db.execute_batch("COMMIT") + .map_err(crate::error::db_ctx("batch_update_media", &ctx))?; + Ok(count) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + async fn find_duplicates(&self) -> Result>> { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { @@ -4810,7 +4926,7 @@ impl StorageBackend for SqliteBackend { let identifiers = metadata.identifiers.clone(); let fut = tokio::task::spawn_blocking(move || { - let mut conn = conn.lock().unwrap(); + let mut conn = conn.lock().expect("connection mutex not poisoned"); let tx = conn.transaction()?; // Upsert book_metadata @@ -4875,7 +4991,7 @@ impl StorageBackend for SqliteBackend { } tx.commit()?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }); tokio::time::timeout(std::time::Duration::from_secs(30), fut) @@ -4897,7 +5013,7 @@ impl StorageBackend for SqliteBackend { let media_id_str = media_id.to_string(); let fut = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); // Get base book metadata let metadata_row = conn @@ -4925,11 +5041,7 @@ impl StorageBackend for SqliteBackend { ) .optional()?; - if metadata_row.is_none() { - return Ok::<_, rusqlite::Error>(None); - } - - let ( + let Some(( isbn, isbn13, publisher, @@ -4941,7 +5053,10 @@ impl StorageBackend for SqliteBackend { format, created_at, updated_at, - ) = metadata_row.unwrap(); + )) = metadata_row + else { + return Ok::<_, PinakesError>(None); + }; // Get authors let mut stmt = conn.prepare( @@ -4990,10 +5105,14 @@ impl StorageBackend for SqliteBackend { authors, identifiers, created_at: chrono::DateTime::parse_from_rfc3339(&created_at) - .unwrap() + .map_err(|e| { + PinakesError::Database(format!("invalid datetime in database: {e}")) + })? .with_timezone(&chrono::Utc), updated_at: chrono::DateTime::parse_from_rfc3339(&updated_at) - .unwrap() + .map_err(|e| { + PinakesError::Database(format!("invalid datetime in database: {e}")) + })? .with_timezone(&chrono::Utc), })) }); @@ -5020,7 +5139,7 @@ impl StorageBackend for SqliteBackend { let author_clone = author.clone(); let fut = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "INSERT INTO book_authors (media_id, author_name, author_sort, role, \ position) @@ -5035,7 +5154,7 @@ impl StorageBackend for SqliteBackend { author_clone.position ], )?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }); tokio::time::timeout(std::time::Duration::from_secs(5), fut) @@ -5055,7 +5174,7 @@ impl StorageBackend for SqliteBackend { let media_id_str = media_id.to_string(); let fut = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let mut stmt = conn.prepare( "SELECT author_name, author_sort, role, position FROM book_authors WHERE media_id = ?1 ORDER BY position", @@ -5070,7 +5189,7 @@ impl StorageBackend for SqliteBackend { }) })? .collect::>>()?; - Ok::<_, rusqlite::Error>(authors) + Ok::<_, PinakesError>(authors) }); Ok( @@ -5094,7 +5213,7 @@ impl StorageBackend for SqliteBackend { let limit = pagination.limit; let fut = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let mut stmt = conn.prepare( "SELECT author_name, COUNT(DISTINCT media_id) as book_count FROM book_authors @@ -5107,7 +5226,7 @@ impl StorageBackend for SqliteBackend { Ok((row.get(0)?, row.get::<_, i64>(1)? as u64)) })? .collect::>>()?; - Ok::<_, rusqlite::Error>(authors) + Ok::<_, PinakesError>(authors) }); Ok( @@ -5126,7 +5245,7 @@ impl StorageBackend for SqliteBackend { let conn = self.conn.clone(); let fut = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let mut stmt = conn.prepare( "SELECT series_name, COUNT(*) as book_count FROM book_metadata @@ -5137,7 +5256,7 @@ impl StorageBackend for SqliteBackend { let series: Vec<(String, u64)> = stmt .query_map([], |row| Ok((row.get(0)?, row.get::<_, i64>(1)? as u64)))? .collect::>>()?; - Ok::<_, rusqlite::Error>(series) + Ok::<_, PinakesError>(series) }); Ok( @@ -5158,7 +5277,7 @@ impl StorageBackend for SqliteBackend { let series = series_name.to_string(); let fut = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let mut stmt = conn.prepare( "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, \ @@ -5174,7 +5293,7 @@ impl StorageBackend for SqliteBackend { let items = stmt .query_map([&series], row_to_media_item)? .collect::>>()?; - Ok::<_, rusqlite::Error>(items) + Ok::<_, PinakesError>(items) }); Ok( @@ -5201,7 +5320,7 @@ impl StorageBackend for SqliteBackend { let media_id_str = media_id.to_string(); let fut = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "INSERT INTO watch_history (user_id, media_id, progress_secs, \ last_watched_at) @@ -5210,7 +5329,7 @@ impl StorageBackend for SqliteBackend { progress_secs = ?3, last_watched_at = datetime('now')", rusqlite::params![user_id_str, media_id_str, current_page as f64], )?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }); tokio::time::timeout(std::time::Duration::from_secs(5), fut) @@ -5234,7 +5353,7 @@ impl StorageBackend for SqliteBackend { let media_id_str = media_id.to_string(); let fut = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let result = conn .query_row( "SELECT wh.progress_secs, bm.page_count, wh.last_watched_at @@ -5251,9 +5370,17 @@ impl StorageBackend for SqliteBackend { ) .optional()?; - Ok::<_, rusqlite::Error>(result.map( - |(current_page, total_pages, last_read_str)| { - crate::model::ReadingProgress { + let progress = match result { + Some((current_page, total_pages, last_read_str)) => { + let last_read_at = + chrono::DateTime::parse_from_rfc3339(&last_read_str) + .map_err(|e| { + PinakesError::Database(format!( + "invalid datetime in database: {e}" + )) + })? + .with_timezone(&chrono::Utc); + Some(crate::model::ReadingProgress { media_id, user_id, current_page, @@ -5267,12 +5394,12 @@ impl StorageBackend for SqliteBackend { } else { 0.0 }, - last_read_at: chrono::DateTime::parse_from_rfc3339(&last_read_str) - .unwrap() - .with_timezone(&chrono::Utc), - } + last_read_at, + }) }, - )) + None => None, + }; + Ok::<_, PinakesError>(progress) }); Ok( @@ -5296,7 +5423,7 @@ impl StorageBackend for SqliteBackend { let user_id_str = user_id.to_string(); let fut = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); // Query books with reading progress for this user // Join with book_metadata to get page counts and media_items for the @@ -5354,7 +5481,7 @@ impl StorageBackend for SqliteBackend { Err(_) => continue, } } - Ok::<_, rusqlite::Error>(results) + Ok::<_, PinakesError>(results) }); Ok( @@ -5389,7 +5516,7 @@ impl StorageBackend for SqliteBackend { let limit = pagination.limit; let fut = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let mut query = String::from( "SELECT DISTINCT m.id, m.path, m.file_name, m.media_type, \ @@ -5445,7 +5572,7 @@ impl StorageBackend for SqliteBackend { let items = stmt .query_map(&*params_refs, row_to_media_item)? .collect::>>()?; - Ok::<_, rusqlite::Error>(items) + Ok::<_, PinakesError>(items) }); Ok( @@ -5462,7 +5589,7 @@ impl StorageBackend for SqliteBackend { let conn = self.conn.clone(); let item = item.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "INSERT INTO media_items (id, path, file_name, media_type, \ content_hash, file_size, @@ -5498,7 +5625,7 @@ impl StorageBackend for SqliteBackend { item.updated_at.to_rfc3339(), ], )?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| { @@ -5519,7 +5646,7 @@ impl StorageBackend for SqliteBackend { let now = chrono::Utc::now().to_rfc3339(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); // Try to get existing blob let existing = conn @@ -5573,7 +5700,7 @@ impl StorageBackend for SqliteBackend { let hash_str = hash.0.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn .query_row( "SELECT content_hash, file_size, mime_type, reference_count, \ @@ -5605,13 +5732,13 @@ impl StorageBackend for SqliteBackend { let hash_str = hash.0.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "UPDATE managed_blobs SET reference_count = reference_count + 1 WHERE \ content_hash = ?1", params![&hash_str], )?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| { @@ -5625,7 +5752,7 @@ impl StorageBackend for SqliteBackend { let hash_str = hash.0.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "UPDATE managed_blobs SET reference_count = reference_count - 1 WHERE \ content_hash = ?1", @@ -5641,7 +5768,7 @@ impl StorageBackend for SqliteBackend { ) .unwrap_or(0); - Ok::<_, rusqlite::Error>(count <= 0) + Ok::<_, PinakesError>(count <= 0) }) .await .map_err(|e| PinakesError::Database(format!("decrement_blob_ref: {}", e)))? @@ -5656,12 +5783,12 @@ impl StorageBackend for SqliteBackend { let now = chrono::Utc::now().to_rfc3339(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "UPDATE managed_blobs SET last_verified = ?1 WHERE content_hash = ?2", params![&now, &hash_str], )?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| { @@ -5674,7 +5801,7 @@ impl StorageBackend for SqliteBackend { let conn = self.conn.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let mut stmt = conn.prepare( "SELECT content_hash, file_size, mime_type, reference_count, \ stored_at, last_verified @@ -5694,7 +5821,7 @@ impl StorageBackend for SqliteBackend { }) })? .collect::>>()?; - Ok::<_, rusqlite::Error>(blobs) + Ok::<_, PinakesError>(blobs) }) .await .map_err(|e| PinakesError::Database(format!("list_orphaned_blobs: {}", e)))? @@ -5708,12 +5835,12 @@ impl StorageBackend for SqliteBackend { let hash_str = hash.0.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "DELETE FROM managed_blobs WHERE content_hash = ?1", params![&hash_str], )?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| PinakesError::Database(format!("delete_blob: {}", e)))??; @@ -5724,7 +5851,7 @@ impl StorageBackend for SqliteBackend { let conn = self.conn.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let total_blobs: u64 = conn.query_row("SELECT COUNT(*) FROM managed_blobs", [], |row| { @@ -5762,7 +5889,7 @@ impl StorageBackend for SqliteBackend { 1.0 }; - Ok::<_, rusqlite::Error>(ManagedStorageStats { + Ok::<_, PinakesError>(ManagedStorageStats { total_blobs, total_size_bytes: total_size, unique_size_bytes: unique_size, @@ -5790,7 +5917,7 @@ impl StorageBackend for SqliteBackend { let token_hash = token_hash.to_string(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "INSERT INTO sync_devices (id, user_id, name, device_type, \ client_version, os_info, @@ -5812,7 +5939,7 @@ impl StorageBackend for SqliteBackend { device.updated_at.to_rfc3339(), ], )?; - Ok::<_, rusqlite::Error>(device) + Ok::<_, PinakesError>(device) }) .await .map_err(|e| PinakesError::Database(format!("register_device: {}", e)))? @@ -5828,7 +5955,7 @@ impl StorageBackend for SqliteBackend { let conn = self.conn.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.query_row( "SELECT id, user_id, name, device_type, client_version, os_info, last_sync_at, last_seen_at, sync_cursor, enabled, \ @@ -5875,7 +6002,7 @@ impl StorageBackend for SqliteBackend { let token_hash = token_hash.to_string(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn .query_row( "SELECT id, user_id, name, device_type, client_version, os_info, @@ -5925,7 +6052,7 @@ impl StorageBackend for SqliteBackend { let conn = self.conn.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let mut stmt = conn.prepare( "SELECT id, user_id, name, device_type, client_version, os_info, last_sync_at, last_seen_at, sync_cursor, enabled, \ @@ -5960,7 +6087,7 @@ impl StorageBackend for SqliteBackend { }) })? .collect::>>()?; - Ok::<_, rusqlite::Error>(devices) + Ok::<_, PinakesError>(devices) }) .await .map_err(|e| PinakesError::Database(format!("list_user_devices: {}", e)))? @@ -5977,7 +6104,7 @@ impl StorageBackend for SqliteBackend { let device = device.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "UPDATE sync_devices SET name = ?1, device_type = ?2, client_version \ = ?3, @@ -5997,7 +6124,7 @@ impl StorageBackend for SqliteBackend { device.id.0.to_string(), ], )?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| PinakesError::Database(format!("update_device: {}", e)))??; @@ -6008,11 +6135,11 @@ impl StorageBackend for SqliteBackend { let conn = self.conn.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute("DELETE FROM sync_devices WHERE id = ?1", params![ id.0.to_string() ])?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| PinakesError::Database(format!("delete_device: {}", e)))??; @@ -6024,13 +6151,13 @@ impl StorageBackend for SqliteBackend { let now = chrono::Utc::now().to_rfc3339(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "UPDATE sync_devices SET last_seen_at = ?1, updated_at = ?1 WHERE id \ = ?2", params![&now, id.0.to_string()], )?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| PinakesError::Database(format!("touch_device: {}", e)))??; @@ -6045,7 +6172,7 @@ impl StorageBackend for SqliteBackend { let change = change.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); // Get and increment sequence let seq: i64 = conn.query_row( @@ -6073,7 +6200,7 @@ impl StorageBackend for SqliteBackend { change.timestamp.to_rfc3339(), ], )?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| { @@ -6090,7 +6217,7 @@ impl StorageBackend for SqliteBackend { let conn = self.conn.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let mut stmt = conn.prepare( "SELECT id, sequence, change_type, media_id, path, content_hash, file_size, metadata_json, changed_by_device, timestamp @@ -6121,7 +6248,7 @@ impl StorageBackend for SqliteBackend { }) })? .collect::>>()?; - Ok::<_, rusqlite::Error>(entries) + Ok::<_, PinakesError>(entries) }) .await .map_err(|e| PinakesError::Database(format!("get_changes_since: {}", e)))? @@ -6134,7 +6261,7 @@ impl StorageBackend for SqliteBackend { let conn = self.conn.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.query_row( "SELECT current_value FROM sync_sequence WHERE id = 1", [], @@ -6155,7 +6282,7 @@ impl StorageBackend for SqliteBackend { let before_str = before.to_rfc3339(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute("DELETE FROM sync_log WHERE timestamp < ?1", params![ &before_str ]) @@ -6179,7 +6306,7 @@ impl StorageBackend for SqliteBackend { let path = path.to_string(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn .query_row( "SELECT device_id, path, local_hash, server_hash, local_mtime, \ @@ -6227,7 +6354,7 @@ impl StorageBackend for SqliteBackend { let state = state.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "INSERT INTO device_sync_state (device_id, path, local_hash, \ server_hash, @@ -6254,7 +6381,7 @@ impl StorageBackend for SqliteBackend { state.conflict_info_json, ], )?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| { @@ -6270,7 +6397,7 @@ impl StorageBackend for SqliteBackend { let conn = self.conn.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let mut stmt = conn.prepare( "SELECT device_id, path, local_hash, server_hash, local_mtime, \ server_mtime, @@ -6301,7 +6428,7 @@ impl StorageBackend for SqliteBackend { }) })? .collect::>>()?; - Ok::<_, rusqlite::Error>(states) + Ok::<_, PinakesError>(states) }) .await .map_err(|e| PinakesError::Database(format!("list_pending_sync: {}", e)))? @@ -6318,7 +6445,7 @@ impl StorageBackend for SqliteBackend { let session = session.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "INSERT INTO upload_sessions (id, device_id, target_path, \ expected_hash, @@ -6339,7 +6466,7 @@ impl StorageBackend for SqliteBackend { session.last_activity.to_rfc3339(), ], )?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| { @@ -6355,7 +6482,7 @@ impl StorageBackend for SqliteBackend { let conn = self.conn.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.query_row( "SELECT id, device_id, target_path, expected_hash, expected_size, \ chunk_size, @@ -6400,7 +6527,7 @@ impl StorageBackend for SqliteBackend { let session = session.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "UPDATE upload_sessions SET status = ?1, last_activity = ?2 WHERE id \ = ?3", @@ -6410,7 +6537,7 @@ impl StorageBackend for SqliteBackend { session.id.to_string(), ], )?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| { @@ -6428,7 +6555,7 @@ impl StorageBackend for SqliteBackend { let chunk = chunk.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "INSERT INTO upload_chunks (upload_id, chunk_index, offset, size, \ hash, received_at) @@ -6445,7 +6572,7 @@ impl StorageBackend for SqliteBackend { chunk.received_at.to_rfc3339(), ], )?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| PinakesError::Database(format!("record_chunk: {}", e)))??; @@ -6459,7 +6586,7 @@ impl StorageBackend for SqliteBackend { let conn = self.conn.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let mut stmt = conn.prepare( "SELECT upload_id, chunk_index, offset, size, hash, received_at FROM upload_chunks WHERE upload_id = ?1 ORDER BY chunk_index", @@ -6476,7 +6603,7 @@ impl StorageBackend for SqliteBackend { }) })? .collect::>>()?; - Ok::<_, rusqlite::Error>(chunks) + Ok::<_, PinakesError>(chunks) }) .await .map_err(|e| PinakesError::Database(format!("get_upload_chunks: {}", e)))? @@ -6490,7 +6617,7 @@ impl StorageBackend for SqliteBackend { let now = chrono::Utc::now().to_rfc3339(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "DELETE FROM upload_sessions WHERE expires_at < ?1", params![&now], @@ -6514,7 +6641,7 @@ impl StorageBackend for SqliteBackend { let conflict = conflict.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "INSERT INTO sync_conflicts (id, device_id, path, local_hash, \ local_mtime, @@ -6531,7 +6658,7 @@ impl StorageBackend for SqliteBackend { conflict.detected_at.to_rfc3339(), ], )?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| PinakesError::Database(format!("record_conflict: {}", e)))??; @@ -6545,7 +6672,7 @@ impl StorageBackend for SqliteBackend { let conn = self.conn.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let mut stmt = conn.prepare( "SELECT id, device_id, path, local_hash, local_mtime, server_hash, \ server_mtime, @@ -6587,7 +6714,7 @@ impl StorageBackend for SqliteBackend { }) })? .collect::>>()?; - Ok::<_, rusqlite::Error>(conflicts) + Ok::<_, PinakesError>(conflicts) }) .await .map_err(|e| { @@ -6613,13 +6740,13 @@ impl StorageBackend for SqliteBackend { }; tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "UPDATE sync_conflicts SET resolved_at = ?1, resolution = ?2 WHERE id \ = ?3", params![&now, resolution_str, id.to_string()], )?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| { @@ -6636,7 +6763,7 @@ impl StorageBackend for SqliteBackend { let share = share.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let (recipient_type, recipient_user_id, public_token, password_hash) = match &share.recipient { @@ -6697,7 +6824,7 @@ impl StorageBackend for SqliteBackend { share.updated_at.to_rfc3339(), ], )?; - Ok::<_, rusqlite::Error>(share) + Ok::<_, PinakesError>(share) }) .await .map_err(|e| PinakesError::Database(format!("create_share: {}", e)))? @@ -6711,7 +6838,7 @@ impl StorageBackend for SqliteBackend { let conn = self.conn.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.query_row( "SELECT id, target_type, target_id, owner_id, recipient_type, \ recipient_user_id, @@ -6739,7 +6866,7 @@ impl StorageBackend for SqliteBackend { let token = token.to_string(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.query_row( "SELECT id, target_type, target_id, owner_id, recipient_type, \ recipient_user_id, @@ -6771,7 +6898,7 @@ impl StorageBackend for SqliteBackend { let limit = pagination.limit; tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let mut stmt = conn.prepare( "SELECT id, target_type, target_id, owner_id, recipient_type, \ recipient_user_id, @@ -6790,7 +6917,7 @@ impl StorageBackend for SqliteBackend { row_to_share, )? .collect::>>()?; - Ok::<_, rusqlite::Error>(shares) + Ok::<_, PinakesError>(shares) }) .await .map_err(|e| { @@ -6811,7 +6938,7 @@ impl StorageBackend for SqliteBackend { let limit = pagination.limit; tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let mut stmt = conn.prepare( "SELECT id, target_type, target_id, owner_id, recipient_type, \ recipient_user_id, @@ -6830,7 +6957,7 @@ impl StorageBackend for SqliteBackend { row_to_share, )? .collect::>>()?; - Ok::<_, rusqlite::Error>(shares) + Ok::<_, PinakesError>(shares) }) .await .map_err(|e| { @@ -6850,7 +6977,7 @@ impl StorageBackend for SqliteBackend { let target_id = target.target_id().to_string(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let mut stmt = conn.prepare( "SELECT id, target_type, target_id, owner_id, recipient_type, \ recipient_user_id, @@ -6865,7 +6992,7 @@ impl StorageBackend for SqliteBackend { let shares = stmt .query_map(params![&target_type, &target_id], row_to_share)? .collect::>>()?; - Ok::<_, rusqlite::Error>(shares) + Ok::<_, PinakesError>(shares) }) .await .map_err(|e| { @@ -6884,7 +7011,7 @@ impl StorageBackend for SqliteBackend { let share = share.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "UPDATE shares SET perm_view = ?1, perm_download = ?2, perm_edit = ?3, \ @@ -6907,7 +7034,7 @@ impl StorageBackend for SqliteBackend { share.id.0.to_string(), ], )?; - Ok::<_, rusqlite::Error>(share) + Ok::<_, PinakesError>(share) }) .await .map_err(|e| PinakesError::Database(format!("update_share: {}", e)))? @@ -6918,11 +7045,11 @@ impl StorageBackend for SqliteBackend { let conn = self.conn.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute("DELETE FROM shares WHERE id = ?1", params![ id.0.to_string() ])?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| PinakesError::Database(format!("delete_share: {}", e)))??; @@ -6937,13 +7064,13 @@ impl StorageBackend for SqliteBackend { let now = chrono::Utc::now().to_rfc3339(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "UPDATE shares SET access_count = access_count + 1, last_accessed = \ ?1 WHERE id = ?2", params![&now, id.0.to_string()], )?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| { @@ -7005,7 +7132,7 @@ impl StorageBackend for SqliteBackend { let media_id_str = media_id.0.to_string(); let collection_ids: Vec = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let mut stmt = conn.prepare( "SELECT collection_id FROM collection_items WHERE media_id = ?1", )?; @@ -7016,7 +7143,7 @@ impl StorageBackend for SqliteBackend { })? .filter_map(|r| r.ok().flatten()) .collect::>(); - Ok::<_, rusqlite::Error>(ids) + Ok::<_, PinakesError>(ids) }) .await .map_err(|e| { @@ -7044,7 +7171,7 @@ impl StorageBackend for SqliteBackend { let media_id_str = media_id.0.to_string(); let tag_ids: Vec = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let mut stmt = conn.prepare("SELECT tag_id FROM media_tags WHERE media_id = ?1")?; let ids = stmt @@ -7054,7 +7181,7 @@ impl StorageBackend for SqliteBackend { })? .filter_map(|r| r.ok().flatten()) .collect::>(); - Ok::<_, rusqlite::Error>(ids) + Ok::<_, PinakesError>(ids) }) .await .map_err(|e| { @@ -7093,7 +7220,7 @@ impl StorageBackend for SqliteBackend { } tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let placeholders: Vec = (1..=id_strings.len()).map(|i| format!("?{}", i)).collect(); let sql = format!( @@ -7119,7 +7246,7 @@ impl StorageBackend for SqliteBackend { let now = chrono::Utc::now().to_rfc3339(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "DELETE FROM shares WHERE expires_at IS NOT NULL AND expires_at < ?1", params![&now], @@ -7143,7 +7270,7 @@ impl StorageBackend for SqliteBackend { let activity = activity.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "INSERT INTO share_activity (id, share_id, actor_id, actor_ip, \ action, details, timestamp) @@ -7158,7 +7285,7 @@ impl StorageBackend for SqliteBackend { activity.timestamp.to_rfc3339(), ], )?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| { @@ -7177,7 +7304,7 @@ impl StorageBackend for SqliteBackend { let limit = pagination.limit; tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let mut stmt = conn.prepare( "SELECT id, share_id, actor_id, actor_ip, action, details, timestamp FROM share_activity WHERE share_id = ?1 ORDER BY timestamp \ @@ -7206,7 +7333,7 @@ impl StorageBackend for SqliteBackend { }, )? .collect::>>()?; - Ok::<_, rusqlite::Error>(activities) + Ok::<_, PinakesError>(activities) }) .await .map_err(|e| PinakesError::Database(format!("get_share_activity: {}", e)))? @@ -7223,7 +7350,7 @@ impl StorageBackend for SqliteBackend { let notification = notification.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "INSERT INTO share_notifications (id, user_id, share_id, \ notification_type, is_read, created_at) @@ -7237,7 +7364,7 @@ impl StorageBackend for SqliteBackend { notification.created_at.to_rfc3339(), ], )?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| { @@ -7253,7 +7380,7 @@ impl StorageBackend for SqliteBackend { let conn = self.conn.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let mut stmt = conn.prepare( "SELECT id, user_id, share_id, notification_type, is_read, created_at FROM share_notifications WHERE user_id = ?1 AND is_read = 0 \ @@ -7278,7 +7405,7 @@ impl StorageBackend for SqliteBackend { }) })? .collect::>>()?; - Ok::<_, rusqlite::Error>(notifications) + Ok::<_, PinakesError>(notifications) }) .await .map_err(|e| { @@ -7293,12 +7420,12 @@ impl StorageBackend for SqliteBackend { let conn = self.conn.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "UPDATE share_notifications SET is_read = 1 WHERE id = ?1", params![id.to_string()], )?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| { @@ -7314,12 +7441,12 @@ impl StorageBackend for SqliteBackend { let conn = self.conn.clone(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "UPDATE share_notifications SET is_read = 1 WHERE user_id = ?1", params![user_id.0.to_string()], )?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| { @@ -7346,14 +7473,14 @@ impl StorageBackend for SqliteBackend { let conn = conn.clone(); let id_str = id_str.clone(); move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let row: (String, String) = conn.query_row( "SELECT path, storage_mode FROM media_items WHERE id = ?1 AND \ deleted_at IS NULL", params![id_str], |row| Ok((row.get(0)?, row.get(1)?)), )?; - Ok::<_, rusqlite::Error>(row) + Ok::<_, PinakesError>(row) } }) .await @@ -7381,13 +7508,13 @@ impl StorageBackend for SqliteBackend { // Update the database let now = chrono::Utc::now().to_rfc3339(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "UPDATE media_items SET file_name = ?1, path = ?2, updated_at = ?3 \ WHERE id = ?4", params![new_name, new_path_str, now, id_str], )?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| { @@ -7410,14 +7537,14 @@ impl StorageBackend for SqliteBackend { let conn = conn.clone(); let id_str = id_str.clone(); move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let row: (String, String, String) = conn.query_row( "SELECT path, file_name, storage_mode FROM media_items WHERE id = \ ?1 AND deleted_at IS NULL", params![id_str], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), )?; - Ok::<_, rusqlite::Error>(row) + Ok::<_, PinakesError>(row) } }) .await @@ -7449,12 +7576,12 @@ impl StorageBackend for SqliteBackend { // Update the database let now = chrono::Utc::now().to_rfc3339(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "UPDATE media_items SET path = ?1, updated_at = ?2 WHERE id = ?3", params![new_path_str, now, id_str], )?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| { @@ -7470,7 +7597,7 @@ impl StorageBackend for SqliteBackend { let now = chrono::Utc::now().to_rfc3339(); let rows_affected = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "UPDATE media_items SET deleted_at = ?1, updated_at = ?1 WHERE id = \ ?2 AND deleted_at IS NULL", @@ -7498,7 +7625,7 @@ impl StorageBackend for SqliteBackend { let now = chrono::Utc::now().to_rfc3339(); let rows_affected = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "UPDATE media_items SET deleted_at = NULL, updated_at = ?1 WHERE id = \ ?2 AND deleted_at IS NOT NULL", @@ -7527,7 +7654,7 @@ impl StorageBackend for SqliteBackend { let limit = pagination.limit; let items = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let mut stmt = conn.prepare( "SELECT id, path, file_name, media_type, content_hash, file_size, title, artist, album, genre, year, duration_secs, \ @@ -7549,7 +7676,7 @@ impl StorageBackend for SqliteBackend { for row in rows { items.push(row?); } - Ok::<_, rusqlite::Error>(items) + Ok::<_, PinakesError>(items) }) .await .map_err(|e| PinakesError::Database(format!("list_trash: {}", e)))??; @@ -7561,7 +7688,7 @@ impl StorageBackend for SqliteBackend { let conn = self.conn.clone(); let count = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); // First, get the IDs to clean up related data let mut stmt = conn @@ -7588,7 +7715,7 @@ impl StorageBackend for SqliteBackend { // Delete the media items let count = conn .execute("DELETE FROM media_items WHERE deleted_at IS NOT NULL", [])?; - Ok::<_, rusqlite::Error>(count as u64) + Ok::<_, PinakesError>(count as u64) }) .await .map_err(|e| PinakesError::Database(format!("empty_trash: {}", e)))??; @@ -7604,7 +7731,7 @@ impl StorageBackend for SqliteBackend { let before_str = before.to_rfc3339(); let count = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); // First, get the IDs to clean up related data let mut stmt = conn.prepare( @@ -7636,7 +7763,7 @@ impl StorageBackend for SqliteBackend { < ?1", params![before_str], )?; - Ok::<_, rusqlite::Error>(count as u64) + Ok::<_, PinakesError>(count as u64) }) .await .map_err(|e| PinakesError::Database(format!("purge_old_trash: {}", e)))??; @@ -7648,13 +7775,13 @@ impl StorageBackend for SqliteBackend { let conn = self.conn.clone(); let count = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let count: i64 = conn.query_row( "SELECT COUNT(*) FROM media_items WHERE deleted_at IS NOT NULL", [], |row| row.get(0), )?; - Ok::<_, rusqlite::Error>(count as u64) + Ok::<_, PinakesError>(count as u64) }) .await .map_err(|e| PinakesError::Database(format!("count_trash: {}", e)))??; @@ -7672,7 +7799,7 @@ impl StorageBackend for SqliteBackend { let links: Vec<_> = links.to_vec(); tokio::task::spawn_blocking(move || { - let mut conn = conn.lock().unwrap(); + let mut conn = conn.lock().expect("connection mutex not poisoned"); // Wrap DELETE + INSERT in transaction to ensure atomicity let tx = conn.transaction()?; @@ -7708,7 +7835,7 @@ impl StorageBackend for SqliteBackend { drop(stmt); tx.commit()?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| { @@ -7726,7 +7853,7 @@ impl StorageBackend for SqliteBackend { let media_id_str = media_id.0.to_string(); let links = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let mut stmt = conn.prepare( "SELECT id, source_media_id, target_path, target_media_id, link_type, link_text, line_number, context, created_at @@ -7741,7 +7868,7 @@ impl StorageBackend for SqliteBackend { for row in rows { links.push(row?); } - Ok::<_, rusqlite::Error>(links) + Ok::<_, PinakesError>(links) }) .await .map_err(|e| { @@ -7759,7 +7886,7 @@ impl StorageBackend for SqliteBackend { let media_id_str = media_id.0.to_string(); let backlinks = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let mut stmt = conn.prepare( "SELECT l.id, l.source_media_id, m.title, m.path, l.link_text, l.line_number, l.context, l.link_type @@ -7797,7 +7924,7 @@ impl StorageBackend for SqliteBackend { for row in rows { backlinks.push(row?); } - Ok::<_, rusqlite::Error>(backlinks) + Ok::<_, PinakesError>(backlinks) }) .await .map_err(|e| PinakesError::Database(format!("get_backlinks: {}", e)))??; @@ -7810,12 +7937,12 @@ impl StorageBackend for SqliteBackend { let media_id_str = media_id.0.to_string(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn .execute("DELETE FROM markdown_links WHERE source_media_id = ?1", [ &media_id_str, ])?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| { @@ -7835,7 +7962,7 @@ impl StorageBackend for SqliteBackend { let depth = depth.min(5); // Limit depth to prevent huge queries let graph_data = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let mut nodes = Vec::new(); let mut edges = Vec::new(); let mut node_ids = std::collections::HashSet::new(); @@ -7972,7 +8099,7 @@ impl StorageBackend for SqliteBackend { } } - Ok::<_, rusqlite::Error>(crate::model::GraphData { nodes, edges }) + Ok::<_, PinakesError>(crate::model::GraphData { nodes, edges }) }) .await .map_err(|e| PinakesError::Database(format!("get_graph_data: {}", e)))??; @@ -7984,7 +8111,7 @@ impl StorageBackend for SqliteBackend { let conn = self.conn.clone(); let count = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); // Find unresolved links and try to resolve them // Strategy 1: Exact path match @@ -8030,7 +8157,7 @@ impl StorageBackend for SqliteBackend { [], )?; - Ok::<_, rusqlite::Error>((updated1 + updated2) as u64) + Ok::<_, PinakesError>((updated1 + updated2) as u64) }) .await .map_err(|e| PinakesError::Database(format!("resolve_links: {}", e)))??; @@ -8044,12 +8171,12 @@ impl StorageBackend for SqliteBackend { let now = chrono::Utc::now().to_rfc3339(); tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); conn.execute( "UPDATE media_items SET links_extracted_at = ?1 WHERE id = ?2", params![now, media_id_str], )?; - Ok::<_, rusqlite::Error>(()) + Ok::<_, PinakesError>(()) }) .await .map_err(|e| { @@ -8063,13 +8190,13 @@ impl StorageBackend for SqliteBackend { let conn = self.conn.clone(); let count = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let conn = conn.lock().expect("connection mutex not poisoned"); let count: i64 = conn.query_row( "SELECT COUNT(*) FROM markdown_links WHERE target_media_id IS NULL", [], |row| row.get(0), )?; - Ok::<_, rusqlite::Error>(count as u64) + Ok::<_, PinakesError>(count as u64) }) .await .map_err(|e| { diff --git a/crates/pinakes-plugin-api/Cargo.toml b/crates/pinakes-plugin-api/Cargo.toml index b761eb2..94f529a 100644 --- a/crates/pinakes-plugin-api/Cargo.toml +++ b/crates/pinakes-plugin-api/Cargo.toml @@ -22,6 +22,9 @@ mime_guess = { workspace = true } # WASM bridge types wit-bindgen = { workspace = true, optional = true } +[lints] +workspace = true + [features] default = [] wasm = ["wit-bindgen"] diff --git a/crates/pinakes-server/Cargo.toml b/crates/pinakes-server/Cargo.toml index b395788..e853715 100644 --- a/crates/pinakes-server/Cargo.toml +++ b/crates/pinakes-server/Cargo.toml @@ -32,6 +32,9 @@ rand = { workspace = true } percent-encoding = { workspace = true } http = { workspace = true } +[lints] +workspace = true + [dev-dependencies] http-body-util = "0.1.3" tempfile = "3.25.0" diff --git a/crates/pinakes-tui/Cargo.toml b/crates/pinakes-tui/Cargo.toml index 2d43d40..5aa9e9e 100644 --- a/crates/pinakes-tui/Cargo.toml +++ b/crates/pinakes-tui/Cargo.toml @@ -18,3 +18,6 @@ tracing-subscriber = { workspace = true } reqwest = { workspace = true } ratatui = { workspace = true } crossterm = { workspace = true } + +[lints] +workspace = true diff --git a/crates/pinakes-ui/Cargo.toml b/crates/pinakes-ui/Cargo.toml index 57eed63..e516438 100644 --- a/crates/pinakes-ui/Cargo.toml +++ b/crates/pinakes-ui/Cargo.toml @@ -26,6 +26,9 @@ dioxus-free-icons = { workspace = true } gloo-timers = { workspace = true } rand = { workspace = true } +[lints] +workspace = true + [features] default = ["web"] web = ["dioxus/web"]