use std::collections::HashMap; use std::path::PathBuf; use chrono::Utc; use deadpool_postgres::{Config as PoolConfig, Pool, Runtime}; use native_tls::TlsConnector; use postgres_native_tls::MakeTlsConnector; use tokio_postgres::types::ToSql; use tokio_postgres::{NoTls, Row}; use uuid::Uuid; use crate::config::PostgresConfig; use crate::error::{PinakesError, Result}; use crate::media_type::MediaType; use crate::model::*; use crate::search::*; use crate::storage::StorageBackend; pub struct PostgresBackend { pool: Pool, } impl PostgresBackend { pub async fn new(config: &PostgresConfig) -> Result { let mut pool_config = PoolConfig::new(); pool_config.host = Some(config.host.clone()); pool_config.port = Some(config.port); pool_config.dbname = Some(config.database.clone()); pool_config.user = Some(config.username.clone()); pool_config.password = Some(config.password.clone()); if config.tls_enabled { // Build TLS connector let mut tls_builder = TlsConnector::builder(); // Load custom CA certificate if provided if let Some(ref ca_cert_path) = config.tls_ca_cert_path { let cert_bytes = std::fs::read(ca_cert_path).map_err(|e| { PinakesError::Config(format!( "failed to read CA certificate file {}: {e}", ca_cert_path.display() )) })?; let cert = native_tls::Certificate::from_pem(&cert_bytes).map_err(|e| { PinakesError::Config(format!( "failed to parse CA certificate {}: {e}", ca_cert_path.display() )) })?; tls_builder.add_root_certificate(cert); } // Configure certificate validation if !config.tls_verify_ca { tracing::warn!( "PostgreSQL TLS certificate verification disabled - this is insecure!" ); tls_builder.danger_accept_invalid_certs(true); } let connector = tls_builder.build().map_err(|e| { PinakesError::Database(format!("failed to build TLS connector: {e}")) })?; let tls = MakeTlsConnector::new(connector); let pool = pool_config .create_pool(Some(Runtime::Tokio1), tls) .map_err(|e| { PinakesError::Database(format!("failed to create connection pool: {e}")) })?; // Verify connectivity let _ = pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to connect to postgres: {e}")) })?; tracing::info!("PostgreSQL connection established with TLS"); Ok(Self { pool }) } else { tracing::warn!( "PostgreSQL TLS is disabled - connection is unencrypted. \ Set postgres.tls_enabled = true to enable encryption." ); let pool = pool_config .create_pool(Some(Runtime::Tokio1), NoTls) .map_err(|e| { PinakesError::Database(format!("failed to create connection pool: {e}")) })?; // Verify connectivity let _ = pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to connect to postgres: {e}")) })?; Ok(Self { pool }) } } } fn media_type_to_string(mt: &MediaType) -> String { serde_json::to_value(mt) .ok() .and_then(|v| v.as_str().map(String::from)) .unwrap_or_else(|| format!("{mt:?}").to_lowercase()) } fn media_type_from_string(s: &str) -> Result { serde_json::from_value(serde_json::Value::String(s.to_string())) .map_err(|_| PinakesError::Database(format!("unknown media type: {s}"))) } fn audit_action_to_string(action: &AuditAction) -> String { // AuditAction uses serde rename_all = "snake_case" serde_json::to_value(action) .ok() .and_then(|v| v.as_str().map(String::from)) .unwrap_or_else(|| format!("{action}")) } fn audit_action_from_string(s: &str) -> Result { serde_json::from_value(serde_json::Value::String(s.to_string())) .map_err(|_| PinakesError::Database(format!("unknown audit action: {s}"))) } fn collection_kind_to_string(kind: &CollectionKind) -> String { serde_json::to_value(kind) .ok() .and_then(|v| v.as_str().map(String::from)) .unwrap_or_else(|| format!("{kind:?}").to_lowercase()) } fn collection_kind_from_string(s: &str) -> Result { serde_json::from_value(serde_json::Value::String(s.to_string())) .map_err(|_| PinakesError::Database(format!("unknown collection kind: {s}"))) } fn custom_field_type_to_string(ft: &CustomFieldType) -> String { serde_json::to_value(ft) .ok() .and_then(|v| v.as_str().map(String::from)) .unwrap_or_else(|| format!("{ft:?}").to_lowercase()) } fn custom_field_type_from_string(s: &str) -> Result { serde_json::from_value(serde_json::Value::String(s.to_string())) .map_err(|_| PinakesError::Database(format!("unknown custom field type: {s}"))) } fn storage_mode_from_string(s: &str) -> StorageMode { match s { "managed" => StorageMode::Managed, _ => StorageMode::External, } } fn row_to_media_item(row: &Row) -> Result { let media_type_str: String = row.get("media_type"); let media_type = media_type_from_string(&media_type_str)?; let storage_mode_str: String = row.get("storage_mode"); let storage_mode = storage_mode_from_string(&storage_mode_str); Ok(MediaItem { id: MediaId(row.get("id")), path: PathBuf::from(row.get::<_, String>("path")), file_name: row.get("file_name"), media_type, content_hash: ContentHash(row.get("content_hash")), file_size: row.get::<_, i64>("file_size") as u64, title: row.get("title"), artist: row.get("artist"), album: row.get("album"), genre: row.get("genre"), year: row.get("year"), duration_secs: row.get("duration_secs"), description: row.get("description"), thumbnail_path: row .get::<_, Option>("thumbnail_path") .map(PathBuf::from), custom_fields: HashMap::new(), file_mtime: row.get("file_mtime"), // Photo-specific fields date_taken: row.get("date_taken"), latitude: row.get("latitude"), longitude: row.get("longitude"), camera_make: row.get("camera_make"), camera_model: row.get("camera_model"), rating: row.get("rating"), perceptual_hash: row.get("perceptual_hash"), // Managed storage fields storage_mode, original_filename: row.get("original_filename"), uploaded_at: row.get("uploaded_at"), storage_key: row.get("storage_key"), created_at: row.get("created_at"), updated_at: row.get("updated_at"), // Trash support deleted_at: row.try_get("deleted_at").ok().flatten(), // Markdown links extraction timestamp links_extracted_at: row.try_get("links_extracted_at").ok().flatten(), }) } fn row_to_tag(row: &Row) -> Result { Ok(Tag { id: row.get("id"), name: row.get("name"), parent_id: row.get("parent_id"), created_at: row.get("created_at"), }) } fn row_to_collection(row: &Row) -> Result { let kind_str: String = row.get("kind"); let kind = collection_kind_from_string(&kind_str)?; Ok(Collection { id: row.get("id"), name: row.get("name"), description: row.get("description"), kind, filter_query: row.get("filter_query"), created_at: row.get("created_at"), updated_at: row.get("updated_at"), }) } fn row_to_audit_entry(row: &Row) -> Result { let action_str: String = row.get("action"); let action = audit_action_from_string(&action_str)?; let media_id: Option = row.get("media_id"); Ok(AuditEntry { id: row.get("id"), media_id: media_id.map(MediaId), action, details: row.get("details"), timestamp: row.get("timestamp"), }) } /// Recursively builds a tsquery string and collects parameters for a SearchQuery. /// /// Returns a tuple of: /// - `sql_fragment`: the WHERE clause fragment (may include $N placeholders) /// - `params`: boxed parameter values matching the placeholders /// - `type_filters`: collected TypeFilter values to append as extra WHERE clauses /// - `tag_filters`: collected TagFilter values to append as extra WHERE clauses /// /// `param_offset` is the current 1-based parameter index; the function returns /// the next available offset. fn build_search_clause( query: &SearchQuery, param_offset: &mut i32, params: &mut Vec>, ) -> Result<(String, Vec, Vec)> { let mut type_filters = Vec::new(); let mut tag_filters = Vec::new(); let fragment = build_search_inner( query, param_offset, params, &mut type_filters, &mut tag_filters, )?; Ok((fragment, type_filters, tag_filters)) } fn build_search_inner( query: &SearchQuery, offset: &mut i32, params: &mut Vec>, type_filters: &mut Vec, tag_filters: &mut Vec, ) -> Result { match query { SearchQuery::FullText(text) => { if text.is_empty() { return Ok("TRUE".to_string()); } // Combine FTS with trigram similarity and ILIKE for comprehensive fuzzy matching // This allows partial matches like "mus" -> "music" let idx_fts = *offset; *offset += 1; let idx_prefix = *offset; *offset += 1; let idx_ilike = *offset; *offset += 1; let idx_sim_title = *offset; *offset += 1; let idx_sim_artist = *offset; *offset += 1; let idx_sim_album = *offset; *offset += 1; let idx_sim_filename = *offset; *offset += 1; // Sanitize for tsquery prefix matching let sanitized = text.replace(['&', '|', '!', '(', ')', ':', '*', '\\', '\''], ""); let prefix_query = if sanitized.contains(' ') { // For multi-word, join with & and add :* to last word let words: Vec<&str> = sanitized.split_whitespace().collect(); if let Some((last, rest)) = words.split_last() { let prefix_parts: Vec = rest.iter().map(|w| w.to_string()).collect(); if prefix_parts.is_empty() { format!("{}:*", last) } else { format!("{} & {}:*", prefix_parts.join(" & "), last) } } else { format!("{}:*", sanitized) } } else { format!("{}:*", sanitized) }; params.push(Box::new(text.clone())); params.push(Box::new(prefix_query)); params.push(Box::new(format!("%{}%", text))); params.push(Box::new(text.clone())); params.push(Box::new(text.clone())); params.push(Box::new(text.clone())); params.push(Box::new(text.clone())); Ok(format!( "(\ search_vector @@ plainto_tsquery('english', ${idx_fts}) OR \ search_vector @@ to_tsquery('english', ${idx_prefix}) OR \ LOWER(COALESCE(title, '')) LIKE LOWER(${idx_ilike}) OR \ LOWER(COALESCE(file_name, '')) LIKE LOWER(${idx_ilike}) OR \ similarity(COALESCE(title, ''), ${idx_sim_title}) > 0.3 OR \ similarity(COALESCE(artist, ''), ${idx_sim_artist}) > 0.3 OR \ similarity(COALESCE(album, ''), ${idx_sim_album}) > 0.3 OR \ similarity(COALESCE(file_name, ''), ${idx_sim_filename}) > 0.25\ )" )) } SearchQuery::Prefix(term) => { let idx = *offset; *offset += 1; // Sanitize by stripping special tsquery characters let sanitized = term.replace(['&', '|', '!', '(', ')', ':', '*', '\\', '\''], ""); params.push(Box::new(format!("{sanitized}:*"))); Ok(format!("search_vector @@ to_tsquery('english', ${idx})")) } SearchQuery::Fuzzy(term) => { // Use trigram similarity on multiple fields let idx_title = *offset; *offset += 1; let idx_artist = *offset; *offset += 1; let idx_album = *offset; *offset += 1; let idx_filename = *offset; *offset += 1; let idx_ilike = *offset; *offset += 1; params.push(Box::new(term.clone())); params.push(Box::new(term.clone())); params.push(Box::new(term.clone())); params.push(Box::new(term.clone())); params.push(Box::new(format!("%{}%", term))); Ok(format!( "(\ similarity(COALESCE(title, ''), ${idx_title}) > 0.3 OR \ similarity(COALESCE(artist, ''), ${idx_artist}) > 0.3 OR \ similarity(COALESCE(album, ''), ${idx_album}) > 0.3 OR \ similarity(COALESCE(file_name, ''), ${idx_filename}) > 0.25 OR \ LOWER(COALESCE(title, '')) LIKE LOWER(${idx_ilike}) OR \ LOWER(COALESCE(file_name, '')) LIKE LOWER(${idx_ilike})\ )" )) } SearchQuery::FieldMatch { field, value } => { let idx = *offset; *offset += 1; params.push(Box::new(value.clone())); let col = match field.as_str() { "title" => "title", "artist" => "artist", "album" => "album", "genre" => "genre", "file_name" => "file_name", "description" => "description", _ => { return Err(PinakesError::SearchParse(format!("unknown field: {field}"))); } }; Ok(format!("LOWER(COALESCE({col}, '')) = LOWER(${idx})")) } SearchQuery::TypeFilter(type_val) => { type_filters.push(type_val.clone()); Ok("TRUE".to_string()) } SearchQuery::TagFilter(tag_name) => { tag_filters.push(tag_name.clone()); Ok("TRUE".to_string()) } SearchQuery::And(children) => { let mut parts = Vec::new(); for child in children { let frag = build_search_inner(child, offset, params, type_filters, tag_filters)?; parts.push(frag); } if parts.is_empty() { Ok("TRUE".to_string()) } else { Ok(format!("({})", parts.join(" AND "))) } } SearchQuery::Or(children) => { let mut parts = Vec::new(); for child in children { let frag = build_search_inner(child, offset, params, type_filters, tag_filters)?; parts.push(frag); } if parts.is_empty() { Ok("TRUE".to_string()) } else { Ok(format!("({})", parts.join(" OR "))) } } SearchQuery::Not(inner) => { let frag = build_search_inner(inner, offset, params, type_filters, tag_filters)?; Ok(format!("NOT ({frag})")) } SearchQuery::RangeQuery { field, start, end } => { let col = match field.as_str() { "year" => "year", "size" | "file_size" => "file_size", "duration" => "duration_secs", _ => return Ok("TRUE".to_string()), // Unknown field, ignore }; match (start, end) { (Some(s), Some(e)) => { let idx_start = *offset; *offset += 1; let idx_end = *offset; *offset += 1; params.push(Box::new(*s)); params.push(Box::new(*e)); Ok(format!("({col} >= ${idx_start} AND {col} <= ${idx_end})")) } (Some(s), None) => { let idx = *offset; *offset += 1; params.push(Box::new(*s)); Ok(format!("{col} >= ${idx}")) } (None, Some(e)) => { let idx = *offset; *offset += 1; params.push(Box::new(*e)); Ok(format!("{col} <= ${idx}")) } (None, None) => Ok("TRUE".to_string()), } } SearchQuery::CompareQuery { field, op, value } => { let col = match field.as_str() { "year" => "year", "size" | "file_size" => "file_size", "duration" => "duration_secs", _ => return Ok("TRUE".to_string()), // Unknown field, ignore }; let op_sql = match op { crate::search::CompareOp::GreaterThan => ">", crate::search::CompareOp::GreaterOrEqual => ">=", crate::search::CompareOp::LessThan => "<", crate::search::CompareOp::LessOrEqual => "<=", }; let idx = *offset; *offset += 1; params.push(Box::new(*value)); Ok(format!("{col} {op_sql} ${idx}")) } SearchQuery::DateQuery { field, value } => { let col = match field.as_str() { "created" => "created_at", "modified" | "updated" => "updated_at", _ => return Ok("TRUE".to_string()), }; Ok(date_value_to_postgres_expr(col, value)) } } } /// Convert a DateValue to a PostgreSQL datetime comparison expression fn date_value_to_postgres_expr(col: &str, value: &crate::search::DateValue) -> String { use crate::search::DateValue; match value { DateValue::Today => format!("{col}::date = CURRENT_DATE"), DateValue::Yesterday => format!("{col}::date = CURRENT_DATE - INTERVAL '1 day'"), DateValue::ThisWeek => format!("{col} >= date_trunc('week', CURRENT_DATE)"), DateValue::LastWeek => format!( "{col} >= date_trunc('week', CURRENT_DATE) - INTERVAL '7 days' AND {col} < date_trunc('week', CURRENT_DATE)" ), DateValue::ThisMonth => format!("{col} >= date_trunc('month', CURRENT_DATE)"), DateValue::LastMonth => format!( "{col} >= date_trunc('month', CURRENT_DATE) - INTERVAL '1 month' AND {col} < date_trunc('month', CURRENT_DATE)" ), DateValue::ThisYear => format!("{col} >= date_trunc('year', CURRENT_DATE)"), DateValue::LastYear => format!( "{col} >= date_trunc('year', CURRENT_DATE) - INTERVAL '1 year' AND {col} < date_trunc('year', CURRENT_DATE)" ), DateValue::DaysAgo(days) => format!("{col} >= CURRENT_DATE - INTERVAL '{days} days'"), } } fn sort_order_clause(sort: &SortOrder) -> &'static str { match sort { SortOrder::Relevance => "created_at DESC", // fallback when no FTS SortOrder::DateAsc => "created_at ASC", SortOrder::DateDesc => "created_at DESC", SortOrder::NameAsc => "file_name ASC", SortOrder::NameDesc => "file_name DESC", SortOrder::SizeAsc => "file_size ASC", SortOrder::SizeDesc => "file_size DESC", } } /// Returns a relevance-aware ORDER BY when there's an active FTS query. fn sort_order_clause_with_rank(sort: &SortOrder, has_fts: bool) -> String { match sort { SortOrder::Relevance if has_fts => "ts_rank(search_vector, query) DESC".to_string(), _ => sort_order_clause(sort).to_string(), } } #[async_trait::async_trait] impl StorageBackend for PostgresBackend { async fn run_migrations(&self) -> Result<()> { let mut obj = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; // deadpool_postgres::Object derefs to tokio_postgres::Client, // but refinery needs &mut Client. We can get the inner client. let client: &mut tokio_postgres::Client = obj.as_mut(); crate::storage::migrations::run_postgres_migrations(client).await } // ---- Root directories ---- async fn add_root_dir(&self, path: PathBuf) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute( "INSERT INTO root_dirs (path) VALUES ($1) ON CONFLICT (path) DO NOTHING", &[&path.to_string_lossy().as_ref()], ) .await?; Ok(()) } async fn list_root_dirs(&self) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client .query("SELECT path FROM root_dirs ORDER BY path", &[]) .await?; Ok(rows .iter() .map(|r| PathBuf::from(r.get::<_, String>(0))) .collect()) } async fn remove_root_dir(&self, path: &std::path::Path) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute( "DELETE FROM root_dirs WHERE path = $1", &[&path.to_string_lossy().as_ref()], ) .await?; Ok(()) } // ---- Media CRUD ---- async fn insert_media(&self, item: &MediaItem) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let media_type_str = media_type_to_string(&item.media_type); let path_str = item.path.to_string_lossy().to_string(); let file_size = item.file_size as i64; client .execute( "INSERT INTO media_items ( id, path, file_name, media_type, content_hash, file_size, title, artist, album, genre, year, duration_secs, description, thumbnail_path, date_taken, latitude, longitude, camera_make, camera_model, rating, perceptual_hash, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23 )", &[ &item.id.0, &path_str, &item.file_name, &media_type_str, &item.content_hash.0, &file_size, &item.title, &item.artist, &item.album, &item.genre, &item.year, &item.duration_secs, &item.description, &item .thumbnail_path .as_ref() .map(|p| p.to_string_lossy().to_string()), &item.date_taken, &item.latitude, &item.longitude, &item.camera_make, &item.camera_model, &item.rating, &item.perceptual_hash, &item.created_at, &item.updated_at, ], ) .await?; // Insert custom fields for (name, field) in &item.custom_fields { let ft = custom_field_type_to_string(&field.field_type); client .execute( "INSERT INTO custom_fields (media_id, field_name, field_type, field_value) VALUES ($1, $2, $3, $4) ON CONFLICT (media_id, field_name) DO UPDATE SET field_type = EXCLUDED.field_type, field_value = EXCLUDED.field_value", &[&item.id.0, &name, &ft, &field.value], ) .await?; } Ok(()) } async fn count_media(&self) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(e.to_string()))?; let row = client .query_one( "SELECT COUNT(*) FROM media_items WHERE deleted_at IS NULL", &[], ) .await?; let count: i64 = row.get(0); Ok(count as u64) } async fn get_media(&self, id: MediaId) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let row = client .query_opt( "SELECT id, path, file_name, media_type, content_hash, file_size, title, artist, album, genre, year, duration_secs, description, thumbnail_path, file_mtime, date_taken, latitude, longitude, camera_make, camera_model, rating, perceptual_hash, created_at, updated_at FROM media_items WHERE id = $1", &[&id.0], ) .await? .ok_or_else(|| PinakesError::NotFound(format!("media item {id}")))?; let mut item = row_to_media_item(&row)?; item.custom_fields = self.get_custom_fields(id).await?; Ok(item) } async fn get_media_by_hash(&self, hash: &ContentHash) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let row = client .query_opt( "SELECT id, path, file_name, media_type, content_hash, file_size, title, artist, album, genre, year, duration_secs, description, thumbnail_path, file_mtime, date_taken, latitude, longitude, camera_make, camera_model, rating, perceptual_hash, created_at, updated_at FROM media_items WHERE content_hash = $1", &[&hash.0], ) .await?; match row { Some(r) => { let mut item = row_to_media_item(&r)?; item.custom_fields = self.get_custom_fields(item.id).await?; Ok(Some(item)) } None => Ok(None), } } async fn get_media_by_path(&self, path: &std::path::Path) -> Result> { let path_str = path.to_string_lossy().to_string(); let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let row = client .query_opt( "SELECT id, path, file_name, media_type, content_hash, file_size, title, artist, album, genre, year, duration_secs, description, thumbnail_path, file_mtime, date_taken, latitude, longitude, camera_make, camera_model, rating, perceptual_hash, created_at, updated_at FROM media_items WHERE path = $1", &[&path_str], ) .await?; match row { Some(r) => { let mut item = row_to_media_item(&r)?; item.custom_fields = self.get_custom_fields(item.id).await?; Ok(Some(item)) } None => Ok(None), } } async fn list_media(&self, pagination: &Pagination) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let order_by = match pagination.sort.as_deref() { Some("created_at_asc") => "created_at ASC", Some("name_asc") => "file_name ASC", Some("name_desc") => "file_name DESC", Some("size_asc") => "file_size ASC", Some("size_desc") => "file_size DESC", Some("type_asc") => "media_type ASC", Some("type_desc") => "media_type DESC", // "created_at_desc" or any unrecognized value falls back to default _ => "created_at DESC", }; let sql = format!( "SELECT id, path, file_name, media_type, content_hash, file_size, title, artist, album, genre, year, duration_secs, description, thumbnail_path, file_mtime, date_taken, latitude, longitude, camera_make, camera_model, rating, perceptual_hash, storage_mode, original_filename, uploaded_at, storage_key, created_at, updated_at, deleted_at FROM media_items WHERE deleted_at IS NULL ORDER BY {order_by} LIMIT $1 OFFSET $2" ); let rows = client .query( &sql, &[&(pagination.limit as i64), &(pagination.offset as i64)], ) .await?; let mut items = Vec::with_capacity(rows.len()); for row in &rows { let item = row_to_media_item(row)?; items.push(item); } // Batch-load custom fields for all items if !items.is_empty() { let ids: Vec = items.iter().map(|i| i.id.0).collect(); let cf_rows = client .query( "SELECT media_id, field_name, field_type, field_value FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) .await?; let mut cf_map: HashMap> = HashMap::new(); for row in &cf_rows { let mid: Uuid = row.get("media_id"); let name: String = row.get("field_name"); let ft_str: String = row.get("field_type"); let value: String = row.get("field_value"); let field_type = custom_field_type_from_string(&ft_str)?; cf_map .entry(mid) .or_default() .insert(name, CustomField { field_type, value }); } for item in &mut items { if let Some(fields) = cf_map.remove(&item.id.0) { item.custom_fields = fields; } } } Ok(items) } async fn update_media(&self, item: &MediaItem) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let media_type_str = media_type_to_string(&item.media_type); let path_str = item.path.to_string_lossy().to_string(); let file_size = item.file_size as i64; let rows_affected = client .execute( "UPDATE media_items SET path = $2, file_name = $3, media_type = $4, content_hash = $5, file_size = $6, title = $7, artist = $8, album = $9, genre = $10, year = $11, duration_secs = $12, description = $13, thumbnail_path = $14, date_taken = $15, latitude = $16, longitude = $17, camera_make = $18, camera_model = $19, rating = $20, perceptual_hash = $21, updated_at = $22 WHERE id = $1", &[ &item.id.0, &path_str, &item.file_name, &media_type_str, &item.content_hash.0, &file_size, &item.title, &item.artist, &item.album, &item.genre, &item.year, &item.duration_secs, &item.description, &item .thumbnail_path .as_ref() .map(|p| p.to_string_lossy().to_string()), &item.date_taken, &item.latitude, &item.longitude, &item.camera_make, &item.camera_model, &item.rating, &item.perceptual_hash, &item.updated_at, ], ) .await?; if rows_affected == 0 { return Err(PinakesError::NotFound(format!("media item {}", item.id))); } // Replace custom fields: delete all then re-insert client .execute( "DELETE FROM custom_fields WHERE media_id = $1", &[&item.id.0], ) .await?; for (name, field) in &item.custom_fields { let ft = custom_field_type_to_string(&field.field_type); client .execute( "INSERT INTO custom_fields (media_id, field_name, field_type, field_value) VALUES ($1, $2, $3, $4)", &[&item.id.0, &name, &ft, &field.value], ) .await?; } Ok(()) } async fn delete_media(&self, id: MediaId) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows_affected = client .execute("DELETE FROM media_items WHERE id = $1", &[&id.0]) .await?; if rows_affected == 0 { return Err(PinakesError::NotFound(format!("media item {id}"))); } Ok(()) } async fn delete_all_media(&self) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let count: i64 = client .query_one("SELECT COUNT(*) FROM media_items", &[]) .await? .get(0); client.execute("DELETE FROM media_items", &[]).await?; Ok(count as u64) } // ---- Batch Operations ---- async fn batch_delete_media(&self, ids: &[MediaId]) -> Result { if ids.is_empty() { return Ok(0); } let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; // Use ANY with array for efficient batch delete let uuids: Vec = ids.iter().map(|id| id.0).collect(); let rows = client .execute("DELETE FROM media_items WHERE id = ANY($1)", &[&uuids]) .await?; Ok(rows) } async fn batch_tag_media(&self, media_ids: &[MediaId], tag_ids: &[Uuid]) -> Result { if media_ids.is_empty() || tag_ids.is_empty() { return Ok(0); } let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; // Use UNNEST for efficient batch insert let mut media_uuids = Vec::new(); let mut tag_uuids = Vec::new(); for mid in media_ids { for tid in tag_ids { media_uuids.push(mid.0); tag_uuids.push(*tid); } } let rows = client .execute( "INSERT INTO media_tags (media_id, tag_id) SELECT * FROM UNNEST($1::uuid[], $2::uuid[]) ON CONFLICT DO NOTHING", &[&media_uuids, &tag_uuids], ) .await?; Ok(rows) } // ---- Tags ---- async fn create_tag(&self, name: &str, parent_id: Option) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let id = Uuid::now_v7(); let now = Utc::now(); client .execute( "INSERT INTO tags (id, name, parent_id, created_at) VALUES ($1, $2, $3, $4)", &[&id, &name, &parent_id, &now], ) .await?; Ok(Tag { id, name: name.to_string(), parent_id, created_at: now, }) } async fn get_tag(&self, id: Uuid) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let row = client .query_opt( "SELECT id, name, parent_id, created_at FROM tags WHERE id = $1", &[&id], ) .await? .ok_or_else(|| PinakesError::TagNotFound(id.to_string()))?; row_to_tag(&row) } async fn list_tags(&self) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client .query( "SELECT id, name, parent_id, created_at FROM tags ORDER BY name", &[], ) .await?; rows.iter().map(row_to_tag).collect() } async fn delete_tag(&self, id: Uuid) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows_affected = client .execute("DELETE FROM tags WHERE id = $1", &[&id]) .await?; if rows_affected == 0 { return Err(PinakesError::TagNotFound(id.to_string())); } Ok(()) } async fn tag_media(&self, media_id: MediaId, tag_id: Uuid) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute( "INSERT INTO media_tags (media_id, tag_id) VALUES ($1, $2) ON CONFLICT (media_id, tag_id) DO NOTHING", &[&media_id.0, &tag_id], ) .await?; Ok(()) } async fn untag_media(&self, media_id: MediaId, tag_id: Uuid) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute( "DELETE FROM media_tags WHERE media_id = $1 AND tag_id = $2", &[&media_id.0, &tag_id], ) .await?; Ok(()) } async fn get_media_tags(&self, media_id: MediaId) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client .query( "SELECT t.id, t.name, t.parent_id, t.created_at FROM tags t JOIN media_tags mt ON mt.tag_id = t.id WHERE mt.media_id = $1 ORDER BY t.name", &[&media_id.0], ) .await?; rows.iter().map(row_to_tag).collect() } async fn get_tag_descendants(&self, tag_id: Uuid) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client .query( "WITH RECURSIVE descendants AS ( SELECT id, name, parent_id, created_at FROM tags WHERE parent_id = $1 UNION ALL SELECT t.id, t.name, t.parent_id, t.created_at FROM tags t JOIN descendants d ON t.parent_id = d.id ) SELECT id, name, parent_id, created_at FROM descendants ORDER BY name", &[&tag_id], ) .await?; rows.iter().map(row_to_tag).collect() } // ---- Collections ---- async fn create_collection( &self, name: &str, kind: CollectionKind, description: Option<&str>, filter_query: Option<&str>, ) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let id = Uuid::now_v7(); let now = Utc::now(); let kind_str = collection_kind_to_string(&kind); client .execute( "INSERT INTO collections (id, name, description, kind, filter_query, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)", &[ &id, &name, &description, &kind_str, &filter_query, &now, &now, ], ) .await?; Ok(Collection { id, name: name.to_string(), description: description.map(String::from), kind, filter_query: filter_query.map(String::from), created_at: now, updated_at: now, }) } async fn get_collection(&self, id: Uuid) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let row = client .query_opt( "SELECT id, name, description, kind, filter_query, created_at, updated_at FROM collections WHERE id = $1", &[&id], ) .await? .ok_or_else(|| PinakesError::CollectionNotFound(id.to_string()))?; row_to_collection(&row) } async fn list_collections(&self) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client .query( "SELECT id, name, description, kind, filter_query, created_at, updated_at FROM collections ORDER BY name", &[], ) .await?; rows.iter().map(row_to_collection).collect() } async fn delete_collection(&self, id: Uuid) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows_affected = client .execute("DELETE FROM collections WHERE id = $1", &[&id]) .await?; if rows_affected == 0 { return Err(PinakesError::CollectionNotFound(id.to_string())); } Ok(()) } async fn add_to_collection( &self, collection_id: Uuid, media_id: MediaId, position: i32, ) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let now = Utc::now(); client .execute( "INSERT INTO collection_members (collection_id, media_id, position, added_at) VALUES ($1, $2, $3, $4) ON CONFLICT (collection_id, media_id) DO UPDATE SET position = EXCLUDED.position", &[&collection_id, &media_id.0, &position, &now], ) .await?; // Update the collection's updated_at timestamp client .execute( "UPDATE collections SET updated_at = $2 WHERE id = $1", &[&collection_id, &now], ) .await?; Ok(()) } async fn remove_from_collection(&self, collection_id: Uuid, media_id: MediaId) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute( "DELETE FROM collection_members WHERE collection_id = $1 AND media_id = $2", &[&collection_id, &media_id.0], ) .await?; let now = Utc::now(); client .execute( "UPDATE collections SET updated_at = $2 WHERE id = $1", &[&collection_id, &now], ) .await?; Ok(()) } async fn get_collection_members(&self, collection_id: Uuid) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client .query( "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.created_at, m.updated_at FROM media_items m JOIN collection_members cm ON cm.media_id = m.id WHERE cm.collection_id = $1 ORDER BY cm.position ASC", &[&collection_id], ) .await?; let mut items = Vec::with_capacity(rows.len()); for row in &rows { items.push(row_to_media_item(row)?); } // Batch-load custom fields if !items.is_empty() { let ids: Vec = items.iter().map(|i| i.id.0).collect(); let cf_rows = client .query( "SELECT media_id, field_name, field_type, field_value FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) .await?; let mut cf_map: HashMap> = HashMap::new(); for row in &cf_rows { let mid: Uuid = row.get("media_id"); let name: String = row.get("field_name"); let ft_str: String = row.get("field_type"); let value: String = row.get("field_value"); let field_type = custom_field_type_from_string(&ft_str)?; cf_map .entry(mid) .or_default() .insert(name, CustomField { field_type, value }); } for item in &mut items { if let Some(fields) = cf_map.remove(&item.id.0) { item.custom_fields = fields; } } } Ok(items) } // ---- Search ---- async fn search(&self, request: &SearchRequest) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let mut param_offset: i32 = 1; let mut params: Vec> = Vec::new(); let (where_clause, type_filters, tag_filters) = build_search_clause(&request.query, &mut param_offset, &mut params)?; // Detect whether we have an FTS condition (for rank-based sorting) let has_fts = query_has_fts(&request.query); // Build additional WHERE conditions for type and tag filters let mut extra_where = Vec::new(); for tf in &type_filters { let idx = param_offset; param_offset += 1; params.push(Box::new(tf.clone())); extra_where.push(format!("m.media_type = ${idx}")); } for tg in &tag_filters { let idx = param_offset; param_offset += 1; params.push(Box::new(tg.clone())); extra_where.push(format!( "EXISTS (SELECT 1 FROM media_tags mt JOIN tags t ON mt.tag_id = t.id WHERE mt.media_id = m.id AND t.name = ${idx})" )); } let full_where = if extra_where.is_empty() { where_clause.clone() } else { format!("{where_clause} AND {}", extra_where.join(" AND ")) }; let order_by = sort_order_clause_with_rank(&request.sort, has_fts); // For relevance sorting with FTS, we need a CTE or subquery to define 'query' let (count_sql, select_sql) = if has_fts && request.sort == SortOrder::Relevance { // Extract the FTS query parameter for ts_rank // We wrap the query in a CTE that exposes the tsquery let fts_param_idx = find_first_fts_param(&request.query); let count = format!("SELECT COUNT(*) FROM media_items m WHERE {full_where}"); let select = format!( "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.file_mtime, m.date_taken, m.latitude, m.longitude, m.camera_make, m.camera_model, m.rating, m.perceptual_hash, m.created_at, m.updated_at, ts_rank(m.search_vector, plainto_tsquery('english', ${fts_param_idx})) AS rank FROM media_items m WHERE {full_where} ORDER BY rank DESC LIMIT ${} OFFSET ${}", param_offset, param_offset + 1 ); (count, select) } else { let count = format!("SELECT COUNT(*) FROM media_items m WHERE {full_where}"); let select = format!( "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.file_mtime, m.date_taken, m.latitude, m.longitude, m.camera_make, m.camera_model, m.rating, m.perceptual_hash, m.created_at, m.updated_at FROM media_items m WHERE {full_where} ORDER BY {order_by} LIMIT ${} OFFSET ${}", param_offset, param_offset + 1 ); (count, select) }; // Count query uses the current params (without limit/offset) let count_params: Vec<&(dyn ToSql + Sync)> = params .iter() .map(|p| p.as_ref() as &(dyn ToSql + Sync)) .collect(); let count_row = client.query_one(&count_sql, &count_params).await?; let total_count: i64 = count_row.get(0); // Add pagination params params.push(Box::new(request.pagination.limit as i64)); params.push(Box::new(request.pagination.offset as i64)); let select_params: Vec<&(dyn ToSql + Sync)> = params .iter() .map(|p| p.as_ref() as &(dyn ToSql + Sync)) .collect(); let rows = client.query(&select_sql, &select_params).await?; let mut items = Vec::with_capacity(rows.len()); for row in &rows { items.push(row_to_media_item(row)?); } // Batch-load custom fields if !items.is_empty() { let ids: Vec = items.iter().map(|i| i.id.0).collect(); let cf_rows = client .query( "SELECT media_id, field_name, field_type, field_value FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) .await?; let mut cf_map: HashMap> = HashMap::new(); for row in &cf_rows { let mid: Uuid = row.get("media_id"); let name: String = row.get("field_name"); let ft_str: String = row.get("field_type"); let value: String = row.get("field_value"); let field_type = custom_field_type_from_string(&ft_str)?; cf_map .entry(mid) .or_default() .insert(name, CustomField { field_type, value }); } for item in &mut items { if let Some(fields) = cf_map.remove(&item.id.0) { item.custom_fields = fields; } } } Ok(SearchResults { items, total_count: total_count as u64, }) } // ---- Audit ---- async fn record_audit(&self, entry: &AuditEntry) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let action_str = audit_action_to_string(&entry.action); let media_id = entry.media_id.map(|m| m.0); client .execute( "INSERT INTO audit_log (id, media_id, action, details, timestamp) VALUES ($1, $2, $3, $4, $5)", &[ &entry.id, &media_id, &action_str, &entry.details, &entry.timestamp, ], ) .await?; Ok(()) } async fn list_audit_entries( &self, media_id: Option, pagination: &Pagination, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = match media_id { Some(mid) => { client .query( "SELECT id, media_id, action, details, timestamp FROM audit_log WHERE media_id = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3", &[ &mid.0, &(pagination.limit as i64), &(pagination.offset as i64), ], ) .await? } None => { client .query( "SELECT id, media_id, action, details, timestamp FROM audit_log ORDER BY timestamp DESC LIMIT $1 OFFSET $2", &[&(pagination.limit as i64), &(pagination.offset as i64)], ) .await? } }; rows.iter().map(row_to_audit_entry).collect() } // ---- Custom fields ---- async fn set_custom_field( &self, media_id: MediaId, name: &str, field: &CustomField, ) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let ft = custom_field_type_to_string(&field.field_type); client .execute( "INSERT INTO custom_fields (media_id, field_name, field_type, field_value) VALUES ($1, $2, $3, $4) ON CONFLICT (media_id, field_name) DO UPDATE SET field_type = EXCLUDED.field_type, field_value = EXCLUDED.field_value", &[&media_id.0, &name, &ft, &field.value], ) .await?; Ok(()) } async fn get_custom_fields(&self, media_id: MediaId) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client .query( "SELECT field_name, field_type, field_value FROM custom_fields WHERE media_id = $1", &[&media_id.0], ) .await?; let mut map = HashMap::new(); for row in &rows { let name: String = row.get("field_name"); let ft_str: String = row.get("field_type"); let value: String = row.get("field_value"); let field_type = custom_field_type_from_string(&ft_str)?; map.insert(name, CustomField { field_type, value }); } Ok(map) } async fn delete_custom_field(&self, media_id: MediaId, name: &str) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute( "DELETE FROM custom_fields WHERE media_id = $1 AND field_name = $2", &[&media_id.0, &name], ) .await?; Ok(()) } // ---- Duplicates ---- async fn find_duplicates(&self) -> Result>> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client .query( "SELECT * FROM media_items WHERE content_hash IN ( SELECT content_hash FROM media_items GROUP BY content_hash HAVING COUNT(*) > 1 ) ORDER BY content_hash, created_at", &[], ) .await?; let mut items = Vec::with_capacity(rows.len()); for row in &rows { items.push(row_to_media_item(row)?); } // Batch-load custom fields if !items.is_empty() { let ids: Vec = items.iter().map(|i| i.id.0).collect(); let cf_rows = client .query( "SELECT media_id, field_name, field_type, field_value FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) .await?; let mut cf_map: HashMap> = HashMap::new(); for row in &cf_rows { let mid: Uuid = row.get("media_id"); let name: String = row.get("field_name"); let ft_str: String = row.get("field_type"); let value: String = row.get("field_value"); let field_type = custom_field_type_from_string(&ft_str)?; cf_map .entry(mid) .or_default() .insert(name, CustomField { field_type, value }); } for item in &mut items { if let Some(fields) = cf_map.remove(&item.id.0) { item.custom_fields = fields; } } } // Group by content_hash let mut groups: Vec> = Vec::new(); let mut current_hash = String::new(); for item in items { if item.content_hash.0 != current_hash { current_hash = item.content_hash.0.clone(); groups.push(Vec::new()); } if let Some(group) = groups.last_mut() { group.push(item); } } Ok(groups) } async fn find_perceptual_duplicates(&self, threshold: u32) -> Result>> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; // Get all images with perceptual hashes let rows = client .query( "SELECT id, path, file_name, media_type, content_hash, file_size, title, artist, album, genre, year, duration_secs, description, thumbnail_path, file_mtime, date_taken, latitude, longitude, camera_make, camera_model, rating, perceptual_hash, created_at, updated_at FROM media_items WHERE perceptual_hash IS NOT NULL ORDER BY id", &[], ) .await?; let mut items = Vec::with_capacity(rows.len()); for row in &rows { items.push(row_to_media_item(row)?); } // Batch-load custom fields if !items.is_empty() { let ids: Vec = items.iter().map(|i| i.id.0).collect(); let cf_rows = client .query( "SELECT media_id, field_name, field_type, field_value FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) .await?; let mut cf_map: HashMap> = HashMap::new(); for row in &cf_rows { let mid: Uuid = row.get("media_id"); let name: String = row.get("field_name"); let ft_str: String = row.get("field_type"); let value: String = row.get("field_value"); let field_type = custom_field_type_from_string(&ft_str)?; cf_map .entry(mid) .or_default() .insert(name, CustomField { field_type, value }); } for item in &mut items { if let Some(fields) = cf_map.remove(&item.id.0) { item.custom_fields = fields; } } } // Compare each pair and build groups use image_hasher::ImageHash; let mut groups: Vec> = Vec::new(); let mut grouped_indices: std::collections::HashSet = std::collections::HashSet::new(); for i in 0..items.len() { if grouped_indices.contains(&i) { continue; } let hash_a = match &items[i].perceptual_hash { Some(h) => match ImageHash::>::from_base64(h) { Ok(hash) => hash, Err(_) => continue, }, None => continue, }; let mut group = vec![items[i].clone()]; grouped_indices.insert(i); for (j, item_j) in items.iter().enumerate().skip(i + 1) { if grouped_indices.contains(&j) { continue; } let hash_b = match &item_j.perceptual_hash { Some(h) => match ImageHash::>::from_base64(h) { Ok(hash) => hash, Err(_) => continue, }, None => continue, }; let distance = hash_a.dist(&hash_b); if distance <= threshold { group.push(item_j.clone()); grouped_indices.insert(j); } } // Only add groups with more than one item (actual duplicates) if group.len() > 1 { groups.push(group); } } Ok(groups) } // ---- Database management ---- async fn database_stats(&self) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let media_count: i64 = client .query_one("SELECT COUNT(*) FROM media_items", &[]) .await? .get(0); let tag_count: i64 = client .query_one("SELECT COUNT(*) FROM tags", &[]) .await? .get(0); let collection_count: i64 = client .query_one("SELECT COUNT(*) FROM collections", &[]) .await? .get(0); let audit_count: i64 = client .query_one("SELECT COUNT(*) FROM audit_log", &[]) .await? .get(0); let database_size_bytes: i64 = client .query_one("SELECT pg_database_size(current_database())", &[]) .await? .get(0); Ok(crate::storage::DatabaseStats { media_count: media_count as u64, tag_count: tag_count as u64, collection_count: collection_count as u64, audit_count: audit_count as u64, database_size_bytes: database_size_bytes as u64, backend_name: "postgres".to_string(), }) } async fn vacuum(&self) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client.execute("VACUUM ANALYZE", &[]).await?; Ok(()) } async fn clear_all_data(&self) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute( "TRUNCATE audit_log, custom_fields, collection_members, media_tags, media_items, tags, collections CASCADE", &[], ) .await?; Ok(()) } async fn list_media_paths(&self) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client .query("SELECT id, path, content_hash FROM media_items", &[]) .await?; let mut results = Vec::with_capacity(rows.len()); for row in rows { let id: Uuid = row.get(0); let path: String = row.get(1); let hash: String = row.get(2); results.push((MediaId(id), PathBuf::from(path), ContentHash::new(hash))); } Ok(results) } async fn save_search( &self, id: Uuid, name: &str, query: &str, sort_order: Option<&str>, ) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let now = Utc::now(); client .execute( "INSERT INTO saved_searches (id, name, query, sort_order, created_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO UPDATE SET name = $2, query = $3, sort_order = $4", &[&id, &name, &query, &sort_order, &now], ) .await?; Ok(()) } async fn list_saved_searches(&self) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client .query("SELECT id, name, query, sort_order, created_at FROM saved_searches ORDER BY created_at DESC", &[]) .await?; let mut results = Vec::with_capacity(rows.len()); for row in rows { results.push(crate::model::SavedSearch { id: row.get(0), name: row.get(1), query: row.get(2), sort_order: row.get(3), created_at: row.get(4), }); } Ok(results) } async fn delete_saved_search(&self, id: Uuid) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM saved_searches WHERE id = $1", &[&id]) .await?; Ok(()) } async fn list_media_ids_for_thumbnails(&self, only_missing: bool) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let sql = if only_missing { "SELECT id FROM media_items WHERE thumbnail_path IS NULL ORDER BY created_at DESC" } else { "SELECT id FROM media_items ORDER BY created_at DESC" }; let rows = client.query(sql, &[]).await?; let ids = rows .iter() .map(|r| { let id: uuid::Uuid = r.get(0); MediaId(id) }) .collect(); Ok(ids) } async fn library_statistics(&self) -> Result { tokio::time::timeout( std::time::Duration::from_secs(30), self.library_statistics_inner(), ) .await .map_err(|_| PinakesError::Database("library_statistics query timed out".to_string()))? } async fn list_users(&self) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client .query("SELECT id, username, password_hash, role, created_at, updated_at FROM users ORDER BY created_at DESC", &[]) .await?; let mut users = Vec::with_capacity(rows.len()); for row in rows { let user_id: uuid::Uuid = row.get::<_, uuid::Uuid>(0); let profile = self.load_user_profile(user_id).await?; users.push(crate::users::User { id: crate::users::UserId(user_id), username: row.get(1), password_hash: row.get(2), role: serde_json::from_value(row.get(3)).unwrap_or(crate::config::UserRole::Viewer), profile, created_at: row.get(4), updated_at: row.get(5), }); } Ok(users) } async fn get_user(&self, id: crate::users::UserId) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let row = client .query_opt("SELECT id, username, password_hash, role, created_at, updated_at FROM users WHERE id = $1", &[&id.0]) .await? .ok_or_else(|| PinakesError::NotFound(format!("user {}", id.0)))?; let profile = self.load_user_profile(id.0).await?; Ok(crate::users::User { id: crate::users::UserId(row.get::<_, uuid::Uuid>(0)), username: row.get(1), password_hash: row.get(2), role: serde_json::from_value(row.get(3)).unwrap_or(crate::config::UserRole::Viewer), profile, created_at: row.get(4), updated_at: row.get(5), }) } async fn get_user_by_username(&self, username: &str) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let row = client .query_opt("SELECT id, username, password_hash, role, created_at, updated_at FROM users WHERE username = $1", &[&username]) .await? .ok_or_else(|| PinakesError::NotFound(format!("user with username {}", username)))?; let user_id: uuid::Uuid = row.get::<_, uuid::Uuid>(0); let profile = self.load_user_profile(user_id).await?; Ok(crate::users::User { id: crate::users::UserId(user_id), username: row.get(1), password_hash: row.get(2), role: serde_json::from_value(row.get(3)).unwrap_or(crate::config::UserRole::Viewer), profile, created_at: row.get(4), updated_at: row.get(5), }) } async fn create_user( &self, username: &str, password_hash: &str, role: crate::config::UserRole, profile: Option, ) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let id = uuid::Uuid::now_v7(); let now = chrono::Utc::now(); let role_json = serde_json::to_value(role)?; client .execute( "INSERT INTO users (id, username, password_hash, role, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)", &[&id, &username, &password_hash, &role_json, &now, &now], ) .await?; let user_profile = if let Some(prof) = profile.clone() { let prefs_json = serde_json::to_value(&prof.preferences)?; client .execute( "INSERT INTO user_profiles (user_id, avatar_path, bio, preferences_json, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)", &[&id, &prof.avatar_path, &prof.bio, &prefs_json, &now, &now], ) .await?; prof } else { crate::users::UserProfile { avatar_path: None, bio: None, preferences: Default::default(), } }; Ok(crate::users::User { id: crate::users::UserId(id), username: username.to_string(), password_hash: password_hash.to_string(), role, profile: user_profile, created_at: now, updated_at: now, }) } async fn update_user( &self, id: crate::users::UserId, password_hash: Option<&str>, role: Option, profile: Option, ) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let now = chrono::Utc::now(); // Update password and/or role if provided if password_hash.is_some() || role.is_some() { let mut updates = vec!["updated_at = $1".to_string()]; let mut param_idx = 2; let pw_update = if password_hash.is_some() { let s = format!("password_hash = ${}", param_idx); param_idx += 1; Some(s) } else { None }; if let Some(ref s) = pw_update { updates.push(s.clone()); } let role_json: Option = if let Some(ref r) = role { param_idx += 1; Some(serde_json::to_value(r)?) } else { None }; if role_json.is_some() { updates.push(format!("role = ${}", param_idx - 1)); } let sql = format!( "UPDATE users SET {} WHERE id = ${}", updates.join(", "), param_idx ); let mut params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = vec![&now]; if let Some(ref pw) = password_hash { params.push(pw); } if let Some(ref rj) = role_json { params.push(rj); } params.push(&id.0); client.execute(&sql, ¶ms).await?; } // Update profile if provided if let Some(prof) = profile { let prefs_json = serde_json::to_value(&prof.preferences)?; client .execute( "INSERT INTO user_profiles (user_id, avatar_path, bio, preferences_json, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (user_id) DO UPDATE SET avatar_path = $2, bio = $3, preferences_json = $4, updated_at = $6", &[&id.0, &prof.avatar_path, &prof.bio, &prefs_json, &now, &now], ) .await?; } // Fetch updated user self.get_user(id).await } async fn delete_user(&self, id: crate::users::UserId) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; // Delete profile first due to foreign key client .execute("DELETE FROM user_profiles WHERE user_id = $1", &[&id.0]) .await?; // Delete library access client .execute("DELETE FROM user_libraries WHERE user_id = $1", &[&id.0]) .await?; // Delete user let affected = client .execute("DELETE FROM users WHERE id = $1", &[&id.0]) .await?; if affected == 0 { return Err(PinakesError::NotFound(format!("user {}", id.0))); } Ok(()) } async fn get_user_libraries( &self, user_id: crate::users::UserId, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client .query("SELECT user_id, root_path, permission, granted_at FROM user_libraries WHERE user_id = $1", &[&user_id.0]) .await?; let mut libraries = Vec::with_capacity(rows.len()); for row in rows { libraries.push(crate::users::UserLibraryAccess { user_id: crate::users::UserId(row.get::<_, uuid::Uuid>(0)), root_path: row.get(1), permission: serde_json::from_value(row.get(2)) .unwrap_or(crate::users::LibraryPermission::Read), granted_at: row.get(3), }); } Ok(libraries) } async fn grant_library_access( &self, user_id: crate::users::UserId, root_path: &str, permission: crate::users::LibraryPermission, ) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let perm_json = serde_json::to_value(permission)?; let now = chrono::Utc::now(); client .execute( "INSERT INTO user_libraries (user_id, root_path, permission, granted_at) VALUES ($1, $2, $3, $4) ON CONFLICT (user_id, root_path) DO UPDATE SET permission = $3, granted_at = $4", &[&user_id.0, &root_path, &perm_json, &now], ) .await?; Ok(()) } async fn revoke_library_access( &self, user_id: crate::users::UserId, root_path: &str, ) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute( "DELETE FROM user_libraries WHERE user_id = $1 AND root_path = $2", &[&user_id.0, &root_path], ) .await?; Ok(()) } // ===== Ratings ===== async fn rate_media( &self, user_id: crate::users::UserId, media_id: MediaId, stars: u8, review: Option<&str>, ) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let id = Uuid::now_v7(); let now = Utc::now(); let stars_i32 = stars as i32; let row = client.query_one( "INSERT INTO ratings (id, user_id, media_id, stars, review_text, created_at) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (user_id, media_id) DO UPDATE SET stars = $4, review_text = $5 RETURNING id, created_at", &[&id, &user_id.0, &media_id.0, &stars_i32, &review, &now], ).await?; let actual_id: Uuid = row.get(0); let actual_created_at: chrono::DateTime = row.get(1); Ok(crate::social::Rating { id: actual_id, user_id, media_id, stars, review_text: review.map(String::from), created_at: actual_created_at, }) } async fn get_media_ratings(&self, media_id: MediaId) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client.query( "SELECT id, user_id, media_id, stars, review_text, created_at FROM ratings WHERE media_id = $1 ORDER BY created_at DESC", &[&media_id.0], ).await?; Ok(rows .iter() .map(|row| crate::social::Rating { id: row.get("id"), user_id: crate::users::UserId(row.get("user_id")), media_id: MediaId(row.get("media_id")), stars: row.get::<_, i32>("stars") as u8, review_text: row.get("review_text"), created_at: row.get("created_at"), }) .collect()) } async fn get_user_rating( &self, user_id: crate::users::UserId, media_id: MediaId, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client.query( "SELECT id, user_id, media_id, stars, review_text, created_at FROM ratings WHERE user_id = $1 AND media_id = $2", &[&user_id.0, &media_id.0], ).await?; Ok(rows.first().map(|row| crate::social::Rating { id: row.get("id"), user_id: crate::users::UserId(row.get("user_id")), media_id: MediaId(row.get("media_id")), stars: row.get::<_, i32>("stars") as u8, review_text: row.get("review_text"), created_at: row.get("created_at"), })) } async fn delete_rating(&self, id: Uuid) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM ratings WHERE id = $1", &[&id]) .await?; Ok(()) } // ===== Comments ===== async fn add_comment( &self, user_id: crate::users::UserId, media_id: MediaId, text: &str, parent_id: Option, ) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let id = Uuid::now_v7(); let now = Utc::now(); client.execute( "INSERT INTO comments (id, user_id, media_id, parent_comment_id, text, created_at) VALUES ($1, $2, $3, $4, $5, $6)", &[&id, &user_id.0, &media_id.0, &parent_id, &text, &now], ).await?; Ok(crate::social::Comment { id, user_id, media_id, parent_comment_id: parent_id, text: text.to_string(), created_at: now, }) } async fn get_media_comments(&self, media_id: MediaId) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client.query( "SELECT id, user_id, media_id, parent_comment_id, text, created_at FROM comments WHERE media_id = $1 ORDER BY created_at ASC", &[&media_id.0], ).await?; Ok(rows .iter() .map(|row| crate::social::Comment { id: row.get("id"), user_id: crate::users::UserId(row.get("user_id")), media_id: MediaId(row.get("media_id")), parent_comment_id: row.get("parent_comment_id"), text: row.get("text"), created_at: row.get("created_at"), }) .collect()) } async fn delete_comment(&self, id: Uuid) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM comments WHERE id = $1", &[&id]) .await?; Ok(()) } // ===== Favorites ===== async fn add_favorite(&self, user_id: crate::users::UserId, media_id: MediaId) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let now = Utc::now(); client.execute( "INSERT INTO favorites (user_id, media_id, created_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", &[&user_id.0, &media_id.0, &now], ).await?; Ok(()) } async fn remove_favorite( &self, user_id: crate::users::UserId, media_id: MediaId, ) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute( "DELETE FROM favorites WHERE user_id = $1 AND media_id = $2", &[&user_id.0, &media_id.0], ) .await?; Ok(()) } async fn get_user_favorites( &self, user_id: crate::users::UserId, pagination: &Pagination, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client.query( "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.file_mtime, m.date_taken, m.latitude, m.longitude, m.camera_make, m.camera_model, m.rating, m.perceptual_hash, m.created_at, m.updated_at FROM media_items m JOIN favorites f ON m.id = f.media_id WHERE f.user_id = $1 ORDER BY f.created_at DESC LIMIT $2 OFFSET $3", &[&user_id.0, &(pagination.limit as i64), &(pagination.offset as i64)], ).await?; let mut items: Vec = rows .iter() .map(row_to_media_item) .collect::>>()?; // Batch-load custom fields if !items.is_empty() { let ids: Vec = items.iter().map(|i| i.id.0).collect(); let cf_rows = client .query( "SELECT media_id, field_name, field_type, field_value FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) .await?; let mut cf_map: HashMap> = HashMap::new(); for row in &cf_rows { let mid: Uuid = row.get("media_id"); let name: String = row.get("field_name"); let ft_str: String = row.get("field_type"); let value: String = row.get("field_value"); let field_type = custom_field_type_from_string(&ft_str)?; cf_map .entry(mid) .or_default() .insert(name, CustomField { field_type, value }); } for item in &mut items { if let Some(fields) = cf_map.remove(&item.id.0) { item.custom_fields = fields; } } } Ok(items) } async fn is_favorite(&self, user_id: crate::users::UserId, media_id: MediaId) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let row = client .query_one( "SELECT COUNT(*) FROM favorites WHERE user_id = $1 AND media_id = $2", &[&user_id.0, &media_id.0], ) .await?; let count: i64 = row.get(0); Ok(count > 0) } // ===== Share Links ===== async fn create_share_link( &self, media_id: MediaId, created_by: crate::users::UserId, token: &str, password_hash: Option<&str>, expires_at: Option>, ) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let id = Uuid::now_v7(); let now = Utc::now(); let view_count: i32 = 0; client.execute( "INSERT INTO share_links (id, media_id, created_by, token, password_hash, expires_at, view_count, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", &[&id, &media_id.0, &created_by.0, &token, &password_hash, &expires_at, &view_count, &now], ).await?; Ok(crate::social::ShareLink { id, media_id, created_by, token: token.to_string(), password_hash: password_hash.map(String::from), expires_at, view_count: 0, created_at: now, }) } async fn get_share_link(&self, token: &str) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client.query( "SELECT id, media_id, created_by, token, password_hash, expires_at, view_count, created_at FROM share_links WHERE token = $1", &[&token], ).await?; let row = rows .first() .ok_or_else(|| PinakesError::NotFound("share link not found".into()))?; Ok(crate::social::ShareLink { id: row.get("id"), media_id: MediaId(row.get("media_id")), created_by: crate::users::UserId(row.get("created_by")), token: row.get("token"), password_hash: row.get("password_hash"), expires_at: row.get("expires_at"), view_count: row.get::<_, i32>("view_count") as u64, created_at: row.get("created_at"), }) } async fn increment_share_views(&self, token: &str) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute( "UPDATE share_links SET view_count = view_count + 1 WHERE token = $1", &[&token], ) .await?; Ok(()) } async fn delete_share_link(&self, id: Uuid) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM share_links WHERE id = $1", &[&id]) .await?; Ok(()) } // ===== Playlists ===== async fn create_playlist( &self, owner_id: crate::users::UserId, name: &str, description: Option<&str>, is_public: bool, is_smart: bool, filter_query: Option<&str>, ) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let id = Uuid::now_v7(); let now = Utc::now(); client.execute( "INSERT INTO playlists (id, owner_id, name, description, is_public, is_smart, filter_query, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", &[&id, &owner_id.0, &name, &description, &is_public, &is_smart, &filter_query, &now, &now], ).await?; Ok(crate::playlists::Playlist { id, owner_id, name: name.to_string(), description: description.map(String::from), is_public, is_smart, filter_query: filter_query.map(String::from), created_at: now, updated_at: now, }) } async fn get_playlist(&self, id: Uuid) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client.query( "SELECT id, owner_id, name, description, is_public, is_smart, filter_query, created_at, updated_at FROM playlists WHERE id = $1", &[&id], ).await?; let row = rows .first() .ok_or_else(|| PinakesError::NotFound(format!("playlist {id}")))?; Ok(crate::playlists::Playlist { id: row.get("id"), owner_id: crate::users::UserId(row.get("owner_id")), name: row.get("name"), description: row.get("description"), is_public: row.get("is_public"), is_smart: row.get("is_smart"), filter_query: row.get("filter_query"), created_at: row.get("created_at"), updated_at: row.get("updated_at"), }) } async fn list_playlists( &self, owner_id: Option, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = match owner_id { Some(uid) => client.query( "SELECT id, owner_id, name, description, is_public, is_smart, filter_query, created_at, updated_at FROM playlists WHERE owner_id = $1 OR is_public = true ORDER BY updated_at DESC", &[&uid.0], ).await?, None => client.query( "SELECT id, owner_id, name, description, is_public, is_smart, filter_query, created_at, updated_at FROM playlists ORDER BY updated_at DESC", &[], ).await?, }; Ok(rows .iter() .map(|row| crate::playlists::Playlist { id: row.get("id"), owner_id: crate::users::UserId(row.get("owner_id")), name: row.get("name"), description: row.get("description"), is_public: row.get("is_public"), is_smart: row.get("is_smart"), filter_query: row.get("filter_query"), created_at: row.get("created_at"), updated_at: row.get("updated_at"), }) .collect()) } async fn update_playlist( &self, id: Uuid, name: Option<&str>, description: Option<&str>, is_public: Option, ) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let now = Utc::now(); // Build dynamic update let mut set_parts = vec!["updated_at = $1".to_string()]; let mut params: Vec> = vec![Box::new(now)]; let mut idx = 2; if let Some(n) = name { set_parts.push(format!("name = ${idx}")); params.push(Box::new(n.to_string())); idx += 1; } if let Some(d) = description { set_parts.push(format!("description = ${idx}")); params.push(Box::new(d.to_string())); idx += 1; } if let Some(p) = is_public { set_parts.push(format!("is_public = ${idx}")); params.push(Box::new(p)); idx += 1; } params.push(Box::new(id)); let sql = format!( "UPDATE playlists SET {} WHERE id = ${idx}", set_parts.join(", ") ); let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = params .iter() .map(|p| &**p as &(dyn tokio_postgres::types::ToSql + Sync)) .collect(); client.execute(&sql, ¶m_refs).await?; self.get_playlist(id).await } async fn delete_playlist(&self, id: Uuid) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM playlists WHERE id = $1", &[&id]) .await?; Ok(()) } async fn add_to_playlist( &self, playlist_id: Uuid, media_id: MediaId, position: i32, ) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let now = Utc::now(); client.execute( "INSERT INTO playlist_items (playlist_id, media_id, position, added_at) VALUES ($1, $2, $3, $4) ON CONFLICT (playlist_id, media_id) DO UPDATE SET position = $3", &[&playlist_id, &media_id.0, &position, &now], ).await?; Ok(()) } async fn remove_from_playlist(&self, playlist_id: Uuid, media_id: MediaId) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute( "DELETE FROM playlist_items WHERE playlist_id = $1 AND media_id = $2", &[&playlist_id, &media_id.0], ) .await?; Ok(()) } async fn get_playlist_items(&self, playlist_id: Uuid) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client.query( "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.file_mtime, m.date_taken, m.latitude, m.longitude, m.camera_make, m.camera_model, m.rating, m.perceptual_hash, m.created_at, m.updated_at FROM media_items m JOIN playlist_items pi ON m.id = pi.media_id WHERE pi.playlist_id = $1 ORDER BY pi.position ASC", &[&playlist_id], ).await?; let mut items: Vec = rows .iter() .map(row_to_media_item) .collect::>>()?; // Batch-load custom fields if !items.is_empty() { let ids: Vec = items.iter().map(|i| i.id.0).collect(); let cf_rows = client .query( "SELECT media_id, field_name, field_type, field_value FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) .await?; let mut cf_map: HashMap> = HashMap::new(); for row in &cf_rows { let mid: Uuid = row.get("media_id"); let name: String = row.get("field_name"); let ft_str: String = row.get("field_type"); let value: String = row.get("field_value"); let field_type = custom_field_type_from_string(&ft_str)?; cf_map .entry(mid) .or_default() .insert(name, CustomField { field_type, value }); } for item in &mut items { if let Some(fields) = cf_map.remove(&item.id.0) { item.custom_fields = fields; } } } Ok(items) } async fn reorder_playlist( &self, playlist_id: Uuid, media_id: MediaId, new_position: i32, ) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute( "UPDATE playlist_items SET position = $1 WHERE playlist_id = $2 AND media_id = $3", &[&new_position, &playlist_id, &media_id.0], ) .await?; Ok(()) } // ===== Analytics ===== async fn record_usage_event(&self, event: &crate::analytics::UsageEvent) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let media_id = event.media_id.map(|m| m.0); let user_id = event.user_id.map(|u| u.0); let event_type = event.event_type.to_string(); let context: Option = event .context_json .as_ref() .and_then(|s| serde_json::from_str(s).ok()); client.execute( "INSERT INTO usage_events (id, media_id, user_id, event_type, timestamp, duration_secs, context_json) VALUES ($1, $2, $3, $4, $5, $6, $7)", &[&event.id, &media_id, &user_id, &event_type, &event.timestamp, &event.duration_secs, &context], ).await?; Ok(()) } async fn get_usage_events( &self, media_id: Option, user_id: Option, limit: u64, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let mut conditions = Vec::new(); let mut params: Vec> = Vec::new(); let mut idx = 1; if let Some(mid) = media_id { conditions.push(format!("media_id = ${idx}")); params.push(Box::new(mid.0)); idx += 1; } if let Some(uid) = user_id { conditions.push(format!("user_id = ${idx}")); params.push(Box::new(uid.0)); idx += 1; } let where_clause = if conditions.is_empty() { String::new() } else { format!("WHERE {}", conditions.join(" AND ")) }; params.push(Box::new(limit as i64)); let sql = format!( "SELECT id, media_id, user_id, event_type, timestamp, duration_secs, context_json FROM usage_events {} ORDER BY timestamp DESC LIMIT ${idx}", where_clause ); let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = params .iter() .map(|p| &**p as &(dyn tokio_postgres::types::ToSql + Sync)) .collect(); let rows = client.query(&sql, ¶m_refs).await?; Ok(rows .iter() .map(|row| { let event_type_str: String = row.get("event_type"); let context_json: Option = row.get("context_json"); crate::analytics::UsageEvent { id: row.get("id"), media_id: row.get::<_, Option>("media_id").map(MediaId), user_id: row .get::<_, Option>("user_id") .map(crate::users::UserId), event_type: event_type_str .parse() .unwrap_or(crate::analytics::UsageEventType::View), timestamp: row.get("timestamp"), duration_secs: row.get("duration_secs"), context_json: context_json.map(|v| v.to_string()), } }) .collect()) } async fn get_most_viewed(&self, limit: u64) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client.query( "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.file_mtime, m.date_taken, m.latitude, m.longitude, m.camera_make, m.camera_model, m.rating, m.perceptual_hash, m.created_at, m.updated_at, COUNT(ue.id) as view_count FROM media_items m JOIN usage_events ue ON m.id = ue.media_id WHERE ue.event_type IN ('view', 'play') GROUP BY m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.file_mtime, m.date_taken, m.latitude, m.longitude, m.camera_make, m.camera_model, m.rating, m.perceptual_hash, m.created_at, m.updated_at ORDER BY view_count DESC LIMIT $1", &[&(limit as i64)], ).await?; let mut results = Vec::new(); for row in &rows { let item = row_to_media_item(row)?; let count: i64 = row.get(24); results.push((item, count as u64)); } // Batch-load custom fields if !results.is_empty() { let ids: Vec = results.iter().map(|(i, _)| i.id.0).collect(); let cf_rows = client .query( "SELECT media_id, field_name, field_type, field_value FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) .await?; let mut cf_map: HashMap> = HashMap::new(); for row in &cf_rows { let mid: Uuid = row.get("media_id"); let name: String = row.get("field_name"); let ft_str: String = row.get("field_type"); let value: String = row.get("field_value"); let field_type = custom_field_type_from_string(&ft_str)?; cf_map .entry(mid) .or_default() .insert(name, CustomField { field_type, value }); } for (item, _) in &mut results { if let Some(fields) = cf_map.remove(&item.id.0) { item.custom_fields = fields; } } } Ok(results) } async fn get_recently_viewed( &self, user_id: crate::users::UserId, limit: u64, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client.query( "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.file_mtime, m.date_taken, m.latitude, m.longitude, m.camera_make, m.camera_model, m.rating, m.perceptual_hash, m.created_at, m.updated_at FROM media_items m JOIN usage_events ue ON m.id = ue.media_id WHERE ue.user_id = $1 AND ue.event_type IN ('view', 'play') GROUP BY m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.file_mtime, m.date_taken, m.latitude, m.longitude, m.camera_make, m.camera_model, m.rating, m.perceptual_hash, m.created_at, m.updated_at ORDER BY MAX(ue.timestamp) DESC LIMIT $2", &[&user_id.0, &(limit as i64)], ).await?; let mut items: Vec = rows .iter() .map(row_to_media_item) .collect::>>()?; // Batch-load custom fields if !items.is_empty() { let ids: Vec = items.iter().map(|i| i.id.0).collect(); let cf_rows = client .query( "SELECT media_id, field_name, field_type, field_value FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) .await?; let mut cf_map: HashMap> = HashMap::new(); for row in &cf_rows { let mid: Uuid = row.get("media_id"); let name: String = row.get("field_name"); let ft_str: String = row.get("field_type"); let value: String = row.get("field_value"); let field_type = custom_field_type_from_string(&ft_str)?; cf_map .entry(mid) .or_default() .insert(name, CustomField { field_type, value }); } for item in &mut items { if let Some(fields) = cf_map.remove(&item.id.0) { item.custom_fields = fields; } } } Ok(items) } async fn update_watch_progress( &self, user_id: crate::users::UserId, media_id: MediaId, progress_secs: f64, ) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let id = Uuid::now_v7(); let now = Utc::now(); client.execute( "INSERT INTO watch_history (id, user_id, media_id, progress_secs, last_watched) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (user_id, media_id) DO UPDATE SET progress_secs = $4, last_watched = $5", &[&id, &user_id.0, &media_id.0, &progress_secs, &now], ).await?; Ok(()) } async fn get_watch_progress( &self, user_id: crate::users::UserId, media_id: MediaId, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client .query( "SELECT progress_secs FROM watch_history WHERE user_id = $1 AND media_id = $2", &[&user_id.0, &media_id.0], ) .await?; Ok(rows.first().map(|row| row.get("progress_secs"))) } async fn cleanup_old_events(&self, before: chrono::DateTime) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let affected = client .execute("DELETE FROM usage_events WHERE timestamp < $1", &[&before]) .await?; Ok(affected) } // ===== Subtitles ===== async fn add_subtitle(&self, subtitle: &crate::subtitles::Subtitle) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let format_str = subtitle.format.to_string(); let file_path = subtitle .file_path .as_ref() .map(|p| p.to_string_lossy().to_string()); let track_index = subtitle.track_index.map(|i| i as i32); let offset_ms = subtitle.offset_ms as i32; client.execute( "INSERT INTO subtitles (id, media_id, language, format, file_path, is_embedded, track_index, offset_ms, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", &[&subtitle.id, &subtitle.media_id.0, &subtitle.language, &format_str, &file_path, &subtitle.is_embedded, &track_index, &offset_ms, &subtitle.created_at], ).await?; Ok(()) } async fn get_media_subtitles( &self, media_id: MediaId, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client.query( "SELECT id, media_id, language, format, file_path, is_embedded, track_index, offset_ms, created_at FROM subtitles WHERE media_id = $1", &[&media_id.0], ).await?; Ok(rows .iter() .map(|row| { let format_str: String = row.get("format"); crate::subtitles::Subtitle { id: row.get("id"), media_id: MediaId(row.get("media_id")), language: row.get("language"), format: format_str .parse() .unwrap_or(crate::subtitles::SubtitleFormat::Srt), file_path: row .get::<_, Option>("file_path") .map(std::path::PathBuf::from), is_embedded: row.get("is_embedded"), track_index: row.get::<_, Option>("track_index").map(|i| i as usize), offset_ms: row.get::<_, i32>("offset_ms") as i64, created_at: row.get("created_at"), } }) .collect()) } async fn delete_subtitle(&self, id: Uuid) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM subtitles WHERE id = $1", &[&id]) .await?; Ok(()) } async fn update_subtitle_offset(&self, id: Uuid, offset_ms: i64) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let offset = offset_ms as i32; client .execute( "UPDATE subtitles SET offset_ms = $1 WHERE id = $2", &[&offset, &id], ) .await?; Ok(()) } // ===== External Metadata (Enrichment) ===== async fn store_external_metadata( &self, meta: &crate::enrichment::ExternalMetadata, ) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let source = meta.source.to_string(); let metadata_json: serde_json::Value = serde_json::from_str(&meta.metadata_json) .unwrap_or_else(|e| { tracing::warn!( "failed to deserialize metadata_json for external metadata {}: {}", meta.id, e ); serde_json::Value::Object(Default::default()) }); client.execute( "INSERT INTO external_metadata (id, media_id, source, external_id, metadata_json, confidence, last_updated) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id) DO UPDATE SET metadata_json = $5, confidence = $6, last_updated = $7", &[&meta.id, &meta.media_id.0, &source, &meta.external_id, &metadata_json, &meta.confidence, &meta.last_updated], ).await?; Ok(()) } async fn get_external_metadata( &self, media_id: MediaId, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client.query( "SELECT id, media_id, source, external_id, metadata_json, confidence, last_updated FROM external_metadata WHERE media_id = $1", &[&media_id.0], ).await?; Ok(rows .iter() .map(|row| { let source_str: String = row.get("source"); let metadata_json: serde_json::Value = row.get("metadata_json"); crate::enrichment::ExternalMetadata { id: row.get("id"), media_id: MediaId(row.get("media_id")), source: source_str .parse() .unwrap_or(crate::enrichment::EnrichmentSourceType::MusicBrainz), external_id: row.get("external_id"), metadata_json: metadata_json.to_string(), confidence: row.get("confidence"), last_updated: row.get("last_updated"), } }) .collect()) } async fn delete_external_metadata(&self, id: Uuid) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM external_metadata WHERE id = $1", &[&id]) .await?; Ok(()) } // ===== Transcode Sessions ===== async fn create_transcode_session( &self, session: &crate::transcode::TranscodeSession, ) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let user_id = session.user_id.map(|u| u.0); let cache_path = session.cache_path.to_string_lossy().to_string(); let status = session.status.as_str().to_string(); let error_message = session.status.error_message().map(String::from); let progress = session.progress as f64; client.execute( "INSERT INTO transcode_sessions (id, media_id, user_id, profile, cache_path, status, progress, error_message, created_at, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", &[&session.id, &session.media_id.0, &user_id, &session.profile, &cache_path, &status, &progress, &error_message, &session.created_at, &session.expires_at], ).await?; Ok(()) } async fn get_transcode_session(&self, id: Uuid) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client.query( "SELECT id, media_id, user_id, profile, cache_path, status, progress, error_message, created_at, expires_at FROM transcode_sessions WHERE id = $1", &[&id], ).await?; let row = rows .first() .ok_or_else(|| PinakesError::NotFound(format!("transcode session {id}")))?; let status_str: String = row.get("status"); let error_msg: Option = row.get("error_message"); let progress: f64 = row.get("progress"); Ok(crate::transcode::TranscodeSession { id: row.get("id"), media_id: MediaId(row.get("media_id")), user_id: row .get::<_, Option>("user_id") .map(crate::users::UserId), profile: row.get("profile"), cache_path: std::path::PathBuf::from(row.get::<_, String>("cache_path")), status: crate::transcode::TranscodeStatus::from_db(&status_str, error_msg.as_deref()), progress: progress as f32, created_at: row.get("created_at"), expires_at: row.get("expires_at"), duration_secs: None, child_cancel: None, }) } async fn list_transcode_sessions( &self, media_id: Option, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = match media_id { Some(mid) => client.query( "SELECT id, media_id, user_id, profile, cache_path, status, progress, error_message, created_at, expires_at FROM transcode_sessions WHERE media_id = $1 ORDER BY created_at DESC", &[&mid.0], ).await?, None => client.query( "SELECT id, media_id, user_id, profile, cache_path, status, progress, error_message, created_at, expires_at FROM transcode_sessions ORDER BY created_at DESC", &[], ).await?, }; Ok(rows .iter() .map(|row| { let status_str: String = row.get("status"); let error_msg: Option = row.get("error_message"); let progress: f64 = row.get("progress"); crate::transcode::TranscodeSession { id: row.get("id"), media_id: MediaId(row.get("media_id")), user_id: row .get::<_, Option>("user_id") .map(crate::users::UserId), profile: row.get("profile"), cache_path: std::path::PathBuf::from(row.get::<_, String>("cache_path")), status: crate::transcode::TranscodeStatus::from_db( &status_str, error_msg.as_deref(), ), progress: progress as f32, created_at: row.get("created_at"), expires_at: row.get("expires_at"), duration_secs: None, child_cancel: None, } }) .collect()) } async fn update_transcode_status( &self, id: Uuid, status: crate::transcode::TranscodeStatus, progress: f32, ) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let status_str = status.as_str().to_string(); let error_message = status.error_message().map(String::from); let progress_f64 = progress as f64; client.execute( "UPDATE transcode_sessions SET status = $1, progress = $2, error_message = $3 WHERE id = $4", &[&status_str, &progress_f64, &error_message, &id], ).await?; Ok(()) } async fn cleanup_expired_transcodes( &self, before: chrono::DateTime, ) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let affected = client .execute( "DELETE FROM transcode_sessions WHERE expires_at IS NOT NULL AND expires_at < $1", &[&before], ) .await?; Ok(affected) } // ===== Session Management ===== async fn create_session(&self, session: &crate::storage::SessionData) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute( "INSERT INTO sessions (session_token, user_id, username, role, created_at, expires_at, last_accessed) VALUES ($1, $2, $3, $4, $5, $6, $7)", &[ &session.session_token, &session.user_id, &session.username, &session.role, &session.created_at, &session.expires_at, &session.last_accessed, ], ) .await?; Ok(()) } async fn get_session( &self, session_token: &str, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let row = client .query_opt( "SELECT session_token, user_id, username, role, created_at, expires_at, last_accessed FROM sessions WHERE session_token = $1", &[&session_token], ) .await?; Ok(row.map(|r| crate::storage::SessionData { session_token: r.get(0), user_id: r.get(1), username: r.get(2), role: r.get(3), created_at: r.get(4), expires_at: r.get(5), last_accessed: r.get(6), })) } async fn touch_session(&self, session_token: &str) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let now = chrono::Utc::now(); client .execute( "UPDATE sessions SET last_accessed = $1 WHERE session_token = $2", &[&now, &session_token], ) .await?; Ok(()) } async fn delete_session(&self, session_token: &str) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute( "DELETE FROM sessions WHERE session_token = $1", &[&session_token], ) .await?; Ok(()) } async fn delete_user_sessions(&self, username: &str) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let affected = client .execute("DELETE FROM sessions WHERE username = $1", &[&username]) .await?; Ok(affected) } async fn delete_expired_sessions(&self) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let now = chrono::Utc::now(); let affected = client .execute("DELETE FROM sessions WHERE expires_at < $1", &[&now]) .await?; Ok(affected) } async fn list_active_sessions( &self, username: Option<&str>, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let now = chrono::Utc::now(); let rows = if let Some(user) = username { client .query( "SELECT session_token, user_id, username, role, created_at, expires_at, last_accessed FROM sessions WHERE expires_at > $1 AND username = $2 ORDER BY last_accessed DESC", &[&now, &user], ) .await? } else { client .query( "SELECT session_token, user_id, username, role, created_at, expires_at, last_accessed FROM sessions WHERE expires_at > $1 ORDER BY last_accessed DESC", &[&now], ) .await? }; Ok(rows .into_iter() .map(|r| crate::storage::SessionData { session_token: r.get(0), user_id: r.get(1), username: r.get(2), role: r.get(3), created_at: r.get(4), expires_at: r.get(5), last_accessed: r.get(6), }) .collect()) } // Book Management Methods async fn upsert_book_metadata(&self, metadata: &crate::model::BookMetadata) -> Result<()> { let mut client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let tx = client.transaction().await?; // Upsert book_metadata tx.execute( "INSERT INTO book_metadata ( media_id, isbn, isbn13, publisher, language, page_count, publication_date, series_name, series_index, format ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT(media_id) DO UPDATE SET isbn = $2, isbn13 = $3, publisher = $4, language = $5, page_count = $6, publication_date = $7, series_name = $8, series_index = $9, format = $10, updated_at = NOW()", &[ &metadata.media_id.0, &metadata.isbn, &metadata.isbn13, &metadata.publisher, &metadata.language, &metadata.page_count, &metadata.publication_date, &metadata.series_name, &metadata.series_index, &metadata.format, ], ) .await?; // Clear existing authors and identifiers tx.execute( "DELETE FROM book_authors WHERE media_id = $1", &[&metadata.media_id.0], ) .await?; tx.execute( "DELETE FROM book_identifiers WHERE media_id = $1", &[&metadata.media_id.0], ) .await?; // Insert authors for author in &metadata.authors { tx.execute( "INSERT INTO book_authors (media_id, author_name, author_sort, role, position) VALUES ($1, $2, $3, $4, $5)", &[ &metadata.media_id.0, &author.name, &author.file_as, &author.role, &author.position, ], ) .await?; } // Insert identifiers for (id_type, values) in &metadata.identifiers { for value in values { tx.execute( "INSERT INTO book_identifiers (media_id, identifier_type, identifier_value) VALUES ($1, $2, $3)", &[&metadata.media_id.0, &id_type, &value], ) .await?; } } tx.commit().await?; Ok(()) } async fn get_book_metadata( &self, media_id: MediaId, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; // Get base book metadata let row = client .query_opt( "SELECT isbn, isbn13, publisher, language, page_count, publication_date, series_name, series_index, format, created_at, updated_at FROM book_metadata WHERE media_id = $1", &[&media_id.0], ) .await?; if row.is_none() { return Ok(None); } let row = row.unwrap(); // Get authors let author_rows = client .query( "SELECT author_name, author_sort, role, position FROM book_authors WHERE media_id = $1 ORDER BY position", &[&media_id.0], ) .await?; let authors: Vec = author_rows .iter() .map(|r| crate::model::AuthorInfo { name: r.get(0), file_as: r.get(1), role: r.get(2), position: r.get(3), }) .collect(); // Get identifiers let id_rows = client .query( "SELECT identifier_type, identifier_value FROM book_identifiers WHERE media_id = $1", &[&media_id.0], ) .await?; let mut identifiers: std::collections::HashMap> = std::collections::HashMap::new(); for r in id_rows { let id_type: String = r.get(0); let value: String = r.get(1); identifiers.entry(id_type).or_default().push(value); } Ok(Some(crate::model::BookMetadata { media_id, isbn: row.get(0), isbn13: row.get(1), publisher: row.get(2), language: row.get(3), page_count: row.get(4), publication_date: row.get(5), series_name: row.get(6), series_index: row.get(7), format: row.get(8), authors, identifiers, created_at: row.get(9), updated_at: row.get(10), })) } async fn add_book_author( &self, media_id: MediaId, author: &crate::model::AuthorInfo, ) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute( "INSERT INTO book_authors (media_id, author_name, author_sort, role, position) VALUES ($1, $2, $3, $4, $5) ON CONFLICT(media_id, author_name, role) DO UPDATE SET author_sort = $3, position = $5", &[ &media_id.0, &author.name, &author.file_as, &author.role, &author.position, ], ) .await?; Ok(()) } async fn get_book_authors(&self, media_id: MediaId) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client .query( "SELECT author_name, author_sort, role, position FROM book_authors WHERE media_id = $1 ORDER BY position", &[&media_id.0], ) .await?; Ok(rows .iter() .map(|r| crate::model::AuthorInfo { name: r.get(0), file_as: r.get(1), role: r.get(2), position: r.get(3), }) .collect()) } async fn list_all_authors(&self, pagination: &Pagination) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client .query( "SELECT author_name, COUNT(DISTINCT media_id) as book_count FROM book_authors GROUP BY author_name ORDER BY book_count DESC, author_name LIMIT $1 OFFSET $2", &[&(pagination.limit as i64), &(pagination.offset as i64)], ) .await?; Ok(rows .iter() .map(|r| (r.get(0), r.get::<_, i64>(1) as u64)) .collect()) } async fn list_series(&self) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client .query( "SELECT series_name, COUNT(*) as book_count FROM book_metadata WHERE series_name IS NOT NULL GROUP BY series_name ORDER BY series_name", &[], ) .await?; Ok(rows .iter() .map(|r| (r.get(0), r.get::<_, i64>(1) as u64)) .collect()) } async fn get_series_books(&self, series_name: &str) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client .query( "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.file_mtime, m.created_at, m.updated_at FROM media_items m INNER JOIN book_metadata b ON m.id = b.media_id WHERE b.series_name = $1 ORDER BY b.series_index, m.title", &[&series_name], ) .await?; rows.iter().map(row_to_media_item).collect() } async fn update_reading_progress( &self, user_id: uuid::Uuid, media_id: MediaId, current_page: i32, ) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute( "INSERT INTO watch_history (user_id, media_id, progress_secs, last_watched_at) VALUES ($1, $2, $3, NOW()) ON CONFLICT(user_id, media_id) DO UPDATE SET progress_secs = $3, last_watched_at = NOW()", &[&user_id, &media_id.0, &(current_page as f64)], ) .await?; Ok(()) } async fn get_reading_progress( &self, user_id: uuid::Uuid, media_id: MediaId, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let row = client .query_opt( "SELECT wh.progress_secs, bm.page_count, wh.last_watched_at FROM watch_history wh LEFT JOIN book_metadata bm ON wh.media_id = bm.media_id WHERE wh.user_id = $1 AND wh.media_id = $2", &[&user_id, &media_id.0], ) .await?; Ok(row.map(|r| { let current_page = r.get::<_, f64>(0) as i32; let total_pages: Option = r.get(1); let progress_percent = if let Some(total) = total_pages { if total > 0 { (current_page as f64 / total as f64 * 100.0).min(100.0) } else { 0.0 } } else { 0.0 }; crate::model::ReadingProgress { media_id, user_id, current_page, total_pages, progress_percent, last_read_at: r.get(2), } })) } async fn get_reading_list( &self, user_id: uuid::Uuid, status: Option, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; // Query books with reading progress for this user let rows = client .query( "SELECT m.*, wh.progress_secs, bm.page_count FROM media_items m INNER JOIN watch_history wh ON m.id = wh.media_id LEFT JOIN book_metadata bm ON m.id = bm.media_id WHERE wh.user_id = $1 ORDER BY wh.last_watched_at DESC", &[&user_id], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; let mut results = Vec::new(); for row in rows { // Parse the media item let item = row_to_media_item(&row)?; // Get progress info let current_page: f64 = row.get("progress_secs"); let current_page = current_page as i32; let total_pages: Option = row.get("page_count"); // Calculate status based on progress let calculated_status = if let Some(total) = total_pages { if total > 0 { let percent = (current_page as f64 / total as f64 * 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 } } else { // No total pages known, assume reading crate::model::ReadingStatus::Reading }; // Filter by status if specified match status { None => results.push(item), Some(s) if s == calculated_status => results.push(item), _ => {} } } Ok(results) } #[allow(clippy::too_many_arguments)] async fn search_books( &self, isbn: Option<&str>, author: Option<&str>, series: Option<&str>, publisher: Option<&str>, language: Option<&str>, pagination: &Pagination, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; // For PostgreSQL, we need to handle parameters carefully due to lifetimes // Simplified approach: use separate queries for different filter combinations let rows = if let (Some(i), Some(a), Some(s), Some(p), Some(l)) = (isbn, author, series, publisher, language) { let author_pattern = format!("%{}%", a); let series_pattern = format!("%{}%", s); let publisher_pattern = format!("%{}%", p); client .query( "SELECT DISTINCT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.file_mtime, m.created_at, m.updated_at FROM media_items m INNER JOIN book_metadata bm ON m.id = bm.media_id INNER JOIN book_authors ba ON m.id = ba.media_id WHERE (bm.isbn = $1 OR bm.isbn13 = $1) AND ba.author_name ILIKE $2 AND bm.series_name ILIKE $3 AND bm.publisher ILIKE $4 AND bm.language = $5 ORDER BY m.title LIMIT $6 OFFSET $7", &[ &i, &author_pattern, &series_pattern, &publisher_pattern, &l, &(pagination.limit as i64), &(pagination.offset as i64), ], ) .await? } else if isbn.is_none() && author.is_none() && series.is_none() && publisher.is_none() && language.is_none() { // No filters client .query( "SELECT DISTINCT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.file_mtime, m.created_at, m.updated_at FROM media_items m INNER JOIN book_metadata bm ON m.id = bm.media_id ORDER BY m.title LIMIT $1 OFFSET $2", &[&(pagination.limit as i64), &(pagination.offset as i64)], ) .await? } else { // For other combinations, use dynamic query (simplified - just filter by what's provided) let mut query = "SELECT DISTINCT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.file_mtime, m.created_at, m.updated_at FROM media_items m INNER JOIN book_metadata bm ON m.id = bm.media_id WHERE 1=1" .to_string(); if isbn.is_some() { query.push_str(" AND (bm.isbn = $1 OR bm.isbn13 = $1)"); } query.push_str(" ORDER BY m.title LIMIT $2 OFFSET $3"); if let Some(i) = isbn { client .query( &query, &[&i, &(pagination.limit as i64), &(pagination.offset as i64)], ) .await? } else { client .query( &query, &[&(pagination.limit as i64), &(pagination.offset as i64)], ) .await? } }; let items: Result> = rows.iter().map(row_to_media_item).collect(); items } // ========================================================================= // Managed Storage // ========================================================================= async fn insert_managed_media(&self, item: &MediaItem) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; client .execute( "INSERT INTO media_items (id, path, file_name, media_type, content_hash, file_size, title, artist, album, genre, year, duration_secs, description, thumbnail_path, storage_mode, original_filename, uploaded_at, storage_key, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)", &[ &item.id.0, &item.path.to_string_lossy().to_string(), &item.file_name, &media_type_to_string(&item.media_type), &item.content_hash.0, &(item.file_size as i64), &item.title, &item.artist, &item.album, &item.genre, &item.year, &item.duration_secs, &item.description, &item.thumbnail_path.as_ref().map(|p| p.to_string_lossy().to_string()), &item.storage_mode.to_string(), &item.original_filename, &item.uploaded_at, &item.storage_key, &item.created_at, &item.updated_at, ], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } async fn get_or_create_blob( &self, hash: &ContentHash, size: u64, mime_type: &str, ) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; // Try to get existing blob let existing = client .query_opt( "SELECT content_hash, file_size, mime_type, reference_count, stored_at, last_verified FROM managed_blobs WHERE content_hash = $1", &[&hash.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; if let Some(row) = existing { return Ok(ManagedBlob { content_hash: ContentHash(row.get(0)), file_size: row.get::<_, i64>(1) as u64, mime_type: row.get(2), reference_count: row.get::<_, i32>(3) as u32, stored_at: row.get(4), last_verified: row.get(5), }); } // Create new blob let now = chrono::Utc::now(); client .execute( "INSERT INTO managed_blobs (content_hash, file_size, mime_type, reference_count, stored_at) VALUES ($1, $2, $3, 1, $4)", &[&hash.0, &(size as i64), &mime_type, &now], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(ManagedBlob { content_hash: hash.clone(), file_size: size, mime_type: mime_type.to_string(), reference_count: 1, stored_at: now, last_verified: None, }) } async fn get_blob(&self, hash: &ContentHash) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let row = client .query_opt( "SELECT content_hash, file_size, mime_type, reference_count, stored_at, last_verified FROM managed_blobs WHERE content_hash = $1", &[&hash.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(row.map(|r| ManagedBlob { content_hash: ContentHash(r.get(0)), file_size: r.get::<_, i64>(1) as u64, mime_type: r.get(2), reference_count: r.get::<_, i32>(3) as u32, stored_at: r.get(4), last_verified: r.get(5), })) } async fn increment_blob_ref(&self, hash: &ContentHash) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; client .execute( "UPDATE managed_blobs SET reference_count = reference_count + 1 WHERE content_hash = $1", &[&hash.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } async fn decrement_blob_ref(&self, hash: &ContentHash) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; client .execute( "UPDATE managed_blobs SET reference_count = reference_count - 1 WHERE content_hash = $1", &[&hash.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; // Check if reference count is now 0 let row = client .query_opt( "SELECT reference_count FROM managed_blobs WHERE content_hash = $1", &[&hash.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; let count: i32 = row.map(|r| r.get(0)).unwrap_or(0); Ok(count <= 0) } async fn update_blob_verified(&self, hash: &ContentHash) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let now = chrono::Utc::now(); client .execute( "UPDATE managed_blobs SET last_verified = $1 WHERE content_hash = $2", &[&now, &hash.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } async fn list_orphaned_blobs(&self) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let rows = client .query( "SELECT content_hash, file_size, mime_type, reference_count, stored_at, last_verified FROM managed_blobs WHERE reference_count <= 0", &[], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows .iter() .map(|r| ManagedBlob { content_hash: ContentHash(r.get(0)), file_size: r.get::<_, i64>(1) as u64, mime_type: r.get(2), reference_count: r.get::<_, i32>(3) as u32, stored_at: r.get(4), last_verified: r.get(5), }) .collect()) } async fn delete_blob(&self, hash: &ContentHash) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; client .execute( "DELETE FROM managed_blobs WHERE content_hash = $1", &[&hash.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } async fn managed_storage_stats(&self) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let total_blobs: i64 = client .query_one("SELECT COUNT(*) FROM managed_blobs", &[]) .await .map_err(|e| PinakesError::Database(e.to_string()))? .get(0); let total_size: i64 = client .query_one("SELECT COALESCE(SUM(file_size), 0) FROM managed_blobs", &[]) .await .map_err(|e| PinakesError::Database(e.to_string()))? .get(0); let unique_size: i64 = client .query_one( "SELECT COALESCE(SUM(file_size), 0) FROM managed_blobs WHERE reference_count = 1", &[], ) .await .map_err(|e| PinakesError::Database(e.to_string()))? .get(0); let managed_media_count: i64 = client .query_one( "SELECT COUNT(*) FROM media_items WHERE storage_mode = 'managed'", &[], ) .await .map_err(|e| PinakesError::Database(e.to_string()))? .get(0); let orphaned_blobs: i64 = client .query_one( "SELECT COUNT(*) FROM managed_blobs WHERE reference_count <= 0", &[], ) .await .map_err(|e| PinakesError::Database(e.to_string()))? .get(0); let dedup_ratio = if total_size > 0 { unique_size as f64 / total_size as f64 } else { 1.0 }; Ok(ManagedStorageStats { total_blobs: total_blobs as u64, total_size_bytes: total_size as u64, unique_size_bytes: unique_size as u64, deduplication_ratio: dedup_ratio, managed_media_count: managed_media_count as u64, orphaned_blobs: orphaned_blobs as u64, }) } // ========================================================================= // Sync Devices // ========================================================================= async fn register_device( &self, device: &crate::sync::SyncDevice, token_hash: &str, ) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; client .execute( "INSERT INTO sync_devices (id, user_id, name, device_type, client_version, os_info, device_token_hash, last_seen_at, sync_cursor, enabled, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", &[ &device.id.0, &device.user_id.0, &device.name, &device.device_type.to_string(), &device.client_version, &device.os_info, &token_hash, &device.last_seen_at, &device.sync_cursor, &device.enabled, &device.created_at, &device.updated_at, ], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(device.clone()) } async fn get_device(&self, id: crate::sync::DeviceId) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let row = client .query_one( "SELECT id, user_id, name, device_type, client_version, os_info, last_sync_at, last_seen_at, sync_cursor, enabled, created_at, updated_at FROM sync_devices WHERE id = $1", &[&id.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(crate::sync::SyncDevice { id: crate::sync::DeviceId(row.get(0)), user_id: crate::users::UserId(row.get(1)), name: row.get(2), device_type: row.get::<_, String>(3).parse().unwrap_or_default(), client_version: row.get(4), os_info: row.get(5), last_sync_at: row.get(6), last_seen_at: row.get(7), sync_cursor: row.get(8), enabled: row.get(9), created_at: row.get(10), updated_at: row.get(11), }) } async fn get_device_by_token( &self, token_hash: &str, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let row = client .query_opt( "SELECT id, user_id, name, device_type, client_version, os_info, last_sync_at, last_seen_at, sync_cursor, enabled, created_at, updated_at FROM sync_devices WHERE device_token_hash = $1", &[&token_hash], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(row.map(|r| crate::sync::SyncDevice { id: crate::sync::DeviceId(r.get(0)), user_id: crate::users::UserId(r.get(1)), name: r.get(2), device_type: r.get::<_, String>(3).parse().unwrap_or_default(), client_version: r.get(4), os_info: r.get(5), last_sync_at: r.get(6), last_seen_at: r.get(7), sync_cursor: r.get(8), enabled: r.get(9), created_at: r.get(10), updated_at: r.get(11), })) } async fn list_user_devices( &self, user_id: crate::users::UserId, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let rows = client .query( "SELECT id, user_id, name, device_type, client_version, os_info, last_sync_at, last_seen_at, sync_cursor, enabled, created_at, updated_at FROM sync_devices WHERE user_id = $1 ORDER BY last_seen_at DESC", &[&user_id.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows .iter() .map(|r| crate::sync::SyncDevice { id: crate::sync::DeviceId(r.get(0)), user_id: crate::users::UserId(r.get(1)), name: r.get(2), device_type: r.get::<_, String>(3).parse().unwrap_or_default(), client_version: r.get(4), os_info: r.get(5), last_sync_at: r.get(6), last_seen_at: r.get(7), sync_cursor: r.get(8), enabled: r.get(9), created_at: r.get(10), updated_at: r.get(11), }) .collect()) } async fn update_device(&self, device: &crate::sync::SyncDevice) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; client .execute( "UPDATE sync_devices SET name = $1, device_type = $2, client_version = $3, os_info = $4, last_sync_at = $5, last_seen_at = $6, sync_cursor = $7, enabled = $8, updated_at = $9 WHERE id = $10", &[ &device.name, &device.device_type.to_string(), &device.client_version, &device.os_info, &device.last_sync_at, &device.last_seen_at, &device.sync_cursor, &device.enabled, &device.updated_at, &device.id.0, ], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } async fn delete_device(&self, id: crate::sync::DeviceId) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; client .execute("DELETE FROM sync_devices WHERE id = $1", &[&id.0]) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } async fn touch_device(&self, id: crate::sync::DeviceId) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let now = chrono::Utc::now(); client .execute( "UPDATE sync_devices SET last_seen_at = $1, updated_at = $1 WHERE id = $2", &[&now, &id.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } // ========================================================================= // Sync Log // ========================================================================= async fn record_sync_change(&self, change: &crate::sync::SyncLogEntry) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; // Get and increment sequence let seq_row = client .query_one( "UPDATE sync_sequence SET current_value = current_value + 1 WHERE id = 1 RETURNING current_value", &[], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; let seq: i64 = seq_row.get(0); client .execute( "INSERT INTO sync_log (id, sequence, change_type, media_id, path, content_hash, file_size, metadata_json, changed_by_device, timestamp) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", &[ &change.id, &seq, &change.change_type.to_string(), &change.media_id.map(|m| m.0), &change.path, &change.content_hash.as_ref().map(|h| h.0.clone()), &change.file_size.map(|s| s as i64), &change.metadata_json, &change.changed_by_device.map(|d| d.0), &change.timestamp, ], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } async fn get_changes_since( &self, cursor: i64, limit: u64, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let rows = client .query( "SELECT id, sequence, change_type, media_id, path, content_hash, file_size, metadata_json, changed_by_device, timestamp FROM sync_log WHERE sequence > $1 ORDER BY sequence LIMIT $2", &[&cursor, &(limit as i64)], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows .iter() .map(|r| crate::sync::SyncLogEntry { id: r.get(0), sequence: r.get(1), change_type: r .get::<_, String>(2) .parse() .unwrap_or(crate::sync::SyncChangeType::Modified), media_id: r.get::<_, Option>(3).map(MediaId), path: r.get(4), content_hash: r.get::<_, Option>(5).map(ContentHash), file_size: r.get::<_, Option>(6).map(|s| s as u64), metadata_json: r.get(7), changed_by_device: r.get::<_, Option>(8).map(crate::sync::DeviceId), timestamp: r.get(9), }) .collect()) } async fn get_current_sync_cursor(&self) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let row = client .query_one("SELECT current_value FROM sync_sequence WHERE id = 1", &[]) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(row.get(0)) } async fn cleanup_old_sync_log(&self, before: chrono::DateTime) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let result = client .execute("DELETE FROM sync_log WHERE timestamp < $1", &[&before]) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(result) } // ========================================================================= // Device Sync State // ========================================================================= async fn get_device_sync_state( &self, device_id: crate::sync::DeviceId, path: &str, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let row = client .query_opt( "SELECT device_id, path, local_hash, server_hash, local_mtime, server_mtime, sync_status, last_synced_at, conflict_info_json FROM device_sync_state WHERE device_id = $1 AND path = $2", &[&device_id.0, &path], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(row.map(|r| crate::sync::DeviceSyncState { device_id: crate::sync::DeviceId(r.get(0)), path: r.get(1), local_hash: r.get(2), server_hash: r.get(3), local_mtime: r.get(4), server_mtime: r.get(5), sync_status: r .get::<_, String>(6) .parse() .unwrap_or(crate::sync::FileSyncStatus::Synced), last_synced_at: r.get(7), conflict_info_json: r.get(8), })) } async fn upsert_device_sync_state(&self, state: &crate::sync::DeviceSyncState) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; client .execute( "INSERT INTO device_sync_state (device_id, path, local_hash, server_hash, local_mtime, server_mtime, sync_status, last_synced_at, conflict_info_json) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT(device_id, path) DO UPDATE SET local_hash = EXCLUDED.local_hash, server_hash = EXCLUDED.server_hash, local_mtime = EXCLUDED.local_mtime, server_mtime = EXCLUDED.server_mtime, sync_status = EXCLUDED.sync_status, last_synced_at = EXCLUDED.last_synced_at, conflict_info_json = EXCLUDED.conflict_info_json", &[ &state.device_id.0, &state.path, &state.local_hash, &state.server_hash, &state.local_mtime, &state.server_mtime, &state.sync_status.to_string(), &state.last_synced_at, &state.conflict_info_json, ], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } async fn list_pending_sync( &self, device_id: crate::sync::DeviceId, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let rows = client .query( "SELECT device_id, path, local_hash, server_hash, local_mtime, server_mtime, sync_status, last_synced_at, conflict_info_json FROM device_sync_state WHERE device_id = $1 AND sync_status IN ('pending_upload', 'pending_download', 'conflict')", &[&device_id.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows .iter() .map(|r| crate::sync::DeviceSyncState { device_id: crate::sync::DeviceId(r.get(0)), path: r.get(1), local_hash: r.get(2), server_hash: r.get(3), local_mtime: r.get(4), server_mtime: r.get(5), sync_status: r .get::<_, String>(6) .parse() .unwrap_or(crate::sync::FileSyncStatus::Synced), last_synced_at: r.get(7), conflict_info_json: r.get(8), }) .collect()) } // ========================================================================= // Upload Sessions // ========================================================================= async fn create_upload_session(&self, session: &crate::sync::UploadSession) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; client .execute( "INSERT INTO upload_sessions (id, device_id, target_path, expected_hash, expected_size, chunk_size, chunk_count, status, created_at, expires_at, last_activity) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", &[ &session.id, &session.device_id.0, &session.target_path, &session.expected_hash.0, &(session.expected_size as i64), &(session.chunk_size as i64), &(session.chunk_count as i64), &session.status.to_string(), &session.created_at, &session.expires_at, &session.last_activity, ], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } async fn get_upload_session(&self, id: Uuid) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let row = client .query_one( "SELECT id, device_id, target_path, expected_hash, expected_size, chunk_size, chunk_count, status, created_at, expires_at, last_activity FROM upload_sessions WHERE id = $1", &[&id], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(crate::sync::UploadSession { id: row.get(0), device_id: crate::sync::DeviceId(row.get(1)), target_path: row.get(2), expected_hash: ContentHash(row.get(3)), expected_size: row.get::<_, i64>(4) as u64, chunk_size: row.get::<_, i64>(5) as u64, chunk_count: row.get::<_, i64>(6) as u64, status: row .get::<_, String>(7) .parse() .unwrap_or(crate::sync::UploadStatus::Pending), created_at: row.get(8), expires_at: row.get(9), last_activity: row.get(10), }) } async fn update_upload_session(&self, session: &crate::sync::UploadSession) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; client .execute( "UPDATE upload_sessions SET status = $1, last_activity = $2 WHERE id = $3", &[ &session.status.to_string(), &session.last_activity, &session.id, ], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } async fn record_chunk(&self, upload_id: Uuid, chunk: &crate::sync::ChunkInfo) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; client .execute( "INSERT INTO upload_chunks (upload_id, chunk_index, offset, size, hash, received_at) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT(upload_id, chunk_index) DO UPDATE SET offset = EXCLUDED.offset, size = EXCLUDED.size, hash = EXCLUDED.hash, received_at = EXCLUDED.received_at", &[ &upload_id, &(chunk.chunk_index as i64), &(chunk.offset as i64), &(chunk.size as i64), &chunk.hash, &chunk.received_at, ], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } async fn get_upload_chunks(&self, upload_id: Uuid) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let rows = client .query( "SELECT upload_id, chunk_index, offset, size, hash, received_at FROM upload_chunks WHERE upload_id = $1 ORDER BY chunk_index", &[&upload_id], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows .iter() .map(|r| crate::sync::ChunkInfo { upload_id: r.get(0), chunk_index: r.get::<_, i64>(1) as u64, offset: r.get::<_, i64>(2) as u64, size: r.get::<_, i64>(3) as u64, hash: r.get(4), received_at: r.get(5), }) .collect()) } async fn cleanup_expired_uploads(&self) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let now = chrono::Utc::now(); let result = client .execute("DELETE FROM upload_sessions WHERE expires_at < $1", &[&now]) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(result) } // ========================================================================= // Sync Conflicts // ========================================================================= async fn record_conflict(&self, conflict: &crate::sync::SyncConflict) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; client .execute( "INSERT INTO sync_conflicts (id, device_id, path, local_hash, local_mtime, server_hash, server_mtime, detected_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", &[ &conflict.id, &conflict.device_id.0, &conflict.path, &conflict.local_hash, &conflict.local_mtime, &conflict.server_hash, &conflict.server_mtime, &conflict.detected_at, ], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } async fn get_unresolved_conflicts( &self, device_id: crate::sync::DeviceId, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let rows = client .query( "SELECT id, device_id, path, local_hash, local_mtime, server_hash, server_mtime, detected_at, resolved_at, resolution FROM sync_conflicts WHERE device_id = $1 AND resolved_at IS NULL", &[&device_id.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows .iter() .map(|r| crate::sync::SyncConflict { id: r.get(0), device_id: crate::sync::DeviceId(r.get(1)), path: r.get(2), local_hash: r.get(3), local_mtime: r.get(4), server_hash: r.get(5), server_mtime: r.get(6), detected_at: r.get(7), resolved_at: r.get(8), resolution: r .get::<_, Option>(9) .and_then(|s| match s.as_str() { "server_wins" => Some(crate::config::ConflictResolution::ServerWins), "client_wins" => Some(crate::config::ConflictResolution::ClientWins), "keep_both" => Some(crate::config::ConflictResolution::KeepBoth), "manual" => Some(crate::config::ConflictResolution::Manual), _ => None, }), }) .collect()) } async fn resolve_conflict( &self, id: Uuid, resolution: crate::config::ConflictResolution, ) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let now = chrono::Utc::now(); let resolution_str = match resolution { crate::config::ConflictResolution::ServerWins => "server_wins", crate::config::ConflictResolution::ClientWins => "client_wins", crate::config::ConflictResolution::KeepBoth => "keep_both", crate::config::ConflictResolution::Manual => "manual", }; client .execute( "UPDATE sync_conflicts SET resolved_at = $1, resolution = $2 WHERE id = $3", &[&now, &resolution_str, &id], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } // ========================================================================= // Shares // ========================================================================= async fn create_share(&self, share: &crate::sharing::Share) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let (recipient_type, recipient_user_id, public_token, password_hash): ( &str, Option, Option, Option, ) = match &share.recipient { crate::sharing::ShareRecipient::PublicLink { token, password_hash, } => ( "public_link", None, Some(token.clone()), password_hash.clone(), ), crate::sharing::ShareRecipient::User { user_id } => { ("user", Some(user_id.0), None, None) } crate::sharing::ShareRecipient::Group { .. } => ("group", None, None, None), crate::sharing::ShareRecipient::Federated { .. } => ("federated", None, None, None), }; client .execute( "INSERT INTO shares (id, target_type, target_id, owner_id, recipient_type, recipient_user_id, public_token, public_password_hash, perm_view, perm_download, perm_edit, perm_delete, perm_reshare, perm_add, note, expires_at, access_count, inherit_to_children, parent_share_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)", &[ &share.id.0, &share.target.target_type(), &share.target.target_id(), &share.owner_id.0, &recipient_type, &recipient_user_id, &public_token, &password_hash, &share.permissions.can_view, &share.permissions.can_download, &share.permissions.can_edit, &share.permissions.can_delete, &share.permissions.can_reshare, &share.permissions.can_add, &share.note, &share.expires_at, &(share.access_count as i64), &share.inherit_to_children, &share.parent_share_id.map(|s| s.0), &share.created_at, &share.updated_at, ], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(share.clone()) } async fn get_share(&self, id: crate::sharing::ShareId) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let row = client .query_one( "SELECT id, target_type, target_id, owner_id, recipient_type, recipient_user_id, public_token, public_password_hash, perm_view, perm_download, perm_edit, perm_delete, perm_reshare, perm_add, note, expires_at, access_count, last_accessed, inherit_to_children, parent_share_id, created_at, updated_at FROM shares WHERE id = $1", &[&id.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; pg_row_to_share(&row) } async fn get_share_by_token(&self, token: &str) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let row = client .query_one( "SELECT id, target_type, target_id, owner_id, recipient_type, recipient_user_id, public_token, public_password_hash, perm_view, perm_download, perm_edit, perm_delete, perm_reshare, perm_add, note, expires_at, access_count, last_accessed, inherit_to_children, parent_share_id, created_at, updated_at FROM shares WHERE public_token = $1", &[&token], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; pg_row_to_share(&row) } async fn list_shares_by_owner( &self, owner_id: crate::users::UserId, pagination: &Pagination, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let rows = client .query( "SELECT id, target_type, target_id, owner_id, recipient_type, recipient_user_id, public_token, public_password_hash, perm_view, perm_download, perm_edit, perm_delete, perm_reshare, perm_add, note, expires_at, access_count, last_accessed, inherit_to_children, parent_share_id, created_at, updated_at FROM shares WHERE owner_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", &[ &owner_id.0, &(pagination.limit as i64), &(pagination.offset as i64), ], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; rows.iter().map(pg_row_to_share).collect() } async fn list_shares_for_user( &self, user_id: crate::users::UserId, pagination: &Pagination, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let rows = client .query( "SELECT id, target_type, target_id, owner_id, recipient_type, recipient_user_id, public_token, public_password_hash, perm_view, perm_download, perm_edit, perm_delete, perm_reshare, perm_add, note, expires_at, access_count, last_accessed, inherit_to_children, parent_share_id, created_at, updated_at FROM shares WHERE recipient_user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", &[ &user_id.0, &(pagination.limit as i64), &(pagination.offset as i64), ], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; rows.iter().map(pg_row_to_share).collect() } async fn list_shares_for_target( &self, target: &crate::sharing::ShareTarget, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let target_type = target.target_type(); let target_id = target.target_id(); let rows = client .query( "SELECT id, target_type, target_id, owner_id, recipient_type, recipient_user_id, public_token, public_password_hash, perm_view, perm_download, perm_edit, perm_delete, perm_reshare, perm_add, note, expires_at, access_count, last_accessed, inherit_to_children, parent_share_id, created_at, updated_at FROM shares WHERE target_type = $1 AND target_id = $2", &[&target_type, &target_id], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; rows.iter().map(pg_row_to_share).collect() } async fn update_share(&self, share: &crate::sharing::Share) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; client .execute( "UPDATE shares SET perm_view = $1, perm_download = $2, perm_edit = $3, perm_delete = $4, perm_reshare = $5, perm_add = $6, note = $7, expires_at = $8, inherit_to_children = $9, updated_at = $10 WHERE id = $11", &[ &share.permissions.can_view, &share.permissions.can_download, &share.permissions.can_edit, &share.permissions.can_delete, &share.permissions.can_reshare, &share.permissions.can_add, &share.note, &share.expires_at, &share.inherit_to_children, &share.updated_at, &share.id.0, ], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(share.clone()) } async fn delete_share(&self, id: crate::sharing::ShareId) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; client .execute("DELETE FROM shares WHERE id = $1", &[&id.0]) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } async fn record_share_access(&self, id: crate::sharing::ShareId) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let now = chrono::Utc::now(); client .execute( "UPDATE shares SET access_count = access_count + 1, last_accessed = $1 WHERE id = $2", &[&now, &id.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } async fn check_share_access( &self, user_id: Option, target: &crate::sharing::ShareTarget, ) -> Result> { let shares = self.list_shares_for_target(target).await?; let now = chrono::Utc::now(); for share in shares { // Skip expired shares if let Some(exp) = share.expires_at { if exp < now { continue; } } match (&share.recipient, user_id) { // Public links are accessible to anyone (crate::sharing::ShareRecipient::PublicLink { .. }, _) => { return Ok(Some(share.permissions)); } // User shares require matching user ( crate::sharing::ShareRecipient::User { user_id: share_user, }, Some(uid), ) if *share_user == uid => { return Ok(Some(share.permissions)); } _ => continue, } } Ok(None) } async fn get_effective_share_permissions( &self, user_id: Option, media_id: MediaId, ) -> Result> { // Check direct media shares let target = crate::sharing::ShareTarget::Media { media_id }; if let Some(perms) = self.check_share_access(user_id, &target).await? { return Ok(Some(perms)); } // Check collection shares (inheritance) let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; // Find collections containing this media let collection_rows = client .query( "SELECT collection_id FROM collection_items WHERE media_id = $1", &[&media_id.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; for row in collection_rows { let collection_id: Uuid = row.get(0); let target = crate::sharing::ShareTarget::Collection { collection_id }; if let Some(perms) = self.check_share_access(user_id, &target).await? { return Ok(Some(perms)); } } // Check tag shares (inheritance) let tag_rows = client .query( "SELECT tag_id FROM media_tags WHERE media_id = $1", &[&media_id.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; for row in tag_rows { let tag_id: Uuid = row.get(0); let target = crate::sharing::ShareTarget::Tag { tag_id }; if let Some(perms) = self.check_share_access(user_id, &target).await? { return Ok(Some(perms)); } } Ok(None) } async fn batch_delete_shares(&self, ids: &[crate::sharing::ShareId]) -> Result { if ids.is_empty() { return Ok(0); } let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let uuid_ids: Vec = ids.iter().map(|id| id.0).collect(); let result = client .execute("DELETE FROM shares WHERE id = ANY($1)", &[&uuid_ids]) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(result) } async fn cleanup_expired_shares(&self) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let now = chrono::Utc::now(); let result = client .execute( "DELETE FROM shares WHERE expires_at IS NOT NULL AND expires_at < $1", &[&now], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(result) } // ========================================================================= // Share Activity // ========================================================================= async fn record_share_activity(&self, activity: &crate::sharing::ShareActivity) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; client .execute( "INSERT INTO share_activity (id, share_id, actor_id, actor_ip, action, details, timestamp) VALUES ($1, $2, $3, $4, $5, $6, $7)", &[ &activity.id, &activity.share_id.0, &activity.actor_id.map(|u| u.0), &activity.actor_ip, &activity.action.to_string(), &activity.details, &activity.timestamp, ], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } async fn get_share_activity( &self, share_id: crate::sharing::ShareId, pagination: &Pagination, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let rows = client .query( "SELECT id, share_id, actor_id, actor_ip, action, details, timestamp FROM share_activity WHERE share_id = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3", &[ &share_id.0, &(pagination.limit as i64), &(pagination.offset as i64), ], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows .iter() .map(|r| crate::sharing::ShareActivity { id: r.get(0), share_id: crate::sharing::ShareId(r.get(1)), actor_id: r.get::<_, Option>(2).map(crate::users::UserId), actor_ip: r.get(3), action: r .get::<_, String>(4) .parse() .unwrap_or(crate::sharing::ShareActivityAction::Accessed), details: r.get(5), timestamp: r.get(6), }) .collect()) } // ========================================================================= // Share Notifications // ========================================================================= async fn create_share_notification( &self, notification: &crate::sharing::ShareNotification, ) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; client .execute( "INSERT INTO share_notifications (id, user_id, share_id, notification_type, is_read, created_at) VALUES ($1, $2, $3, $4, $5, $6)", &[ ¬ification.id, ¬ification.user_id.0, ¬ification.share_id.0, ¬ification.notification_type.to_string(), ¬ification.is_read, ¬ification.created_at, ], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } async fn get_unread_notifications( &self, user_id: crate::users::UserId, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; let rows = client .query( "SELECT id, user_id, share_id, notification_type, is_read, created_at FROM share_notifications WHERE user_id = $1 AND is_read = false ORDER BY created_at DESC", &[&user_id.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows .iter() .map(|r| crate::sharing::ShareNotification { id: r.get(0), user_id: crate::users::UserId(r.get(1)), share_id: crate::sharing::ShareId(r.get(2)), notification_type: r .get::<_, String>(3) .parse() .unwrap_or(crate::sharing::ShareNotificationType::NewShare), is_read: r.get(4), created_at: r.get(5), }) .collect()) } async fn mark_notification_read(&self, id: Uuid) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; client .execute( "UPDATE share_notifications SET is_read = true WHERE id = $1", &[&id], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } async fn mark_all_notifications_read(&self, user_id: crate::users::UserId) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; client .execute( "UPDATE share_notifications SET is_read = true WHERE user_id = $1", &[&user_id.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } // ===== File Management ===== async fn rename_media(&self, id: MediaId, new_name: &str) -> Result { // Validate the new name if new_name.is_empty() || new_name.contains('/') || new_name.contains('\\') { return Err(PinakesError::InvalidOperation( "Invalid file name: must not be empty or contain path separators".into(), )); } let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; // Get the current path and storage mode let row = client .query_one( "SELECT path, storage_mode FROM media_items WHERE id = $1 AND deleted_at IS NULL", &[&id.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; let old_path: String = row.get(0); let storage_mode: String = row.get(1); let old_path_buf = std::path::PathBuf::from(&old_path); let parent = old_path_buf.parent().unwrap_or(std::path::Path::new("")); let new_path = parent.join(new_name); let new_path_str = new_path.to_string_lossy().to_string(); // For external storage, actually rename the file on disk if storage_mode == "external" && old_path_buf.exists() { tokio::fs::rename(&old_path_buf, &new_path) .await .map_err(|e| { PinakesError::Io(std::io::Error::new( e.kind(), format!("Failed to rename file: {}", e), )) })?; } // Update the database client .execute( "UPDATE media_items SET file_name = $1, path = $2, updated_at = NOW() WHERE id = $3", &[&new_name, &new_path_str, &id.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(old_path) } async fn move_media(&self, id: MediaId, new_directory: &std::path::Path) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; // Get the current path, file_name, and storage mode let row = client .query_one( "SELECT path, file_name, storage_mode FROM media_items WHERE id = $1 AND deleted_at IS NULL", &[&id.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; let old_path: String = row.get(0); let file_name: String = row.get(1); let storage_mode: String = row.get(2); let old_path_buf = std::path::PathBuf::from(&old_path); let new_path = new_directory.join(&file_name); let new_path_str = new_path.to_string_lossy().to_string(); // Ensure the target directory exists if !new_directory.exists() { tokio::fs::create_dir_all(new_directory).await?; } // For external storage, actually move the file on disk if storage_mode == "external" && old_path_buf.exists() { tokio::fs::rename(&old_path_buf, &new_path) .await .map_err(|e| { PinakesError::Io(std::io::Error::new( e.kind(), format!("Failed to move file: {}", e), )) })?; } // Update the database client .execute( "UPDATE media_items SET path = $1, updated_at = NOW() WHERE id = $2", &[&new_path_str, &id.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(old_path) } // ===== Trash / Soft Delete ===== async fn soft_delete_media(&self, id: MediaId) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows_affected = client .execute( "UPDATE media_items SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL", &[&id.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; if rows_affected == 0 { return Err(PinakesError::NotFound(format!( "Media item {} not found or already deleted", id ))); } Ok(()) } async fn restore_media(&self, id: MediaId) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows_affected = client .execute( "UPDATE media_items SET deleted_at = NULL, updated_at = NOW() WHERE id = $1 AND deleted_at IS NOT NULL", &[&id.0], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; if rows_affected == 0 { return Err(PinakesError::NotFound(format!( "Media item {} not found in trash", id ))); } Ok(()) } async fn list_trash(&self, pagination: &Pagination) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client .query( "SELECT id, path, file_name, media_type, content_hash, file_size, title, artist, album, genre, year, duration_secs, description, thumbnail_path, created_at, updated_at, file_mtime, date_taken, latitude, longitude, camera_make, camera_model, rating, storage_mode, original_filename, uploaded_at, storage_key, perceptual_hash, deleted_at FROM media_items WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT $1 OFFSET $2", &[&(pagination.limit as i64), &(pagination.offset as i64)], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items = Vec::new(); for row in rows { items.push(row_to_media_item(&row)?); } Ok(items) } async fn empty_trash(&self) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; // First, get the IDs to clean up related data let id_rows = client .query( "SELECT id FROM media_items WHERE deleted_at IS NOT NULL", &[], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; // Delete related data for row in &id_rows { let id: uuid::Uuid = row.get(0); client .execute("DELETE FROM media_tags WHERE media_id = $1", &[&id]) .await .ok(); client .execute("DELETE FROM collection_items WHERE media_id = $1", &[&id]) .await .ok(); client .execute("DELETE FROM custom_fields WHERE media_id = $1", &[&id]) .await .ok(); } // Delete the media items let count = client .execute("DELETE FROM media_items WHERE deleted_at IS NOT NULL", &[]) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(count) } async fn purge_old_trash(&self, before: chrono::DateTime) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; // First, get the IDs to clean up related data let id_rows = client .query( "SELECT id FROM media_items WHERE deleted_at IS NOT NULL AND deleted_at < $1", &[&before], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; // Delete related data for row in &id_rows { let id: uuid::Uuid = row.get(0); client .execute("DELETE FROM media_tags WHERE media_id = $1", &[&id]) .await .ok(); client .execute("DELETE FROM collection_items WHERE media_id = $1", &[&id]) .await .ok(); client .execute("DELETE FROM custom_fields WHERE media_id = $1", &[&id]) .await .ok(); } // Delete the media items let count = client .execute( "DELETE FROM media_items WHERE deleted_at IS NOT NULL AND deleted_at < $1", &[&before], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(count) } async fn count_trash(&self) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let row = client .query_one( "SELECT COUNT(*) FROM media_items WHERE deleted_at IS NOT NULL", &[], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; let count: i64 = row.get(0); Ok(count as u64) } // ===== Markdown Links (Obsidian-style) ===== async fn save_markdown_links( &self, media_id: MediaId, links: &[crate::model::MarkdownLink], ) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let media_id_str = media_id.0.to_string(); // Delete existing links for this source client .execute( "DELETE FROM markdown_links WHERE source_media_id = $1", &[&media_id_str], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; // Insert new links for link in links { let target_media_id = link.target_media_id.map(|id| id.0.to_string()); client .execute( "INSERT INTO markdown_links ( id, source_media_id, target_path, target_media_id, link_type, link_text, line_number, context, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", &[ &link.id.to_string(), &media_id_str, &link.target_path, &target_media_id, &link.link_type.to_string(), &link.link_text, &link.line_number, &link.context, &link.created_at, ], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; } Ok(()) } async fn get_outgoing_links( &self, media_id: MediaId, ) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let media_id_str = media_id.0.to_string(); let rows = client .query( "SELECT id, source_media_id, target_path, target_media_id, link_type, link_text, line_number, context, created_at FROM markdown_links WHERE source_media_id = $1 ORDER BY line_number", &[&media_id_str], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; let mut links = Vec::new(); for row in rows { links.push(row_to_markdown_link(&row)?); } Ok(links) } async fn get_backlinks(&self, media_id: MediaId) -> Result> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let media_id_str = media_id.0.to_string(); let rows = client .query( "SELECT l.id, l.source_media_id, m.title, m.path, l.link_text, l.line_number, l.context, l.link_type FROM markdown_links l JOIN media_items m ON l.source_media_id = m.id WHERE l.target_media_id = $1 ORDER BY m.title, l.line_number", &[&media_id_str], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; let mut backlinks = Vec::new(); for row in rows { let link_id_str: String = row.get(0); let source_id_str: String = row.get(1); let source_title: Option = row.get(2); let source_path: String = row.get(3); let link_text: Option = row.get(4); let line_number: Option = row.get(5); let context: Option = row.get(6); let link_type_str: String = row.get(7); backlinks.push(crate::model::BacklinkInfo { link_id: Uuid::parse_str(&link_id_str) .map_err(|e| PinakesError::Database(e.to_string()))?, source_id: MediaId( Uuid::parse_str(&source_id_str) .map_err(|e| PinakesError::Database(e.to_string()))?, ), source_title, source_path, link_text, line_number, context, link_type: link_type_str .parse() .unwrap_or(crate::model::LinkType::Wikilink), }); } Ok(backlinks) } async fn clear_links_for_media(&self, media_id: MediaId) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let media_id_str = media_id.0.to_string(); client .execute( "DELETE FROM markdown_links WHERE source_media_id = $1", &[&media_id_str], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } async fn get_graph_data( &self, center_id: Option, depth: u32, ) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let depth = depth.min(5); // Limit depth let mut nodes = Vec::new(); let mut edges = Vec::new(); let mut node_ids: std::collections::HashSet = std::collections::HashSet::new(); if let Some(center) = center_id { // BFS to find connected nodes within depth let mut frontier = vec![center.0.to_string()]; let mut visited = std::collections::HashSet::new(); visited.insert(center.0.to_string()); for _ in 0..depth { if frontier.is_empty() { break; } let mut next_frontier = Vec::new(); for node_id in &frontier { // Get outgoing links let rows = client .query( "SELECT target_media_id FROM markdown_links WHERE source_media_id = $1 AND target_media_id IS NOT NULL", &[node_id], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; for row in rows { let id: String = row.get(0); if !visited.contains(&id) { visited.insert(id.clone()); next_frontier.push(id); } } // Get incoming links let rows = client .query( "SELECT source_media_id FROM markdown_links WHERE target_media_id = $1", &[node_id], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; for row in rows { let id: String = row.get(0); if !visited.contains(&id) { visited.insert(id.clone()); next_frontier.push(id); } } } frontier = next_frontier; } node_ids = visited; } else { // Get all markdown files with links (limit to 500) let rows = client .query( "SELECT DISTINCT id FROM media_items WHERE media_type = 'markdown' AND deleted_at IS NULL LIMIT 500", &[], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; for row in rows { let id: String = row.get(0); node_ids.insert(id); } } // Build nodes with metadata for node_id in &node_ids { let row = client .query_opt( "SELECT id, COALESCE(title, file_name) as label, title, media_type FROM media_items WHERE id = $1", &[node_id], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; if let Some(row) = row { let id: String = row.get(0); let label: String = row.get(1); let title: Option = row.get(2); let media_type: String = row.get(3); // Count outgoing links let link_count_row = client .query_one( "SELECT COUNT(*) FROM markdown_links WHERE source_media_id = $1", &[&id], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; let link_count: i64 = link_count_row.get(0); // Count incoming links let backlink_count_row = client .query_one( "SELECT COUNT(*) FROM markdown_links WHERE target_media_id = $1", &[&id], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; let backlink_count: i64 = backlink_count_row.get(0); nodes.push(crate::model::GraphNode { id: id.clone(), label, title, media_type, link_count: link_count as u32, backlink_count: backlink_count as u32, }); } } // Build edges for node_id in &node_ids { let rows = client .query( "SELECT source_media_id, target_media_id, link_type FROM markdown_links WHERE source_media_id = $1 AND target_media_id IS NOT NULL", &[node_id], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; for row in rows { let source: String = row.get(0); let target: String = row.get(1); let link_type_str: String = row.get(2); if node_ids.contains(&target) { edges.push(crate::model::GraphEdge { source, target, link_type: link_type_str .parse() .unwrap_or(crate::model::LinkType::Wikilink), }); } } } Ok(crate::model::GraphData { nodes, edges }) } async fn resolve_links(&self) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; // Strategy 1: Exact path match let result1 = client .execute( "UPDATE markdown_links SET target_media_id = ( SELECT id FROM media_items WHERE path = markdown_links.target_path AND deleted_at IS NULL LIMIT 1 ) WHERE target_media_id IS NULL AND EXISTS ( SELECT 1 FROM media_items WHERE path = markdown_links.target_path AND deleted_at IS NULL )", &[], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; // Strategy 2: Filename match let result2 = client .execute( "UPDATE markdown_links SET target_media_id = ( SELECT id FROM media_items WHERE (file_name = markdown_links.target_path OR file_name = markdown_links.target_path || '.md' OR REPLACE(file_name, '.md', '') = markdown_links.target_path) AND deleted_at IS NULL LIMIT 1 ) WHERE target_media_id IS NULL AND EXISTS ( SELECT 1 FROM media_items WHERE (file_name = markdown_links.target_path OR file_name = markdown_links.target_path || '.md' OR REPLACE(file_name, '.md', '') = markdown_links.target_path) AND deleted_at IS NULL )", &[], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(result1 + result2) } async fn mark_links_extracted(&self, media_id: MediaId) -> Result<()> { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let media_id_str = media_id.0.to_string(); let now = chrono::Utc::now(); client .execute( "UPDATE media_items SET links_extracted_at = $1 WHERE id = $2", &[&now, &media_id_str], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } async fn count_unresolved_links(&self) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let row = client .query_one( "SELECT COUNT(*) FROM markdown_links WHERE target_media_id IS NULL", &[], ) .await .map_err(|e| PinakesError::Database(e.to_string()))?; let count: i64 = row.get(0); Ok(count as u64) } } impl PostgresBackend { async fn load_user_profile(&self, user_id: uuid::Uuid) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let row = client .query_opt( "SELECT avatar_path, bio, preferences_json FROM user_profiles WHERE user_id = $1", &[&user_id], ) .await?; match row { Some(row) => { let prefs_json: serde_json::Value = row.get::<_, serde_json::Value>(2); let preferences: crate::users::UserPreferences = serde_json::from_value(prefs_json).unwrap_or_default(); Ok(crate::users::UserProfile { avatar_path: row.get(0), bio: row.get(1), preferences, }) } None => Ok(crate::users::UserProfile { avatar_path: None, bio: None, preferences: Default::default(), }), } } async fn library_statistics_inner(&self) -> Result { let client = self .pool .get() .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let row = client .query_one( "SELECT COUNT(*), COALESCE(SUM(file_size), 0) FROM media_items", &[], ) .await?; let total_media: i64 = row.get(0); let total_size: i64 = row.get(1); let avg_size = if total_media > 0 { total_size / total_media } else { 0 }; let rows = client.query("SELECT media_type, COUNT(*) FROM media_items GROUP BY media_type ORDER BY COUNT(*) DESC", &[]).await?; let media_by_type: Vec<(String, u64)> = rows .iter() .map(|r| { let mt: String = r.get(0); let cnt: i64 = r.get(1); (mt, cnt as u64) }) .collect(); let rows = client.query("SELECT media_type, COALESCE(SUM(file_size), 0) FROM media_items GROUP BY media_type ORDER BY SUM(file_size) DESC", &[]).await?; let storage_by_type: Vec<(String, u64)> = rows .iter() .map(|r| { let mt: String = r.get(0); let sz: i64 = r.get(1); (mt, sz as u64) }) .collect(); let newest: Option = client .query_opt( "SELECT created_at::text FROM media_items ORDER BY created_at DESC LIMIT 1", &[], ) .await? .map(|r| r.get(0)); let oldest: Option = client .query_opt( "SELECT created_at::text FROM media_items ORDER BY created_at ASC LIMIT 1", &[], ) .await? .map(|r| r.get(0)); let rows = client.query( "SELECT t.name, COUNT(*) as cnt FROM media_tags mt JOIN tags t ON mt.tag_id = t.id GROUP BY t.id, t.name ORDER BY cnt DESC LIMIT 10", &[], ).await?; let top_tags: Vec<(String, u64)> = rows .iter() .map(|r| { let name: String = r.get(0); let cnt: i64 = r.get(1); (name, cnt as u64) }) .collect(); let rows = client.query( "SELECT c.name, COUNT(*) as cnt FROM collection_members cm JOIN collections c ON cm.collection_id = c.id GROUP BY c.id, c.name ORDER BY cnt DESC LIMIT 10", &[], ).await?; let top_collections: Vec<(String, u64)> = rows .iter() .map(|r| { let name: String = r.get(0); let cnt: i64 = r.get(1); (name, cnt as u64) }) .collect(); let total_tags: i64 = client .query_one("SELECT COUNT(*) FROM tags", &[]) .await? .get(0); let total_collections: i64 = client .query_one("SELECT COUNT(*) FROM collections", &[]) .await? .get(0); let total_duplicates: i64 = client.query_one( "SELECT COUNT(*) FROM (SELECT content_hash FROM media_items GROUP BY content_hash HAVING COUNT(*) > 1) sub", &[], ).await?.get(0); Ok(super::LibraryStatistics { total_media: total_media as u64, total_size_bytes: total_size as u64, avg_file_size_bytes: avg_size as u64, media_by_type, storage_by_type, newest_item: newest, oldest_item: oldest, top_tags, top_collections, total_tags: total_tags as u64, total_collections: total_collections as u64, total_duplicates: total_duplicates as u64, }) } } /// Helper function to parse a share row from PostgreSQL fn pg_row_to_share(row: &Row) -> Result { let id: Uuid = row.get(0); let target_type: String = row.get(1); let target_id: Uuid = row.get(2); let owner_id: Uuid = row.get(3); let recipient_type: String = row.get(4); let recipient_user_id: Option = row.get(5); let public_token: Option = row.get(6); let password_hash: Option = row.get(7); let target = match target_type.as_str() { "media" => crate::sharing::ShareTarget::Media { media_id: MediaId(target_id), }, "collection" => crate::sharing::ShareTarget::Collection { collection_id: target_id, }, "tag" => crate::sharing::ShareTarget::Tag { tag_id: target_id }, "saved_search" => crate::sharing::ShareTarget::SavedSearch { search_id: target_id, }, _ => crate::sharing::ShareTarget::Media { media_id: MediaId(target_id), }, }; 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(recipient_user_id.unwrap_or(Uuid::nil())), }, "group" => crate::sharing::ShareRecipient::Group { group_id: Uuid::nil(), }, _ => crate::sharing::ShareRecipient::PublicLink { token: public_token.unwrap_or_default(), password_hash, }, }; Ok(crate::sharing::Share { id: crate::sharing::ShareId(id), target, owner_id: crate::users::UserId(owner_id), recipient, permissions: crate::sharing::SharePermissions { can_view: row.get(8), can_download: row.get(9), can_edit: row.get(10), can_delete: row.get(11), can_reshare: row.get(12), can_add: row.get(13), }, note: row.get(14), expires_at: row.get(15), access_count: row.get::<_, i64>(16) as u64, last_accessed: row.get(17), inherit_to_children: row.get(18), parent_share_id: row.get::<_, Option>(19).map(crate::sharing::ShareId), created_at: row.get(20), updated_at: row.get(21), }) } /// Check if a SearchQuery tree contains any FullText or Prefix node (i.e. uses the FTS index). fn query_has_fts(query: &SearchQuery) -> bool { match query { SearchQuery::FullText(t) => !t.is_empty(), SearchQuery::Prefix(_) => true, SearchQuery::Fuzzy(_) => false, SearchQuery::FieldMatch { .. } => false, SearchQuery::TypeFilter(_) => false, SearchQuery::TagFilter(_) => false, SearchQuery::RangeQuery { .. } => false, SearchQuery::CompareQuery { .. } => false, SearchQuery::DateQuery { .. } => false, SearchQuery::And(children) | SearchQuery::Or(children) => { children.iter().any(query_has_fts) } SearchQuery::Not(inner) => query_has_fts(inner), } } /// Find the 1-based parameter index of the first FullText query parameter. /// Used to pass the same text to ts_rank for relevance sorting. /// Falls back to 1 if not found (should not happen when has_fts is true). fn find_first_fts_param(query: &SearchQuery) -> i32 { fn find_inner(query: &SearchQuery, offset: &mut i32) -> Option { match query { SearchQuery::FullText(t) => { if t.is_empty() { None } else { let idx = *offset; *offset += 7; // FullText now uses 7 params (fts, prefix, ilike, sim_title, sim_artist, sim_album, sim_filename) Some(idx) } } SearchQuery::Prefix(_) => { let idx = *offset; *offset += 1; Some(idx) } SearchQuery::Fuzzy(_) => { *offset += 5; // Fuzzy now uses 5 params (sim_title, sim_artist, sim_album, sim_filename, ilike) None } SearchQuery::FieldMatch { .. } => { *offset += 1; None } SearchQuery::TypeFilter(_) | SearchQuery::TagFilter(_) => None, SearchQuery::RangeQuery { start, end, .. } => { // Range queries use 0-2 params depending on bounds if start.is_some() { *offset += 1; } if end.is_some() { *offset += 1; } None } SearchQuery::CompareQuery { .. } => { *offset += 1; None } SearchQuery::DateQuery { .. } => None, // No params, uses inline SQL SearchQuery::And(children) | SearchQuery::Or(children) => { for child in children { if let Some(idx) = find_inner(child, offset) { return Some(idx); } } None } SearchQuery::Not(inner) => find_inner(inner, offset), } } let mut offset = 1; find_inner(query, &mut offset).unwrap_or(1) } // Helper function to parse a markdown link row fn row_to_markdown_link(row: &Row) -> Result { let id_str: String = row.get(0); let source_id_str: String = row.get(1); let target_path: String = row.get(2); let target_id: Option = row.get(3); let link_type_str: String = row.get(4); let link_text: Option = row.get(5); let line_number: Option = row.get(6); let context: Option = row.get(7); let created_at: chrono::DateTime = row.get(8); Ok(crate::model::MarkdownLink { id: Uuid::parse_str(&id_str).map_err(|e| PinakesError::Database(e.to_string()))?, source_media_id: MediaId( Uuid::parse_str(&source_id_str).map_err(|e| PinakesError::Database(e.to_string()))?, ), target_path, target_media_id: target_id .and_then(|s| Uuid::parse_str(&s).ok()) .map(MediaId), link_type: link_type_str .parse() .unwrap_or(crate::model::LinkType::Wikilink), link_text, line_number, context, created_at, }) } #[cfg(test)] mod tests { use super::*; #[test] fn test_media_type_roundtrip() { use crate::media_type::BuiltinMediaType; let mt = MediaType::Builtin(BuiltinMediaType::Mp3); let s = media_type_to_string(&mt); assert_eq!(s, "mp3"); let parsed = media_type_from_string(&s).unwrap(); assert_eq!(parsed, mt); } #[test] fn test_audit_action_roundtrip() { let action = AuditAction::AddedToCollection; let s = audit_action_to_string(&action); assert_eq!(s, "added_to_collection"); let parsed = audit_action_from_string(&s).unwrap(); assert_eq!(parsed, action); } #[test] fn test_collection_kind_roundtrip() { let kind = CollectionKind::Virtual; let s = collection_kind_to_string(&kind); assert_eq!(s, "virtual"); let parsed = collection_kind_from_string(&s).unwrap(); assert_eq!(parsed, kind); } #[test] fn test_custom_field_type_roundtrip() { let ft = CustomFieldType::Boolean; let s = custom_field_type_to_string(&ft); assert_eq!(s, "boolean"); let parsed = custom_field_type_from_string(&s).unwrap(); assert_eq!(parsed, ft); } #[test] fn test_build_search_fulltext() { let query = SearchQuery::FullText("hello world".into()); let mut offset = 1; let mut params: Vec> = Vec::new(); let (clause, types, tags) = build_search_clause(&query, &mut offset, &mut params).unwrap(); // Fuzzy search combines FTS, prefix, ILIKE, and trigram similarity assert!(clause.contains("plainto_tsquery")); assert!(clause.contains("to_tsquery")); assert!(clause.contains("LIKE")); assert!(clause.contains("similarity")); assert!(types.is_empty()); assert!(tags.is_empty()); // FullText now uses 7 parameters assert_eq!(offset, 8); } #[test] fn test_build_search_and() { let query = SearchQuery::And(vec![ SearchQuery::FullText("foo".into()), SearchQuery::TypeFilter("pdf".into()), ]); let mut offset = 1; let mut params: Vec> = Vec::new(); let (clause, types, _tags) = build_search_clause(&query, &mut offset, &mut params).unwrap(); assert!(clause.contains("AND")); assert_eq!(types, vec!["pdf"]); } #[test] fn test_query_has_fts() { assert!(query_has_fts(&SearchQuery::FullText("test".into()))); assert!(!query_has_fts(&SearchQuery::FullText(String::new()))); assert!(query_has_fts(&SearchQuery::Prefix("te".into()))); assert!(!query_has_fts(&SearchQuery::Fuzzy("test".into()))); assert!(query_has_fts(&SearchQuery::And(vec![ SearchQuery::Fuzzy("x".into()), SearchQuery::FullText("y".into()), ]))); } #[test] fn test_sort_order_clause() { assert_eq!(sort_order_clause(&SortOrder::DateAsc), "created_at ASC"); assert_eq!(sort_order_clause(&SortOrder::NameDesc), "file_name DESC"); assert_eq!(sort_order_clause(&SortOrder::SizeAsc), "file_size ASC"); } }