diff --git a/crates/pinakes-core/src/events.rs b/crates/pinakes-core/src/events.rs index cbe90ae..e2222e9 100644 --- a/crates/pinakes-core/src/events.rs +++ b/crates/pinakes-core/src/events.rs @@ -3,10 +3,7 @@ use chrono::{DateTime, Utc}; -use crate::{ - error::Result, - model::{MediaId, MediaItem}, -}; +use crate::model::{MediaId, MediaItem}; /// Configuration for event detection #[derive(Debug, Clone)] @@ -68,15 +65,16 @@ fn haversine_distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { } /// Detect photo events from a list of media items +#[must_use] pub fn detect_events( mut items: Vec, config: &EventDetectionConfig, -) -> Result> { +) -> Vec { // Filter to only photos with date_taken items.retain(|item| item.date_taken.is_some()); if items.is_empty() { - return Ok(Vec::new()); + return Vec::new(); } // Sort by date_taken (None < Some, but all are Some after retain) @@ -84,7 +82,7 @@ pub fn detect_events( let mut events: Vec = Vec::new(); let Some(first_date) = items[0].date_taken else { - return Ok(Vec::new()); + return Vec::new(); }; let mut current_event_items: Vec = vec![items[0].id]; let mut current_start_time = first_date; @@ -171,21 +169,22 @@ pub fn detect_events( }); } - Ok(events) + events } /// Detect photo bursts (rapid sequences of photos) /// Returns groups of media IDs that are likely burst sequences +#[must_use] pub fn detect_bursts( mut items: Vec, max_gap_secs: i64, min_burst_size: usize, -) -> Result>> { +) -> Vec> { // Filter to only photos with date_taken items.retain(|item| item.date_taken.is_some()); if items.is_empty() { - return Ok(Vec::new()); + return Vec::new(); } // Sort by date_taken (None < Some, but all are Some after retain) @@ -193,7 +192,7 @@ pub fn detect_bursts( let mut bursts: Vec> = Vec::new(); let Some(first_date) = items[0].date_taken else { - return Ok(Vec::new()); + return Vec::new(); }; let mut current_burst: Vec = vec![items[0].id]; let mut last_time = first_date; @@ -221,5 +220,5 @@ pub fn detect_bursts( bursts.push(current_burst); } - Ok(bursts) + bursts } diff --git a/crates/pinakes-core/src/jobs.rs b/crates/pinakes-core/src/jobs.rs index d4bc106..ba68220 100644 --- a/crates/pinakes-core/src/jobs.rs +++ b/crates/pinakes-core/src/jobs.rs @@ -185,12 +185,12 @@ impl JobQueue { }; { - let mut map = self.jobs.write().await; - map.insert(id, job); // Prune old terminal jobs to prevent unbounded memory growth. // Keep at most 500 completed/failed/cancelled entries, removing the // oldest. const MAX_TERMINAL_JOBS: usize = 500; + let mut map = self.jobs.write().await; + map.insert(id, job); let mut terminal: Vec<(Uuid, chrono::DateTime)> = map .iter() .filter(|(_, j)| { diff --git a/crates/pinakes-core/src/opener.rs b/crates/pinakes-core/src/opener.rs index 5326154..db3b90e 100644 --- a/crates/pinakes-core/src/opener.rs +++ b/crates/pinakes-core/src/opener.rs @@ -3,6 +3,11 @@ use std::{path::Path, process::Command}; use crate::error::{PinakesError, Result}; pub trait Opener: Send + Sync { + /// Open the file at `path` with the system default application. + /// + /// # Errors + /// + /// Returns an error if the opener command fails to launch or exits non-zero. fn open(&self, path: &Path) -> Result<()>; } diff --git a/crates/pinakes-core/src/plugin/pipeline.rs b/crates/pinakes-core/src/plugin/pipeline.rs index 3a8439e..fcd7a3d 100644 --- a/crates/pinakes-core/src/plugin/pipeline.rs +++ b/crates/pinakes-core/src/plugin/pipeline.rs @@ -20,6 +20,7 @@ use std::{ use pinakes_metadata::ExtractedMetadata; use pinakes_plugin::{ + CapabilityEnforcer, PluginManager, rpc::{ CanHandleRequest, @@ -131,7 +132,7 @@ impl PluginPipeline { pub async fn discover_capabilities(&self) -> crate::error::Result<()> { info!("discovering plugin capabilities"); - let timeout = Duration::from_secs(self.timeouts.capability_query_secs); + let timeout = Duration::from_secs(self.timeouts.capability_query); let mut caps = CachedCapabilities::new(); // Discover metadata extractors @@ -322,7 +323,7 @@ impl PluginPipeline { /// Iterates `MediaTypeProvider` plugins in priority order, falling back to /// the built-in resolver at implicit priority 100. pub async fn resolve_media_type(&self, path: &Path) -> Option { - let timeout = Duration::from_secs(self.timeouts.processing_secs); + let timeout = Duration::from_secs(self.timeouts.processing); let plugins = self.manager.get_enabled_by_kind_sorted("media_type").await; let mut builtin_ran = false; @@ -341,11 +342,7 @@ impl PluginPipeline { } // Validate the call is allowed for this plugin kind - if !self - .manager - .enforcer() - .validate_function_call(kinds, "can_handle") - { + if !CapabilityEnforcer::validate_function_call(kinds, "can_handle") { continue; } @@ -441,7 +438,7 @@ impl PluginPipeline { path: &Path, media_type: &MediaType, ) -> crate::error::Result { - let timeout = Duration::from_secs(self.timeouts.processing_secs); + let timeout = Duration::from_secs(self.timeouts.processing); let plugins = self .manager .get_enabled_by_kind_sorted("metadata_extractor") @@ -475,10 +472,7 @@ impl PluginPipeline { continue; } - if !self - .manager - .enforcer() - .validate_function_call(kinds, "extract_metadata") + if !CapabilityEnforcer::validate_function_call(kinds, "extract_metadata") { continue; } @@ -558,7 +552,7 @@ impl PluginPipeline { media_type: &MediaType, thumb_dir: &Path, ) -> crate::error::Result> { - let timeout = Duration::from_secs(self.timeouts.processing_secs); + let timeout = Duration::from_secs(self.timeouts.processing); let plugins = self .manager .get_enabled_by_kind_sorted("thumbnail_generator") @@ -596,11 +590,10 @@ impl PluginPipeline { continue; } - if !self - .manager - .enforcer() - .validate_function_call(kinds, "generate_thumbnail") - { + if !CapabilityEnforcer::validate_function_call( + kinds, + "generate_thumbnail", + ) { continue; } @@ -693,7 +686,7 @@ impl PluginPipeline { event_type: &str, payload: &serde_json::Value, ) { - let timeout = Duration::from_secs(self.timeouts.event_handler_secs); + let timeout = Duration::from_secs(self.timeouts.event_handler); // Collect plugin IDs interested in this event let interested_ids: Vec = { @@ -725,11 +718,7 @@ impl PluginPipeline { continue; } - if !self - .manager - .enforcer() - .validate_function_call(kinds, "handle_event") - { + if !CapabilityEnforcer::validate_function_call(kinds, "handle_event") { continue; } @@ -789,7 +778,7 @@ impl PluginPipeline { limit: usize, offset: usize, ) -> Vec { - let timeout = Duration::from_secs(self.timeouts.processing_secs); + let timeout = Duration::from_secs(self.timeouts.processing); let plugins = self .manager .get_enabled_by_kind_sorted("search_backend") @@ -801,11 +790,7 @@ impl PluginPipeline { if !self.is_healthy(id).await { continue; } - if !self - .manager - .enforcer() - .validate_function_call(kinds, "search") - { + if !CapabilityEnforcer::validate_function_call(kinds, "search") { continue; } @@ -862,7 +847,7 @@ impl PluginPipeline { /// Index a media item in all search backend plugins (fan-out). pub async fn index_item(&self, req: &IndexItemRequest) { - let timeout = Duration::from_secs(self.timeouts.processing_secs); + let timeout = Duration::from_secs(self.timeouts.processing); let plugins = self .manager .get_enabled_by_kind_sorted("search_backend") @@ -872,11 +857,7 @@ impl PluginPipeline { if !self.is_healthy(id).await { continue; } - if !self - .manager - .enforcer() - .validate_function_call(kinds, "index_item") - { + if !CapabilityEnforcer::validate_function_call(kinds, "index_item") { continue; } @@ -905,7 +886,7 @@ impl PluginPipeline { /// Remove a media item from all search backend plugins (fan-out). pub async fn remove_item(&self, media_id: &str) { - let timeout = Duration::from_secs(self.timeouts.processing_secs); + let timeout = Duration::from_secs(self.timeouts.processing); let plugins = self .manager .get_enabled_by_kind_sorted("search_backend") @@ -919,11 +900,7 @@ impl PluginPipeline { if !self.is_healthy(id).await { continue; } - if !self - .manager - .enforcer() - .validate_function_call(kinds, "remove_item") - { + if !CapabilityEnforcer::validate_function_call(kinds, "remove_item") { continue; } @@ -963,7 +940,7 @@ impl PluginPipeline { /// Load a specific theme by ID from the provider that registered it. pub async fn load_theme(&self, theme_id: &str) -> Option { - let timeout = Duration::from_secs(self.timeouts.processing_secs); + let timeout = Duration::from_secs(self.timeouts.processing); // Find which plugin owns this theme let owner_id = { @@ -990,11 +967,7 @@ impl PluginPipeline { let plugin = plugins.iter().find(|(id, ..)| id == &owner_id)?; let (id, _priority, kinds, wasm) = plugin; - if !self - .manager - .enforcer() - .validate_function_call(kinds, "load_theme") - { + if !CapabilityEnforcer::validate_function_call(kinds, "load_theme") { return None; } diff --git a/crates/pinakes-core/src/sharing.rs b/crates/pinakes-core/src/sharing.rs index 62856bc..66fc2dc 100644 --- a/crates/pinakes-core/src/sharing.rs +++ b/crates/pinakes-core/src/sharing.rs @@ -204,7 +204,7 @@ impl SharePermissions { /// Merges two permission sets, taking the most permissive values. #[must_use] - pub const fn merge(&self, other: &Self) -> Self { + pub const fn merge(self, other: Self) -> Self { Self { view: ShareViewPermissions { can_view: self.view.can_view || other.view.can_view, diff --git a/crates/pinakes-core/src/storage/migrations.rs b/crates/pinakes-core/src/storage/migrations.rs index ac78968..2de24b7 100644 --- a/crates/pinakes-core/src/storage/migrations.rs +++ b/crates/pinakes-core/src/storage/migrations.rs @@ -1,3 +1,6 @@ +/// # Errors +/// +/// Returns an error if migrations fail to apply. #[cfg(feature = "sqlite")] pub fn run_sqlite_migrations( conn: &mut rusqlite::Connection, @@ -7,6 +10,9 @@ pub fn run_sqlite_migrations( .map_err(|e| crate::error::PinakesError::Migration(e.to_string())) } +/// # Errors +/// +/// Returns an error if migrations fail to apply. #[cfg(feature = "postgres")] pub async fn run_postgres_migrations( client: &mut tokio_postgres::Client, diff --git a/crates/pinakes-core/src/storage/mod.rs b/crates/pinakes-core/src/storage/mod.rs index 968e3f2..a505e42 100644 --- a/crates/pinakes-core/src/storage/mod.rs +++ b/crates/pinakes-core/src/storage/mod.rs @@ -423,10 +423,12 @@ pub trait StorageBackend: Send + Sync + 'static { user_id: crate::users::UserId, media_id: crate::model::MediaId, ) -> Result { - match self.check_library_access(user_id, media_id).await { - Ok(perm) => Ok(perm.can_read()), - Err(_) => Ok(false), - } + Ok( + self + .check_library_access(user_id, media_id) + .await + .is_ok_and(|_perm| crate::users::LibraryPermission::can_read()), + ) } /// Check if a user has write access to a media item @@ -435,10 +437,12 @@ pub trait StorageBackend: Send + Sync + 'static { user_id: crate::users::UserId, media_id: crate::model::MediaId, ) -> Result { - match self.check_library_access(user_id, media_id).await { - Ok(perm) => Ok(perm.can_write()), - Err(_) => Ok(false), - } + Ok( + self + .check_library_access(user_id, media_id) + .await + .is_ok_and(crate::users::LibraryPermission::can_write), + ) } /// Rate a media item (1-5 stars) with an optional text review. diff --git a/crates/pinakes-core/src/storage/sqlite.rs b/crates/pinakes-core/src/storage/sqlite.rs index 82987f0..f11f2ee 100644 --- a/crates/pinakes-core/src/storage/sqlite.rs +++ b/crates/pinakes-core/src/storage/sqlite.rs @@ -255,7 +255,6 @@ fn row_to_audit_entry(row: &Row) -> rusqlite::Result { let action = match action_str.as_str() { "imported" => AuditAction::Imported, - "updated" => AuditAction::Updated, "deleted" => AuditAction::Deleted, "tagged" => AuditAction::Tagged, "untagged" => AuditAction::Untagged, @@ -725,7 +724,6 @@ impl StorageBackend for SqliteBackend { .collect::>>() .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); - drop(db); rows }; Ok(rows) @@ -863,7 +861,6 @@ impl StorageBackend for SqliteBackend { drop(stmt); item.custom_fields = load_custom_fields_sync(&db, item.id) .map_err(|e| PinakesError::Database(e.to_string()))?; - drop(db); item }; Ok(item) @@ -902,10 +899,8 @@ impl StorageBackend for SqliteBackend { if let Some(mut item) = result { item.custom_fields = load_custom_fields_sync(&db, item.id) .map_err(|e| PinakesError::Database(e.to_string()))?; - drop(db); Some(item) } else { - drop(db); None } }; @@ -945,10 +940,8 @@ impl StorageBackend for SqliteBackend { if let Some(mut item) = result { item.custom_fields = load_custom_fields_sync(&db, item.id) .map_err(|e| PinakesError::Database(e.to_string()))?; - drop(db); Some(item) } else { - drop(db); None } }; @@ -1006,7 +999,6 @@ impl StorageBackend for SqliteBackend { drop(stmt); load_custom_fields_batch(&db, &mut rows) .map_err(|e| PinakesError::Database(e.to_string()))?; - drop(db); rows }; Ok(rows) @@ -1062,7 +1054,6 @@ impl StorageBackend for SqliteBackend { ], ) .map_err(db_ctx("update_media", &item.id))?; - drop(db); if changed == 0 { return Err(PinakesError::NotFound(format!( "media item {}", @@ -1088,7 +1079,6 @@ impl StorageBackend for SqliteBackend { id.0.to_string() ]) .map_err(db_ctx("delete_media", id))?; - drop(db); if changed == 0 { return Err(PinakesError::NotFound(format!("media item {id}"))); } @@ -1147,7 +1137,6 @@ impl StorageBackend for SqliteBackend { ], ) .map_err(db_ctx("create_tag", &name))?; - drop(db); Tag { id, name, @@ -1184,7 +1173,6 @@ impl StorageBackend for SqliteBackend { } })?; drop(stmt); - drop(db); tag }; Ok(tag) @@ -1211,7 +1199,6 @@ impl StorageBackend for SqliteBackend { .collect::>>() .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); - drop(db); rows }; Ok(rows) @@ -1230,7 +1217,6 @@ impl StorageBackend for SqliteBackend { let changed = db .execute("DELETE FROM tags WHERE id = ?1", params![id.to_string()]) .map_err(db_ctx("delete_tag", id))?; - drop(db); if changed == 0 { return Err(PinakesError::TagNotFound(id.to_string())); } @@ -1299,7 +1285,6 @@ impl StorageBackend for SqliteBackend { .collect::>>() .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); - drop(db); rows }; Ok(rows) @@ -1330,7 +1315,6 @@ impl StorageBackend for SqliteBackend { .collect::>>() .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); - drop(db); rows }; Ok(rows) @@ -1372,7 +1356,6 @@ impl StorageBackend for SqliteBackend { ], ) .map_err(db_ctx("create_collection", &name))?; - drop(db); Collection { id, name, @@ -1413,7 +1396,6 @@ impl StorageBackend for SqliteBackend { } })?; drop(stmt); - drop(db); collection }; Ok(collection) @@ -1441,7 +1423,6 @@ impl StorageBackend for SqliteBackend { .collect::>>() .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); - drop(db); rows }; Ok(rows) @@ -1462,7 +1443,6 @@ impl StorageBackend for SqliteBackend { id.to_string() ]) .map_err(db_ctx("delete_collection", id))?; - drop(db); if changed == 0 { return Err(PinakesError::CollectionNotFound(id.to_string())); } @@ -1565,7 +1545,6 @@ impl StorageBackend for SqliteBackend { drop(stmt); load_custom_fields_batch(&db, &mut rows) .map_err(|e| PinakesError::Database(e.to_string()))?; - drop(db); rows }; Ok(rows) @@ -1675,7 +1654,6 @@ impl StorageBackend for SqliteBackend { let total_count: i64 = db .query_row(&count_sql, count_param_refs.as_slice(), |row| row.get(0)) .map_err(|e| PinakesError::Database(e.to_string()))?; - drop(db); SearchResults { items, @@ -1777,7 +1755,6 @@ impl StorageBackend for SqliteBackend { .map_err(|e| PinakesError::Database(e.to_string()))? }; drop(stmt); - drop(db); rows }; @@ -1854,7 +1831,6 @@ impl StorageBackend for SqliteBackend { map.insert(name, field); } drop(stmt); - drop(db); map }; Ok(map) @@ -2907,7 +2883,7 @@ impl StorageBackend for SqliteBackend { crate::users::UserProfile { avatar_path: None, bio: None, - preferences: Default::default(), + preferences: crate::users::UserPreferences::default(), } }; @@ -3017,36 +2993,34 @@ impl StorageBackend for SqliteBackend { .map_err(|e| PinakesError::Database(e.to_string()))?; // Fetch updated user - Ok( - db.query_row( - "SELECT id, username, password_hash, role, created_at, updated_at \ - FROM users WHERE id = ?", - [&id_str], - |row| { - let id_str: String = row.get(0)?; - let profile = load_user_profile_sync(&db, &id_str)?; - Ok(crate::users::User { - id: crate::users::UserId(parse_uuid(&id_str)?), - username: row.get(1)?, - password_hash: row.get(2)?, - role: serde_json::from_str(&row.get::<_, String>(3)?) - .unwrap_or(crate::config::UserRole::Viewer), - profile, - created_at: chrono::DateTime::parse_from_rfc3339( - &row.get::<_, String>(4)?, - ) - .unwrap_or_else(|_| chrono::Utc::now().into()) - .with_timezone(&chrono::Utc), - updated_at: chrono::DateTime::parse_from_rfc3339( - &row.get::<_, String>(5)?, - ) - .unwrap_or_else(|_| chrono::Utc::now().into()) - .with_timezone(&chrono::Utc), - }) - }, - ) - .map_err(|e| PinakesError::Database(e.to_string()))?, + db.query_row( + "SELECT id, username, password_hash, role, created_at, updated_at \ + FROM users WHERE id = ?", + [&id_str], + |row| { + let id_str: String = row.get(0)?; + let profile = load_user_profile_sync(&db, &id_str)?; + Ok(crate::users::User { + id: crate::users::UserId(parse_uuid(&id_str)?), + username: row.get(1)?, + password_hash: row.get(2)?, + role: serde_json::from_str(&row.get::<_, String>(3)?) + .unwrap_or(crate::config::UserRole::Viewer), + profile, + created_at: chrono::DateTime::parse_from_rfc3339( + &row.get::<_, String>(4)?, + ) + .unwrap_or_else(|_| chrono::Utc::now().into()) + .with_timezone(&chrono::Utc), + updated_at: chrono::DateTime::parse_from_rfc3339( + &row.get::<_, String>(5)?, + ) + .unwrap_or_else(|_| chrono::Utc::now().into()) + .with_timezone(&chrono::Utc), + }) + }, ) + .map_err(|e| PinakesError::Database(e.to_string())) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) .await @@ -5949,39 +5923,33 @@ impl StorageBackend for SqliteBackend { .map_err(|e| PinakesError::Database(e.to_string()))?; let mut results = Vec::new(); - for row in rows { - match row { - Ok((item, current_page, total_pages)) => { - // Calculate status based on progress - let calculated_status = total_pages.map_or( - // No total pages known, assume reading - crate::model::ReadingStatus::Reading, - |total| { - if total > 0 { - let percent = (f64::from(current_page) / f64::from(total) - * 100.0) - .min(100.0); - if percent >= 100.0 { - crate::model::ReadingStatus::Completed - } else if percent > 0.0 { - crate::model::ReadingStatus::Reading - } else { - crate::model::ReadingStatus::ToRead - } - } else { - crate::model::ReadingStatus::Reading - } - }, - ); - - // Filter by status if specified - match status { - None => results.push(item), - Some(s) if s == calculated_status => results.push(item), - _ => {}, + for (item, current_page, total_pages) in rows.flatten() { + // Calculate status based on progress + let calculated_status = total_pages.map_or( + // No total pages known, assume reading + crate::model::ReadingStatus::Reading, + |total| { + if total > 0 { + let percent = + (f64::from(current_page) / f64::from(total) * 100.0).min(100.0); + if percent >= 100.0 { + crate::model::ReadingStatus::Completed + } else if percent > 0.0 { + crate::model::ReadingStatus::Reading + } else { + crate::model::ReadingStatus::ToRead + } + } else { + crate::model::ReadingStatus::Reading } }, - Err(_) => continue, + ); + + // Filter by status if specified + match status { + None => results.push(item), + Some(s) if s == calculated_status => results.push(item), + _ => {}, } } Ok::<_, PinakesError>(results) @@ -7809,7 +7777,7 @@ impl StorageBackend for SqliteBackend { ) if *share_user == uid => { return Ok(Some(share.permissions)); }, - _ => continue, + _ => {}, } } @@ -8216,7 +8184,7 @@ impl StorageBackend for SqliteBackend { let new_name = new_name.to_string(); let (old_path, storage_mode) = tokio::task::spawn_blocking({ - let conn = conn.clone(); + let conn = Arc::clone(&conn); let id_str = id_str.clone(); move || { let conn = conn.lock().map_err(|e| { @@ -8239,7 +8207,7 @@ impl StorageBackend for SqliteBackend { })??; let old_path_buf = std::path::PathBuf::from(&old_path); - let parent = old_path_buf.parent().unwrap_or(std::path::Path::new("")); + let parent = old_path_buf.parent().unwrap_or_else(|| Path::new("")); let new_path = parent.join(&new_name); let new_path_str = new_path.to_string_lossy().to_string(); @@ -8288,7 +8256,7 @@ impl StorageBackend for SqliteBackend { let new_dir = new_directory.to_path_buf(); let (old_path, file_name, storage_mode) = tokio::task::spawn_blocking({ - let conn = conn.clone(); + let conn = Arc::clone(&conn); let id_str = id_str.clone(); move || { let conn = conn.lock().map_err(|e| { @@ -8788,10 +8756,8 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| PinakesError::Database(format!("connection mutex poisoned: {e}")))?; let mut nodes = Vec::new(); let mut edges = Vec::new(); - let mut node_ids = rustc_hash::FxHashSet::default(); - // Get nodes - either all markdown files or those connected to center - if let Some(center_id) = center_id_str { + let node_ids = if let Some(center_id) = center_id_str { // BFS to find connected nodes within depth let mut frontier = vec![center_id.clone()]; let mut visited = rustc_hash::FxHashSet::default(); @@ -8839,9 +8805,10 @@ impl StorageBackend for SqliteBackend { frontier = next_frontier; } - node_ids = visited; + visited } else { // Get all markdown files with links (limit to 500 for performance) + let mut ids = rustc_hash::FxHashSet::default(); let mut stmt = conn.prepare( "SELECT DISTINCT id FROM media_items WHERE media_type = 'markdown' AND deleted_at IS NULL @@ -8852,10 +8819,10 @@ impl StorageBackend for SqliteBackend { Ok(id) }).map_err(|e| PinakesError::Database(e.to_string()))?; for row in rows { - node_ids.insert(row.map_err(|e| PinakesError::Database(e.to_string()))?); - + ids.insert(row.map_err(|e| PinakesError::Database(e.to_string()))?); } - } + ids + }; // Build nodes with metadata for node_id in &node_ids { @@ -9108,11 +9075,6 @@ fn row_to_share(row: &Row) -> rusqlite::Result { let password_hash: Option = row.get(7)?; let target = match target_type.as_str() { - "media" => { - crate::sharing::ShareTarget::Media { - media_id: MediaId(parse_uuid(&target_id_str)?), - } - }, "collection" => { crate::sharing::ShareTarget::Collection { collection_id: parse_uuid(&target_id_str)?, @@ -9136,12 +9098,6 @@ fn row_to_share(row: &Row) -> rusqlite::Result { }; let recipient = match recipient_type.as_str() { - "public_link" => { - crate::sharing::ShareRecipient::PublicLink { - token: public_token.unwrap_or_default(), - password_hash, - } - }, "user" => { crate::sharing::ShareRecipient::User { user_id: crate::users::UserId(parse_uuid( diff --git a/crates/pinakes-core/src/subtitles.rs b/crates/pinakes-core/src/subtitles.rs index 76cc8b5..d6ddb9c 100644 --- a/crates/pinakes-core/src/subtitles.rs +++ b/crates/pinakes-core/src/subtitles.rs @@ -172,9 +172,8 @@ pub async fn list_embedded_tracks( } })?; - let streams = match json.get("streams").and_then(|s| s.as_array()) { - Some(s) => s, - None => return Ok(vec![]), + let Some(streams) = json.get("streams").and_then(|s| s.as_array()) else { + return Ok(vec![]); }; let mut tracks = Vec::new(); @@ -203,7 +202,7 @@ pub async fn list_embedded_tracks( .map(str::to_owned); tracks.push(SubtitleTrackInfo { - index: idx as u32, + index: u32::try_from(idx).unwrap_or(u32::MAX), language, format, title, diff --git a/crates/pinakes-core/src/thumbnail.rs b/crates/pinakes-core/src/thumbnail.rs index 3c79b25..118b568 100644 --- a/crates/pinakes-core/src/thumbnail.rs +++ b/crates/pinakes-core/src/thumbnail.rs @@ -367,7 +367,7 @@ pub enum CoverSize { impl CoverSize { #[must_use] - pub const fn dimensions(&self) -> Option<(u32, u32)> { + pub const fn dimensions(self) -> Option<(u32, u32)> { match self { Self::Tiny => Some((64, 64)), Self::Grid => Some((320, 320)), @@ -377,7 +377,7 @@ impl CoverSize { } #[must_use] - pub const fn filename(&self) -> &'static str { + pub const fn filename(self) -> &'static str { match self { Self::Tiny => "tiny.jpg", Self::Grid => "grid.jpg", @@ -541,7 +541,7 @@ pub enum ThumbnailSize { impl ThumbnailSize { /// Get the pixel size for this thumbnail variant #[must_use] - pub const fn pixels(&self) -> u32 { + pub const fn pixels(self) -> u32 { match self { Self::Tiny => 64, Self::Grid => 320, @@ -551,7 +551,7 @@ impl ThumbnailSize { /// Get the subdirectory name for this size #[must_use] - pub const fn subdir_name(&self) -> &'static str { + pub const fn subdir_name(self) -> &'static str { match self { Self::Tiny => "tiny", Self::Grid => "grid", diff --git a/crates/pinakes-core/src/transcode.rs b/crates/pinakes-core/src/transcode.rs index 416c1a6..1edfd8d 100644 --- a/crates/pinakes-core/src/transcode.rs +++ b/crates/pinakes-core/src/transcode.rs @@ -103,9 +103,9 @@ impl TranscodeService { pub fn new(config: TranscodingConfig) -> Self { let max_concurrent = config.max_concurrent.max(1); Self { + config, sessions: Arc::new(RwLock::new(FxHashMap::default())), semaphore: Arc::new(Semaphore::new(max_concurrent)), - config, } } diff --git a/crates/pinakes-core/src/users.rs b/crates/pinakes-core/src/users.rs index 159b972..2c3a774 100644 --- a/crates/pinakes-core/src/users.rs +++ b/crates/pinakes-core/src/users.rs @@ -1,15 +1,15 @@ //! User management and authentication use chrono::{DateTime, Utc}; +pub use pinakes_types::model::UserId; use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; + use crate::{ config::UserRole, error::{PinakesError, Result}, }; -pub use pinakes_types::model::UserId; - /// User account with profile information #[derive(Debug, Clone, Serialize, Deserialize)] pub struct User { @@ -67,24 +67,24 @@ pub enum LibraryPermission { impl LibraryPermission { /// Checks if read permission is granted. #[must_use] - pub const fn can_read(&self) -> bool { + pub const fn can_read() -> bool { true } /// Checks if write permission is granted. #[must_use] - pub const fn can_write(&self) -> bool { + pub const fn can_write(self) -> bool { matches!(self, Self::Write | Self::Admin) } /// Checks if admin permission is granted. #[must_use] - pub const fn can_admin(&self) -> bool { + pub const fn can_admin(self) -> bool { matches!(self, Self::Admin) } #[must_use] - pub const fn as_str(&self) -> &'static str { + pub const fn as_str(self) -> &'static str { match self { Self::Read => "read", Self::Write => "write", @@ -132,6 +132,10 @@ pub mod auth { use super::{PinakesError, Result}; /// Hash a password using Argon2 + /// + /// # Errors + /// + /// Returns an error if password hashing fails. pub fn hash_password(password: &str) -> Result { use argon2::{ Argon2, @@ -150,6 +154,10 @@ pub mod auth { } /// Verify a password against a hash + /// + /// # Errors + /// + /// Returns an error if the hash is invalid or cannot be parsed. pub fn verify_password(password: &str, hash: &str) -> Result { use argon2::{ Argon2, @@ -193,17 +201,17 @@ mod tests { #[test] fn test_library_permission_levels() { let read = LibraryPermission::Read; - assert!(read.can_read()); + assert!(LibraryPermission::can_read()); assert!(!read.can_write()); assert!(!read.can_admin()); let write = LibraryPermission::Write; - assert!(write.can_read()); + assert!(LibraryPermission::can_read()); assert!(write.can_write()); assert!(!write.can_admin()); let admin = LibraryPermission::Admin; - assert!(admin.can_read()); + assert!(LibraryPermission::can_read()); assert!(admin.can_write()); assert!(admin.can_admin()); } diff --git a/crates/pinakes-core/src/webhooks.rs b/crates/pinakes-core/src/webhooks.rs index 424d86a..fae9669 100644 --- a/crates/pinakes-core/src/webhooks.rs +++ b/crates/pinakes-core/src/webhooks.rs @@ -69,7 +69,7 @@ impl WebhookDispatcher { /// Dispatch an event to all matching webhooks. /// This is fire-and-forget, errors are logged but not propagated. pub fn dispatch(self: &Arc, event: WebhookEvent) { - let this = self.clone(); + let this = Arc::clone(self); tokio::spawn(async move { this.dispatch_inner(&event).await; }); diff --git a/crates/pinakes-enrichment/src/books.rs b/crates/pinakes-enrichment/src/books.rs index 63b09c6..6786ca1 100644 --- a/crates/pinakes-enrichment/src/books.rs +++ b/crates/pinakes-enrichment/src/books.rs @@ -74,7 +74,7 @@ impl BookEnricher { })?; Ok(Some(ExternalMetadata { - id: Uuid::new_v4(), + id: Uuid::now_v7(), media_id: pinakes_types::model::MediaId(Uuid::nil()), /* Will be set by caller */ source: EnrichmentSourceType::OpenLibrary, external_id: None, @@ -104,7 +104,7 @@ impl BookEnricher { })?; Ok(Some(ExternalMetadata { - id: Uuid::new_v4(), + id: Uuid::now_v7(), media_id: pinakes_types::model::MediaId(Uuid::nil()), /* Will be set by caller */ source: EnrichmentSourceType::GoogleBooks, external_id: Some(book.id.clone()), @@ -136,7 +136,7 @@ impl BookEnricher { })?; return Ok(Some(ExternalMetadata { - id: Uuid::new_v4(), + id: Uuid::now_v7(), media_id: pinakes_types::model::MediaId(Uuid::nil()), source: EnrichmentSourceType::OpenLibrary, external_id: result.key.clone(), @@ -155,7 +155,7 @@ impl BookEnricher { })?; return Ok(Some(ExternalMetadata { - id: Uuid::new_v4(), + id: Uuid::now_v7(), media_id: pinakes_types::model::MediaId(Uuid::nil()), source: EnrichmentSourceType::GoogleBooks, external_id: Some(book.id.clone()), diff --git a/crates/pinakes-enrichment/src/tmdb.rs b/crates/pinakes-enrichment/src/tmdb.rs index 810db2e..027402e 100644 --- a/crates/pinakes-enrichment/src/tmdb.rs +++ b/crates/pinakes-enrichment/src/tmdb.rs @@ -20,21 +20,21 @@ pub struct TmdbEnricher { impl TmdbEnricher { /// Create a new `TMDb` enricher. /// - /// # Panics + /// # Errors /// - /// Panics if the HTTP client cannot be built (programming error in client - /// configuration). - #[must_use] - pub fn new(api_key: String) -> Self { - Self { - client: reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .connect_timeout(Duration::from_secs(5)) - .build() - .expect("failed to build HTTP client with configured timeouts"), + /// Returns an error if the HTTP client cannot be built (e.g. TLS + /// initialisation failure). + pub fn new(api_key: String) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .connect_timeout(Duration::from_secs(5)) + .build() + .map_err(|e| PinakesError::External(e.to_string()))?; + Ok(Self { + client, api_key, base_url: "https://api.themoviedb.org/3".to_string(), - } + }) } } diff --git a/crates/pinakes-migrations/src/lib.rs b/crates/pinakes-migrations/src/lib.rs index a1fc8e5..2890155 100644 --- a/crates/pinakes-migrations/src/lib.rs +++ b/crates/pinakes-migrations/src/lib.rs @@ -5,6 +5,7 @@ mod postgres_migrations { embed_migrations!("migrations/postgres"); } +#[must_use] pub fn sqlite_migrations() -> Migrations<'static> { Migrations::new(vec![ M::up(include_str!("../migrations/sqlite/V1__initial_schema.sql")), @@ -49,6 +50,7 @@ pub fn sqlite_migrations() -> Migrations<'static> { ]) } +#[must_use] pub fn postgres_runner() -> refinery::Runner { postgres_migrations::migrations::runner() } diff --git a/crates/pinakes-plugin/src/loader.rs b/crates/pinakes-plugin/src/loader.rs index f8242e8..5aeb484 100644 --- a/crates/pinakes-plugin/src/loader.rs +++ b/crates/pinakes-plugin/src/loader.rs @@ -21,11 +21,7 @@ impl PluginLoader { } /// Discover all plugins in configured directories - /// - /// # Errors - /// - /// Returns an error if a plugin directory cannot be searched. - pub fn discover_plugins(&self) -> Result> { + pub fn discover_plugins(&self) -> Vec { let mut manifests = Vec::new(); for dir in &self.plugin_dirs { @@ -41,7 +37,7 @@ impl PluginLoader { manifests.extend(found); } - Ok(manifests) + manifests } /// Discover plugins in a specific directory @@ -271,7 +267,7 @@ impl PluginLoader { /// /// Returns an error if the path does not exist, is missing `plugin.toml`, /// the WASM binary is not found, or the WASM file is invalid. - pub fn validate_plugin_package(&self, path: &Path) -> Result<()> { + pub fn validate_plugin_package(path: &Path) -> Result<()> { // Check that the path exists if !path.exists() { return Err(anyhow!("Plugin path does not exist: {}", path.display())); @@ -339,7 +335,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]); - let manifests = loader.discover_plugins().unwrap(); + let manifests = loader.discover_plugins(); assert_eq!(manifests.len(), 0); } @@ -367,7 +363,7 @@ wasm = "plugin.wasm" .unwrap(); let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]); - let manifests = loader.discover_plugins().unwrap(); + let manifests = loader.discover_plugins(); assert_eq!(manifests.len(), 1); assert_eq!(manifests[0].plugin.name, "test-plugin"); @@ -392,17 +388,15 @@ wasm = "plugin.wasm" "#; std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap(); - let loader = PluginLoader::new(vec![]); - // Should fail without WASM file - assert!(loader.validate_plugin_package(&plugin_dir).is_err()); + assert!(PluginLoader::validate_plugin_package(&plugin_dir).is_err()); // Create valid WASM file (magic number only) std::fs::write(plugin_dir.join("plugin.wasm"), b"\0asm\x01\x00\x00\x00") .unwrap(); // Should succeed now - assert!(loader.validate_plugin_package(&plugin_dir).is_ok()); + assert!(PluginLoader::validate_plugin_package(&plugin_dir).is_ok()); } #[test] @@ -426,7 +420,6 @@ wasm = "plugin.wasm" // Create invalid WASM file std::fs::write(plugin_dir.join("plugin.wasm"), b"not wasm").unwrap(); - let loader = PluginLoader::new(vec![]); - assert!(loader.validate_plugin_package(&plugin_dir).is_err()); + assert!(PluginLoader::validate_plugin_package(&plugin_dir).is_err()); } } diff --git a/crates/pinakes-plugin/src/manager.rs b/crates/pinakes-plugin/src/manager.rs index 22609e2..cfe33a9 100644 --- a/crates/pinakes-plugin/src/manager.rs +++ b/crates/pinakes-plugin/src/manager.rs @@ -144,7 +144,7 @@ impl PluginManager { pub async fn discover_and_load_all(&self) -> Result> { info!("Discovering plugins from {:?}", self.config.plugin_dirs); - let manifests = self.loader.discover_plugins()?; + let manifests = self.loader.discover_plugins(); let ordered = Self::resolve_load_order(&manifests); let mut loaded_plugins = Vec::new(); @@ -645,6 +645,7 @@ impl PluginManager { }, } } + drop(registry); pages } @@ -666,6 +667,7 @@ impl PluginManager { merged.insert(k.clone(), v.clone()); } } + drop(registry); merged } @@ -686,6 +688,7 @@ impl PluginManager { widgets.push((plugin.id.clone(), widget.clone())); } } + drop(registry); widgets } diff --git a/crates/pinakes-plugin/src/runtime.rs b/crates/pinakes-plugin/src/runtime.rs index e07a1c4..72afb43 100644 --- a/crates/pinakes-plugin/src/runtime.rs +++ b/crates/pinakes-plugin/src/runtime.rs @@ -561,9 +561,7 @@ impl HostFunctions { if let Some(ref allowed) = caller.data().context.capabilities.network.allowed_domains { - let parsed = if let Ok(u) = url::Url::parse(&url_str) { - u - } else { + let Ok(parsed) = url::Url::parse(&url_str) else { tracing::warn!(url = %url_str, "plugin provided invalid URL"); return -1; }; @@ -717,15 +715,12 @@ impl HostFunctions { return -2; } - match std::env::var(&key_str) { - Ok(value) => { - let bytes = value.into_bytes(); - let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX); - caller.data_mut().exchange_buffer = bytes; - len - }, - Err(_) => -1, - } + std::env::var(&key_str).map_or(-1, |value| { + let bytes = value.into_bytes(); + let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX); + caller.data_mut().exchange_buffer = bytes; + len + }) }, )?; diff --git a/crates/pinakes-plugin/src/security.rs b/crates/pinakes-plugin/src/security.rs index 6bebb94..b9810a3 100644 --- a/crates/pinakes-plugin/src/security.rs +++ b/crates/pinakes-plugin/src/security.rs @@ -242,7 +242,6 @@ impl CapabilityEnforcer { /// bugs from calling wrong functions on plugins. Returns `true` if allowed. #[must_use] pub fn validate_function_call( - &self, plugin_kinds: &[String], function_name: &str, ) -> bool { @@ -423,51 +422,91 @@ mod tests { #[test] fn test_validate_function_call_lifecycle_always_allowed() { - let enforcer = CapabilityEnforcer::new(); let kinds = vec!["metadata_extractor".to_string()]; - assert!(enforcer.validate_function_call(&kinds, "initialize")); - assert!(enforcer.validate_function_call(&kinds, "shutdown")); - assert!(enforcer.validate_function_call(&kinds, "health_check")); + assert!(CapabilityEnforcer::validate_function_call( + &kinds, + "initialize" + )); + assert!(CapabilityEnforcer::validate_function_call( + &kinds, "shutdown" + )); + assert!(CapabilityEnforcer::validate_function_call( + &kinds, + "health_check" + )); } #[test] fn test_validate_function_call_metadata_extractor() { - let enforcer = CapabilityEnforcer::new(); let kinds = vec!["metadata_extractor".to_string()]; - assert!(enforcer.validate_function_call(&kinds, "extract_metadata")); - assert!(enforcer.validate_function_call(&kinds, "supported_types")); - assert!(!enforcer.validate_function_call(&kinds, "search")); - assert!(!enforcer.validate_function_call(&kinds, "generate_thumbnail")); - assert!(!enforcer.validate_function_call(&kinds, "can_handle")); + assert!(CapabilityEnforcer::validate_function_call( + &kinds, + "extract_metadata" + )); + assert!(CapabilityEnforcer::validate_function_call( + &kinds, + "supported_types" + )); + assert!(!CapabilityEnforcer::validate_function_call( + &kinds, "search" + )); + assert!(!CapabilityEnforcer::validate_function_call( + &kinds, + "generate_thumbnail" + )); + assert!(!CapabilityEnforcer::validate_function_call( + &kinds, + "can_handle" + )); } #[test] fn test_validate_function_call_multi_kind() { - let enforcer = CapabilityEnforcer::new(); let kinds = vec!["media_type".to_string(), "metadata_extractor".to_string()]; - assert!(enforcer.validate_function_call(&kinds, "can_handle")); - assert!(enforcer.validate_function_call(&kinds, "supported_media_types")); - assert!(enforcer.validate_function_call(&kinds, "extract_metadata")); - assert!(!enforcer.validate_function_call(&kinds, "search")); + assert!(CapabilityEnforcer::validate_function_call( + &kinds, + "can_handle" + )); + assert!(CapabilityEnforcer::validate_function_call( + &kinds, + "supported_media_types" + )); + assert!(CapabilityEnforcer::validate_function_call( + &kinds, + "extract_metadata" + )); + assert!(!CapabilityEnforcer::validate_function_call( + &kinds, "search" + )); } #[test] fn test_validate_function_call_unknown_function() { - let enforcer = CapabilityEnforcer::new(); let kinds = vec!["metadata_extractor".to_string()]; - assert!(!enforcer.validate_function_call(&kinds, "unknown_func")); - assert!(!enforcer.validate_function_call(&kinds, "")); + assert!(!CapabilityEnforcer::validate_function_call( + &kinds, + "unknown_func" + )); + assert!(!CapabilityEnforcer::validate_function_call(&kinds, "")); } #[test] fn test_validate_function_call_shared_supported_types() { - let enforcer = CapabilityEnforcer::new(); let extractor = vec!["metadata_extractor".to_string()]; let generator = vec!["thumbnail_generator".to_string()]; let search = vec!["search_backend".to_string()]; - assert!(enforcer.validate_function_call(&extractor, "supported_types")); - assert!(enforcer.validate_function_call(&generator, "supported_types")); - assert!(!enforcer.validate_function_call(&search, "supported_types")); + assert!(CapabilityEnforcer::validate_function_call( + &extractor, + "supported_types" + )); + assert!(CapabilityEnforcer::validate_function_call( + &generator, + "supported_types" + )); + assert!(!CapabilityEnforcer::validate_function_call( + &search, + "supported_types" + )); } } diff --git a/crates/pinakes-sync/src/conflict.rs b/crates/pinakes-sync/src/conflict.rs index 986ccdd..0a9993b 100644 --- a/crates/pinakes-sync/src/conflict.rs +++ b/crates/pinakes-sync/src/conflict.rs @@ -94,8 +94,7 @@ pub const fn resolve_by_mtime(conflict: &ConflictInfo) -> ConflictOutcome { } }, (Some(_), None) => ConflictOutcome::UseLocal, - (None, Some(_)) => ConflictOutcome::UseServer, - (None, None) => ConflictOutcome::UseServer, // Default to server + (None, Some(_) | None) => ConflictOutcome::UseServer, // Default to server } } diff --git a/crates/pinakes-sync/src/models.rs b/crates/pinakes-sync/src/models.rs index 229c5e9..6588d47 100644 --- a/crates/pinakes-sync/src/models.rs +++ b/crates/pinakes-sync/src/models.rs @@ -364,7 +364,7 @@ impl UploadSession { chunk_count, status: UploadStatus::Pending, created_at: now, - expires_at: now + chrono::Duration::hours(timeout_hours as i64), + expires_at: now + chrono::Duration::hours(timeout_hours.cast_signed()), last_activity: now, } } diff --git a/crates/pinakes-types/src/config.rs b/crates/pinakes-types/src/config.rs index 9c131a8..29e31f1 100644 --- a/crates/pinakes-types/src/config.rs +++ b/crates/pinakes-types/src/config.rs @@ -504,7 +504,7 @@ pub enum UserRole { impl UserRole { #[must_use] - pub const fn can_read(self) -> bool { + pub const fn can_read() -> bool { true } @@ -533,14 +533,20 @@ impl std::fmt::Display for UserRole { pub struct PluginTimeoutConfig { /// Timeout for capability discovery queries (`supported_types`, /// `interested_events`) - #[serde(default = "default_capability_query_timeout")] - pub capability_query_secs: u64, + #[serde( + default = "default_capability_query_timeout", + rename = "capability_query_secs" + )] + pub capability_query: u64, /// Timeout for processing calls (`extract_metadata`, `generate_thumbnail`) - #[serde(default = "default_processing_timeout")] - pub processing_secs: u64, + #[serde(default = "default_processing_timeout", rename = "processing_secs")] + pub processing: u64, /// Timeout for event handler calls - #[serde(default = "default_event_handler_timeout")] - pub event_handler_secs: u64, + #[serde( + default = "default_event_handler_timeout", + rename = "event_handler_secs" + )] + pub event_handler: u64, } const fn default_capability_query_timeout() -> u64 { @@ -558,9 +564,9 @@ const fn default_event_handler_timeout() -> u64 { impl Default for PluginTimeoutConfig { fn default() -> Self { Self { - capability_query_secs: default_capability_query_timeout(), - processing_secs: default_processing_timeout(), - event_handler_secs: default_event_handler_timeout(), + capability_query: default_capability_query_timeout(), + processing: default_processing_timeout(), + event_handler: default_event_handler_timeout(), } } } @@ -1138,7 +1144,7 @@ pub enum StorageBackendType { impl StorageBackendType { #[must_use] - pub const fn as_str(&self) -> &'static str { + pub const fn as_str(self) -> &'static str { match self { Self::Sqlite => "sqlite", Self::Postgres => "postgres", diff --git a/crates/pinakes-types/src/media_type/builtin.rs b/crates/pinakes-types/src/media_type/builtin.rs index 93701b7..9201b6f 100644 --- a/crates/pinakes-types/src/media_type/builtin.rs +++ b/crates/pinakes-types/src/media_type/builtin.rs @@ -63,7 +63,7 @@ pub enum MediaCategory { impl BuiltinMediaType { /// Get the unique, stable ID for this media type. #[must_use] - pub const fn id(&self) -> &'static str { + pub const fn id(self) -> &'static str { match self { Self::Mp3 => "mp3", Self::Flac => "flac", @@ -100,7 +100,7 @@ impl BuiltinMediaType { /// Get the display name for this media type #[must_use] - pub fn name(&self) -> String { + pub fn name(self) -> String { match self { Self::Mp3 => "MP3 Audio".to_string(), Self::Flac => "FLAC Audio".to_string(), @@ -180,7 +180,7 @@ impl BuiltinMediaType { } #[must_use] - pub const fn mime_type(&self) -> &'static str { + pub const fn mime_type(self) -> &'static str { match self { Self::Mp3 => "audio/mpeg", Self::Flac => "audio/flac", @@ -216,7 +216,7 @@ impl BuiltinMediaType { } #[must_use] - pub const fn category(&self) -> MediaCategory { + pub const fn category(self) -> MediaCategory { match self { Self::Mp3 | Self::Flac @@ -246,7 +246,7 @@ impl BuiltinMediaType { } #[must_use] - pub const fn extensions(&self) -> &'static [&'static str] { + pub const fn extensions(self) -> &'static [&'static str] { match self { Self::Mp3 => &["mp3"], Self::Flac => &["flac"], @@ -283,7 +283,7 @@ impl BuiltinMediaType { /// Returns true if this is a RAW image format. #[must_use] - pub const fn is_raw(&self) -> bool { + pub const fn is_raw(self) -> bool { matches!( self, Self::Cr2 | Self::Nef | Self::Arw | Self::Dng | Self::Orf | Self::Rw2 diff --git a/crates/pinakes-types/src/media_type/registry.rs b/crates/pinakes-types/src/media_type/registry.rs index 871f12c..0c116dd 100644 --- a/crates/pinakes-types/src/media_type/registry.rs +++ b/crates/pinakes-types/src/media_type/registry.rs @@ -49,6 +49,10 @@ impl MediaTypeRegistry { } /// Register a new media type + /// + /// # Errors + /// + /// Returns an error if a media type with the same ID is already registered. pub fn register(&mut self, descriptor: MediaTypeDescriptor) -> Result<()> { // Check if ID is already registered if self.types.contains_key(&descriptor.id) { @@ -74,6 +78,10 @@ impl MediaTypeRegistry { } /// Unregister a media type + /// + /// # Errors + /// + /// Returns an error if no media type with the given ID is registered. pub fn unregister(&mut self, id: &str) -> Result<()> { let descriptor = self .types @@ -146,6 +154,10 @@ impl MediaTypeRegistry { } /// Unregister all types from a specific plugin + /// + /// # Errors + /// + /// Returns an error if unregistering any individual type fails. pub fn unregister_plugin(&mut self, plugin_id: &str) -> Result { let type_ids: Vec = self .types diff --git a/crates/pinakes-types/src/model.rs b/crates/pinakes-types/src/model.rs index f2f2863..c978f51 100644 --- a/crates/pinakes-types/src/model.rs +++ b/crates/pinakes-types/src/model.rs @@ -215,7 +215,7 @@ pub enum CustomFieldType { impl CustomFieldType { #[must_use] - pub const fn as_str(&self) -> &'static str { + pub const fn as_str(self) -> &'static str { match self { Self::Text => "text", Self::Number => "number", @@ -262,7 +262,7 @@ pub enum CollectionKind { impl CollectionKind { #[must_use] - pub const fn as_str(&self) -> &'static str { + pub const fn as_str(self) -> &'static str { match self { Self::Manual => "manual", Self::Virtual => "virtual", diff --git a/packages/pinakes-server/src/app.rs b/packages/pinakes-server/src/app.rs index 3d2f531..88f0080 100644 --- a/packages/pinakes-server/src/app.rs +++ b/packages/pinakes-server/src/app.rs @@ -27,8 +27,6 @@ pub fn create_router( create_router_with_tls(state, rate_limits, None) } -/// Build a governor rate limiter from per-second and burst-size values. -/// Panics if the config is invalid (callers must validate before use). fn build_governor( per_second: u64, burst_size: u32, @@ -38,13 +36,18 @@ fn build_governor( governor::middleware::NoOpMiddleware, >, > { - Arc::new( - GovernorConfigBuilder::default() - .per_second(per_second) - .burst_size(burst_size) - .finish() - .expect("rate limit config was validated at startup"), - ) + // finish() returns None only when per_second=0; clamp to ensure it always + // returns Some + let per_second = per_second.max(1); + let burst_size = burst_size.max(1); + let Some(config) = GovernorConfigBuilder::default() + .per_second(per_second) + .burst_size(burst_size) + .finish() + else { + return build_governor(1, 1); + }; + Arc::new(config) } /// Create the router with TLS configuration for security headers @@ -521,8 +524,16 @@ pub fn create_router_with_tls( // CORS configuration: use config-driven origins if specified, // otherwise fall back to default localhost origins let cors = { - let origins: Vec = - if let Ok(config_read) = state.config.try_read() { + let default_origins = || { + vec![ + HeaderValue::from_static("http://localhost:3000"), + HeaderValue::from_static("http://127.0.0.1:3000"), + HeaderValue::from_static("tauri://localhost"), + ] + }; + let origins: Vec = state.config.try_read().map_or_else( + |_| default_origins(), + |config_read| { if config_read.server.cors_enabled && !config_read.server.cors_origins.is_empty() { @@ -533,19 +544,10 @@ pub fn create_router_with_tls( .filter_map(|o| HeaderValue::from_str(o).ok()) .collect() } else { - vec![ - HeaderValue::from_static("http://localhost:3000"), - HeaderValue::from_static("http://127.0.0.1:3000"), - HeaderValue::from_static("tauri://localhost"), - ] + default_origins() } - } else { - vec![ - HeaderValue::from_static("http://localhost:3000"), - HeaderValue::from_static("http://127.0.0.1:3000"), - HeaderValue::from_static("tauri://localhost"), - ] - }; + }, + ); CorsLayer::new() .allow_origin(origins) diff --git a/packages/pinakes-server/src/auth.rs b/packages/pinakes-server/src/auth.rs index 6405612..ca81026 100644 --- a/packages/pinakes-server/src/auth.rs +++ b/packages/pinakes-server/src/auth.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use axum::{ extract::{Request, State}, http::StatusCode, @@ -90,8 +92,10 @@ pub async fn require_auth( if session.expires_at < now { let username = session.username; // Delete expired session in a bounded background task - if let Ok(permit) = state.session_semaphore.clone().try_acquire_owned() { - let storage = state.storage.clone(); + if let Ok(permit) = + Arc::clone(&state.session_semaphore).try_acquire_owned() + { + let storage = Arc::clone(&state.storage); let token_owned = token.clone(); tokio::spawn(async move { if let Err(e) = storage.delete_session(&token_owned).await { @@ -105,8 +109,9 @@ pub async fn require_auth( } // Update last_accessed timestamp in a bounded background task - if let Ok(permit) = state.session_semaphore.clone().try_acquire_owned() { - let storage = state.storage.clone(); + if let Ok(permit) = Arc::clone(&state.session_semaphore).try_acquire_owned() + { + let storage = Arc::clone(&state.storage); let token_owned = token.clone(); tokio::spawn(async move { if let Err(e) = storage.touch_session(&token_owned).await { @@ -209,7 +214,9 @@ pub async fn require_admin(request: Request, next: Next) -> Response { /// Resolve the authenticated username (from request extensions) to a `UserId`. /// -/// Returns an error if the user cannot be found. +/// # Errors +/// +/// Returns an error if the user cannot be found in the database. pub async fn resolve_user_id( storage: &pinakes_core::storage::DynStorageBackend, username: &str, diff --git a/packages/pinakes-server/src/dto/sharing.rs b/packages/pinakes-server/src/dto/sharing.rs index 4757e26..65dc4c1 100644 --- a/packages/pinakes-server/src/dto/sharing.rs +++ b/packages/pinakes-server/src/dto/sharing.rs @@ -17,6 +17,7 @@ pub struct CreateShareRequest { } #[derive(Debug, Deserialize, utoipa::ToSchema)] +#[allow(clippy::struct_field_names)] pub struct SharePermissionsRequest { pub can_view: Option, pub can_download: Option, @@ -47,6 +48,7 @@ pub struct ShareResponse { } #[derive(Debug, Serialize, utoipa::ToSchema)] +#[allow(clippy::struct_excessive_bools, clippy::struct_field_names)] pub struct SharePermissionsResponse { pub can_view: bool, pub can_download: bool, @@ -197,6 +199,6 @@ pub struct AccessSharedRequest { #[derive(Debug, Serialize, utoipa::ToSchema)] #[serde(untagged)] pub enum SharedContentResponse { - Single(super::MediaResponse), + Single(Box), Multiple { items: Vec }, } diff --git a/packages/pinakes-server/src/error.rs b/packages/pinakes-server/src/error.rs index c18592d..b5016ed 100644 --- a/packages/pinakes-server/src/error.rs +++ b/packages/pinakes-server/src/error.rs @@ -15,7 +15,11 @@ impl IntoResponse for ApiError { fn into_response(self) -> Response { use pinakes_core::error::PinakesError; let (status, message) = match &self.0 { - PinakesError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), + PinakesError::NotFound(msg) + | PinakesError::TagNotFound(msg) + | PinakesError::CollectionNotFound(msg) => { + (StatusCode::NOT_FOUND, msg.clone()) + }, PinakesError::FileNotFound(path) => { // Only expose the file name, not the full path let name = path.file_name().map_or_else( @@ -25,10 +29,6 @@ impl IntoResponse for ApiError { tracing::debug!(path = %path.display(), "file not found"); (StatusCode::NOT_FOUND, format!("file not found: {name}")) }, - PinakesError::TagNotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), - PinakesError::CollectionNotFound(msg) => { - (StatusCode::NOT_FOUND, msg.clone()) - }, PinakesError::DuplicateHash(msg) => (StatusCode::CONFLICT, msg.clone()), PinakesError::UnsupportedMediaType(path) => { let name = path.file_name().map_or_else( diff --git a/packages/pinakes-server/src/main.rs b/packages/pinakes-server/src/main.rs index d48f079..3c8c030 100644 --- a/packages/pinakes-server/src/main.rs +++ b/packages/pinakes-server/src/main.rs @@ -4,6 +4,7 @@ use anyhow::Result; use axum::{Router, response::Redirect, routing::any}; use clap::Parser; use pinakes_core::{config::Config, storage::StorageBackend}; +use pinakes_enrichment::EnrichmentSourceType; use pinakes_server::{app, state::AppState}; use tokio::sync::RwLock; use tracing::info; @@ -189,7 +190,7 @@ async fn main() -> Result<()> { // Start filesystem watcher if configured if config.scanning.watch { - let watch_storage = storage.clone(); + let watch_storage = Arc::clone(&storage); let watch_dirs = config.directories.roots.clone(); let watch_ignore = config.scanning.ignore_patterns.clone(); tokio::spawn(async move { @@ -245,9 +246,9 @@ async fn main() -> Result<()> { max_concurrent_ops: p.max_concurrent_ops, plugin_timeout_secs: p.plugin_timeout_secs, timeouts: pinakes_types::config::PluginTimeoutConfig { - capability_query_secs: p.timeouts.capability_query_secs, - processing_secs: p.timeouts.processing_secs, - event_handler_secs: p.timeouts.event_handler_secs, + capability_query: p.timeouts.capability_query, + processing: p.timeouts.processing, + event_handler: p.timeouts.event_handler, }, max_consecutive_failures: p.max_consecutive_failures, trusted_keys: p.trusted_keys.clone(), @@ -297,7 +298,7 @@ async fn main() -> Result<()> { }; // Initialize job queue with executor - let job_storage = storage.clone(); + let job_storage = Arc::clone(&storage); let job_config = config.clone(); let job_transcode = transcode_service.clone(); let job_webhooks = webhook_dispatcher.clone(); @@ -306,7 +307,7 @@ async fn main() -> Result<()> { config.jobs.worker_count, config.jobs.job_timeout_secs, move |job_id, kind, cancel, jobs| { - let storage = job_storage.clone(); + let storage = Arc::clone(&job_storage); let config = job_config.clone(); let transcode_svc = job_transcode.clone(); let webhooks = job_webhooks.clone(); @@ -400,10 +401,16 @@ async fn main() -> Result<()> { if cancel.is_cancelled() { break; } + #[expect( + clippy::cast_precision_loss, + reason = "progress ratio; precision loss negligible for \ + display" + )] + let progress = i as f32 / total as f32; JobQueue::update_progress( &jobs, job_id, - i as f32 / total as f32, + progress, format!("{i}/{total}"), ) .await; @@ -575,7 +582,12 @@ async fn main() -> Result<()> { enrich_cfg.sources.tmdb.enabled, enrich_cfg.sources.tmdb.api_key.clone(), ) { - enrichers.push(Box::new(TmdbEnricher::new(key))); + match TmdbEnricher::new(key) { + Ok(e) => enrichers.push(Box::new(e)), + Err(err) => { + tracing::warn!("Failed to build TMDB enricher: {err}"); + }, + } } if let (true, Some(key)) = ( enrich_cfg.sources.lastfm.enabled, @@ -613,7 +625,6 @@ async fn main() -> Result<()> { let category = item.media_type.category(); for enricher in &enrichers { let source = enricher.source(); - use pinakes_enrichment::EnrichmentSourceType; let applicable = match source { EnrichmentSourceType::MusicBrainz | EnrichmentSourceType::LastFm => { @@ -674,7 +685,7 @@ async fn main() -> Result<()> { JobKind::CleanupAnalytics => { let retention_days = config.analytics.retention_days; let before = chrono::Utc::now() - - chrono::Duration::days(retention_days as i64); + - chrono::Duration::days(retention_days.cast_signed()); match storage.cleanup_old_events(before).await { Ok(count) => { JobQueue::complete( @@ -690,7 +701,7 @@ async fn main() -> Result<()> { JobKind::TrashPurge => { let retention_days = config.trash.retention_days; let before = chrono::Utc::now() - - chrono::Duration::days(retention_days as i64); + - chrono::Duration::days(retention_days.cast_signed()); match storage.purge_old_trash(before).await { Ok(count) => { @@ -723,9 +734,9 @@ async fn main() -> Result<()> { let shutdown_token = tokio_util::sync::CancellationToken::new(); let config_arc = Arc::new(RwLock::new(config)); let scheduler = pinakes_core::scheduler::TaskScheduler::new( - job_queue.clone(), + Arc::clone(&job_queue), shutdown_token.clone(), - config_arc.clone(), + Arc::clone(&config_arc), Some(config_path.clone()), ); let scheduler = Arc::new(scheduler); @@ -735,7 +746,7 @@ async fn main() -> Result<()> { // Spawn scheduler background loop { - let scheduler = scheduler.clone(); + let scheduler = Arc::clone(&scheduler); tokio::spawn(async move { scheduler.run().await; }); @@ -796,8 +807,8 @@ async fn main() -> Result<()> { }; let state = AppState { - storage: storage.clone(), - config: config_arc.clone(), + storage: Arc::clone(&storage), + config: Arc::clone(&config_arc), config_path: Some(config_path), scan_progress: pinakes_core::scan::ScanProgress::new(), job_queue, @@ -816,7 +827,7 @@ async fn main() -> Result<()> { // Periodic session cleanup (every 15 minutes) { - let storage_clone = storage.clone(); + let storage_clone = Arc::clone(&storage); let cancel = shutdown_token.clone(); tokio::spawn(async move { let mut interval = @@ -844,7 +855,7 @@ async fn main() -> Result<()> { // Periodic chunked upload cleanup (every hour) if let Some(ref manager) = state.chunked_upload_manager { - let manager_clone = manager.clone(); + let manager_clone = Arc::clone(manager); let cancel = shutdown_token.clone(); tokio::spawn(async move { let mut interval = diff --git a/packages/pinakes-server/src/routes/analytics.rs b/packages/pinakes-server/src/routes/analytics.rs index fda8fd9..9b61bf8 100644 --- a/packages/pinakes-server/src/routes/analytics.rs +++ b/packages/pinakes-server/src/routes/analytics.rs @@ -39,6 +39,9 @@ const MAX_LIMIT: u64 = 100; ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_most_viewed( State(state): State, Query(params): Query, @@ -74,6 +77,9 @@ pub async fn get_most_viewed( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_recently_viewed( State(state): State, Extension(username): Extension, @@ -103,6 +109,9 @@ pub async fn get_recently_viewed( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn record_event( State(state): State, Extension(username): Extension, @@ -141,6 +150,9 @@ pub async fn record_event( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_watch_progress( State(state): State, Extension(username): Extension, @@ -174,6 +186,9 @@ pub async fn get_watch_progress( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn update_watch_progress( State(state): State, Extension(username): Extension, diff --git a/packages/pinakes-server/src/routes/audit.rs b/packages/pinakes-server/src/routes/audit.rs index 80ccd10..272a37e 100644 --- a/packages/pinakes-server/src/routes/audit.rs +++ b/packages/pinakes-server/src/routes/audit.rs @@ -24,6 +24,9 @@ use crate::{ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_audit( State(state): State, Query(params): Query, diff --git a/packages/pinakes-server/src/routes/auth.rs b/packages/pinakes-server/src/routes/auth.rs index a4561f5..d75e9d6 100644 --- a/packages/pinakes-server/src/routes/auth.rs +++ b/packages/pinakes-server/src/routes/auth.rs @@ -1,8 +1,10 @@ +use argon2::password_hash::PasswordVerifier; use axum::{ Json, extract::State, http::{HeaderMap, StatusCode}, }; +use rand::seq::IndexedRandom as _; use crate::{ dto::{LoginRequest, LoginResponse, UserInfoResponse}, @@ -17,6 +19,16 @@ const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,\ p=1$VGltaW5nU2FmZUR1bW15$c2ltdWxhdGVkX2hhc2hfZm9yX3RpbWluZ19zYWZldHk"; +/// Authenticate a user with username and password, creating a session. +/// +/// # Errors +/// +/// Returns an error if the credentials are invalid or the session cannot be +/// created. +/// +/// # Panics +/// +/// Panics if the CHARSET is empty (it is not). #[utoipa::path( post, path = "/api/v1/auth/login", @@ -53,12 +65,8 @@ pub async fn login( // Always perform password verification to prevent timing attacks. // If the user doesn't exist, we verify against a dummy hash to ensure // consistent response times regardless of whether the username exists. - use argon2::password_hash::PasswordVerifier; - - let (hash_to_verify, user_found) = match user { - Some(u) => (&u.password_hash as &str, true), - None => (DUMMY_HASH, false), - }; + let (hash_to_verify, user_found) = + user.map_or((DUMMY_HASH, false), |u| (&u.password_hash as &str, true)); let parsed_hash = argon2::password_hash::PasswordHash::new(hash_to_verify) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -97,13 +105,14 @@ pub async fn login( // Generate session token using unbiased uniform distribution #[expect(clippy::expect_used)] let token: String = { - use rand::seq::IndexedRandom; const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let mut rng = rand::rng(); - (0..48) - .map(|_| *CHARSET.choose(&mut rng).expect("non-empty charset") as char) - .collect() + std::iter::repeat_with(|| { + *CHARSET.choose(&mut rng).expect("non-empty charset") as char + }) + .take(48) + .collect() }; let role = user.role; @@ -118,7 +127,9 @@ pub async fn login( role: role.to_string(), created_at: now, expires_at: now - + chrono::Duration::hours(config.accounts.session_expiry_hours as i64), + + chrono::Duration::hours( + config.accounts.session_expiry_hours.cast_signed(), + ), last_accessed: now, }; @@ -195,6 +206,12 @@ pub async fn logout( StatusCode::OK } +/// Return current user info from the bearer token session. +/// +/// # Errors +/// +/// Returns an error if the token is missing, invalid, or the session lookup +/// fails. #[utoipa::path( get, path = "/api/v1/auth/me", @@ -243,6 +260,11 @@ fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> { /// Refresh the current session, extending its expiry by the configured /// duration. +/// +/// # Errors +/// +/// Returns an error if the token is missing, the session does not exist, or the +/// database update fails. #[utoipa::path( post, path = "/api/v1/auth/refresh", @@ -261,7 +283,7 @@ pub async fn refresh( let token = extract_bearer_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?; let config = state.config.read().await; - let expiry_hours = config.accounts.session_expiry_hours as i64; + let expiry_hours = config.accounts.session_expiry_hours.cast_signed(); drop(config); let new_expires_at = @@ -297,9 +319,8 @@ pub async fn revoke_all_sessions( State(state): State, headers: HeaderMap, ) -> StatusCode { - let token = match extract_bearer_token(&headers) { - Some(t) => t, - None => return StatusCode::UNAUTHORIZED, + let Some(token) = extract_bearer_token(&headers) else { + return StatusCode::UNAUTHORIZED; }; // Get current session to find username @@ -340,7 +361,11 @@ pub async fn revoke_all_sessions( } } -/// List all active sessions (admin only) +/// List all active sessions (admin only). +/// +/// # Errors +/// +/// Returns an error if the database query fails. #[derive(serde::Serialize, utoipa::ToSchema)] pub struct SessionListResponse { pub sessions: Vec, @@ -367,6 +392,9 @@ pub struct SessionInfo { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_active_sessions( State(state): State, ) -> Result, StatusCode> { diff --git a/packages/pinakes-server/src/routes/backup.rs b/packages/pinakes-server/src/routes/backup.rs index d80b31f..69e84e3 100644 --- a/packages/pinakes-server/src/routes/backup.rs +++ b/packages/pinakes-server/src/routes/backup.rs @@ -23,6 +23,9 @@ use crate::{error::ApiError, state::AppState}; ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn create_backup( State(state): State, ) -> Result { diff --git a/packages/pinakes-server/src/routes/books.rs b/packages/pinakes-server/src/routes/books.rs index 9993492..5640bba 100644 --- a/packages/pinakes-server/src/routes/books.rs +++ b/packages/pinakes-server/src/routes/books.rs @@ -168,19 +168,23 @@ pub struct AuthorSummary { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_book_metadata( State(state): State, Path(media_id): Path, ) -> Result { let media_id = MediaId(media_id); - let metadata = - state - .storage - .get_book_metadata(media_id) - .await? - .ok_or(ApiError(PinakesError::NotFound( + let metadata = state + .storage + .get_book_metadata(media_id) + .await? + .ok_or_else(|| { + ApiError(PinakesError::NotFound( "Book metadata not found".to_string(), - )))?; + )) + })?; Ok(Json(BookMetadataResponse::from(metadata))) } @@ -206,6 +210,9 @@ pub async fn get_book_metadata( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_books( State(state): State, Query(query): Query, @@ -247,6 +254,9 @@ pub async fn list_books( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_series( State(state): State, ) -> Result { @@ -276,6 +286,9 @@ pub async fn list_series( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_series_books( State(state): State, Path(series_name): Path, @@ -304,6 +317,9 @@ pub async fn get_series_books( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_authors( State(state): State, Query(pagination): Query, @@ -338,6 +354,9 @@ pub async fn list_authors( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_author_books( State(state): State, Path(author_name): Path, @@ -369,6 +388,9 @@ pub async fn get_author_books( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_reading_progress( State(state): State, Extension(username): Extension, @@ -381,9 +403,11 @@ pub async fn get_reading_progress( .storage .get_reading_progress(user_id.0, media_id) .await? - .ok_or(ApiError(PinakesError::NotFound( - "Reading progress not found".to_string(), - )))?; + .ok_or_else(|| { + ApiError(PinakesError::NotFound( + "Reading progress not found".to_string(), + )) + })?; Ok(Json(ReadingProgressResponse::from(progress))) } @@ -402,6 +426,9 @@ pub async fn get_reading_progress( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn update_reading_progress( State(state): State, Extension(username): Extension, @@ -438,6 +465,9 @@ pub async fn update_reading_progress( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_reading_list( State(state): State, Extension(username): Extension, diff --git a/packages/pinakes-server/src/routes/collections.rs b/packages/pinakes-server/src/routes/collections.rs index a1df04d..49b7b0a 100644 --- a/packages/pinakes-server/src/routes/collections.rs +++ b/packages/pinakes-server/src/routes/collections.rs @@ -30,6 +30,9 @@ use crate::{ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn create_collection( State(state): State, Json(req): Json, @@ -85,6 +88,9 @@ pub async fn create_collection( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_collections( State(state): State, ) -> Result>, ApiError> { @@ -107,6 +113,9 @@ pub async fn list_collections( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_collection( State(state): State, Path(id): Path, @@ -129,6 +138,9 @@ pub async fn get_collection( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_collection( State(state): State, Path(id): Path, @@ -158,6 +170,9 @@ pub async fn delete_collection( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn add_member( State(state): State, Path(collection_id): Path, @@ -190,6 +205,9 @@ pub async fn add_member( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn remove_member( State(state): State, Path((collection_id, media_id)): Path<(Uuid, Uuid)>, @@ -216,6 +234,9 @@ pub async fn remove_member( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_members( State(state): State, Path(collection_id): Path, diff --git a/packages/pinakes-server/src/routes/config.rs b/packages/pinakes-server/src/routes/config.rs index 7a76f83..55fdbaa 100644 --- a/packages/pinakes-server/src/routes/config.rs +++ b/packages/pinakes-server/src/routes/config.rs @@ -26,6 +26,9 @@ use crate::{ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_config( State(state): State, ) -> Result, ApiError> { @@ -36,18 +39,15 @@ pub async fn get_config( .config_path .as_ref() .map(|p| p.to_string_lossy().to_string()); - let config_writable = match &state.config_path { - Some(path) => { - if path.exists() { - std::fs::metadata(path).is_ok_and(|m| !m.permissions().readonly()) - } else { - path.parent().is_some_and(|parent| { - std::fs::metadata(parent).is_ok_and(|m| !m.permissions().readonly()) - }) - } - }, - None => false, - }; + let config_writable = state.config_path.as_ref().is_some_and(|path| { + if path.exists() { + std::fs::metadata(path).is_ok_and(|m| !m.permissions().readonly()) + } else { + path.parent().is_some_and(|parent| { + std::fs::metadata(parent).is_ok_and(|m| !m.permissions().readonly()) + }) + } + }); Ok(Json(ConfigResponse { backend: config.storage.backend.to_string(), @@ -86,6 +86,9 @@ pub async fn get_config( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_ui_config( State(state): State, ) -> Result, ApiError> { @@ -106,6 +109,9 @@ pub async fn get_ui_config( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn update_ui_config( State(state): State, Json(req): Json, @@ -153,6 +159,9 @@ pub async fn update_ui_config( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn update_scanning_config( State(state): State, Json(req): Json, @@ -179,18 +188,15 @@ pub async fn update_scanning_config( .config_path .as_ref() .map(|p| p.to_string_lossy().to_string()); - let config_writable = match &state.config_path { - Some(path) => { - if path.exists() { - std::fs::metadata(path).is_ok_and(|m| !m.permissions().readonly()) - } else { - path.parent().is_some_and(|parent| { - std::fs::metadata(parent).is_ok_and(|m| !m.permissions().readonly()) - }) - } - }, - None => false, - }; + let config_writable = state.config_path.as_ref().is_some_and(|path| { + if path.exists() { + std::fs::metadata(path).is_ok_and(|m| !m.permissions().readonly()) + } else { + path.parent().is_some_and(|parent| { + std::fs::metadata(parent).is_ok_and(|m| !m.permissions().readonly()) + }) + } + }); Ok(Json(ConfigResponse { backend: config.storage.backend.to_string(), @@ -232,6 +238,9 @@ pub async fn update_scanning_config( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn add_root( State(state): State, Json(req): Json, @@ -272,6 +281,9 @@ pub async fn add_root( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn remove_root( State(state): State, Json(req): Json, diff --git a/packages/pinakes-server/src/routes/database.rs b/packages/pinakes-server/src/routes/database.rs index e88fcb8..a927dd4 100644 --- a/packages/pinakes-server/src/routes/database.rs +++ b/packages/pinakes-server/src/routes/database.rs @@ -14,6 +14,9 @@ use crate::{dto::DatabaseStatsResponse, error::ApiError, state::AppState}; ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn database_stats( State(state): State, ) -> Result, ApiError> { @@ -40,6 +43,9 @@ pub async fn database_stats( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn vacuum_database( State(state): State, ) -> Result, ApiError> { @@ -59,6 +65,9 @@ pub async fn vacuum_database( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn clear_database( State(state): State, ) -> Result, ApiError> { diff --git a/packages/pinakes-server/src/routes/duplicates.rs b/packages/pinakes-server/src/routes/duplicates.rs index 6150979..ddcb5f4 100644 --- a/packages/pinakes-server/src/routes/duplicates.rs +++ b/packages/pinakes-server/src/routes/duplicates.rs @@ -17,6 +17,9 @@ use crate::{ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_duplicates( State(state): State, ) -> Result>, ApiError> { diff --git a/packages/pinakes-server/src/routes/enrichment.rs b/packages/pinakes-server/src/routes/enrichment.rs index 1060cc3..c7b9897 100644 --- a/packages/pinakes-server/src/routes/enrichment.rs +++ b/packages/pinakes-server/src/routes/enrichment.rs @@ -25,6 +25,9 @@ use crate::{ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn trigger_enrichment( State(state): State, Path(id): Path, @@ -52,6 +55,9 @@ pub async fn trigger_enrichment( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_external_metadata( State(state): State, Path(id): Path, @@ -79,6 +85,9 @@ pub async fn get_external_metadata( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_enrich( State(state): State, Json(req): Json, // Reuse: has media_ids field diff --git a/packages/pinakes-server/src/routes/export.rs b/packages/pinakes-server/src/routes/export.rs index 8251272..4172b2c 100644 --- a/packages/pinakes-server/src/routes/export.rs +++ b/packages/pinakes-server/src/routes/export.rs @@ -24,6 +24,9 @@ pub struct ExportRequest { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn trigger_export( State(state): State, ) -> Result, ApiError> { @@ -51,6 +54,9 @@ pub async fn trigger_export( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn trigger_export_with_options( State(state): State, Json(req): Json, diff --git a/packages/pinakes-server/src/routes/health.rs b/packages/pinakes-server/src/routes/health.rs index 7d30c27..14d9a0a 100644 --- a/packages/pinakes-server/src/routes/health.rs +++ b/packages/pinakes-server/src/routes/health.rs @@ -66,7 +66,8 @@ pub async fn health(State(state): State) -> Json { Ok(count) => { DatabaseHealth { status: "ok".to_string(), - latency_ms: db_start.elapsed().as_millis() as u64, + latency_ms: u64::try_from(db_start.elapsed().as_millis()) + .unwrap_or(u64::MAX), media_count: Some(count), } }, @@ -74,7 +75,8 @@ pub async fn health(State(state): State) -> Json { response.status = "degraded".to_string(); DatabaseHealth { status: format!("error: {e}"), - latency_ms: db_start.elapsed().as_millis() as u64, + latency_ms: u64::try_from(db_start.elapsed().as_millis()) + .unwrap_or(u64::MAX), media_count: None, } }, @@ -147,7 +149,8 @@ pub async fn readiness(State(state): State) -> impl IntoResponse { let db_start = Instant::now(); match state.storage.count_media().await { Ok(_) => { - let latency = db_start.elapsed().as_millis() as u64; + let latency = + u64::try_from(db_start.elapsed().as_millis()).unwrap_or(u64::MAX); ( StatusCode::OK, Json(serde_json::json!({ @@ -203,7 +206,8 @@ pub async fn health_detailed( Ok(count) => ("ok".to_string(), Some(count)), Err(e) => (format!("error: {e}"), None), }; - let db_latency = db_start.elapsed().as_millis() as u64; + let db_latency = + u64::try_from(db_start.elapsed().as_millis()).unwrap_or(u64::MAX); // Check filesystem let roots = state.storage.list_root_dirs().await.unwrap_or_default(); diff --git a/packages/pinakes-server/src/routes/integrity.rs b/packages/pinakes-server/src/routes/integrity.rs index f688e79..f3bda1a 100644 --- a/packages/pinakes-server/src/routes/integrity.rs +++ b/packages/pinakes-server/src/routes/integrity.rs @@ -21,6 +21,9 @@ pub struct OrphanResolveRequest { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn trigger_orphan_detection( State(state): State, ) -> Result, ApiError> { @@ -42,6 +45,9 @@ pub async fn trigger_orphan_detection( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn trigger_verify_integrity( State(state): State, Json(req): Json, @@ -73,6 +79,9 @@ pub struct VerifyIntegrityRequest { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn trigger_cleanup_thumbnails( State(state): State, ) -> Result, ApiError> { @@ -102,6 +111,9 @@ pub struct GenerateThumbnailsRequest { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn generate_all_thumbnails( State(state): State, body: Option>, @@ -140,6 +152,9 @@ pub async fn generate_all_thumbnails( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn resolve_orphans( State(state): State, Json(req): Json, diff --git a/packages/pinakes-server/src/routes/jobs.rs b/packages/pinakes-server/src/routes/jobs.rs index c7319cb..ba2863c 100644 --- a/packages/pinakes-server/src/routes/jobs.rs +++ b/packages/pinakes-server/src/routes/jobs.rs @@ -34,6 +34,9 @@ pub async fn list_jobs(State(state): State) -> Json> { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_job( State(state): State, Path(id): Path, @@ -57,6 +60,9 @@ pub async fn get_job( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn cancel_job( State(state): State, Path(id): Path, diff --git a/packages/pinakes-server/src/routes/media.rs b/packages/pinakes-server/src/routes/media.rs index ed4a7be..ff4c0ce 100644 --- a/packages/pinakes-server/src/routes/media.rs +++ b/packages/pinakes-server/src/routes/media.rs @@ -2,7 +2,10 @@ use axum::{ Json, extract::{Path, Query, State}, }; -use pinakes_core::{model::MediaId, storage::DynStorageBackend}; +use pinakes_core::{ + model::{CustomField, CustomFieldType, MediaId}, + storage::DynStorageBackend, +}; use rustc_hash::FxHashMap; use uuid::Uuid; @@ -113,6 +116,9 @@ async fn apply_import_post_processing( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn import_media( State(state): State, Json(req): Json, @@ -156,6 +162,9 @@ pub async fn import_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_media( State(state): State, Query(params): Query, @@ -184,6 +193,9 @@ pub async fn list_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_media( State(state): State, Path(id): Path, @@ -199,7 +211,7 @@ const MAX_SHORT_TEXT: usize = 500; const MAX_LONG_TEXT: usize = 10_000; fn validate_optional_text( - field: &Option, + field: Option<&str>, name: &str, max: usize, ) -> Result<(), ApiError> { @@ -231,16 +243,23 @@ fn validate_optional_text( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn update_media( State(state): State, Path(id): Path, Json(req): Json, ) -> Result, ApiError> { - validate_optional_text(&req.title, "title", MAX_SHORT_TEXT)?; - validate_optional_text(&req.artist, "artist", MAX_SHORT_TEXT)?; - validate_optional_text(&req.album, "album", MAX_SHORT_TEXT)?; - validate_optional_text(&req.genre, "genre", MAX_SHORT_TEXT)?; - validate_optional_text(&req.description, "description", MAX_LONG_TEXT)?; + validate_optional_text(req.title.as_deref(), "title", MAX_SHORT_TEXT)?; + validate_optional_text(req.artist.as_deref(), "artist", MAX_SHORT_TEXT)?; + validate_optional_text(req.album.as_deref(), "album", MAX_SHORT_TEXT)?; + validate_optional_text(req.genre.as_deref(), "genre", MAX_SHORT_TEXT)?; + validate_optional_text( + req.description.as_deref(), + "description", + MAX_LONG_TEXT, + )?; let mut item = state.storage.get_media(MediaId(id)).await?; @@ -302,6 +321,9 @@ pub async fn update_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_media( State(state): State, Path(id): Path, @@ -353,6 +375,9 @@ pub async fn delete_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn open_media( State(state): State, Path(id): Path, @@ -384,6 +409,9 @@ pub async fn open_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn stream_media( State(state): State, Path(id): Path, @@ -509,6 +537,9 @@ fn parse_range(header: &str, total_size: u64) -> Option<(u64, u64)> { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn import_with_options( State(state): State, Json(req): Json, @@ -557,6 +588,9 @@ pub async fn import_with_options( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_import( State(state): State, Json(req): Json, @@ -645,6 +679,9 @@ pub async fn batch_import( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn import_directory_endpoint( State(state): State, Json(req): Json, @@ -713,6 +750,50 @@ pub async fn import_directory_endpoint( })) } +fn walk_dir_preview( + dir: &std::path::Path, + recursive: bool, + roots: &[std::path::PathBuf], + result: &mut Vec, +) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + // Skip hidden files/dirs + if path + .file_name() + .is_some_and(|n| n.to_string_lossy().starts_with('.')) + { + continue; + } + if path.is_dir() { + if recursive { + walk_dir_preview(&path, recursive, roots, result); + } + } else if path.is_file() + && let Some(mt) = pinakes_core::media_type::MediaType::from_path(&path) + { + let size = entry.metadata().ok().map_or(0, |m| m.len()); + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + let media_type = serde_json::to_value(mt) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default(); + result.push(DirectoryPreviewFile { + path: crate::dto::relativize_path(&path, roots), + file_name, + media_type, + file_size: size, + }); + } + } +} + #[utoipa::path( post, path = "/api/v1/media/import/preview", @@ -726,6 +807,9 @@ pub async fn import_directory_endpoint( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn preview_directory( State(state): State, Json(req): Json, @@ -765,51 +849,7 @@ pub async fn preview_directory( let files: Vec = tokio::task::spawn_blocking(move || { let mut result = Vec::new(); - fn walk_dir( - dir: &std::path::Path, - recursive: bool, - roots: &[std::path::PathBuf], - result: &mut Vec, - ) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - // Skip hidden files/dirs - if path - .file_name() - .is_some_and(|n| n.to_string_lossy().starts_with('.')) - { - continue; - } - if path.is_dir() { - if recursive { - walk_dir(&path, recursive, roots, result); - } - } else if path.is_file() - && let Some(mt) = - pinakes_core::media_type::MediaType::from_path(&path) - { - let size = entry.metadata().ok().map_or(0, |m| m.len()); - let file_name = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - let media_type = serde_json::to_value(mt) - .ok() - .and_then(|v| v.as_str().map(String::from)) - .unwrap_or_default(); - result.push(DirectoryPreviewFile { - path: crate::dto::relativize_path(&path, roots), - file_name, - media_type, - file_size: size, - }); - } - } - } - walk_dir(&dir, recursive, &roots_for_walk, &mut result); + walk_dir_preview(&dir, recursive, &roots_for_walk, &mut result); result }) .await @@ -843,6 +883,9 @@ pub async fn preview_directory( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn set_custom_field( State(state): State, Path(id): Path, @@ -862,7 +905,6 @@ pub async fn set_custom_field( )), )); } - use pinakes_core::model::{CustomField, CustomFieldType}; let field_type = match req.field_type.as_str() { "number" => CustomFieldType::Number, "date" => CustomFieldType::Date, @@ -897,6 +939,9 @@ pub async fn set_custom_field( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_custom_field( State(state): State, Path((id, name)): Path<(Uuid, String)>, @@ -922,6 +967,9 @@ pub async fn delete_custom_field( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_tag( State(state): State, Json(req): Json, @@ -943,7 +991,7 @@ pub async fn batch_tag( { Ok(count) => { Ok(Json(BatchOperationResponse { - processed: count as usize, + processed: usize::try_from(count).unwrap_or(usize::MAX), errors: Vec::new(), })) }, @@ -968,6 +1016,9 @@ pub async fn batch_tag( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_all_media( State(state): State, ) -> Result, ApiError> { @@ -986,7 +1037,7 @@ pub async fn delete_all_media( match state.storage.delete_all_media().await { Ok(count) => { Ok(Json(BatchOperationResponse { - processed: count as usize, + processed: usize::try_from(count).unwrap_or(usize::MAX), errors: Vec::new(), })) }, @@ -1013,6 +1064,9 @@ pub async fn delete_all_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_delete( State(state): State, Json(req): Json, @@ -1044,7 +1098,7 @@ pub async fn batch_delete( match state.storage.batch_delete_media(&media_ids).await { Ok(count) => { Ok(Json(BatchOperationResponse { - processed: count as usize, + processed: usize::try_from(count).unwrap_or(usize::MAX), errors: Vec::new(), })) }, @@ -1071,6 +1125,9 @@ pub async fn batch_delete( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_add_to_collection( State(state): State, Json(req): Json, @@ -1090,7 +1147,7 @@ pub async fn batch_add_to_collection( &state.storage, req.collection_id, MediaId(*media_id), - i as i32, + i32::try_from(i).unwrap_or(i32::MAX), ) .await { @@ -1115,6 +1172,9 @@ pub async fn batch_add_to_collection( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_update( State(state): State, Json(req): Json, @@ -1144,7 +1204,7 @@ pub async fn batch_update( { Ok(count) => { Ok(Json(BatchOperationResponse { - processed: count as usize, + processed: usize::try_from(count).unwrap_or(usize::MAX), errors: Vec::new(), })) }, @@ -1170,6 +1230,9 @@ pub async fn batch_update( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_thumbnail( State(state): State, Path(id): Path, @@ -1214,6 +1277,9 @@ pub async fn get_thumbnail( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_media_count( State(state): State, ) -> Result, ApiError> { @@ -1237,6 +1303,9 @@ pub async fn get_media_count( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn rename_media( State(state): State, Path(id): Path, @@ -1305,6 +1374,9 @@ pub async fn rename_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn move_media_endpoint( State(state): State, Path(id): Path, @@ -1368,6 +1440,9 @@ pub async fn move_media_endpoint( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_move_media( State(state): State, Json(req): Json, @@ -1451,6 +1526,9 @@ pub async fn batch_move_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn soft_delete_media( State(state): State, Path(id): Path, @@ -1511,6 +1589,9 @@ pub async fn soft_delete_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn restore_media( State(state): State, Path(id): Path, @@ -1573,6 +1654,9 @@ pub async fn restore_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_trash( State(state): State, Query(params): Query, @@ -1603,6 +1687,9 @@ pub async fn list_trash( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn trash_info( State(state): State, ) -> Result, ApiError> { @@ -1622,6 +1709,9 @@ pub async fn trash_info( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn empty_trash( State(state): State, ) -> Result, ApiError> { @@ -1656,6 +1746,14 @@ pub async fn empty_trash( ), security(("bearer_auth" = [])) )] +// axum handlers cannot be generic over hasher types without breaking routing +#[expect( + clippy::implicit_hasher, + reason = "axum handler; generic over hasher breaks routing" +)] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn permanent_delete_media( State(state): State, Path(id): Path, diff --git a/packages/pinakes-server/src/routes/notes.rs b/packages/pinakes-server/src/routes/notes.rs index 6fe3a37..30a7e5f 100644 --- a/packages/pinakes-server/src/routes/notes.rs +++ b/packages/pinakes-server/src/routes/notes.rs @@ -12,13 +12,16 @@ use axum::{ extract::{Path, Query, State}, routing::{get, post}, }; -use pinakes_core::model::{ - BacklinkInfo, - GraphData, - GraphEdge, - GraphNode, - MarkdownLink, - MediaId, +use pinakes_core::{ + media_type::{BuiltinMediaType, MediaType}, + model::{ + BacklinkInfo, + GraphData, + GraphEdge, + GraphNode, + MarkdownLink, + MediaId, + }, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -214,6 +217,9 @@ pub struct UnresolvedLinksResponse { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_backlinks( State(state): State, Path(id): Path, @@ -247,6 +253,9 @@ pub async fn get_backlinks( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_outgoing_links( State(state): State, Path(id): Path, @@ -282,6 +291,9 @@ pub async fn get_outgoing_links( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_graph( State(state): State, Query(params): Query, @@ -310,6 +322,9 @@ pub async fn get_graph( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn reindex_links( State(state): State, Path(id): Path, @@ -320,7 +335,6 @@ pub async fn reindex_links( let media = state.storage.get_media(media_id).await?; // Only process markdown files - use pinakes_core::media_type::{BuiltinMediaType, MediaType}; match &media.media_type { MediaType::Builtin(BuiltinMediaType::Markdown) => {}, _ => { @@ -369,6 +383,9 @@ pub async fn reindex_links( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn resolve_links( State(state): State, ) -> Result, ApiError> { @@ -391,6 +408,9 @@ pub async fn resolve_links( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_unresolved_count( State(state): State, ) -> Result, ApiError> { diff --git a/packages/pinakes-server/src/routes/photos.rs b/packages/pinakes-server/src/routes/photos.rs index 7320427..9da3687 100644 --- a/packages/pinakes-server/src/routes/photos.rs +++ b/packages/pinakes-server/src/routes/photos.rs @@ -81,6 +81,10 @@ pub struct MapMarker { security(("bearer_auth" = [])) )] /// Get timeline of photos grouped by date +/// +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_timeline( State(state): State, Query(query): Query, @@ -183,6 +187,10 @@ pub async fn get_timeline( security(("bearer_auth" = [])) )] /// Get photos in a bounding box for map view +/// +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_map_photos( State(state): State, Query(query): Query, diff --git a/packages/pinakes-server/src/routes/playlists.rs b/packages/pinakes-server/src/routes/playlists.rs index 420897e..7a2cefc 100644 --- a/packages/pinakes-server/src/routes/playlists.rs +++ b/packages/pinakes-server/src/routes/playlists.rs @@ -3,6 +3,7 @@ use axum::{ extract::{Extension, Path, State}, }; use pinakes_core::{model::MediaId, playlists::Playlist, users::UserId}; +use rand::seq::SliceRandom as _; use uuid::Uuid; use crate::{ @@ -64,6 +65,9 @@ async fn check_playlist_access( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn create_playlist( State(state): State, Extension(username): Extension, @@ -102,6 +106,9 @@ pub async fn create_playlist( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_playlists( State(state): State, Extension(username): Extension, @@ -130,6 +137,9 @@ pub async fn list_playlists( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_playlist( State(state): State, Extension(username): Extension, @@ -156,6 +166,9 @@ pub async fn get_playlist( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn update_playlist( State(state): State, Extension(username): Extension, @@ -198,6 +211,9 @@ pub async fn update_playlist( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_playlist( State(state): State, Extension(username): Extension, @@ -223,6 +239,9 @@ pub async fn delete_playlist( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn add_item( State(state): State, Extension(username): Extension, @@ -235,7 +254,7 @@ pub async fn add_item( p } else { let items = state.storage.get_playlist_items(id).await?; - items.len() as i32 + i32::try_from(items.len()).unwrap_or(i32::MAX) }; state .storage @@ -260,6 +279,9 @@ pub async fn add_item( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn remove_item( State(state): State, Extension(username): Extension, @@ -287,6 +309,9 @@ pub async fn remove_item( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_items( State(state): State, Extension(username): Extension, @@ -318,6 +343,9 @@ pub async fn list_items( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn reorder_item( State(state): State, Extension(username): Extension, @@ -346,6 +374,9 @@ pub async fn reorder_item( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn shuffle_playlist( State(state): State, Extension(username): Extension, @@ -353,7 +384,6 @@ pub async fn shuffle_playlist( ) -> Result>, ApiError> { let user_id = resolve_user_id(&state.storage, &username).await?; check_playlist_access(&state.storage, id, user_id, false).await?; - use rand::seq::SliceRandom; let mut items = state.storage.get_playlist_items(id).await?; items.shuffle(&mut rand::rng()); let roots = state.config.read().await.directories.roots.clone(); diff --git a/packages/pinakes-server/src/routes/plugins.rs b/packages/pinakes-server/src/routes/plugins.rs index e5399d5..ae6a63d 100644 --- a/packages/pinakes-server/src/routes/plugins.rs +++ b/packages/pinakes-server/src/routes/plugins.rs @@ -42,6 +42,9 @@ fn require_plugin_manager( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_plugins( State(state): State, ) -> Result>, ApiError> { @@ -69,6 +72,9 @@ pub async fn list_plugins( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_plugin( State(state): State, Path(id): Path, @@ -99,6 +105,9 @@ pub async fn get_plugin( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn install_plugin( State(state): State, Json(req): Json, @@ -140,6 +149,9 @@ pub async fn install_plugin( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn uninstall_plugin( State(state): State, Path(id): Path, @@ -170,6 +182,9 @@ pub async fn uninstall_plugin( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn toggle_plugin( State(state): State, Path(id): Path, @@ -219,6 +234,9 @@ pub async fn toggle_plugin( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_plugin_ui_pages( State(state): State, ) -> Result>, ApiError> { @@ -249,6 +267,9 @@ pub async fn list_plugin_ui_pages( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_plugin_ui_widgets( State(state): State, ) -> Result>, ApiError> { @@ -275,6 +296,9 @@ pub async fn list_plugin_ui_widgets( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn emit_plugin_event( State(state): State, Json(req): Json, @@ -297,6 +321,9 @@ pub async fn emit_plugin_event( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_plugin_ui_theme_extensions( State(state): State, ) -> Result>, ApiError> { @@ -318,6 +345,9 @@ pub async fn list_plugin_ui_theme_extensions( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn reload_plugin( State(state): State, Path(id): Path, diff --git a/packages/pinakes-server/src/routes/saved_searches.rs b/packages/pinakes-server/src/routes/saved_searches.rs index 11bb4f0..9ccc981 100644 --- a/packages/pinakes-server/src/routes/saved_searches.rs +++ b/packages/pinakes-server/src/routes/saved_searches.rs @@ -44,6 +44,9 @@ const VALID_SORT_ORDERS: &[&str] = &[ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn create_saved_search( State(state): State, Json(req): Json, @@ -100,6 +103,9 @@ pub async fn create_saved_search( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_saved_searches( State(state): State, ) -> Result>, ApiError> { @@ -137,6 +143,9 @@ pub async fn list_saved_searches( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_saved_search( State(state): State, Path(id): Path, diff --git a/packages/pinakes-server/src/routes/scan.rs b/packages/pinakes-server/src/routes/scan.rs index f78b089..e0e89e7 100644 --- a/packages/pinakes-server/src/routes/scan.rs +++ b/packages/pinakes-server/src/routes/scan.rs @@ -20,6 +20,9 @@ use crate::{ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn trigger_scan( State(state): State, Json(req): Json, diff --git a/packages/pinakes-server/src/routes/scheduled_tasks.rs b/packages/pinakes-server/src/routes/scheduled_tasks.rs index 270c4ab..4b7c3fb 100644 --- a/packages/pinakes-server/src/routes/scheduled_tasks.rs +++ b/packages/pinakes-server/src/routes/scheduled_tasks.rs @@ -16,6 +16,9 @@ use crate::{dto::ScheduledTaskResponse, error::ApiError, state::AppState}; ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_scheduled_tasks( State(state): State, ) -> Result>, ApiError> { @@ -50,23 +53,26 @@ pub async fn list_scheduled_tasks( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn toggle_scheduled_task( State(state): State, Path(id): Path, ) -> Result, ApiError> { - match state.scheduler.toggle_task(&id).await { - Some(enabled) => { + state.scheduler.toggle_task(&id).await.map_or_else( + || { + Err(ApiError(pinakes_core::error::PinakesError::NotFound( + format!("scheduled task not found: {id}"), + ))) + }, + |enabled| { Ok(Json(serde_json::json!({ "id": id, "enabled": enabled, }))) }, - None => { - Err(ApiError(pinakes_core::error::PinakesError::NotFound( - format!("scheduled task not found: {id}"), - ))) - }, - } + ) } #[utoipa::path( @@ -82,21 +88,24 @@ pub async fn toggle_scheduled_task( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn run_scheduled_task_now( State(state): State, Path(id): Path, ) -> Result, ApiError> { - match state.scheduler.run_now(&id).await { - Some(job_id) => { + state.scheduler.run_now(&id).await.map_or_else( + || { + Err(ApiError(pinakes_core::error::PinakesError::NotFound( + format!("scheduled task not found: {id}"), + ))) + }, + |job_id| { Ok(Json(serde_json::json!({ "id": id, "job_id": job_id, }))) }, - None => { - Err(ApiError(pinakes_core::error::PinakesError::NotFound( - format!("scheduled task not found: {id}"), - ))) - }, - } + ) } diff --git a/packages/pinakes-server/src/routes/search.rs b/packages/pinakes-server/src/routes/search.rs index bebb04b..a062f1f 100644 --- a/packages/pinakes-server/src/routes/search.rs +++ b/packages/pinakes-server/src/routes/search.rs @@ -40,6 +40,9 @@ fn resolve_sort(sort: Option<&str>) -> SortOrder { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn search( State(state): State, Query(params): Query, @@ -87,6 +90,9 @@ pub async fn search( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn search_post( State(state): State, Json(body): Json, diff --git a/packages/pinakes-server/src/routes/shares.rs b/packages/pinakes-server/src/routes/shares.rs index 965b79e..e4f48bb 100644 --- a/packages/pinakes-server/src/routes/shares.rs +++ b/packages/pinakes-server/src/routes/shares.rs @@ -61,6 +61,9 @@ use crate::{ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn create_share( State(state): State, Extension(username): Extension, @@ -149,27 +152,28 @@ pub async fn create_share( }; // Parse permissions - let permissions = if let Some(perms) = req.permissions { - SharePermissions { - view: ShareViewPermissions { - can_view: perms.can_view.unwrap_or(true), - can_download: perms.can_download.unwrap_or(false), - can_reshare: perms.can_reshare.unwrap_or(false), - }, - mutate: ShareMutatePermissions { - can_edit: perms.can_edit.unwrap_or(false), - can_delete: perms.can_delete.unwrap_or(false), - can_add: perms.can_add.unwrap_or(false), - }, - } - } else { - SharePermissions::view_only() - }; + let permissions = + req + .permissions + .map_or_else(SharePermissions::view_only, |perms| { + SharePermissions { + view: ShareViewPermissions { + can_view: perms.can_view.unwrap_or(true), + can_download: perms.can_download.unwrap_or(false), + can_reshare: perms.can_reshare.unwrap_or(false), + }, + mutate: ShareMutatePermissions { + can_edit: perms.can_edit.unwrap_or(false), + can_delete: perms.can_delete.unwrap_or(false), + can_add: perms.can_add.unwrap_or(false), + }, + } + }); // Calculate expiration - let expires_at = req - .expires_in_hours - .map(|hours| Utc::now() + chrono::Duration::hours(hours as i64)); + let expires_at = req.expires_in_hours.map(|hours: u64| { + Utc::now() + chrono::Duration::hours(hours.cast_signed()) + }); let share = Share { id: ShareId(Uuid::now_v7()), @@ -228,6 +232,9 @@ pub async fn create_share( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_outgoing( State(state): State, Extension(username): Extension, @@ -261,6 +268,9 @@ pub async fn list_outgoing( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_incoming( State(state): State, Extension(username): Extension, @@ -293,6 +303,9 @@ pub async fn list_incoming( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_share( State(state): State, Extension(username): Extension, @@ -337,6 +350,9 @@ pub async fn get_share( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn update_share( State(state): State, Extension(username): Extension, @@ -430,6 +446,9 @@ pub async fn update_share( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_share( State(state): State, Extension(username): Extension, @@ -487,6 +506,9 @@ pub async fn delete_share( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_delete( State(state): State, Extension(username): Extension, @@ -540,6 +562,9 @@ pub async fn batch_delete( (status = 404, description = "Not found"), ) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn access_shared( State(state): State, Path(token): Path, @@ -618,8 +643,8 @@ pub async fn access_shared( .await .map_err(|e| ApiError::not_found(format!("Media not found: {e}")))?; - Ok(Json(SharedContentResponse::Single(MediaResponse::new( - item, &roots, + Ok(Json(SharedContentResponse::Single(Box::new( + MediaResponse::new(item, &roots), )))) }, ShareTarget::Collection { collection_id } => { @@ -724,6 +749,9 @@ pub async fn access_shared( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_activity( State(state): State, Extension(username): Extension, @@ -767,6 +795,9 @@ pub async fn get_activity( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_notifications( State(state): State, Extension(username): Extension, @@ -796,6 +827,9 @@ pub async fn get_notifications( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn mark_notification_read( State(state): State, Extension(username): Extension, @@ -823,6 +857,9 @@ pub async fn mark_notification_read( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn mark_all_read( State(state): State, Extension(username): Extension, diff --git a/packages/pinakes-server/src/routes/social.rs b/packages/pinakes-server/src/routes/social.rs index b378026..5fd01b9 100644 --- a/packages/pinakes-server/src/routes/social.rs +++ b/packages/pinakes-server/src/routes/social.rs @@ -22,6 +22,8 @@ use crate::{ state::AppState, }; +const MAX_SHARE_EXPIRY_HOURS: u64 = 8760; // 1 year + #[derive(Deserialize)] pub struct ShareLinkQuery { pub password: Option, @@ -41,6 +43,9 @@ pub struct ShareLinkQuery { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn rate_media( State(state): State, Extension(username): Extension, @@ -85,6 +90,9 @@ pub async fn rate_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_media_ratings( State(state): State, Path(id): Path, @@ -109,6 +117,9 @@ pub async fn get_media_ratings( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn add_comment( State(state): State, Extension(username): Extension, @@ -143,6 +154,9 @@ pub async fn add_comment( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_media_comments( State(state): State, Path(id): Path, @@ -165,6 +179,9 @@ pub async fn get_media_comments( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn add_favorite( State(state): State, Extension(username): Extension, @@ -190,6 +207,9 @@ pub async fn add_favorite( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn remove_favorite( State(state): State, Extension(username): Extension, @@ -214,6 +234,9 @@ pub async fn remove_favorite( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_favorites( State(state): State, Extension(username): Extension, @@ -245,6 +268,9 @@ pub async fn list_favorites( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn create_share_link( State(state): State, Extension(username): Extension, @@ -265,19 +291,18 @@ pub async fn create_share_link( }, None => None, }; - const MAX_EXPIRY_HOURS: u64 = 8760; // 1 year if let Some(h) = req.expires_in_hours - && h > MAX_EXPIRY_HOURS + && h > MAX_SHARE_EXPIRY_HOURS { return Err(ApiError( pinakes_core::error::PinakesError::InvalidOperation(format!( - "expires_in_hours cannot exceed {MAX_EXPIRY_HOURS}" + "expires_in_hours cannot exceed {MAX_SHARE_EXPIRY_HOURS}" )), )); } - let expires_at = req - .expires_in_hours - .map(|h| chrono::Utc::now() + chrono::Duration::hours(h as i64)); + let expires_at = req.expires_in_hours.map(|h: u64| { + chrono::Utc::now() + chrono::Duration::hours(h.cast_signed()) + }); let link = state .storage .create_share_link( @@ -305,6 +330,9 @@ pub async fn create_share_link( (status = 404, description = "Not found"), ) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn access_shared_media( State(state): State, Path(token): Path, @@ -330,15 +358,10 @@ pub async fn access_shared_media( } // Verify password if set if let Some(ref hash) = link.password_hash { - let password = match query.password.as_deref() { - Some(p) => p, - None => { - return Err(ApiError( - pinakes_core::error::PinakesError::Authentication( - "password required for this share link".into(), - ), - )); - }, + let Some(password) = query.password.as_deref() else { + return Err(ApiError(pinakes_core::error::PinakesError::Authentication( + "password required for this share link".into(), + ))); }; let valid = pinakes_core::users::auth::verify_password(password, hash) .unwrap_or(false); diff --git a/packages/pinakes-server/src/routes/statistics.rs b/packages/pinakes-server/src/routes/statistics.rs index 47d1a3b..d03d089 100644 --- a/packages/pinakes-server/src/routes/statistics.rs +++ b/packages/pinakes-server/src/routes/statistics.rs @@ -13,6 +13,9 @@ use crate::{dto::LibraryStatisticsResponse, error::ApiError, state::AppState}; ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn library_statistics( State(state): State, ) -> Result, ApiError> { diff --git a/packages/pinakes-server/src/routes/streaming.rs b/packages/pinakes-server/src/routes/streaming.rs index 622b5aa..c1748fe 100644 --- a/packages/pinakes-server/src/routes/streaming.rs +++ b/packages/pinakes-server/src/routes/streaming.rs @@ -1,3 +1,5 @@ +use std::fmt::Write as _; + use axum::{ extract::{Path, State}, http::StatusCode, @@ -61,6 +63,9 @@ fn escape_xml(s: &str) -> String { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn hls_master_playlist( State(state): State, Path(id): Path, @@ -78,10 +83,11 @@ pub async fn hls_master_playlist( let bandwidth = estimate_bandwidth(profile); let encoded_name = utf8_percent_encode(&profile.name, NON_ALPHANUMERIC).to_string(); - playlist.push_str(&format!( + let _ = write!( + playlist, "#EXT-X-STREAM-INF:BANDWIDTH={bandwidth},RESOLUTION={w}x{h}\n/api/v1/\ media/{id}/stream/hls/{encoded_name}/playlist.m3u8\n\n", - )); + ); } build_response("application/vnd.apple.mpegurl", playlist) @@ -103,6 +109,9 @@ pub async fn hls_master_playlist( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn hls_variant_playlist( State(state): State, Path((id, profile)): Path<(Uuid, String)>, @@ -118,6 +127,12 @@ pub async fn hls_variant_playlist( )); } let segment_duration = 10.0; + #[expect( + clippy::cast_sign_loss, + clippy::cast_possible_truncation, + reason = "duration/segment_duration is always non-negative and bounded by \ + media length" + )] let num_segments = (duration / segment_duration).ceil() as usize; let mut playlist = String::from( @@ -126,14 +141,20 @@ pub async fn hls_variant_playlist( ); for i in 0..num_segments.max(1) { let seg_dur = if i == num_segments.saturating_sub(1) && duration > 0.0 { - (i as f64).mul_add(-segment_duration, duration) + #[expect( + clippy::cast_precision_loss, + reason = "segment index is small, precision loss is negligible" + )] + let i_f64 = i as f64; + i_f64.mul_add(-segment_duration, duration) } else { segment_duration }; - playlist.push_str(&format!("#EXTINF:{seg_dur:.3},\n")); - playlist.push_str(&format!( - "/api/v1/media/{id}/stream/hls/{profile}/segment{i}.ts\n" - )); + let _ = writeln!(playlist, "#EXTINF:{seg_dur:.3},"); + let _ = writeln!( + playlist, + "/api/v1/media/{id}/stream/hls/{profile}/segment{i}.ts" + ); } playlist.push_str("#EXT-X-ENDLIST\n"); @@ -157,6 +178,9 @@ pub async fn hls_variant_playlist( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn hls_segment( State(state): State, Path((id, profile, segment)): Path<(Uuid, String, String)>, @@ -206,7 +230,7 @@ pub async fn hls_segment( Err(ApiError( pinakes_core::error::PinakesError::InvalidOperation( "no transcode session found; start a transcode first via POST \ - /media/{id}/transcode" + /media/:id/transcode" .into(), ), )) @@ -225,6 +249,9 @@ pub async fn hls_segment( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn dash_manifest( State(state): State, Path(id): Path, @@ -239,7 +266,19 @@ pub async fn dash_manifest( ), )); } + #[expect( + clippy::cast_sign_loss, + clippy::cast_possible_truncation, + reason = "duration is always non-negative and bounded; hours/minutes fit \ + in u32" + )] let hours = (duration / 3600.0) as u32; + #[expect( + clippy::cast_sign_loss, + clippy::cast_possible_truncation, + reason = "duration is always non-negative and bounded; hours/minutes fit \ + in u32" + )] let minutes = ((duration % 3600.0) / 60.0) as u32; let seconds = duration % 60.0; @@ -253,12 +292,13 @@ pub async fn dash_manifest( let xml_name = escape_xml(&profile.name); let url_name = utf8_percent_encode(&profile.name, NON_ALPHANUMERIC).to_string(); - representations.push_str(&format!( - r#" + let _ = write!( + representations, + r#" "#, - )); + ); } let mpd = format!( @@ -291,6 +331,9 @@ pub async fn dash_manifest( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn dash_segment( State(state): State, Path((id, profile, segment)): Path<(Uuid, String, String)>, @@ -338,7 +381,7 @@ pub async fn dash_segment( Err(ApiError( pinakes_core::error::PinakesError::InvalidOperation( "no transcode session found; start a transcode first via POST \ - /media/{id}/transcode" + /media/:id/transcode" .into(), ), )) diff --git a/packages/pinakes-server/src/routes/subtitles.rs b/packages/pinakes-server/src/routes/subtitles.rs index 3e55af3..3f80bce 100644 --- a/packages/pinakes-server/src/routes/subtitles.rs +++ b/packages/pinakes-server/src/routes/subtitles.rs @@ -1,3 +1,5 @@ +use std::path::Component; + use axum::{ Json, extract::{Path, State}, @@ -38,6 +40,9 @@ use crate::{ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_subtitles( State(state): State, Path(id): Path, @@ -74,6 +79,9 @@ pub async fn list_subtitles( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn add_subtitle( State(state): State, Path(id): Path, @@ -139,7 +147,6 @@ pub async fn add_subtitle( let path = std::path::PathBuf::from(&path_str); - use std::path::Component; if !path.is_absolute() || path.components().any(|c| c == Component::ParentDir) { @@ -204,6 +211,9 @@ pub async fn add_subtitle( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_subtitle( State(state): State, Path(id): Path, @@ -227,6 +237,9 @@ pub async fn delete_subtitle( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_subtitle_content( State(state): State, Path((media_id, subtitle_id)): Path<(Uuid, Uuid)>, @@ -300,6 +313,9 @@ pub async fn get_subtitle_content( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn update_offset( State(state): State, Path(id): Path, diff --git a/packages/pinakes-server/src/routes/sync.rs b/packages/pinakes-server/src/routes/sync.rs index 33a09d6..7b2fd34 100644 --- a/packages/pinakes-server/src/routes/sync.rs +++ b/packages/pinakes-server/src/routes/sync.rs @@ -68,6 +68,9 @@ const DEFAULT_CHANGES_LIMIT: u64 = 100; ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn register_device( State(state): State, Extension(username): Extension, @@ -132,6 +135,9 @@ pub async fn register_device( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_devices( State(state): State, Extension(username): Extension, @@ -161,6 +167,9 @@ pub async fn list_devices( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_device( State(state): State, Extension(username): Extension, @@ -197,6 +206,9 @@ pub async fn get_device( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn update_device( State(state): State, Extension(username): Extension, @@ -246,6 +258,9 @@ pub async fn update_device( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_device( State(state): State, Extension(username): Extension, @@ -287,6 +302,9 @@ pub async fn delete_device( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn regenerate_token( State(state): State, Extension(username): Extension, @@ -342,6 +360,9 @@ pub async fn regenerate_token( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_changes( State(state): State, Query(params): Query, @@ -391,6 +412,9 @@ pub async fn get_changes( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn report_changes( State(state): State, Extension(_username): Extension, @@ -505,6 +529,9 @@ pub async fn report_changes( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn acknowledge_changes( State(state): State, Extension(_username): Extension, @@ -545,6 +572,9 @@ pub async fn acknowledge_changes( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_conflicts( State(state): State, Extension(_username): Extension, @@ -587,6 +617,9 @@ pub async fn list_conflicts( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn resolve_conflict( State(state): State, Extension(_username): Extension, @@ -625,6 +658,9 @@ pub async fn resolve_conflict( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn create_upload( State(state): State, Extension(_username): Extension, @@ -665,7 +701,8 @@ pub async fn create_upload( chunk_count, status: UploadStatus::Pending, created_at: now, - expires_at: now + chrono::Duration::hours(upload_timeout_hours as i64), + expires_at: now + + chrono::Duration::hours(upload_timeout_hours.cast_signed()), last_activity: now, }; @@ -706,6 +743,9 @@ pub async fn create_upload( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn upload_chunk( State(state): State, Path((session_id, chunk_index)): Path<(Uuid, u64)>, @@ -767,6 +807,9 @@ pub async fn upload_chunk( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_upload_status( State(state): State, Path(id): Path, @@ -793,6 +836,9 @@ pub async fn get_upload_status( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn complete_upload( State(state): State, Path(id): Path, @@ -809,7 +855,8 @@ pub async fn complete_upload( .await .map_err(|e| ApiError::internal(format!("Failed to get chunks: {e}")))?; - if chunks.len() != session.chunk_count as usize { + if chunks.len() != usize::try_from(session.chunk_count).unwrap_or(usize::MAX) + { return Err(ApiError::bad_request(format!( "Missing chunks: expected {}, got {}", session.chunk_count, @@ -961,6 +1008,9 @@ pub async fn complete_upload( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn cancel_upload( State(state): State, Path(id): Path, @@ -1004,6 +1054,9 @@ pub async fn cancel_upload( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn download_file( State(state): State, Path(path): Path, diff --git a/packages/pinakes-server/src/routes/tags.rs b/packages/pinakes-server/src/routes/tags.rs index 506f855..90fffa6 100644 --- a/packages/pinakes-server/src/routes/tags.rs +++ b/packages/pinakes-server/src/routes/tags.rs @@ -25,6 +25,9 @@ use crate::{ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn create_tag( State(state): State, Json(req): Json, @@ -53,6 +56,9 @@ pub async fn create_tag( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_tags( State(state): State, ) -> Result>, ApiError> { @@ -73,6 +79,9 @@ pub async fn list_tags( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_tag( State(state): State, Path(id): Path, @@ -95,6 +104,9 @@ pub async fn get_tag( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_tag( State(state): State, Path(id): Path, @@ -118,6 +130,9 @@ pub async fn delete_tag( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn tag_media( State(state): State, Path(media_id): Path, @@ -154,6 +169,9 @@ pub async fn tag_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn untag_media( State(state): State, Path((media_id, tag_id)): Path<(Uuid, Uuid)>, @@ -185,6 +203,9 @@ pub async fn untag_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_media_tags( State(state): State, Path(media_id): Path, diff --git a/packages/pinakes-server/src/routes/transcode.rs b/packages/pinakes-server/src/routes/transcode.rs index 81a8b5c..2cc9169 100644 --- a/packages/pinakes-server/src/routes/transcode.rs +++ b/packages/pinakes-server/src/routes/transcode.rs @@ -25,6 +25,9 @@ use crate::{ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn start_transcode( State(state): State, Path(id): Path, @@ -55,6 +58,9 @@ pub async fn start_transcode( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_session( State(state): State, Path(id): Path, @@ -73,6 +79,9 @@ pub async fn get_session( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_sessions( State(state): State, Query(params): Query, @@ -99,6 +108,9 @@ pub async fn list_sessions( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn cancel_session( State(state): State, Path(id): Path, diff --git a/packages/pinakes-server/src/routes/upload.rs b/packages/pinakes-server/src/routes/upload.rs index cba6451..693cb62 100644 --- a/packages/pinakes-server/src/routes/upload.rs +++ b/packages/pinakes-server/src/routes/upload.rs @@ -44,6 +44,9 @@ fn sanitize_content_disposition(filename: &str) -> String { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn upload_file( State(state): State, mut multipart: Multipart, @@ -110,6 +113,9 @@ pub async fn upload_file( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn download_file( State(state): State, Path(id): Path, @@ -192,6 +198,9 @@ pub async fn download_file( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn move_to_managed( State(state): State, Path(id): Path, @@ -226,6 +235,9 @@ pub async fn move_to_managed( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn managed_stats( State(state): State, ) -> ApiResult> { diff --git a/packages/pinakes-server/src/routes/users.rs b/packages/pinakes-server/src/routes/users.rs index f88e466..29c6ad3 100644 --- a/packages/pinakes-server/src/routes/users.rs +++ b/packages/pinakes-server/src/routes/users.rs @@ -27,6 +27,9 @@ use crate::{ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_users( State(state): State, ) -> Result>, ApiError> { @@ -53,6 +56,9 @@ pub async fn list_users( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn create_user( State(state): State, Json(req): Json, @@ -116,6 +122,9 @@ pub async fn create_user( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_user( State(state): State, Path(id): Path, @@ -151,6 +160,9 @@ pub async fn get_user( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn update_user( State(state): State, Path(id): Path, @@ -199,6 +211,9 @@ pub async fn update_user( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_user( State(state): State, Path(id): Path, @@ -227,6 +242,9 @@ pub async fn delete_user( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_user_libraries( State(state): State, Path(id): Path, @@ -277,6 +295,9 @@ fn validate_root_path(path: &str) -> Result<(), ApiError> { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn grant_library_access( State(state): State, Path(id): Path, @@ -316,6 +337,9 @@ pub async fn grant_library_access( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn revoke_library_access( State(state): State, Path(id): Path, diff --git a/packages/pinakes-server/src/routes/webhooks.rs b/packages/pinakes-server/src/routes/webhooks.rs index ca53d70..875ec37 100644 --- a/packages/pinakes-server/src/routes/webhooks.rs +++ b/packages/pinakes-server/src/routes/webhooks.rs @@ -20,6 +20,9 @@ pub struct WebhookInfo { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_webhooks( State(state): State, ) -> Result>, ApiError> { @@ -48,6 +51,9 @@ pub async fn list_webhooks( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn test_webhook( State(state): State, ) -> Result, ApiError> { @@ -55,17 +61,20 @@ pub async fn test_webhook( let count = config.webhooks.len(); drop(config); - if let Some(ref dispatcher) = state.webhook_dispatcher { - dispatcher.dispatch(pinakes_core::webhooks::WebhookEvent::Test); - Ok(Json(serde_json::json!({ - "webhooks_configured": count, - "test_sent": true - }))) - } else { - Ok(Json(serde_json::json!({ - "webhooks_configured": 0, - "test_sent": false, - "message": "no webhooks configured" - }))) - } + state.webhook_dispatcher.as_ref().map_or_else( + || { + Ok(Json(serde_json::json!({ + "webhooks_configured": 0, + "test_sent": false, + "message": "no webhooks configured" + }))) + }, + |dispatcher| { + dispatcher.dispatch(pinakes_core::webhooks::WebhookEvent::Test); + Ok(Json(serde_json::json!({ + "webhooks_configured": count, + "test_sent": true + }))) + }, + ) }